fix(editor): align CodeMirror markdown event handling

- intercept file paste and drop before CodeMirror inserts file text
- reconfigure localized placeholders without remounting the editor
- add regression coverage and document the CodeMirror review
This commit is contained in:
boojack
2026-07-10 18:59:33 +08:00
parent a9ac008a68
commit 42ad4105c1
9 changed files with 139 additions and 68 deletions
@@ -1,7 +1,7 @@
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { indentUnit } from "@codemirror/language";
import type { Extension } from "@codemirror/state";
import { Compartment, type Extension } from "@codemirror/state";
import { placeholder as cmPlaceholder, drawSelection, dropCursor, EditorView, type KeyBinding, keymap } from "@codemirror/view";
import { GFM } from "@lezer/markdown";
import { headingDecorations } from "./headingDecorations";
@@ -30,12 +30,33 @@ const editorKeys: KeyBinding[] = [
export interface EditorExtensionsOptions {
placeholder: string;
onChange: (markdown: string) => void;
onFiles: (files: File[]) => void;
onUpdate: () => void;
onSubmit: () => void;
getTags: () => string[];
}
export function buildEditorExtensions({ placeholder, onChange, onUpdate, onSubmit, getTags }: EditorExtensionsOptions): Extension[] {
export const placeholderCompartment = new Compartment();
function clipboardFiles(event: ClipboardEvent): File[] {
const clipboard = event.clipboardData;
if (!clipboard) return [];
const itemFiles = Array.from(clipboard.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
return itemFiles.length > 0 ? itemFiles : Array.from(clipboard.files);
}
export function buildEditorExtensions({
placeholder,
onChange,
onFiles,
onUpdate,
onSubmit,
getTags,
}: EditorExtensionsOptions): Extension[] {
// Submitting must outrank defaultKeymap's own Mod-Enter (insertBlankLine): the save
// shortcut ends the memo, it must not also edit the document. Meta and Ctrl are bound
// explicitly (not via the platform-dependent Mod-) so Cmd+Enter and Ctrl+Enter both
@@ -60,7 +81,21 @@ export function buildEditorExtensions({ placeholder, onChange, onUpdate, onSubmi
markdown({ extensions: [GFM] }),
...memoEditorTheme,
EditorView.lineWrapping,
cmPlaceholder(placeholder),
placeholderCompartment.of(cmPlaceholder(placeholder)),
EditorView.domEventHandlers({
paste: (event) => {
const files = clipboardFiles(event);
if (files.length === 0) return false;
onFiles(files);
return true;
},
drop: (event) => {
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length === 0) return false;
onFiles(files);
return true;
},
}),
tagMentionDecorations,
headingDecorations,
// tagAutocomplete must precede the editing keymap so the completion popup's
+15 -5
View File
@@ -1,12 +1,12 @@
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { placeholder as cmPlaceholder, EditorView } from "@codemirror/view";
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react";
import { useTagCounts } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import type { EditorController } from "../types/editorController";
import { createController } from "./controller";
import "./editor.css";
import { buildEditorExtensions } from "./extensions";
import { buildEditorExtensions, placeholderCompartment } from "./extensions";
import { createFormattingController } from "./formatting";
interface EditorProps {
@@ -14,21 +14,24 @@ interface EditorProps {
initialContent: string;
placeholder: string;
onContentChange: (content: string) => void;
onPaste: (event: React.ClipboardEvent) => void;
onFiles: (files: File[]) => void;
/** Invoked by the in-editor save shortcut (Cmd/Ctrl+Enter). */
onSubmit: () => void;
isFocusMode?: boolean;
}
const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorController>) {
const { className, initialContent, placeholder, onContentChange, onPaste, onSubmit, isFocusMode } = props;
const { className, initialContent, placeholder, onContentChange, onFiles, onSubmit, isFocusMode } = props;
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const controllerRef = useRef<EditorController | null>(null);
const onChangeRef = useRef(onContentChange);
onChangeRef.current = onContentChange;
const onFilesRef = useRef(onFiles);
onFilesRef.current = onFiles;
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
const placeholderRef = useRef(placeholder);
const listenersRef = useRef(new Set<() => void>());
const { data: tagData } = useTagCounts();
const tags = useMemo(() => Object.keys(tagData ?? {}), [tagData]);
@@ -46,6 +49,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
extensions: buildEditorExtensions({
placeholder,
onChange: (md) => onChangeRef.current(md),
onFiles: (files) => onFilesRef.current(files),
onUpdate: () => listenersRef.current.forEach((l) => l()),
onSubmit: () => onSubmitRef.current(),
getTags: () => tagsRef.current,
@@ -64,6 +68,12 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useLayoutEffect(() => {
if (placeholderRef.current === placeholder) return;
placeholderRef.current = placeholder;
viewRef.current?.dispatch({ effects: placeholderCompartment.reconfigure(cmPlaceholder(placeholder)) });
}, [placeholder]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
@@ -80,7 +90,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
className={cn("relative flex w-full flex-col items-start justify-start bg-inherit", isFocusMode && "min-h-0 flex-1", className)}
data-focus-mode={isFocusMode || undefined}
>
<div ref={hostRef} className={cn("w-full text-base", isFocusMode && "min-h-0 flex-1")} onPaste={onPaste} />
<div ref={hostRef} className={cn("w-full text-base", isFocusMode && "min-h-0 flex-1")} />
</div>
);
});
+1 -1
View File
@@ -75,7 +75,7 @@ Uses `useReducer` + Context for predictable state transitions. All state changes
### Editor extensions
`Editor/extensions.ts` exports `buildEditorExtensions()`, which composes the CodeMirror extension set: `@codemirror/lang-markdown` (with GFM), line wrapping, a placeholder, the editor theme, the `#tag`/`@mention` decoration plugin, the `#tag` autocomplete, and an update listener that pushes document changes back to the reducer via `onChange`.
`Editor/extensions.ts` exports `buildEditorExtensions()`, which composes the CodeMirror extension set: `@codemirror/lang-markdown` (with GFM), line wrapping, a reconfigurable placeholder, the editor theme, the `#tag`/`@mention` decoration plugin, the `#tag` autocomplete, and an update listener that pushes document changes back to the reducer via `onChange`. Native CodeMirror paste/drop handlers intercept file payloads before its text insertion behavior and pass them to the attachment layer; ordinary markdown text paste/drop remains CodeMirror-owned.
`Editor/theme.ts` defines the decorated-source look: a `HighlightStyle` over the Lezer markdown highlight tags (headings, strong, emphasis, code, links, quotes, markers) and an `EditorView.theme`. Colors come from CSS custom properties so light/dark themes just work. This is the editor's own styling — the read-only memo view styles itself separately via `@/lib/markdownStyles`.
@@ -1,6 +1,6 @@
import { forwardRef } from "react";
import Editor from "../Editor";
import { useBlobUrls, useDragAndDrop } from "../hooks";
import { useBlobUrls } from "../hooks";
import { useEditorContext, useEditorSelector } from "../state";
import type { EditorContentProps } from "../types";
import type { LocalFile } from "../types/attachment";
@@ -22,47 +22,21 @@ export const EditorContent = forwardRef<EditorController, EditorContentProps>(({
const content = useEditorSelector((s) => s.content);
const isFocusMode = useEditorSelector((s) => s.ui.isFocusMode);
const { dragHandlers } = useDragAndDrop((files: FileList) => {
const localFiles: LocalFile[] = Array.from(files).map((file) => ({
file,
previewUrl: createBlobUrl(file),
origin: "upload",
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
});
const handleContentChange = (content: string) => {
dispatch(actions.updateContent(content));
};
const handlePaste = (event: React.ClipboardEvent<Element>) => {
const clipboard = event.clipboardData;
if (!clipboard) return;
const files: File[] = [];
if (clipboard.items && clipboard.items.length > 0) {
for (const item of Array.from(clipboard.items)) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (file) files.push(file);
}
} else if (clipboard.files && clipboard.files.length > 0) {
files.push(...Array.from(clipboard.files));
}
if (files.length === 0) return;
const handleFiles = (files: File[]) => {
const localFiles: LocalFile[] = files.map((file) => ({
file,
previewUrl: createBlobUrl(file),
origin: "upload",
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
event.preventDefault();
};
const handleContentChange = (content: string) => {
dispatch(actions.updateContent(content));
};
return (
<div className="w-full flex flex-col flex-1" {...dragHandlers}>
<div className="w-full flex flex-col flex-1">
<Editor
ref={ref}
className="memo-editor-content"
@@ -70,7 +44,7 @@ export const EditorContent = forwardRef<EditorController, EditorContentProps>(({
placeholder={placeholder || ""}
isFocusMode={isFocusMode}
onContentChange={handleContentChange}
onPaste={handlePaste}
onFiles={handleFiles}
onSubmit={onSubmit}
/>
</div>
@@ -4,7 +4,6 @@ export { useAudioRecorder } from "./useAudioRecorder";
export { useAudioWaveform } from "./useAudioWaveform";
export { useAutoSave } from "./useAutoSave";
export { useBlobUrls } from "./useBlobUrls";
export { useDragAndDrop } from "./useDragAndDrop";
export { useEditorActiveState } from "./useEditorActiveState";
export { COMPACT_TOOLBAR_WIDTH, isCompactWidth, useElementWidth } from "./useElementWidth";
export { useFileUpload } from "./useFileUpload";
@@ -1,21 +0,0 @@
export function useDragAndDrop(onDrop: (files: FileList) => void) {
return {
dragHandlers: {
onDragOver: (e: React.DragEvent) => {
if (e.dataTransfer?.types.includes("Files")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
},
onDragLeave: (e: React.DragEvent) => {
e.preventDefault();
},
onDrop: (e: React.DragEvent) => {
if (e.dataTransfer?.files.length) {
e.preventDefault();
onDrop(e.dataTransfer.files);
}
},
},
};
}
+8 -1
View File
@@ -10,7 +10,14 @@ function makeView(doc: string, onSubmit: () => void = () => {}) {
return new EditorView({
state: EditorState.create({
doc,
extensions: buildEditorExtensions({ placeholder: "", onChange: () => {}, onUpdate: () => {}, onSubmit, getTags: () => [] }),
extensions: buildEditorExtensions({
placeholder: "",
onChange: () => {},
onFiles: () => {},
onUpdate: () => {},
onSubmit,
getTags: () => [],
}),
}),
parent: document.body,
});
+32 -2
View File
@@ -18,7 +18,8 @@ describe("Editor", () => {
initialContent={"# Title\n\n- a\n 1. b"}
placeholder="memo"
onContentChange={vi.fn()}
onPaste={vi.fn()}
onFiles={vi.fn()}
onSubmit={vi.fn()}
/>,
);
expect(ref.current?.getMarkdown()).toBe("# Title\n\n- a\n 1. b");
@@ -27,8 +28,37 @@ describe("Editor", () => {
it("emits changes through onContentChange", () => {
const ref = createRef<EditorController>();
const onChange = vi.fn();
render(<Editor ref={ref} className="x" initialContent="" placeholder="memo" onContentChange={onChange} onPaste={vi.fn()} />);
render(
<Editor
ref={ref}
className="x"
initialContent=""
placeholder="memo"
onContentChange={onChange}
onFiles={vi.fn()}
onSubmit={vi.fn()}
/>,
);
ref.current?.setMarkdown("hello");
expect(onChange).toHaveBeenCalledWith("hello");
});
it("reconfigures the placeholder when its translation changes", () => {
const props = {
className: "x",
initialContent: "",
onContentChange: vi.fn(),
onFiles: vi.fn(),
onSubmit: vi.fn(),
};
const { container, rerender } = render(<Editor {...props} placeholder="Any thoughts?" />);
expect(container.querySelector(".cm-content")).toHaveAttribute("aria-placeholder", "Any thoughts?");
expect(container.querySelector(".cm-placeholder")).toHaveTextContent("Any thoughts?");
rerender(<Editor {...props} placeholder="有什么想法?" />);
expect(container.querySelector(".cm-content")).toHaveAttribute("aria-placeholder", "有什么想法?");
expect(container.querySelector(".cm-placeholder")).toHaveTextContent("有什么想法?");
});
});
+37
View File
@@ -20,6 +20,7 @@ describe("MemoEditor CodeMirror extensions", () => {
extensions: buildEditorExtensions({
placeholder: "Any thoughts...",
onChange: vi.fn(),
onFiles: vi.fn(),
onUpdate: vi.fn(),
onSubmit: vi.fn(),
getTags: () => [],
@@ -34,4 +35,40 @@ describe("MemoEditor CodeMirror extensions", () => {
expect(view.contentDOM).toHaveAttribute("aria-placeholder", "Any thoughts...");
expect(view.dom.querySelector(".cm-placeholder")).toHaveTextContent("Any thoughts...");
});
it.each([
["paste", "clipboardData"],
["drop", "dataTransfer"],
] as const)("intercepts files on %s before CodeMirror inserts their contents", (eventType, transferProperty) => {
const onFiles = vi.fn();
const parent = document.body.appendChild(document.createElement("div"));
const view = new EditorView({
state: EditorState.create({
doc: "memo",
extensions: buildEditorExtensions({
placeholder: "",
onChange: vi.fn(),
onFiles,
onUpdate: vi.fn(),
onSubmit: vi.fn(),
getTags: () => [],
}),
}),
parent,
});
views.push(view);
const file = new File(["must not become memo content"], "attachment.txt", { type: "text/plain" });
const transfer =
eventType === "paste"
? { items: [{ kind: "file", getAsFile: () => file }], files: [file] }
: { files: [file], types: ["Files"] };
const event = new Event(eventType, { bubbles: true, cancelable: true });
Object.defineProperty(event, transferProperty, { value: transfer });
view.contentDOM.dispatchEvent(event);
expect(onFiles).toHaveBeenCalledWith([file]);
expect(event.defaultPrevented).toBe(true);
expect(view.state.doc.toString()).toBe("memo");
});
});