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:
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 { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||||
import { markdown } from "@codemirror/lang-markdown";
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
import { indentUnit } from "@codemirror/language";
|
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 { placeholder as cmPlaceholder, drawSelection, dropCursor, EditorView, type KeyBinding, keymap } from "@codemirror/view";
|
||||||
import { GFM } from "@lezer/markdown";
|
import { GFM } from "@lezer/markdown";
|
||||||
import { headingDecorations } from "./headingDecorations";
|
import { headingDecorations } from "./headingDecorations";
|
||||||
|
|
@ -30,12 +30,33 @@ const editorKeys: KeyBinding[] = [
|
||||||
export interface EditorExtensionsOptions {
|
export interface EditorExtensionsOptions {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
onChange: (markdown: string) => void;
|
onChange: (markdown: string) => void;
|
||||||
|
onFiles: (files: File[]) => void;
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
getTags: () => string[];
|
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
|
// 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
|
// 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
|
// 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] }),
|
markdown({ extensions: [GFM] }),
|
||||||
...memoEditorTheme,
|
...memoEditorTheme,
|
||||||
EditorView.lineWrapping,
|
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,
|
tagMentionDecorations,
|
||||||
headingDecorations,
|
headingDecorations,
|
||||||
// tagAutocomplete must precede the editing keymap so the completion popup's
|
// tagAutocomplete must precede the editing keymap so the completion popup's
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { EditorState } from "@codemirror/state";
|
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 { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react";
|
||||||
import { useTagCounts } from "@/hooks/useUserQueries";
|
import { useTagCounts } from "@/hooks/useUserQueries";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { EditorController } from "../types/editorController";
|
import type { EditorController } from "../types/editorController";
|
||||||
import { createController } from "./controller";
|
import { createController } from "./controller";
|
||||||
import "./editor.css";
|
import "./editor.css";
|
||||||
import { buildEditorExtensions } from "./extensions";
|
import { buildEditorExtensions, placeholderCompartment } from "./extensions";
|
||||||
import { createFormattingController } from "./formatting";
|
import { createFormattingController } from "./formatting";
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
|
|
@ -14,21 +14,24 @@ interface EditorProps {
|
||||||
initialContent: string;
|
initialContent: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
onContentChange: (content: string) => void;
|
onContentChange: (content: string) => void;
|
||||||
onPaste: (event: React.ClipboardEvent) => void;
|
onFiles: (files: File[]) => void;
|
||||||
/** Invoked by the in-editor save shortcut (Cmd/Ctrl+Enter). */
|
/** Invoked by the in-editor save shortcut (Cmd/Ctrl+Enter). */
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
isFocusMode?: boolean;
|
isFocusMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorController>) {
|
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 hostRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
const controllerRef = useRef<EditorController | null>(null);
|
const controllerRef = useRef<EditorController | null>(null);
|
||||||
const onChangeRef = useRef(onContentChange);
|
const onChangeRef = useRef(onContentChange);
|
||||||
onChangeRef.current = onContentChange;
|
onChangeRef.current = onContentChange;
|
||||||
|
const onFilesRef = useRef(onFiles);
|
||||||
|
onFilesRef.current = onFiles;
|
||||||
const onSubmitRef = useRef(onSubmit);
|
const onSubmitRef = useRef(onSubmit);
|
||||||
onSubmitRef.current = onSubmit;
|
onSubmitRef.current = onSubmit;
|
||||||
|
const placeholderRef = useRef(placeholder);
|
||||||
const listenersRef = useRef(new Set<() => void>());
|
const listenersRef = useRef(new Set<() => void>());
|
||||||
const { data: tagData } = useTagCounts();
|
const { data: tagData } = useTagCounts();
|
||||||
const tags = useMemo(() => Object.keys(tagData ?? {}), [tagData]);
|
const tags = useMemo(() => Object.keys(tagData ?? {}), [tagData]);
|
||||||
|
|
@ -46,6 +49,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
||||||
extensions: buildEditorExtensions({
|
extensions: buildEditorExtensions({
|
||||||
placeholder,
|
placeholder,
|
||||||
onChange: (md) => onChangeRef.current(md),
|
onChange: (md) => onChangeRef.current(md),
|
||||||
|
onFiles: (files) => onFilesRef.current(files),
|
||||||
onUpdate: () => listenersRef.current.forEach((l) => l()),
|
onUpdate: () => listenersRef.current.forEach((l) => l()),
|
||||||
onSubmit: () => onSubmitRef.current(),
|
onSubmit: () => onSubmitRef.current(),
|
||||||
getTags: () => tagsRef.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
|
// 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(() => {
|
useEffect(() => {
|
||||||
const view = viewRef.current;
|
const view = viewRef.current;
|
||||||
if (!view) return;
|
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)}
|
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}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ Uses `useReducer` + Context for predictable state transitions. All state changes
|
||||||
|
|
||||||
### Editor extensions
|
### 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`.
|
`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 { forwardRef } from "react";
|
||||||
import Editor from "../Editor";
|
import Editor from "../Editor";
|
||||||
import { useBlobUrls, useDragAndDrop } from "../hooks";
|
import { useBlobUrls } from "../hooks";
|
||||||
import { useEditorContext, useEditorSelector } from "../state";
|
import { useEditorContext, useEditorSelector } from "../state";
|
||||||
import type { EditorContentProps } from "../types";
|
import type { EditorContentProps } from "../types";
|
||||||
import type { LocalFile } from "../types/attachment";
|
import type { LocalFile } from "../types/attachment";
|
||||||
|
|
@ -22,47 +22,21 @@ export const EditorContent = forwardRef<EditorController, EditorContentProps>(({
|
||||||
const content = useEditorSelector((s) => s.content);
|
const content = useEditorSelector((s) => s.content);
|
||||||
const isFocusMode = useEditorSelector((s) => s.ui.isFocusMode);
|
const isFocusMode = useEditorSelector((s) => s.ui.isFocusMode);
|
||||||
|
|
||||||
const { dragHandlers } = useDragAndDrop((files: FileList) => {
|
const handleFiles = (files: File[]) => {
|
||||||
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 localFiles: LocalFile[] = files.map((file) => ({
|
const localFiles: LocalFile[] = files.map((file) => ({
|
||||||
file,
|
file,
|
||||||
previewUrl: createBlobUrl(file),
|
previewUrl: createBlobUrl(file),
|
||||||
origin: "upload",
|
origin: "upload",
|
||||||
}));
|
}));
|
||||||
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
|
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
|
||||||
event.preventDefault();
|
};
|
||||||
|
|
||||||
|
const handleContentChange = (content: string) => {
|
||||||
|
dispatch(actions.updateContent(content));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col flex-1" {...dragHandlers}>
|
<div className="w-full flex flex-col flex-1">
|
||||||
<Editor
|
<Editor
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="memo-editor-content"
|
className="memo-editor-content"
|
||||||
|
|
@ -70,7 +44,7 @@ export const EditorContent = forwardRef<EditorController, EditorContentProps>(({
|
||||||
placeholder={placeholder || ""}
|
placeholder={placeholder || ""}
|
||||||
isFocusMode={isFocusMode}
|
isFocusMode={isFocusMode}
|
||||||
onContentChange={handleContentChange}
|
onContentChange={handleContentChange}
|
||||||
onPaste={handlePaste}
|
onFiles={handleFiles}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ export { useAudioRecorder } from "./useAudioRecorder";
|
||||||
export { useAudioWaveform } from "./useAudioWaveform";
|
export { useAudioWaveform } from "./useAudioWaveform";
|
||||||
export { useAutoSave } from "./useAutoSave";
|
export { useAutoSave } from "./useAutoSave";
|
||||||
export { useBlobUrls } from "./useBlobUrls";
|
export { useBlobUrls } from "./useBlobUrls";
|
||||||
export { useDragAndDrop } from "./useDragAndDrop";
|
|
||||||
export { useEditorActiveState } from "./useEditorActiveState";
|
export { useEditorActiveState } from "./useEditorActiveState";
|
||||||
export { COMPACT_TOOLBAR_WIDTH, isCompactWidth, useElementWidth } from "./useElementWidth";
|
export { COMPACT_TOOLBAR_WIDTH, isCompactWidth, useElementWidth } from "./useElementWidth";
|
||||||
export { useFileUpload } from "./useFileUpload";
|
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({
|
return new EditorView({
|
||||||
state: EditorState.create({
|
state: EditorState.create({
|
||||||
doc,
|
doc,
|
||||||
extensions: buildEditorExtensions({ placeholder: "", onChange: () => {}, onUpdate: () => {}, onSubmit, getTags: () => [] }),
|
extensions: buildEditorExtensions({
|
||||||
|
placeholder: "",
|
||||||
|
onChange: () => {},
|
||||||
|
onFiles: () => {},
|
||||||
|
onUpdate: () => {},
|
||||||
|
onSubmit,
|
||||||
|
getTags: () => [],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
parent: document.body,
|
parent: document.body,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ describe("Editor", () => {
|
||||||
initialContent={"# Title\n\n- a\n 1. b"}
|
initialContent={"# Title\n\n- a\n 1. b"}
|
||||||
placeholder="memo"
|
placeholder="memo"
|
||||||
onContentChange={vi.fn()}
|
onContentChange={vi.fn()}
|
||||||
onPaste={vi.fn()}
|
onFiles={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(ref.current?.getMarkdown()).toBe("# Title\n\n- a\n 1. b");
|
expect(ref.current?.getMarkdown()).toBe("# Title\n\n- a\n 1. b");
|
||||||
|
|
@ -27,8 +28,37 @@ describe("Editor", () => {
|
||||||
it("emits changes through onContentChange", () => {
|
it("emits changes through onContentChange", () => {
|
||||||
const ref = createRef<EditorController>();
|
const ref = createRef<EditorController>();
|
||||||
const onChange = vi.fn();
|
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");
|
ref.current?.setMarkdown("hello");
|
||||||
expect(onChange).toHaveBeenCalledWith("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({
|
extensions: buildEditorExtensions({
|
||||||
placeholder: "Any thoughts...",
|
placeholder: "Any thoughts...",
|
||||||
onChange: vi.fn(),
|
onChange: vi.fn(),
|
||||||
|
onFiles: vi.fn(),
|
||||||
onUpdate: vi.fn(),
|
onUpdate: vi.fn(),
|
||||||
onSubmit: vi.fn(),
|
onSubmit: vi.fn(),
|
||||||
getTags: () => [],
|
getTags: () => [],
|
||||||
|
|
@ -34,4 +35,40 @@ describe("MemoEditor CodeMirror extensions", () => {
|
||||||
expect(view.contentDOM).toHaveAttribute("aria-placeholder", "Any thoughts...");
|
expect(view.contentDOM).toHaveAttribute("aria-placeholder", "Any thoughts...");
|
||||||
expect(view.dom.querySelector(".cm-placeholder")).toHaveTextContent("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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue