1 parent
0aef755513
commit
b73604d7ba
9 files changed
+215
-46
No files matched your search
@@ -2,5 +2,9 @@
|
||||
"name": "@edgeever/web",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tiptap/extension-code-block-lowlight": "3.27.1",
|
||||
"lowlight": "^3.3.0"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useRef, useState, useEffect, useCallback, useMemo, type PointerEvent as
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer, useEditor, EditorContent, type Editor, type NodeViewProps } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -54,6 +55,7 @@ import { api } from "@/lib/api";
|
||||
import { consumeStandaloneMobileEditorReturn, openStandaloneMobileEditor } from "@/lib/mobile-editor";
|
||||
import { cn, formatDateTime, parseTagsText } from "@/lib/utils";
|
||||
import { docToMarkdown, markdownToDoc, type Notebook, type MemoDetail, type MemoEditSession, type TiptapDoc } from "@edgeever/shared";
|
||||
import { codeBlockLowlight } from "@/lib/code-block";
|
||||
import { compressImageForUpload } from "@/lib/image-compression";
|
||||
import { localDb, type MemoUpdateSyncPayload } from "@/lib/local-db";
|
||||
import { getMemoUpdateQueueId, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue";
|
||||
@@ -1304,7 +1306,8 @@ const RichEditorPane = ({
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
StarterKit.configure({ codeBlock: false }),
|
||||
CodeBlockLowlight.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }),
|
||||
ResizableImage.configure({
|
||||
allowBase64: false,
|
||||
inline: false,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getActiveBlockValue } from "@/lib/app-helpers";
|
||||
import { CODE_BLOCK_LANGUAGES, getCodeBlockLanguageValue } from "@/lib/code-block";
|
||||
|
||||
const EditorToolbarButton = ({
|
||||
active = false,
|
||||
@@ -87,6 +88,9 @@ export const EditorToolbar = ({ editor, readOnly }: { editor: Editor | null; rea
|
||||
const editorReady = isToolbarEditorReady(editor);
|
||||
const disabled = readOnly || !editorReady;
|
||||
const blockValue = getActiveBlockValue(editor);
|
||||
const codeBlockLanguage = editorReady
|
||||
? getCodeBlockLanguageValue(editor.getAttributes("codeBlock").language)
|
||||
: "plaintext";
|
||||
const isActive = (name: string) => {
|
||||
if (!editorReady) {
|
||||
return false;
|
||||
@@ -257,6 +261,29 @@ export const EditorToolbar = ({ editor, readOnly }: { editor: Editor | null; rea
|
||||
>
|
||||
<SquareCode className="h-4 w-4" />
|
||||
</EditorToolbarButton>
|
||||
{isActive("codeBlock") && (
|
||||
<Select
|
||||
value={codeBlockLanguage}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) =>
|
||||
run((current) => current.chain().focus().updateAttributes("codeBlock", { language: value }).run())
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-32 shrink-0 whitespace-nowrap border-slate-200 bg-white text-xs text-slate-800 [&>span]:truncate [&>span]:whitespace-nowrap"
|
||||
aria-label={t("editorToolbar.codeLanguage")}
|
||||
>
|
||||
<SelectValue placeholder={t("editorToolbar.plainText")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white border border-slate-200 rounded-md py-1 shadow-md">
|
||||
{CODE_BLOCK_LANGUAGES.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.value === "plaintext" ? t("editorToolbar.plainText") : language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<EditorToolbarButton
|
||||
title={t("editorToolbar.horizontalRule")}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -447,6 +447,8 @@ export const enUS = {
|
||||
orderedList: "Numbered list",
|
||||
quote: "Quote",
|
||||
codeBlock: "Code block",
|
||||
codeLanguage: "Code language",
|
||||
plainText: "Plain text",
|
||||
horizontalRule: "Divider",
|
||||
},
|
||||
mobileNotebookPicker: {
|
||||
|
||||
@@ -447,6 +447,8 @@ export const zhCN = {
|
||||
orderedList: "有序列表",
|
||||
quote: "引用",
|
||||
codeBlock: "代码块",
|
||||
codeLanguage: "代码语言",
|
||||
plainText: "纯文本",
|
||||
horizontalRule: "分割线",
|
||||
},
|
||||
mobileNotebookPicker: {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { common, createLowlight } from "lowlight";
|
||||
|
||||
export const codeBlockLowlight = createLowlight(common);
|
||||
|
||||
export const CODE_BLOCK_LANGUAGES = [
|
||||
{ value: "plaintext", label: "Plain text" },
|
||||
{ value: "bash", label: "Bash" },
|
||||
{ value: "css", label: "CSS" },
|
||||
{ value: "html", label: "HTML" },
|
||||
{ value: "javascript", label: "JavaScript" },
|
||||
{ value: "json", label: "JSON" },
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "python", label: "Python" },
|
||||
{ value: "sql", label: "SQL" },
|
||||
{ value: "typescript", label: "TypeScript" },
|
||||
] as const;
|
||||
|
||||
export const getCodeBlockLanguageValue = (language: unknown) => {
|
||||
if (typeof language !== "string" || !language) {
|
||||
return "plaintext";
|
||||
}
|
||||
|
||||
return CODE_BLOCK_LANGUAGES.some((option) => option.value === language) ? language : "plaintext";
|
||||
};
|
||||
@@ -187,6 +187,42 @@ textarea {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Highlight.js-compatible token classes emitted by Tiptap/lowlight. */
|
||||
.ProseMirror pre .hljs-comment,
|
||||
.ProseMirror pre .hljs-quote {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.ProseMirror pre .hljs-keyword,
|
||||
.ProseMirror pre .hljs-selector-tag,
|
||||
.ProseMirror pre .hljs-literal,
|
||||
.ProseMirror pre .hljs-name,
|
||||
.ProseMirror pre .hljs-attribute {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.ProseMirror pre .hljs-string,
|
||||
.ProseMirror pre .hljs-title,
|
||||
.ProseMirror pre .hljs-section,
|
||||
.ProseMirror pre .hljs-built_in,
|
||||
.ProseMirror pre .hljs-type {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.ProseMirror pre .hljs-number,
|
||||
.ProseMirror pre .hljs-symbol,
|
||||
.ProseMirror pre .hljs-bullet,
|
||||
.ProseMirror pre .hljs-variable,
|
||||
.ProseMirror pre .hljs-template-variable {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.ProseMirror pre .hljs-meta,
|
||||
.ProseMirror pre .hljs-regexp,
|
||||
.ProseMirror pre .hljs-link {
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.ProseMirror hr {
|
||||
margin: 1.5rem 0;
|
||||
border: 0;
|
||||
|
||||
@@ -133,6 +133,10 @@
|
||||
"apps/web": {
|
||||
"name": "@edgeever/web",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"@tiptap/extension-code-block-lowlight": "3.27.1",
|
||||
"lowlight": "^3.3.0",
|
||||
},
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@edgeever/client",
|
||||
@@ -1013,6 +1017,8 @@
|
||||
|
||||
"@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA=="],
|
||||
|
||||
"@tiptap/extension-code-block-lowlight": ["@tiptap/extension-code-block-lowlight@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/extension-code-block": "3.27.1", "@tiptap/pm": "3.27.1", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "sha512-rDZYDwKpV3Uw8xjI5pzSQTLJ7pEyJkDrVhnzdh9mBY3sHVwP1maYh+0kRUrHi5N3EhiNp/GXG1kPjysPIQ4nqQ=="],
|
||||
|
||||
"@tiptap/extension-document": ["@tiptap/extension-document@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw=="],
|
||||
|
||||
"@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.27.1", "", { "peerDependencies": { "@tiptap/extensions": "3.27.1" } }, "sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ=="],
|
||||
@@ -1747,6 +1753,8 @@
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
|
||||
|
||||
"hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="],
|
||||
@@ -1979,6 +1987,8 @@
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.21.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ=="],
|
||||
|
||||
+105
-44
@@ -22,53 +22,85 @@ export const emptyDoc = (): TiptapDoc => ({
|
||||
});
|
||||
|
||||
export const markdownToDoc = (markdown: string): TiptapDoc => {
|
||||
const blocks = markdown
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean);
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const content: TiptapNode[] = [];
|
||||
|
||||
if (blocks.length === 0) {
|
||||
for (let index = 0; index < lines.length; ) {
|
||||
if (!lines[index].trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fence = /^```([^\s`]*)\s*$/.exec(lines[index].trim());
|
||||
|
||||
if (fence) {
|
||||
const codeLines: string[] = [];
|
||||
index += 1;
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== "```") {
|
||||
codeLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (index < lines.length) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
content.push({
|
||||
type: "codeBlock",
|
||||
attrs: { language: fence[1] || "plaintext" },
|
||||
content: [{ type: "text", text: codeLines.join("\n") }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockLines: string[] = [];
|
||||
while (index < lines.length && lines[index].trim()) {
|
||||
blockLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const block = blockLines.join("\n").trim();
|
||||
const heading = /^(#{1,3})\s+(.+)$/.exec(block);
|
||||
const image = /^!\[([^\]]*)\]\((\S+?)(?:\s+"([^"]+)")?\)$/.exec(block);
|
||||
|
||||
if (heading) {
|
||||
content.push({
|
||||
type: "heading",
|
||||
attrs: { level: heading[1].length },
|
||||
content: [{ type: "text", text: heading[2] }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (image) {
|
||||
content.push({
|
||||
type: "image",
|
||||
attrs: {
|
||||
src: image[2],
|
||||
alt: image[1] || null,
|
||||
title: image[3] || null,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^-{3,}$/.test(block)) {
|
||||
content.push({ type: "horizontalRule" });
|
||||
continue;
|
||||
}
|
||||
|
||||
content.push({
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: block }],
|
||||
});
|
||||
}
|
||||
|
||||
if (content.length === 0) {
|
||||
return emptyDoc();
|
||||
}
|
||||
|
||||
return {
|
||||
type: "doc",
|
||||
content: blocks.map((block) => {
|
||||
const heading = /^(#{1,3})\s+(.+)$/.exec(block);
|
||||
const image = /^!\[([^\]]*)\]\((\S+?)(?:\s+"([^"]+)")?\)$/.exec(block);
|
||||
|
||||
if (heading) {
|
||||
return {
|
||||
type: "heading",
|
||||
attrs: { level: heading[1].length },
|
||||
content: [{ type: "text", text: heading[2] }],
|
||||
};
|
||||
}
|
||||
|
||||
if (image) {
|
||||
return {
|
||||
type: "image",
|
||||
attrs: {
|
||||
src: image[2],
|
||||
alt: image[1] || null,
|
||||
title: image[3] || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (/^-{3,}$/.test(block)) {
|
||||
return {
|
||||
type: "horizontalRule",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: block }],
|
||||
};
|
||||
}),
|
||||
};
|
||||
return { type: "doc", content };
|
||||
};
|
||||
|
||||
export const docToText = (doc: unknown): string => {
|
||||
@@ -178,12 +210,41 @@ const blockToMarkdown = (node: unknown): string => {
|
||||
}
|
||||
|
||||
if (current.type === "codeBlock") {
|
||||
return `\`\`\`\n${docToText({ content: current.content })}\n\`\`\``;
|
||||
const language = getStringAttr(current.attrs, "language");
|
||||
const languageSuffix = language && language !== "plaintext" ? language : "";
|
||||
const code = contentToPlainText(current.content);
|
||||
return `\`\`\`${languageSuffix}\n${code}\n\`\`\``;
|
||||
}
|
||||
|
||||
return inlineToMarkdown(current.content);
|
||||
};
|
||||
|
||||
const contentToPlainText = (content: unknown): string => {
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return content
|
||||
.map((node) => {
|
||||
if (!node || typeof node !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const current = node as { type?: unknown; text?: unknown; content?: unknown };
|
||||
|
||||
if (typeof current.text === "string") {
|
||||
return current.text;
|
||||
}
|
||||
|
||||
if (current.type === "hardBreak") {
|
||||
return "\n";
|
||||
}
|
||||
|
||||
return contentToPlainText(current.content);
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
const inlineToMarkdown = (content: unknown): string => {
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
|
||||
Reference in new issue
Block a user