feat: 升级 CodeMirror 编辑器并新增 Markdown 工具栏

This commit is contained in:
SkyJourney
2026-07-30 12:39:35 +08:00
parent f7d4efeafc
commit 7831bc23f0
11 changed files with 1603 additions and 35 deletions
+5
View File
@@ -10,9 +10,14 @@
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.7",
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/markdown-echarts": "0.1.0",
"@md-to-pdf/preview-engine": "0.1.0",
"@uiw/react-codemirror": "^4.25.11",
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"pdfjs-dist": "6.1.200",
+40 -14
View File
@@ -9,6 +9,7 @@ import {
useRef,
useState
} from "react";
import type { EditorView } from "@codemirror/view";
import {
createPagedDocumentPayload,
getPaperDimensionsMm,
@@ -71,6 +72,11 @@ import {
applyThemeDefaults,
selectTheme
} from "./theme-margins";
import {
MarkdownEditor,
type MarkdownEditorHandle
} from "./MarkdownEditor";
import { MarkdownToolbar } from "./MarkdownToolbar";
const PrecisePdfPreview = lazy(async () => {
const module = await import("./PrecisePdfPreview");
@@ -107,6 +113,11 @@ const initialDocumentKind: DocumentKind = startsWithBlankMarkdown
export function App() {
const [markdown, setMarkdown] = useState(initialMarkdown);
const [editorDocumentVersion, setEditorDocumentVersion] =
useState(0);
const [editorView, setEditorView] = useState<EditorView | null>(
null
);
const [fileName, setFileName] = useState(initialFileName);
const [documentKind, setDocumentKind] =
useState<DocumentKind>(initialDocumentKind);
@@ -141,7 +152,7 @@ export function App() {
current: 0,
total: 0
});
const editorRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<MarkdownEditorHandle>(null);
const markdownFileInputRef = useRef<HTMLInputElement>(null);
const moreMenuRef = useRef<HTMLDivElement>(null);
const previewScrollRef = useRef<HTMLDivElement>(null);
@@ -158,6 +169,11 @@ export function App() {
const themeRefreshTargetRef = useRef<number | undefined>(undefined);
documentDirtyRef.current = documentDirty;
function replaceMarkdownDocument(content: string) {
setMarkdown(content);
setEditorDocumentVersion((version) => version + 1);
}
useEffect(() => {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
@@ -191,7 +207,7 @@ export function App() {
return;
}
setAppError("");
setMarkdown(opened.markdown);
replaceMarkdownDocument(opened.markdown);
setFileName(opened.fileName);
setDocumentKind("file");
setSavedMarkdown(opened.markdown);
@@ -658,9 +674,13 @@ export function App() {
function synchronizeScroll(origin: "editor" | "preview") {
const source =
origin === "editor" ? editorRef.current : getPreviewScroller();
origin === "editor"
? editorRef.current?.getScrollElement()
: getPreviewScroller();
const target =
origin === "editor" ? getPreviewScroller() : editorRef.current;
origin === "editor"
? getPreviewScroller()
: editorRef.current?.getScrollElement();
if (!source || !target) {
return;
}
@@ -866,7 +886,7 @@ export function App() {
try {
setAppError("");
const content = await file.text();
setMarkdown(content);
replaceMarkdownDocument(content);
setFileName(file.name);
setDocumentKind("file");
setSavedMarkdown(content);
@@ -885,7 +905,7 @@ export function App() {
if (!opened) {
return;
}
setMarkdown(opened.markdown);
replaceMarkdownDocument(opened.markdown);
setFileName(opened.fileName);
setDocumentKind("file");
setSavedMarkdown(opened.markdown);
@@ -923,7 +943,7 @@ export function App() {
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
setMarkdown("");
replaceMarkdownDocument("");
setFileName("");
setDocumentKind("new");
setSavedMarkdown("");
@@ -1034,7 +1054,7 @@ export function App() {
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
setMarkdown(sample.markdown);
replaceMarkdownDocument(sample.markdown);
setFileName(sample.fileName);
setDocumentKind("sample");
setSavedMarkdown(sample.markdown);
@@ -1078,7 +1098,7 @@ export function App() {
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
setMarkdown(tutorial.markdown);
replaceMarkdownDocument(tutorial.markdown);
setFileName(tutorial.fileName);
setDocumentKind("sample");
setSavedMarkdown(tutorial.markdown);
@@ -1522,12 +1542,13 @@ export function App() {
</button>
</div>
<textarea
<MarkdownToolbar editorView={editorView} />
<MarkdownEditor
ref={editorRef}
aria-label="Markdown 内容"
value={markdown}
spellCheck={false}
onChange={(event) => setMarkdown(event.target.value)}
documentVersion={editorDocumentVersion}
onChange={setMarkdown}
onEditorReady={setEditorView}
onScroll={() => synchronizeScroll("editor")}
/>
</div>
@@ -1537,7 +1558,12 @@ export function App() {
className="source-panel-restore"
aria-controls="markdown-source-content"
aria-expanded="false"
onClick={() => setSourcePanelCollapsed(false)}
onClick={() => {
setSourcePanelCollapsed(false);
window.requestAnimationFrame(() =>
editorRef.current?.focus()
);
}}
>
</button>
+158
View File
@@ -0,0 +1,158 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef
} from "react";
import CodeMirror, {
type ReactCodeMirrorRef
} from "@uiw/react-codemirror";
import { markdown } from "@codemirror/lang-markdown";
import {
EditorView,
keymap,
type KeyBinding
} from "@codemirror/view";
import {
executeMarkdownEditorCommand,
markdownEditorCommands
} from "./markdown-editor-commands";
export interface MarkdownEditorHandle {
focus: () => void;
getScrollElement: () => HTMLElement | null;
getView: () => EditorView | null;
}
interface MarkdownEditorProps {
value: string;
documentVersion: number;
onChange: (value: string) => void;
onEditorReady?: (view: EditorView | null) => void;
onScroll?: () => void;
}
const editorTheme = EditorView.theme({
"&": {
height: "100%",
backgroundColor: "#f6f7f5",
color: "#26312d",
fontSize: "0.86rem"
},
"&.cm-focused": {
outline: "none",
boxShadow: "inset 3px 0 #74a88f"
},
".cm-scroller": {
fontFamily:
'"Cascadia Code", "SFMono-Regular", Consolas, monospace',
lineHeight: "1.72",
tabSize: "2",
overflow: "auto"
},
".cm-content": {
minHeight: "100%",
padding: "24px"
},
".cm-line": {
padding: "0"
},
".cm-gutters": {
border: "0",
backgroundColor: "#eef1ef",
color: "#8a9690"
},
".cm-activeLine, .cm-activeLineGutter": {
backgroundColor: "rgb(116 168 143 / 9%)"
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "rgb(89 135 113 / 24%)"
}
});
export const MarkdownEditor = forwardRef<
MarkdownEditorHandle,
MarkdownEditorProps
>(function MarkdownEditor(
{
value,
documentVersion,
onChange,
onEditorReady,
onScroll
},
forwardedRef
) {
const codeMirrorRef = useRef<ReactCodeMirrorRef>(null);
const viewRef = useRef<EditorView | null>(null);
const shortcutBindings = useMemo(
() =>
markdownEditorCommands.flatMap<KeyBinding>((command) =>
command.shortcut
? [
{
key: command.shortcut,
run: (view) =>
executeMarkdownEditorCommand(command.id, { view })
}
]
: []
),
[]
);
const extensions = useMemo(
() => [markdown(), keymap.of(shortcutBindings)],
[shortcutBindings]
);
useImperativeHandle(
forwardedRef,
() => ({
focus: () => viewRef.current?.focus(),
getScrollElement: () => viewRef.current?.scrollDOM ?? null,
getView: () => viewRef.current
}),
[]
);
useEffect(
() => () => {
onEditorReady?.(null);
viewRef.current = null;
},
[documentVersion, onEditorReady]
);
return (
<div
className="markdown-editor"
onScrollCapture={onScroll}
>
<CodeMirror
key={documentVersion}
ref={codeMirrorRef}
value={value}
height="100%"
basicSetup={{
autocompletion: false,
bracketMatching: true,
closeBrackets: true,
foldGutter: false,
highlightActiveLine: true,
highlightActiveLineGutter: true,
lineNumbers: true,
searchKeymap: true
}}
extensions={extensions}
theme={editorTheme}
aria-label="Markdown 内容"
onChange={onChange}
onCreateEditor={(view) => {
viewRef.current = view;
onEditorReady?.(view);
}}
/>
</div>
);
});
+208
View File
@@ -0,0 +1,208 @@
import {
type KeyboardEvent,
useEffect,
useRef,
useState
} from "react";
import type { EditorView } from "@codemirror/view";
import { insertMarkdownTable } from "./markdown-editor-commands";
interface MarkdownTableToolProps {
editorView: EditorView | null;
}
const maximumColumns = 10;
const maximumRows = 10;
const minimumRows = 2;
interface TableSize {
columns: number;
rows: number;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
export function MarkdownTableTool({
editorView
}: MarkdownTableToolProps) {
const [open, setOpen] = useState(false);
const [size, setSize] = useState<TableSize>({
columns: 3,
rows: 3
});
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
function focusSize(nextSize: TableSize) {
window.requestAnimationFrame(() => {
rootRef.current
?.querySelector<HTMLButtonElement>(
`[data-table-size="${nextSize.columns}x${nextSize.rows}"]`
)
?.focus();
});
}
useEffect(() => {
if (!open) {
return;
}
focusSize(size);
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!rootRef.current?.contains(event.target)
) {
setOpen(false);
}
};
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useEffect(() => {
if (!editorView) {
setOpen(false);
}
}, [editorView]);
function moveSelection(
event: KeyboardEvent<HTMLButtonElement>,
columnsDelta: number,
rowsDelta: number
) {
event.preventDefault();
const nextSize = {
columns: clamp(
size.columns + columnsDelta,
1,
maximumColumns
),
rows: clamp(
size.rows + rowsDelta,
minimumRows,
maximumRows
)
};
setSize(nextSize);
focusSize(nextSize);
}
function insertTable(selectedSize: TableSize) {
if (!editorView) {
return;
}
insertMarkdownTable(
editorView,
selectedSize.columns,
selectedSize.rows
);
setOpen(false);
}
return (
<div className="markdown-table-tool" ref={rootRef}>
<button
ref={triggerRef}
type="button"
className="markdown-toolbar-button"
aria-label="插入表格"
aria-haspopup="dialog"
aria-expanded={open}
title="插入表格"
disabled={!editorView}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setOpen((current) => !current)}
>
</button>
{open ? (
<div
className="markdown-table-popover"
role="dialog"
aria-label="选择表格尺寸"
>
<strong>
{size.columns} × {size.rows}
</strong>
<span></span>
<div
className="markdown-table-grid"
role="grid"
aria-label="表格尺寸"
aria-rowcount={maximumRows - minimumRows + 1}
aria-colcount={maximumColumns}
>
{Array.from(
{
length:
maximumColumns *
(maximumRows - minimumRows + 1)
},
(_, index) => {
const columns = (index % maximumColumns) + 1;
const rows =
Math.floor(index / maximumColumns) + minimumRows;
const selected =
columns <= size.columns && rows <= size.rows;
const active =
columns === size.columns && rows === size.rows;
return (
<button
key={`${columns}x${rows}`}
type="button"
role="gridcell"
className={
selected
? "markdown-table-cell is-selected"
: "markdown-table-cell"
}
data-table-size={`${columns}x${rows}`}
aria-label={`插入 ${columns}× ${rows} 行表格`}
aria-selected={active}
tabIndex={active ? 0 : -1}
onPointerEnter={() =>
setSize({ columns, rows })
}
onFocus={() => setSize({ columns, rows })}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
moveSelection(event, -1, 0);
} else if (event.key === "ArrowRight") {
moveSelection(event, 1, 0);
} else if (event.key === "ArrowUp") {
moveSelection(event, 0, -1);
} else if (event.key === "ArrowDown") {
moveSelection(event, 0, 1);
} else if (
event.key === "Enter" ||
event.key === " "
) {
event.preventDefault();
insertTable({ columns, rows });
}
}}
onClick={() => insertTable({ columns, rows })}
/>
);
}
)}
</div>
</div>
) : null}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import type { MouseEvent } from "react";
import type { EditorView } from "@codemirror/view";
import {
executeMarkdownEditorCommand,
getMarkdownEditorCommand
} from "./markdown-editor-commands";
import { MarkdownTableTool } from "./MarkdownTableTool";
interface MarkdownToolbarProps {
editorView: EditorView | null;
}
const toolbarGroups = [
["undo", "redo"],
["bold", "italic", "strikethrough", "inline-code"],
[
"code-block",
"quote",
"unordered-list",
"ordered-list",
"task-list"
],
["table", "horizontal-rule"]
] as const;
function preserveEditorSelection(event: MouseEvent<HTMLElement>) {
event.preventDefault();
}
export function MarkdownToolbar({
editorView
}: MarkdownToolbarProps) {
function execute(commandId: string) {
if (!editorView) {
return;
}
executeMarkdownEditorCommand(commandId, { view: editorView });
}
return (
<div
className="markdown-toolbar"
role="toolbar"
aria-label="Markdown 格式工具栏"
>
<label className="markdown-toolbar-heading">
<span className="sr-only"></span>
<select
aria-label="设置标题级别"
defaultValue=""
disabled={!editorView}
onChange={(event) => {
if (event.target.value) {
execute(event.target.value);
event.target.value = "";
}
}}
>
<option value=""></option>
{Array.from({ length: 6 }, (_, index) => (
<option
key={index + 1}
value={`heading-${index + 1}`}
>
H{index + 1}
</option>
))}
</select>
</label>
{toolbarGroups.map((group, groupIndex) => (
<div
className="markdown-toolbar-group"
key={group.join("-")}
aria-label={`工具组 ${groupIndex + 1}`}
>
{group.map((commandId) => {
if (commandId === "table") {
return (
<MarkdownTableTool
key={commandId}
editorView={editorView}
/>
);
}
const command = getMarkdownEditorCommand(commandId);
if (!command) {
return null;
}
const disabled =
!editorView ||
(command.isEnabled?.({ view: editorView }) === false);
return (
<button
key={command.id}
type="button"
className={`markdown-toolbar-button command-${command.id}`}
aria-label={command.label}
title={
command.shortcut
? `${command.label}${command.shortcut}`
: command.label
}
disabled={disabled}
onMouseDown={preserveEditorSelection}
onClick={() => execute(command.id)}
>
{command.toolbarLabel}
</button>
);
})}
</div>
))}
</div>
);
}
+514
View File
@@ -0,0 +1,514 @@
import {
EditorSelection,
type EditorState,
type SelectionRange
} from "@codemirror/state";
import {
redo,
redoDepth,
undo,
undoDepth
} from "@codemirror/commands";
import type { EditorView } from "@codemirror/view";
export type MarkdownEditorCommandGroup =
| "history"
| "text"
| "block"
| "insert"
| "document";
export interface EditorCommandContext {
view: EditorView;
openPanel?: (panelId: string) => void;
}
export interface MarkdownEditorCommand {
id: string;
group: MarkdownEditorCommandGroup;
label: string;
toolbarLabel: string;
shortcut?: string;
isEnabled?: (context: EditorCommandContext) => boolean;
execute: (
context: EditorCommandContext
) => void | Promise<void>;
}
export interface MarkdownTextEdit {
insert: string;
selectionFrom: number;
selectionTo: number;
}
type SelectionEditor = (
selectedText: string,
range: SelectionRange,
state: EditorState
) => MarkdownTextEdit;
export function wrapMarkdownSelection(
selectedText: string,
prefix: string,
suffix = prefix,
placeholder = "文本"
): MarkdownTextEdit {
if (
selectedText.length >= prefix.length + suffix.length &&
selectedText.startsWith(prefix) &&
selectedText.endsWith(suffix)
) {
const insert = selectedText.slice(
prefix.length,
selectedText.length - suffix.length
);
return {
insert,
selectionFrom: 0,
selectionTo: insert.length
};
}
const content = selectedText || placeholder;
return {
insert: `${prefix}${content}${suffix}`,
selectionFrom: prefix.length,
selectionTo: prefix.length + content.length
};
}
function mapNonBlankLines(
text: string,
mapper: (line: string, index: number) => string
) {
let contentLineIndex = 0;
return text
.split("\n")
.map((line) => {
if (!line.trim()) {
return line;
}
const mapped = mapper(line, contentLineIndex);
contentLineIndex += 1;
return mapped;
})
.join("\n");
}
export type MarkdownLinePrefix =
| "quote"
| "unordered-list"
| "ordered-list"
| "task-list";
const linePrefixPatterns: Record<MarkdownLinePrefix, RegExp> = {
quote: /^>\s?/u,
"unordered-list": /^[-*+]\s+/u,
"ordered-list": /^\d+[.)]\s+/u,
"task-list": /^[-*+]\s+\[[ xX]\]\s+/u
};
export function toggleMarkdownLinePrefix(
text: string,
kind: MarkdownLinePrefix
) {
const pattern = linePrefixPatterns[kind];
const nonBlankLines = text
.split("\n")
.filter((line) => line.trim());
const shouldRemove =
nonBlankLines.length > 0 &&
nonBlankLines.every((line) => pattern.test(line));
return mapNonBlankLines(text, (line, index) => {
if (shouldRemove) {
return line.replace(pattern, "");
}
if (kind === "quote") {
return `> ${line.replace(linePrefixPatterns.quote, "")}`;
}
if (kind === "ordered-list") {
return `${index + 1}. ${line.replace(
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
""
)}`;
}
if (kind === "task-list") {
return `- [ ] ${line.replace(
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
""
)}`;
}
return `- ${line.replace(
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
""
)}`;
});
}
export function setMarkdownHeading(text: string, level: number) {
const prefix = level > 0 ? `${"#".repeat(level)} ` : "";
return mapNonBlankLines(
text,
(line) => `${prefix}${line.replace(/^#{1,6}\s+/u, "")}`
);
}
export function createMarkdownTable(
columnCount: number,
rowCount: number
): MarkdownTextEdit {
const columns = Math.trunc(columnCount);
const rows = Math.trunc(rowCount);
if (columns < 1 || columns > 10 || rows < 2 || rows > 10) {
throw new RangeError("表格尺寸必须为 110 列、210 行");
}
const headerCells = Array.from(
{ length: columns },
(_, index) => ` ${index + 1}`
);
const separatorCells = Array.from(
{ length: columns },
() => "---"
);
const contentRow = `| ${Array.from(
{ length: columns },
() => "内容"
).join(" | ")} |`;
const insert = [
`| ${headerCells.join(" | ")} |`,
`| ${separatorCells.join(" | ")} |`,
...Array.from({ length: rows - 1 }, () => contentRow)
].join("\n");
return {
insert,
selectionFrom: 2,
selectionTo: 5
};
}
function replaceSelections(
view: EditorView,
createEdit: SelectionEditor
) {
const transaction = view.state.changeByRange((range) => {
const selectedText = view.state.sliceDoc(range.from, range.to);
const edit = createEdit(selectedText, range, view.state);
return {
changes: {
from: range.from,
to: range.to,
insert: edit.insert
},
range: EditorSelection.range(
range.from + edit.selectionFrom,
range.from + edit.selectionTo
)
};
});
view.dispatch(transaction);
view.focus();
}
function editSelectedLines(
view: EditorView,
transform: (text: string) => string
) {
const transaction = view.state.changeByRange((range) => {
const startLine = view.state.doc.lineAt(range.from);
const endProbe =
range.to > range.from &&
view.state.doc.lineAt(range.to).from === range.to
? range.to - 1
: range.to;
const endLine = view.state.doc.lineAt(endProbe);
const selectedLines = view.state.sliceDoc(
startLine.from,
endLine.to
);
const insert = transform(selectedLines);
return {
changes: {
from: startLine.from,
to: endLine.to,
insert
},
range: EditorSelection.range(
startLine.from,
startLine.from + insert.length
)
};
});
view.dispatch(transaction);
view.focus();
}
function insertMarkdownBlock(
block: string,
range?: SelectionRange,
state?: EditorState
): MarkdownTextEdit {
const before =
range && state ? state.sliceDoc(0, range.from) : "";
const after = range && state ? state.sliceDoc(range.to) : "";
const leading =
!before || before.endsWith("\n\n")
? ""
: before.endsWith("\n")
? "\n"
: "\n\n";
const trailing =
!after || after.startsWith("\n\n")
? ""
: after.startsWith("\n")
? "\n"
: "\n\n";
const insert = `${leading}${block}${trailing}`;
return {
insert,
selectionFrom: leading.length,
selectionTo: leading.length + block.length
};
}
export function insertMarkdownTable(
view: EditorView,
columnCount: number,
rowCount: number
) {
replaceSelections(view, (_, range, state) => {
const table = createMarkdownTable(columnCount, rowCount);
const block = insertMarkdownBlock(
table.insert,
range,
state
);
return {
insert: block.insert,
selectionFrom: block.selectionFrom + table.selectionFrom,
selectionTo: block.selectionFrom + table.selectionTo
};
});
}
function createInlineCommand(
id: string,
label: string,
toolbarLabel: string,
prefix: string,
suffix = prefix,
placeholder = "文本",
shortcut?: string
): MarkdownEditorCommand {
return {
id,
group: "text",
label,
toolbarLabel,
...(shortcut ? { shortcut } : {}),
execute: ({ view }) =>
replaceSelections(view, (selectedText) =>
wrapMarkdownSelection(
selectedText,
prefix,
suffix,
placeholder
)
)
};
}
function createLineCommand(
id: string,
label: string,
toolbarLabel: string,
kind: MarkdownLinePrefix,
shortcut?: string
): MarkdownEditorCommand {
return {
id,
group: "block",
label,
toolbarLabel,
...(shortcut ? { shortcut } : {}),
execute: ({ view }) =>
editSelectedLines(view, (text) =>
toggleMarkdownLinePrefix(text, kind)
)
};
}
const historyCommands: MarkdownEditorCommand[] = [
{
id: "undo",
group: "history",
label: "撤销",
toolbarLabel: "↶",
shortcut: "Mod-z",
isEnabled: ({ view }) => undoDepth(view.state) > 0,
execute: ({ view }) => {
undo(view);
view.focus();
}
},
{
id: "redo",
group: "history",
label: "重做",
toolbarLabel: "↷",
shortcut: "Mod-Shift-z",
isEnabled: ({ view }) => redoDepth(view.state) > 0,
execute: ({ view }) => {
redo(view);
view.focus();
}
}
];
const textCommands: MarkdownEditorCommand[] = [
createInlineCommand(
"bold",
"加粗",
"B",
"**",
"**",
"粗体文本",
"Mod-b"
),
createInlineCommand(
"italic",
"斜体",
"I",
"*",
"*",
"斜体文本",
"Mod-i"
),
createInlineCommand(
"strikethrough",
"删除线",
"S",
"~~",
"~~",
"删除线文本",
"Mod-Shift-x"
),
createInlineCommand(
"inline-code",
"行内代码",
"</>",
"`",
"`",
"code"
)
];
const blockCommands: MarkdownEditorCommand[] = [
{
id: "code-block",
group: "block",
label: "代码块",
toolbarLabel: "代码块",
execute: ({ view }) =>
replaceSelections(view, (selectedText) =>
wrapMarkdownSelection(
selectedText,
"```\n",
"\n```",
"code"
)
)
},
createLineCommand("quote", "引用", "❞", "quote"),
createLineCommand(
"unordered-list",
"无序列表",
"• 列表",
"unordered-list",
"Mod-Shift-8"
),
createLineCommand(
"ordered-list",
"有序列表",
"1. 列表",
"ordered-list",
"Mod-Shift-7"
),
createLineCommand("task-list", "任务列表", "☑", "task-list")
];
const insertCommands: MarkdownEditorCommand[] = [
{
id: "table",
group: "insert",
label: "插入表格",
toolbarLabel: "表格",
execute: ({ view, openPanel }) => {
if (openPanel) {
openPanel("table");
return;
}
insertMarkdownTable(view, 3, 3);
}
},
{
id: "horizontal-rule",
group: "insert",
label: "插入分隔线",
toolbarLabel: "—",
execute: ({ view }) =>
replaceSelections(view, (_, range, state) =>
insertMarkdownBlock("---", range, state)
)
}
];
const headingCommands = Array.from(
{ length: 6 },
(_, index): MarkdownEditorCommand => {
const level = index + 1;
return {
id: `heading-${level}`,
group: "block",
label: `${level} `,
toolbarLabel: `H${level}`,
execute: ({ view }) =>
editSelectedLines(view, (text) =>
setMarkdownHeading(text, level)
)
};
}
);
export const markdownEditorCommands: readonly MarkdownEditorCommand[] =
[
...historyCommands,
...headingCommands,
...textCommands,
...blockCommands,
...insertCommands
];
const markdownEditorCommandMap = new Map(
markdownEditorCommands.map((command) => [command.id, command])
);
export function getMarkdownEditorCommand(commandId: string) {
return markdownEditorCommandMap.get(commandId);
}
export function executeMarkdownEditorCommand(
commandId: string,
context: EditorCommandContext
) {
const command = getMarkdownEditorCommand(commandId);
if (
!command ||
(command.isEnabled && !command.isEnabled(context))
) {
return false;
}
void command.execute(context);
return true;
}
+123 -13
View File
@@ -551,24 +551,134 @@ h1 {
border: 0;
}
textarea {
.markdown-editor {
flex: 1;
width: 100%;
min-height: 0;
padding: 24px;
border: 0;
outline: 0;
resize: none;
background: #f6f7f5;
color: #26312d;
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-size: 0.86rem;
line-height: 1.72;
tab-size: 2;
overflow: hidden;
}
textarea:focus {
box-shadow: inset 3px 0 #74a88f;
.markdown-editor > div,
.markdown-editor .cm-editor {
height: 100%;
}
.markdown-toolbar {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 6px 10px;
border-bottom: 1px solid #d9dedb;
background: #f8faf9;
}
.markdown-toolbar-group {
display: flex;
align-items: center;
gap: 3px;
padding-left: 6px;
border-left: 1px solid #d9dedb;
}
.markdown-toolbar-button {
min-width: 32px;
min-height: 30px;
padding: 0 7px;
border-color: transparent;
border-radius: 6px;
background: transparent;
color: #41584e;
font-size: 0.72rem;
white-space: nowrap;
}
.markdown-toolbar-button:hover:not(:disabled) {
border-color: #c3d1ca;
background: #e8efeb;
}
.markdown-toolbar-button.command-bold {
font-weight: 900;
}
.markdown-toolbar-button.command-italic {
font-style: italic;
}
.markdown-toolbar-button.command-strikethrough {
text-decoration: line-through;
}
.markdown-toolbar-heading select {
height: 30px;
padding: 0 24px 0 8px;
border: 1px solid #c9d4ce;
border-radius: 6px;
background: #fff;
color: #41584e;
font-size: 0.72rem;
}
.markdown-table-tool {
position: relative;
}
.markdown-table-popover {
position: absolute;
z-index: 30;
top: calc(100% + 8px);
left: 0;
display: flex;
width: 250px;
padding: 12px;
border: 1px solid #bfcac4;
border-radius: 9px;
background: #fff;
box-shadow: 0 12px 30px rgb(40 58 49 / 18%);
flex-direction: column;
gap: 4px;
}
.markdown-table-popover strong {
color: #2f493e;
font-size: 0.8rem;
}
.markdown-table-popover > span {
color: #718078;
font-size: 0.68rem;
}
.markdown-table-grid {
display: grid;
margin-top: 6px;
grid-template-columns: repeat(10, 18px);
gap: 4px;
}
.markdown-table-cell {
width: 18px;
min-width: 18px;
height: 18px;
min-height: 18px;
padding: 0;
border: 1px solid #cbd4cf;
border-radius: 3px;
background: #f4f7f5;
}
.markdown-table-cell.is-selected {
border-color: #65957d;
background: #a9cbb9;
}
.markdown-table-cell:focus-visible {
outline: 2px solid #356c55;
outline-offset: 1px;
}
.preview-panel {
@@ -1190,7 +1300,7 @@ textarea:focus {
min-height: 650px;
}
textarea {
.markdown-editor {
min-height: 560px;
}
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
createMarkdownTable,
setMarkdownHeading,
toggleMarkdownLinePrefix,
wrapMarkdownSelection
} from "../src/markdown-editor-commands";
describe("Markdown 编辑器命令", () => {
it("包装、取消包装文本并保持内容选区", () => {
expect(wrapMarkdownSelection("正文", "**")).toEqual({
insert: "**正文**",
selectionFrom: 2,
selectionTo: 4
});
expect(wrapMarkdownSelection("**正文**", "**")).toEqual({
insert: "正文",
selectionFrom: 0,
selectionTo: 2
});
expect(
wrapMarkdownSelection("", "`", "`", "code")
).toEqual({
insert: "`code`",
selectionFrom: 1,
selectionTo: 5
});
});
it("切换引用和无序列表前缀", () => {
expect(toggleMarkdownLinePrefix("甲\n乙", "quote")).toBe(
"> 甲\n> 乙"
);
expect(
toggleMarkdownLinePrefix("> 甲\n> 乙", "quote")
).toBe("甲\n乙");
expect(
toggleMarkdownLinePrefix("1. 甲\n2. 乙", "unordered-list")
).toBe("- 甲\n- 乙");
});
it("为选中的多行生成连续有序列表并保留空行", () => {
expect(
toggleMarkdownLinePrefix(
"第一项\n\n第二项\n第三项",
"ordered-list"
)
).toBe("1. 第一项\n\n2. 第二项\n3. 第三项");
});
it("为选中的多行生成无序列表和任务列表", () => {
expect(
toggleMarkdownLinePrefix(
"第一项\n第二项\n第三项",
"unordered-list"
)
).toBe("- 第一项\n- 第二项\n- 第三项");
expect(
toggleMarkdownLinePrefix(
"第一项\n第二项\n第三项",
"task-list"
)
).toBe("- [ ] 第一项\n- [ ] 第二项\n- [ ] 第三项");
});
it("设置标题时替换现有标题级别", () => {
expect(setMarkdownHeading("## 标题\n正文", 3)).toBe(
"### 标题\n### 正文"
);
expect(setMarkdownHeading("### 标题", 1)).toBe("# 标题");
});
it("生成指定列数和总行数的 Markdown 表格", () => {
expect(createMarkdownTable(3, 4)).toEqual({
insert:
"| 列 1 | 列 2 | 列 3 |\n" +
"| --- | --- | --- |\n" +
"| 内容 | 内容 | 内容 |\n" +
"| 内容 | 内容 | 内容 |\n" +
"| 内容 | 内容 | 内容 |",
selectionFrom: 2,
selectionTo: 5
});
expect(() => createMarkdownTable(0, 2)).toThrow(RangeError);
expect(() => createMarkdownTable(3, 11)).toThrow(RangeError);
});
});