feat(web): highlight Markdown source and switch themes from the toolbar

Light auto theme now colors headings, marks, quotes, and links so source
mode is readable. The editor toolbar can pick the same Markdown themes as
settings.
This commit is contained in:
tianma-if committed 2026-09-12 00:07:05 +08:00
1 parent a10267b0d6
commit a9cd05e960
9 files changed
+102 -10

No files matched your search

+1
View File
@@ -6,6 +6,7 @@
"dependencies": {
"@antv/x6": "3.1.8",
"@codemirror/lang-markdown": "^6.5.2",
"@lezer/highlight": "^1.2.3",
"@edgeever/client": "workspace:*",
"@floating-ui/react-dom": "^2.1.8",
"@fontsource-variable/noto-sans-sc": "^5.2.8",
+20 -1
View File
@@ -35,6 +35,7 @@ import {
import { CODE_BLOCK_LANGUAGES, getCodeBlockLanguageValue } from "@/lib/code-block";
import { EditorTableMenu } from "@/components/EditorTableMenu";
import { wrapIndentedParagraphInList } from "@/lib/editor-shortcuts";
import { MARKDOWN_THEME_PREFERENCES, useMarkdownTheme } from "@/components/ThemeProvider";
const EditorToolbarButton = ({
active = false,
@@ -159,6 +160,7 @@ export const EditorToolbar = ({
externalLinkActive?: boolean;
}) => {
const { t } = useTranslation();
const { markdownThemePreference, setMarkdownTheme } = useMarkdownTheme();
const controlsRef = useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = useState(readEditorToolbarExpandedPreference);
const [hasOverflow, setHasOverflow] = useState(false);
@@ -366,7 +368,24 @@ export const EditorToolbar = ({
</>
)}
{markdownMode ? (
<span className="shrink-0 text-xs text-slate-500">{t("editorToolbar.markdownSource")}</span>
<Select
value={markdownThemePreference}
onValueChange={(value) => setMarkdownTheme(value as typeof markdownThemePreference)}
>
<SelectTrigger
aria-label={t("editorToolbar.markdownTheme")}
className="h-8 w-[11.5rem] shrink-0 whitespace-nowrap border-slate-200 bg-card text-xs text-slate-800 [&>span]:truncate [&>span]:whitespace-nowrap"
>
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-card border border-slate-200 rounded-md py-1 shadow-md">
{MARKDOWN_THEME_PREFERENCES.map((theme) => (
<SelectItem key={theme} value={theme}>
{t(`settings.markdownThemes.${theme}`)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<>
<Select
@@ -87,6 +87,14 @@ export const MARKDOWN_THEME_NAMES = [
export type MarkdownThemeName = (typeof MARKDOWN_THEME_NAMES)[number];
export const MARKDOWN_THEME_PREFERENCES = ["auto", ...MARKDOWN_THEME_NAMES] as const;
export type MarkdownThemePreference = (typeof MARKDOWN_THEME_PREFERENCES)[number];
export const MARKDOWN_LIGHT_THEME_NAMES = [
"github-light",
"solarized-light",
"xcode-light",
"duotone-light",
] as const satisfies readonly MarkdownThemeName[];
export const isMarkdownLightTheme = (theme: MarkdownThemeName): boolean =>
(MARKDOWN_LIGHT_THEME_NAMES as readonly MarkdownThemeName[]).includes(theme);
export const EDITOR_THEME_NAMES = [
"default",
@@ -6,7 +6,7 @@ import CodeMirror, {
} from "@uiw/react-codemirror";
import { markdown } from "@codemirror/lang-markdown";
import {
githubLight,
githubLightInit,
githubDark,
atomone,
tokyoNight,
@@ -14,17 +14,18 @@ import {
dracula,
nord,
monokai,
solarizedLight,
solarizedLightInit,
solarizedDark,
vscodeDark,
xcodeLight,
xcodeLightInit,
sublime,
duotoneLight,
duotoneLightInit,
duotoneDark,
gruvboxDark,
} from "@uiw/codemirror-themes-all";
import type { MarkdownThemeName } from "../ThemeProvider";
import { getAiSlashCommandStart } from "@/lib/editor-shortcuts";
import { lightMarkdownHighlightStyles } from "@/lib/markdown-source-highlight";
import { cn } from "@/lib/utils";
export interface MarkdownSourceEditorRef {
@@ -49,7 +50,7 @@ export interface MarkdownSourceEditorProps {
}
export const CODE_MIRROR_THEME_MAP: Record<MarkdownThemeName, Extension> = {
"github-light": githubLight,
"github-light": githubLightInit({ styles: lightMarkdownHighlightStyles }),
"github-dark": githubDark,
"one-dark": atomone,
"tokyo-night": tokyoNight,
@@ -57,12 +58,12 @@ export const CODE_MIRROR_THEME_MAP: Record<MarkdownThemeName, Extension> = {
dracula,
nord,
monokai,
"solarized-light": solarizedLight,
"solarized-light": solarizedLightInit({ styles: lightMarkdownHighlightStyles }),
"solarized-dark": solarizedDark,
"vscode-dark": vscodeDark,
"xcode-light": xcodeLight,
"xcode-light": xcodeLightInit({ styles: lightMarkdownHighlightStyles }),
sublime,
"duotone-light": duotoneLight,
"duotone-light": duotoneLightInit({ styles: lightMarkdownHighlightStyles }),
"duotone-dark": duotoneDark,
"gruvbox-dark": gruvboxDark,
};
@@ -0,0 +1,32 @@
import { tags as t } from "@lezer/highlight";
/**
* Appended to light CodeMirror themes so Markdown source tokens stay
* distinct. Later rules in the same HighlightStyle win, which is why these
* go through `*Init({ styles })` instead of a second highlighter.
*/
export const lightMarkdownHighlightStyles = [
{ tag: t.heading1, color: "#0550ae", fontWeight: "700" },
{ tag: t.heading2, color: "#0550ae", fontWeight: "700" },
{ tag: t.heading3, color: "#0a3069", fontWeight: "700" },
{ tag: t.heading4, color: "#0f766e", fontWeight: "700" },
{ tag: t.heading5, color: "#0f766e", fontWeight: "700" },
{ tag: t.heading6, color: "#0f766e", fontWeight: "700" },
{ tag: t.heading, color: "#0550ae", fontWeight: "700" },
{ tag: t.processingInstruction, color: "#64748b" },
{ tag: t.meta, color: "#64748b" },
{ tag: t.comment, color: "#64748b" },
{ tag: t.strong, color: "#0f172a", fontWeight: "700" },
{ tag: t.emphasis, color: "#9f1239", fontStyle: "italic" },
{ tag: t.strikethrough, color: "#64748b", textDecoration: "line-through" },
{ tag: t.link, color: "#0969da" },
{ tag: t.url, color: "#0969da", textDecoration: "underline" },
{ tag: t.monospace, color: "#be185d" },
{ tag: t.quote, color: "#15803d" },
{ tag: t.atom, color: "#0f766e", fontWeight: "700" },
{ tag: t.contentSeparator, color: "#94a3b8" },
{ tag: t.labelName, color: "#7c3aed" },
{ tag: t.string, color: "#0f766e" },
{ tag: t.escape, color: "#b45309" },
{ tag: t.character, color: "#b45309" },
];
+28
View File
@@ -1,11 +1,15 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import {
getStoredMarkdownTheme,
isMarkdownLightTheme,
MARKDOWN_LIGHT_THEME_NAMES,
MARKDOWN_THEME_NAMES,
MARKDOWN_THEME_PREFERENCES,
resolveMarkdownTheme,
} from "../components/ThemeProvider";
import { CODE_MIRROR_THEME_MAP } from "../components/editor/MarkdownSourceEditor";
import { lightMarkdownHighlightStyles } from "./markdown-source-highlight";
describe("markdown theme contracts", () => {
test("automatic Markdown themes follow the resolved appearance", () => {
@@ -34,4 +38,28 @@ describe("markdown theme contracts", () => {
expect(extension).toBeDefined();
}
});
test("light markdown themes receive an extra syntax highlight layer", () => {
const editorSource = readFileSync(new URL("../components/editor/MarkdownSourceEditor.tsx", import.meta.url), "utf8");
expect(lightMarkdownHighlightStyles.length).toBeGreaterThan(0);
expect(editorSource).toContain("lightMarkdownHighlightStyles");
for (const theme of MARKDOWN_LIGHT_THEME_NAMES) {
expect(MARKDOWN_THEME_NAMES).toContain(theme);
expect(isMarkdownLightTheme(theme)).toBe(true);
expect(CODE_MIRROR_THEME_MAP[theme]).toBeDefined();
expect(editorSource).toContain(`"${theme}":`);
}
expect(isMarkdownLightTheme("tokyo-night")).toBe(false);
expect(editorSource).toContain("githubLightInit({ styles: lightMarkdownHighlightStyles })");
expect(editorSource).toContain("xcodeLightInit({ styles: lightMarkdownHighlightStyles })");
});
test("the editor toolbar can switch Markdown source themes", () => {
const toolbar = readFileSync(new URL("../components/EditorToolbar.tsx", import.meta.url), "utf8");
expect(toolbar).toContain("useMarkdownTheme");
expect(toolbar).toContain("MARKDOWN_THEME_PREFERENCES");
expect(toolbar).toContain("markdownThemePreference");
expect(toolbar).toContain('t("editorToolbar.markdownTheme")');
expect(toolbar).not.toContain('t("editorToolbar.markdownSource")');
});
});
+2 -1
View File
@@ -95,7 +95,7 @@
},
"apps/desktop": {
"name": "@edgeever/desktop",
"version": "1.59.0",
"version": "1.67.0",
"dependencies": {
"@edgeever/public-network": "workspace:*",
"croner": "^10.0.1",
@@ -217,6 +217,7 @@
"@edgeever/client": "workspace:*",
"@floating-ui/react-dom": "^2.1.8",
"@fontsource-variable/noto-sans-sc": "^5.2.8",
"@lezer/highlight": "^1.2.3",
"@tiptap/extension-code-block-lowlight": "3.31.0",
"@tiptap/pm": "3.31.0",
"@uiw/codemirror-themes-all": "^4.25.11",
+1
View File
@@ -1879,6 +1879,7 @@ export const enUS = {
markdown: "Switch to Markdown source",
richText: "Switch to rich text",
markdownSource: "Markdown source",
markdownTheme: "Markdown source theme",
switchToMarkdown: "Switch to Markdown mode",
switchToRichText: "Switch to rich text mode",
attachment: "Upload attachment",
+1
View File
@@ -1877,6 +1877,7 @@ export const zhCN = {
markdown: "切换到 Markdown 源码",
richText: "切换到富文本编辑",
markdownSource: "Markdown 源码",
markdownTheme: "Markdown 源码主题",
switchToMarkdown: "切换到 Markdown 模式",
switchToRichText: "切换到富文本模式",
attachment: "上传附件",