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
+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>
);
}