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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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("有什么想法?");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user