From 42ad4105c134d255da8359f4019c86bd84368553 Mon Sep 17 00:00:00 2001 From: boojack Date: Fri, 10 Jul 2026 18:59:33 +0800 Subject: [PATCH] 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 --- .../MemoEditor/Editor/extensions.ts | 41 ++++++++++++++++-- .../components/MemoEditor/Editor/index.tsx | 20 ++++++--- web/src/components/MemoEditor/README.md | 2 +- .../MemoEditor/components/EditorContent.tsx | 42 ++++--------------- web/src/components/MemoEditor/hooks/index.ts | 1 - .../MemoEditor/hooks/useDragAndDrop.ts | 21 ---------- web/tests/editor-keys.test.ts | 9 +++- web/tests/editor.test.tsx | 34 ++++++++++++++- web/tests/memo-editor-extensions.test.ts | 37 ++++++++++++++++ 9 files changed, 139 insertions(+), 68 deletions(-) delete mode 100644 web/src/components/MemoEditor/hooks/useDragAndDrop.ts diff --git a/web/src/components/MemoEditor/Editor/extensions.ts b/web/src/components/MemoEditor/Editor/extensions.ts index 514cf04a..4d9c4280 100644 --- a/web/src/components/MemoEditor/Editor/extensions.ts +++ b/web/src/components/MemoEditor/Editor/extensions.ts @@ -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 diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index d560dfd2..132fa84a 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -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) { - const { className, initialContent, placeholder, onContentChange, onPaste, onSubmit, isFocusMode } = props; + const { className, initialContent, placeholder, onContentChange, onFiles, onSubmit, isFocusMode } = props; const hostRef = useRef(null); const viewRef = useRef(null); const controllerRef = useRef(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} > -
+
); }); diff --git a/web/src/components/MemoEditor/README.md b/web/src/components/MemoEditor/README.md index 64fce3ae..a50de19e 100644 --- a/web/src/components/MemoEditor/README.md +++ b/web/src/components/MemoEditor/README.md @@ -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`. diff --git a/web/src/components/MemoEditor/components/EditorContent.tsx b/web/src/components/MemoEditor/components/EditorContent.tsx index edf40545..f87e71a3 100644 --- a/web/src/components/MemoEditor/components/EditorContent.tsx +++ b/web/src/components/MemoEditor/components/EditorContent.tsx @@ -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(({ 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) => { - 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 ( -
+
(({ placeholder={placeholder || ""} isFocusMode={isFocusMode} onContentChange={handleContentChange} - onPaste={handlePaste} + onFiles={handleFiles} onSubmit={onSubmit} />
diff --git a/web/src/components/MemoEditor/hooks/index.ts b/web/src/components/MemoEditor/hooks/index.ts index 0f89c275..5965829f 100644 --- a/web/src/components/MemoEditor/hooks/index.ts +++ b/web/src/components/MemoEditor/hooks/index.ts @@ -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"; diff --git a/web/src/components/MemoEditor/hooks/useDragAndDrop.ts b/web/src/components/MemoEditor/hooks/useDragAndDrop.ts deleted file mode 100644 index eb8edb12..00000000 --- a/web/src/components/MemoEditor/hooks/useDragAndDrop.ts +++ /dev/null @@ -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); - } - }, - }, - }; -} diff --git a/web/tests/editor-keys.test.ts b/web/tests/editor-keys.test.ts index e9d325ff..a5b99620 100644 --- a/web/tests/editor-keys.test.ts +++ b/web/tests/editor-keys.test.ts @@ -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, }); diff --git a/web/tests/editor.test.tsx b/web/tests/editor.test.tsx index ba456919..47f44cde 100644 --- a/web/tests/editor.test.tsx +++ b/web/tests/editor.test.tsx @@ -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(); const onChange = vi.fn(); - render(); + render( + , + ); 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(); + + expect(container.querySelector(".cm-content")).toHaveAttribute("aria-placeholder", "Any thoughts?"); + expect(container.querySelector(".cm-placeholder")).toHaveTextContent("Any thoughts?"); + + rerender(); + + expect(container.querySelector(".cm-content")).toHaveAttribute("aria-placeholder", "有什么想法?"); + expect(container.querySelector(".cm-placeholder")).toHaveTextContent("有什么想法?"); + }); }); diff --git a/web/tests/memo-editor-extensions.test.ts b/web/tests/memo-editor-extensions.test.ts index 2ea27d44..d139d946 100644 --- a/web/tests/memo-editor-extensions.test.ts +++ b/web/tests/memo-editor-extensions.test.ts @@ -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"); + }); });