diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 3df0a75..3eefec4 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -1,9 +1,17 @@ -import { Annotation, EditorSelection, EditorState } from "@codemirror/state"; +import { + Annotation, + EditorSelection, + EditorState, + StateEffect, + StateField, + type ChangeDesc, +} from "@codemirror/state"; // Annotation attached to dispatches that load content programmatically (e.g. // setDocument from file open) so updateListener can skip the user-edit path — // no onChange emission, no AST reparse for the onChange pipeline. const silentDocChange = Annotation.define(); + import { EditorView, keymap, dropCursor, lineNumbers, type Direction } from "@codemirror/view"; import { indentWithTab, undo as cmUndo, redo as cmRedo } from "@codemirror/commands"; import { closeBrackets } from "@codemirror/autocomplete"; @@ -42,6 +50,79 @@ import type { } from "./types"; import { createWidgetExtension } from "./widget-extension"; +interface PendingAssetRange { + from: number; + to: number; + touched: boolean; +} + +interface PendingAssetInsertion { + id: number; + ranges: readonly PendingAssetRange[]; + mainIndex: number; +} + +function pendingAssetRangeTouched(range: PendingAssetRange, changes: ChangeDesc): boolean { + let touched = range.touched; + changes.iterChangedRanges((from, to) => { + if (from === to) { + touched ||= from > range.from && from < range.to; + } else { + touched ||= from < range.to && to > range.from; + } + }, true); + return touched; +} + +const addPendingAssetInsertion = StateEffect.define(); +const removePendingAssetInsertion = StateEffect.define(); +const clearPendingAssetInsertions = StateEffect.define(); + +const pendingAssetInsertions = StateField.define>({ + create: () => new Map(), + update(entries, transaction) { + let next = entries; + + if (transaction.docChanged && entries.size > 0) { + const mapped = new Map(); + for (const entry of entries.values()) { + const ranges = entry.ranges.map((range): PendingAssetRange => { + if (range.from === range.to) { + const position = transaction.changes.mapPos(range.from, -1); + return { from: position, to: position, touched: range.touched }; + } + + const from = transaction.changes.mapPos(range.from, 1); + const to = transaction.changes.mapPos(range.to, -1); + return { + from, + to: Math.max(from, to), + touched: pendingAssetRangeTouched(range, transaction.changes), + }; + }); + mapped.set(entry.id, { ...entry, ranges }); + } + next = mapped; + } + + for (const effect of transaction.effects) { + if (effect.is(clearPendingAssetInsertions)) { + next = new Map(); + } else if (effect.is(addPendingAssetInsertion)) { + const updated = new Map(next); + updated.set(effect.value.id, effect.value); + next = updated; + } else if (effect.is(removePendingAssetInsertion)) { + const updated = new Map(next); + updated.delete(effect.value); + next = updated; + } + } + + return next; + }, +}); + const FLOATBOAT_MARKDOWN_DEBUG_STORAGE_KEY = "floatboat:markdown-debug"; const COMPOSITION_FLUSH_DELAY_MS = 60; @@ -287,6 +368,7 @@ export function createEditor(config: EditorConfig): EditorAPI { let parseTimer: ReturnType | undefined; let compositionFlushTimer: ReturnType | undefined; let pendingCompositionMarkdown: string | null = null; + let nextAssetInsertionId = 0; // 组合输入(IME)状态与被推迟的文档回灌。组合输入进行中调用 setDocument 会被 // 推迟到 compositionend 再应用,避免整文档替换打断输入法、丢失合成中文字、视口跳顶。 let composing = false; @@ -430,6 +512,7 @@ export function createEditor(config: EditorConfig): EditorAPI { to: view.state.doc.length, insert: next }, + effects: clearPendingAssetInsertions.of(null), annotations: silent ? silentDocChange.of(true) : undefined, ...(selection ? { selection } : {}), }; @@ -478,23 +561,81 @@ export function createEditor(config: EditorConfig): EditorAPI { } // 默认资源兜底:粘贴 / 拖拽进来的图片或文件依次走宿主上传管线并插入 markdown 引用。 - function insertUploadedAssets(files: File[]): void { + function insertUploadedAssets( + files: File[], + target: { ranges: readonly { from: number; to: number }[]; mainIndex: number } + ): void { const upload = config.onAssetUpload; - if (!upload || destroyed || files.length === 0) return; + if (!upload || config.readOnly || destroyed || files.length === 0) return; + const id = nextAssetInsertionId++; + view.dispatch({ + effects: addPendingAssetInsertion.of({ + id, + mainIndex: target.mainIndex, + ranges: target.ranges.map((range) => ({ ...range, touched: false })), + }), + }); + void (async () => { + const markdownParts: string[] = []; for (const file of files) { - let url: string | null = null; + if (destroyed || !view.state.field(pendingAssetInsertions).has(id)) break; try { - url = await upload(file); + const url = await upload(file); + if (!url) continue; + const isImage = file.type.startsWith("image/"); + const label = file.name || (isImage ? "image" : "file"); + markdownParts.push(isImage ? `![${label}](${url})` : `[${label}](${url})`); } catch { - url = null; + // 单个文件失败不影响同批次中的其它文件。 } - if (!url || destroyed) continue; - const isImage = file.type.startsWith("image/"); - const label = file.name || (isImage ? "image" : "file"); - const markdown = isImage ? `![${label}](${url})` : `[${label}](${url})`; - view.dispatch(view.state.replaceSelection(markdown)); } + + if (destroyed || config.readOnly) return; + const pending = view.state.field(pendingAssetInsertions).get(id); + if (!pending) return; + + const markdown = markdownParts.join(""); + if (!markdown) { + view.dispatch({ effects: removePendingAssetInsertion.of(id) }); + return; + } + + const selection = view.state.selection; + const ownsSelection = + selection.mainIndex === pending.mainIndex && + selection.ranges.length === pending.ranges.length && + pending.ranges.every( + (range, index) => + !range.touched && + selection.ranges[index].from === range.from && + selection.ranges[index].to === range.to + ); + const changes = view.state.changes( + pending.ranges.map((range) => ({ + from: range.from, + to: range.touched ? range.from : range.to, + insert: markdown, + })) + ); + view.dispatch({ + changes, + effects: removePendingAssetInsertion.of(id), + ...(ownsSelection + ? { + selection: EditorSelection.create( + pending.ranges.map((range) => + EditorSelection.cursor( + range.from === range.to + ? changes.mapPos(range.from, 1) + : changes.mapPos(range.to, -1) + ) + ), + pending.mainIndex + ), + } + : {}), + }); })(); } @@ -545,6 +686,7 @@ export function createEditor(config: EditorConfig): EditorAPI { state: EditorState.create({ doc: config.initialValue ?? "", extensions: [ + pendingAssetInsertions, EditorView.domEventHandlers({ focus() { setFocused(true); @@ -679,12 +821,16 @@ export function createEditor(config: EditorConfig): EditorAPI { return true; } // 默认兜底:剪贴板里有图片 / 文件时走资源上传;纯文本粘贴交回 CodeMirror。 - if (!config.onAssetUpload || destroyed) return false; + if (!config.onAssetUpload || config.readOnly || destroyed) return false; const files = collectFilesFromDataTransfer(event.clipboardData); if (files.length === 0) return false; event.preventDefault(); - insertUploadedAssets(files); + const selection = view.state.selection; + insertUploadedAssets(files, { + ranges: selection.ranges.map((range) => ({ from: range.from, to: range.to })), + mainIndex: selection.mainIndex, + }); return true; }, drop(event) { @@ -692,12 +838,19 @@ export function createEditor(config: EditorConfig): EditorAPI { event.preventDefault(); return true; } - if (!config.onAssetUpload || destroyed) return false; + if (!config.onAssetUpload || config.readOnly || destroyed) return false; const files = collectFilesFromDataTransfer(event.dataTransfer); if (files.length === 0) return false; event.preventDefault(); - insertUploadedAssets(files); + let position: number | null = null; + try { + position = view.posAtCoords({ x: event.clientX, y: event.clientY }); + } catch { + position = null; + } + const target = position ?? view.state.selection.main.head; + insertUploadedAssets(files, { ranges: [{ from: target, to: target }], mainIndex: 0 }); return true; }, keydown(event) { @@ -810,6 +963,7 @@ export function createEditor(config: EditorConfig): EditorAPI { // 组合输入(IME)进行中:整文档替换会打断输入法、丢失合成中文字并把视口重置到顶部。 // 推迟到 compositionend 再应用,只保留最后一次请求。 if (composing || view.composing || view.compositionStarted) { + view.dispatch({ effects: clearPendingAssetInsertions.of(null) }); pendingDocumentLoad = { next, opts }; debugNexus("setDocument-deferred-composing", { silent: opts?.silent === true, diff --git a/packages/core/test/editor.test.ts b/packages/core/test/editor.test.ts index 85505eb..21bc125 100644 --- a/packages/core/test/editor.test.ts +++ b/packages/core/test/editor.test.ts @@ -644,8 +644,28 @@ function makePasteEvent(clipboardData: Record): Event { return event; } +function makeDropEvent(dataTransfer: Record, clientX = 0, clientY = 0): Event { + const event = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + dataTransfer: { configurable: true, value: dataTransfer }, + clientX: { configurable: true, value: clientX }, + clientY: { configurable: true, value: clientY }, + }); + return event; +} + const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + describe("createEditor — composition-safe setDocument", () => { it("reports composition state via isComposing()", () => { const container = document.createElement("div"); @@ -868,6 +888,383 @@ describe("createEditor — DOM event hook layer", () => { editor.destroy(); }); + it("keeps a pending asset paste at its mapped anchor without stealing a moved cursor", async () => { + const container = document.createElement("div"); + const upload = createDeferred(); + let capturedView: EditorView | null = null; + const editor = createEditor({ + container, + initialValue: "AB", + onAssetUpload: () => upload.promise, + plugins: [{ name: "capture", cmExtensions: [captureViewPlugin((view) => (capturedView = view))] }], + }); + const view = requireEditorView(capturedView); + const content = container.querySelector("[contenteditable='true']") as HTMLElement; + + editor.setSelection(1); + const file = new File(["img"], "x.png", { type: "image/png" }); + content.dispatchEvent(makePasteEvent({ files: [file], items: [] })); + view.dispatch({ changes: { from: 0, insert: "X" }, selection: { anchor: 3 } }); + + upload.resolve("assets/x.png"); + await flushMicrotasks(); + + expect(editor.getDocument()).toBe("XA![x.png](assets/x.png)B"); + expect(editor.getSelection()).toEqual({ anchor: editor.getDocument().length, head: editor.getDocument().length }); + editor.destroy(); + }); + + it("replaces an untouched selection when its pending asset upload completes", async () => { + const container = document.createElement("div"); + const upload = createDeferred(); + const editor = createEditor({ + container, + initialValue: "abc", + onAssetUpload: () => upload.promise, + }); + const content = container.querySelector("[contenteditable='true']") as HTMLElement; + + editor.setSelection(1, 2); + const file = new File(["img"], "x.png", { type: "image/png" }); + content.dispatchEvent(makePasteEvent({ files: [file], items: [] })); + upload.resolve("assets/x.png"); + await flushMicrotasks(); + + expect(editor.getDocument()).toBe("a![x.png](assets/x.png)c"); + expect(editor.getSelection()).toEqual({ anchor: 23, head: 23 }); + editor.destroy(); + }); + + it("does not overwrite a selection edited while its asset upload is pending", async () => { + const container = document.createElement("div"); + const upload = createDeferred(); + let capturedView: EditorView | null = null; + const editor = createEditor({ + container, + initialValue: "abc", + onAssetUpload: () => upload.promise, + plugins: [{ name: "capture", cmExtensions: [captureViewPlugin((view) => (capturedView = view))] }], + }); + const view = requireEditorView(capturedView); + const content = container.querySelector("[contenteditable='true']") as HTMLElement; + + editor.setSelection(1, 2); + const file = new File(["img"], "x.png", { type: "image/png" }); + content.dispatchEvent(makePasteEvent({ files: [file], items: [] })); + view.dispatch({ changes: { from: 1, to: 2, insert: "X" }, selection: { anchor: 2 } }); + + upload.resolve("assets/x.png"); + await flushMicrotasks(); + + expect(editor.getDocument()).toBe("a![x.png](assets/x.png)Xc"); + editor.destroy(); + }); + + it.each([ + { boundary: "start", position: 1, expected: "aX![x.png](assets/x.png)c" }, + { boundary: "end", position: 2, expected: "a![x.png](assets/x.png)Xc" }, + ])( + "keeps an edit at the selection $boundary boundary outside the pending asset replacement", + async ({ position, expected }) => { + const container = document.createElement("div"); + const upload = createDeferred(); + let capturedView: EditorView | null = null; + const editor = createEditor({ + container, + initialValue: "abc", + onAssetUpload: () => upload.promise, + plugins: [{ name: "capture", cmExtensions: [captureViewPlugin((view) => (capturedView = view))] }], + }); + const view = requireEditorView(capturedView); + const content = container.querySelector("[contenteditable='true']") as HTMLElement; + + editor.setSelection(1, 2); + const file = new File(["img"], "x.png", { type: "image/png" }); + content.dispatchEvent(makePasteEvent({ files: [file], items: [] })); + view.dispatch({ changes: { from: position, insert: "X" }, selection: { anchor: position + 1 } }); + + upload.resolve("assets/x.png"); + await flushMicrotasks(); + + expect(editor.getDocument()).toBe(expected); + editor.destroy(); + } + ); + + it("preserves every initiating selection when a pending asset paste completes", async () => { + const container = document.createElement("div"); + const upload = createDeferred(); + const editor = createEditor({ + container, + initialValue: "AB", + multiCursor: true, + onAssetUpload: () => upload.promise, + }); + const content = container.querySelector("[contenteditable='true']") as HTMLElement; + const markdown = "![x.png](assets/x.png)"; + + editor.setSelections([{ anchor: 0 }, { anchor: 2 }], 1); + const file = new File(["img"], "x.png", { type: "image/png" }); + content.dispatchEvent(makePasteEvent({ files: [file], items: [] })); + upload.resolve("assets/x.png"); + await flushMicrotasks(); + + expect(editor.getDocument()).toBe(`${markdown}AB${markdown}`); + expect(editor.getSelections()).toEqual({ + ranges: [ + { anchor: markdown.length, head: markdown.length }, + { anchor: markdown.length * 2 + 2, head: markdown.length * 2 + 2 }, + ], + mainIndex: 1, + }); + editor.destroy(); + }); + + it("inserts a dropped asset at the pointer-derived document position", async () => { + const container = document.createElement("div"); + const upload = createDeferred(); + let capturedView: EditorView | null = null; + const editor = createEditor({ + container, + initialValue: "AB", + onAssetUpload: () => upload.promise, + plugins: [{ name: "capture", cmExtensions: [captureViewPlugin((view) => (capturedView = view))] }], + }); + const view = requireEditorView(capturedView); + const content = container.querySelector("[contenteditable='true']") as HTMLElement; + const posAtCoords = vi.spyOn(view, "posAtCoords").mockReturnValue(1); + + editor.setSelection(2); + const file = new File(["img"], "drop.png", { type: "image/png" }); + const event = makeDropEvent({ files: [file], items: [] }, 12, 34); + content.dispatchEvent(event); + upload.resolve("assets/drop.png"); + await flushMicrotasks(); + + expect(event.defaultPrevented).toBe(true); + expect(posAtCoords).toHaveBeenCalledWith({ x: 12, y: 34 }); + expect(editor.getDocument()).toBe("A![drop.png](assets/drop.png)B"); + + posAtCoords.mockReturnValue(null); + editor.setSelection(0); + const fallbackFile = new File(["img"], "fallback.png", { type: "image/png" }); + content.dispatchEvent(makeDropEvent({ files: [fallbackFile], items: [] })); + await flushMicrotasks(); + + expect(editor.getDocument()).toBe( + "![fallback.png](assets/drop.png)A![drop.png](assets/drop.png)B" + ); + editor.destroy(); + }); + + it("uploads a file batch serially and commits it as one undoable document change", async () => { + const container = document.createElement("div"); + const firstUpload = createDeferred(); + const secondUpload = createDeferred(); + const firstFile = new File(["one"], "one.png", { type: "image/png" }); + const secondFile = new File(["two"], "two.pdf", { type: "application/pdf" }); + const uploads: File[] = []; + let documentChanges = 0; + const editor = createEditor({ + container, + initialValue: "A", + onAssetUpload(file) { + uploads.push(file); + return file === firstFile ? firstUpload.promise : secondUpload.promise; + }, + plugins: [ + createHistoryPlugin(), + { + name: "count-document-changes", + cmExtensions: [ + EditorView.updateListener.of((update) => { + if (update.docChanged) documentChanges += 1; + }), + ], + }, + ], + }); + const content = container.querySelector("[contenteditable='true']") as HTMLElement; + + editor.setSelection(1); + content.dispatchEvent(makePasteEvent({ files: [firstFile, secondFile], items: [] })); + expect(uploads).toEqual([firstFile]); + + firstUpload.resolve("assets/one.png"); + await flushMicrotasks(); + expect(uploads).toEqual([firstFile, secondFile]); + expect(editor.getDocument()).toBe("A"); + + secondUpload.resolve("assets/two.pdf"); + await flushMicrotasks(); + expect(editor.getDocument()).toBe("A![one.png](assets/one.png)[two.pdf](assets/two.pdf)"); + expect(documentChanges).toBe(1); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("A"); + editor.destroy(); + }); + + it("isolates individual upload failures and skips a batch when every upload fails", async () => { + const partialContainer = document.createElement("div"); + const failedFile = new File(["bad"], "bad.png", { type: "image/png" }); + const goodFile = new File(["good"], "good.pdf", { type: "application/pdf" }); + const partialEditor = createEditor({ + container: partialContainer, + initialValue: "A", + onAssetUpload: (file) => + file === failedFile ? Promise.reject(new Error("upload failed")) : Promise.resolve("assets/good.pdf"), + }); + const partialContent = partialContainer.querySelector("[contenteditable='true']") as HTMLElement; + + partialEditor.setSelection(1); + partialContent.dispatchEvent(makePasteEvent({ files: [failedFile, goodFile], items: [] })); + await flushMicrotasks(); + expect(partialEditor.getDocument()).toBe("A[good.pdf](assets/good.pdf)"); + partialEditor.destroy(); + + const failedContainer = document.createElement("div"); + const failedEditor = createEditor({ + container: failedContainer, + initialValue: "unchanged", + onAssetUpload: () => Promise.reject(new Error("upload failed")), + }); + const failedContent = failedContainer.querySelector("[contenteditable='true']") as HTMLElement; + + failedContent.dispatchEvent(makePasteEvent({ files: [failedFile], items: [] })); + await flushMicrotasks(); + expect(failedEditor.getDocument()).toBe("unchanged"); + failedEditor.destroy(); + }); + + it("maps concurrent asset batches independently when they resolve out of order", async () => { + const container = document.createElement("div"); + const firstUpload = createDeferred(); + const secondUpload = createDeferred(); + const firstFile = new File(["one"], "one.png", { type: "image/png" }); + const secondFile = new File(["two"], "two.png", { type: "image/png" }); + const editor = createEditor({ + container, + initialValue: "AB", + onAssetUpload: (file) => (file === firstFile ? firstUpload.promise : secondUpload.promise), + }); + const content = container.querySelector("[contenteditable='true']") as HTMLElement; + + editor.setSelection(1); + content.dispatchEvent(makePasteEvent({ files: [firstFile], items: [] })); + editor.setSelection(0); + content.dispatchEvent(makePasteEvent({ files: [secondFile], items: [] })); + + secondUpload.resolve("assets/two.png"); + await flushMicrotasks(); + firstUpload.resolve("assets/one.png"); + await flushMicrotasks(); + + expect(editor.getDocument()).toBe( + "![two.png](assets/two.png)A![one.png](assets/one.png)B" + ); + editor.destroy(); + }); + + it("discards a pending asset result after the document is replaced", async () => { + const container = document.createElement("div"); + const upload = createDeferred(); + const editor = createEditor({ + container, + initialValue: "old", + onAssetUpload: () => upload.promise, + }); + const content = container.querySelector("[contenteditable='true']") as HTMLElement; + + content.dispatchEvent( + makePasteEvent({ files: [new File(["img"], "x.png", { type: "image/png" })], items: [] }) + ); + editor.setDocument("new"); + upload.resolve("assets/x.png"); + await flushMicrotasks(); + + expect(editor.getDocument()).toBe("new"); + editor.destroy(); + }); + + it("discards a pending asset result when setDocument is deferred by composition", async () => { + const container = document.createElement("div"); + const upload = createDeferred(); + let capturedView: EditorView | null = null; + const editor = createEditor({ + container, + initialValue: "old", + onAssetUpload: () => upload.promise, + plugins: [{ name: "capture", cmExtensions: [captureViewPlugin((view) => (capturedView = view))] }], + }); + const view = requireEditorView(capturedView); + const content = container.querySelector("[contenteditable='true']") as HTMLElement; + + content.dispatchEvent( + makePasteEvent({ files: [new File(["img"], "x.png", { type: "image/png" })], items: [] }) + ); + view.contentDOM.dispatchEvent(new Event("compositionstart", { bubbles: true })); + editor.setDocument("new"); + upload.resolve("assets/x.png"); + await flushMicrotasks(); + + expect(editor.getDocument()).toBe("old"); + view.contentDOM.dispatchEvent(new Event("compositionend", { bubbles: true })); + expect(editor.getDocument()).toBe("new"); + editor.destroy(); + }); + + it("does not start the default asset pipeline in a read-only editor", () => { + const container = document.createElement("div"); + const uploads: File[] = []; + const editor = createEditor({ + container, + readOnly: true, + onAssetUpload(file) { + uploads.push(file); + return Promise.resolve("assets/x.png"); + }, + }); + const content = container.querySelector(".cm-content") as HTMLElement; + const event = makePasteEvent({ + files: [new File(["img"], "x.png", { type: "image/png" })], + items: [], + }); + + content.dispatchEvent(event); + + expect(uploads).toEqual([]); + expect(editor.getDocument()).toBe(""); + editor.destroy(); + }); + + it("does not dispatch a pending asset result after the editor is destroyed", async () => { + const container = document.createElement("div"); + const upload = createDeferred(); + const firstFile = new File(["first"], "first.png", { type: "image/png" }); + const secondFile = new File(["second"], "second.png", { type: "image/png" }); + const uploads: File[] = []; + let capturedView: EditorView | null = null; + const editor = createEditor({ + container, + onAssetUpload(file) { + uploads.push(file); + return upload.promise; + }, + plugins: [{ name: "capture", cmExtensions: [captureViewPlugin((view) => (capturedView = view))] }], + }); + const view = requireEditorView(capturedView); + const content = container.querySelector("[contenteditable='true']") as HTMLElement; + + content.dispatchEvent(makePasteEvent({ files: [firstFile, secondFile], items: [] })); + editor.destroy(); + const dispatch = vi.spyOn(view, "dispatch"); + upload.resolve("assets/first.png"); + await flushMicrotasks(); + + expect(uploads).toEqual([firstFile]); + expect(dispatch).not.toHaveBeenCalled(); + }); + it("does not route plain-text paste through the asset pipeline", () => { const container = document.createElement("div"); const uploads: File[] = [];