Add syntax highlighting for code blocks

Refs #12
This commit is contained in:
yoma committed 2026-07-11 21:43:01 +08:00
1 parent 0aef755513
commit b73604d7ba
9 files changed
+215 -46

No files matched your search

+5 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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,
+27
View File
@@ -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}
+2
View File
@@ -447,6 +447,8 @@ export const enUS = {
orderedList: "Numbered list",
quote: "Quote",
codeBlock: "Code block",
codeLanguage: "Code language",
plainText: "Plain text",
horizontalRule: "Divider",
},
mobileNotebookPicker: {
+2
View File
@@ -447,6 +447,8 @@ export const zhCN = {
orderedList: "有序列表",
quote: "引用",
codeBlock: "代码块",
codeLanguage: "代码语言",
plainText: "纯文本",
horizontalRule: "分割线",
},
mobileNotebookPicker: {
+24
View File
@@ -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";
};
+36
View File
@@ -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;
+10
View File
@@ -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
View File
@@ -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 "";