diff --git a/src/renderer/src/components/editor/rich-markdown-clipboard-insertion-order.ts b/src/renderer/src/components/editor/rich-markdown-clipboard-insertion-order.ts new file mode 100644 index 00000000000..e7d1182e5ad --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-clipboard-insertion-order.ts @@ -0,0 +1,13 @@ +import type { Editor } from '@tiptap/core' +import { PluginKey } from '@tiptap/pm/state' + +export const richMarkdownClipboardInsertionOrderKey = new PluginKey( + 'richMarkdownClipboardInsertionOrder' +) +const requestOrders = new WeakMap() + +export function captureRichMarkdownClipboardInsertionOrder(editor: Editor): number { + const requestOrder = (requestOrders.get(editor) ?? 0) + 1 + requestOrders.set(editor, requestOrder) + return requestOrder +} diff --git a/src/renderer/src/components/editor/rich-markdown-image-context.ts b/src/renderer/src/components/editor/rich-markdown-image-context.ts index a86d2cdfcbc..2e9e9e4ed99 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-context.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-context.ts @@ -23,6 +23,15 @@ type RichMarkdownImageStorage = { } } +export function getRichMarkdownImageResolverContextVersion(editor: Editor): number { + const image: unknown = 'image' in editor.storage ? editor.storage.image : null + if (!image || typeof image !== 'object') { + return 0 + } + const version: unknown = 'contextVersion' in image ? image.contextVersion : null + return typeof version === 'number' ? version : 0 +} + export function createRichMarkdownImageResolverContext({ filePath, externalSshTargetId, diff --git a/src/renderer/src/components/editor/rich-markdown-large-paste-history.ts b/src/renderer/src/components/editor/rich-markdown-large-paste-history.ts new file mode 100644 index 00000000000..bce579dd94b --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-large-paste-history.ts @@ -0,0 +1,59 @@ +import type { Editor } from '@tiptap/core' +import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state' +import { closeHistory } from '@tiptap/pm/history' + +export function trackRichMarkdownLargePasteHistory(editor: Editor, onInterrupt: () => void) { + const key = new PluginKey('richMarkdownLargePasteHistory') + let writingChunk = false + let hasWrittenChunk = false + let active = true + let boundaryAtDisposal = false + editor.registerPlugin( + new Plugin({ + key, + state: { + init: () => false, + apply(transaction, externalBoundaryStarted) { + if (writingChunk || !hasWrittenChunk) { + return false + } + if (transaction.docChanged && !externalBoundaryStarted) { + onInterrupt() + } + return externalBoundaryStarted || transaction.docChanged + } + }, + filterTransaction(transaction, state) { + // An external event may first change the document in an appended transaction. + if (hasWrittenChunk && !writingChunk && transaction.docChanged && !key.getState(state)) { + closeHistory(transaction) + } + return true + } + }) + ) + return { + hasExternalBoundary(): boolean { + return active ? key.getState(editor.state) === true : boundaryAtDisposal + }, + dispatchChunk(transaction: Transaction): void { + hasWrittenChunk = true + writingChunk = true + try { + editor.view.dispatch(transaction) + } finally { + writingChunk = false + } + }, + dispose(): void { + if (!active) { + return + } + boundaryAtDisposal = !editor.isDestroyed && key.getState(editor.state) === true + active = false + if (!editor.isDestroyed) { + editor.unregisterPlugin(key) + } + } + } +} diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.fidelity.test.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.fidelity.test.ts new file mode 100644 index 00000000000..d12a9579248 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.fidelity.test.ts @@ -0,0 +1,394 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/core' +import { Plugin } from '@tiptap/pm/state' +import { afterEach, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste' +import { + captureRichMarkdownClipboardInsertionOrder, + richMarkdownClipboardInsertionOrderKey +} from './rich-markdown-clipboard-insertion-order' + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn() } })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +const editors: Editor[] = [] + +function createProductionEditor(content = 'hello world\n\nsecond paragraph') { + const editor = new Editor({ + extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }), + content, + contentType: 'markdown' + }) + editors.push(editor) + document.body.append(editor.view.dom) + editor.view.dom.focus() + editor.commands.setTextSelection({ from: 7, to: 12 }) + return editor +} + +function pasteEvent(text = 'ABCDE'): ClipboardEvent { + const clipboardData = new DataTransfer() + clipboardData.setData('text/plain', text) + return new ClipboardEvent('paste', { clipboardData, cancelable: true }) +} + +function pause() { + let resume: () => void = () => {} + const promise = new Promise((resolve) => { + resume = resolve + }) + return { promise, resume: () => resume() } +} + +async function flush(count = 40) { + for (let index = 0; index < count; index += 1) { + await Promise.resolve() + } +} + +function paragraphs(editor: Editor): string[] { + return Array.from( + { length: editor.state.doc.childCount }, + (_, index) => editor.state.doc.child(index).textContent + ) +} + +afterEach(() => { + editors.splice(0).forEach((editor) => editor.destroy()) + document.body.replaceChildren() + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +it('keeps every byte literal with the production extensions across production chunk boundaries', async () => { + const editor = createProductionEditor() + const payload = 'PASTE_SENTINEL '.repeat(5500) + expect(new TextEncoder().encode(payload).byteLength).toBe(82500) + const event = pasteEvent(payload) + const delay = pause() + const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + expect(handleRichMarkdownLargeTextPaste(editor, event, { yieldToEventLoop })).toBe(true) + expect(event.defaultPrevented).toBe(true) + expect(yieldToEventLoop).toHaveBeenCalledOnce() + editor.commands.setTextSelection(14) + delay.resume() + await flush() + expect(editor.state.doc.child(0).textContent === `hello ${payload}`).toBe(true) + expect(editor.state.doc.child(0).textContent.match(/PASTE_SENTINEL/g)?.length).toBe(5500) + expect(editor.state.doc.child(1).textContent).toBe('second paragraph') + expect(editor.state.selection.from).toBe(editor.state.doc.child(0).nodeSize + 1) + expect(toast.info).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + let markedPayload = false + editor.state.doc.descendants((node) => { + if (node.isText && node.text?.includes('PASTE')) { + markedPayload ||= node.marks.length > 0 + } + }) + expect(markedPayload).toBe(false) + editor.commands.insertContent('!') + expect(editor.state.doc.child(1).textContent).toBe('!second paragraph') +}) + +it('continues after its own chunk converts a document link while the live caret is elsewhere', async () => { + const editor = createProductionEditor('hello world\n\nother paragraph') + let firstYield = true + handleRichMarkdownLargeTextPaste(editor, pasteEvent('aaa[[y]]ZZZ'), { + directMaxBytes: 1, + chunkMaxBytes: 8, + measureYieldAfterCodeUnits: 1, + yieldToEventLoop: async () => { + if (firstYield) { + firstYield = false + editor.commands.setTextSelection(editor.state.doc.child(0).nodeSize + 1) + } + } + }) + await flush(60) + expect(editor.getMarkdown()).toBe('hello aaa[[y]]ZZZ\n\nother paragraph') + expect(editor.view.dom.querySelector('[data-doc-link-target="y"]')).not.toBeNull() + expect(editor.state.selection.from).toBe(editor.state.doc.child(0).nodeSize + 1) + expect(toast.info).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + editor.state.doc.check() + editor.commands.insertContent('!') + expect(editor.getMarkdown()).toBe('hello aaa[[y]]ZZZ\n\n!other paragraph') +}) + +it.each(['first', 'second', 'interleaved'] as const)( + 'preserves two pending paste requests, live caret and chronological Undo when %s completes first', + async (schedule) => { + vi.spyOn(Date, 'now').mockReturnValue(10000) + const editor = createProductionEditor() + editor.commands.setTextSelection(7) + const initialPlugins = editor.state.plugins.length + const first = pause() + const second = pause() + const middle = pause() + handleRichMarkdownLargeTextPaste(editor, pasteEvent('AABB'), { + directMaxBytes: 1, + chunkMaxBytes: 2, + measureYieldAfterCodeUnits: 1, + yieldToEventLoop: vi + .fn() + .mockReturnValueOnce(first.promise) + .mockImplementation(() => + schedule === 'interleaved' && paragraphs(editor)[0] === 'hello AAworld' + ? middle.promise + : Promise.resolve() + ) + }) + handleRichMarkdownLargeTextPaste(editor, pasteEvent('CCDD'), { + directMaxBytes: 1, + chunkMaxBytes: 2, + measureYieldAfterCodeUnits: 1, + yieldToEventLoop: vi.fn().mockReturnValueOnce(second.promise).mockResolvedValue(undefined) + }) + expect(paragraphs(editor)[0]).toBe('hello world') + if (schedule === 'second') { + second.resume() + await flush() + expect(paragraphs(editor)[0]).toBe('hello CCDDworld') + } + first.resume() + await flush() + if (schedule !== 'second') { + expect(paragraphs(editor)[0]).toBe( + schedule === 'interleaved' ? 'hello AAworld' : 'hello AABBworld' + ) + second.resume() + await flush() + } + middle.resume() + await flush() + expect(paragraphs(editor)).toEqual(['hello AABBCCDDworld', 'second paragraph']) + expect(editor.state.selection.from).toBe(15) + expect(document.activeElement).toBe(editor.view.dom) + expect(editor.state.plugins.length).toBe(initialPlugins) + expect(toast.info).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + editor.state.doc.check() + const undoStates = + schedule === 'interleaved' + ? ['hello AACCDDworld', 'hello AAworld', 'hello world'] + : [schedule === 'first' ? 'hello AABBworld' : 'hello CCDDworld', 'hello world'] + for (const expected of undoStates) { + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)).toEqual([expected, 'second paragraph']) + } + expect(editor.commands.undo()).toBe(false) + } +) + +it('groups native composition updates without a pending paste', () => { + vi.spyOn(Date, 'now').mockReturnValue(10000) + const editor = createProductionEditor() + const position = editor.state.doc.child(0).nodeSize + 1 + editor.commands.setTextSelection(position) + editor.view.dispatch(editor.state.tr.insertText('n').setMeta('composition', 7)) + editor.view.dispatch( + editor.state.tr.insertText('你', position, position + 1).setMeta('composition', 7) + ) + expect(paragraphs(editor)[1]).toBe('你second paragraph') + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)[1]).toBe('second paragraph') +}) + +it.each(['typing', 'composition'] as const)( + 'preserves native %s Undo grouping during a paused paste', + async (mode) => { + vi.spyOn(Date, 'now').mockReturnValue(10000) + const editor = createProductionEditor() + const delay = pause() + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { + directMaxBytes: 1, + chunkMaxBytes: 2, + yieldToEventLoop: vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + }) + await flush() + const position = editor.state.doc.child(0).nodeSize + 1 + editor.commands.setTextSelection(position) + if (mode === 'typing') { + editor.view.dispatch(editor.state.tr.insertText('T')) + editor.view.dispatch(editor.state.tr.insertText('U')) + } else { + editor.view.dispatch(editor.state.tr.insertText('n').setMeta('composition', 7)) + editor.view.dispatch( + editor.state.tr.insertText('你', position, position + 1).setMeta('composition', 7) + ) + } + const finishedTyping = mode === 'typing' ? 'TUsecond paragraph' : '你second paragraph' + expect(paragraphs(editor)[1]).toBe(finishedTyping) + delay.resume() + await flush() + expect(paragraphs(editor)).toEqual(['hello ABCDE', finishedTyping]) + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)).toEqual(['hello AB', finishedTyping]) + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)).toEqual(['hello AB', 'second paragraph']) + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)).toEqual(['hello world', 'second paragraph']) + } +) + +it.each(['disabled', 'disconnected'] as const)( + 'isolates partial paste history after a %s editor becomes available again', + async (condition) => { + vi.spyOn(Date, 'now').mockReturnValue(10000) + const editor = createProductionEditor() + const initialPlugins = editor.state.plugins.length + const delay = pause() + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { + directMaxBytes: 1, + chunkMaxBytes: 2, + yieldToEventLoop: vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + }) + await flush() + expect(paragraphs(editor)[0]).toBe('hello AB') + if (condition === 'disabled') { + editor.setEditable(false) + } else { + editor.view.dom.remove() + } + delay.resume() + await flush() + expect(editor.state.plugins.length).toBe(initialPlugins) + expect(toast.info).toHaveBeenCalledExactlyOnceWith('Large paste stopped before it finished.') + expect(toast.error).not.toHaveBeenCalled() + if (condition === 'disabled') { + editor.setEditable(true) + } else { + document.body.append(editor.view.dom) + } + editor.view.dom.focus() + editor.commands.insertContent('T') + expect(paragraphs(editor)[0]).toBe('hello ABT') + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)[0]).toBe('hello AB') + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)[0]).toBe('hello world') + } +) + +it('ignores a filtered foreign edit when separating a later accepted edit from paste history', async () => { + vi.spyOn(Date, 'now').mockReturnValue(10000) + const editor = createProductionEditor() + const delay = pause() + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { + directMaxBytes: 1, + chunkMaxBytes: 2, + yieldToEventLoop: vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + }) + await flush() + editor.registerPlugin(new Plugin({ filterTransaction: (tr) => !tr.getMeta('rejectForeign') })) + const position = editor.state.doc.child(0).nodeSize + 1 + editor.commands.setTextSelection(position) + editor.view.dispatch(editor.state.tr.insertText('rejected').setMeta('rejectForeign', true)) + expect(paragraphs(editor)[1]).toBe('second paragraph') + editor.view.dispatch(editor.state.tr.insertText('T')) + delay.resume() + await flush() + expect(paragraphs(editor)).toEqual(['hello ABCDE', 'Tsecond paragraph']) + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)).toEqual(['hello AB', 'Tsecond paragraph']) + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)).toEqual(['hello AB', 'second paragraph']) + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)).toEqual(['hello world', 'second paragraph']) +}) + +it.each(['typing', 'composition'] as const)( + 'keeps a cancelling target edit and subsequent native %s grouped after cleanup', + async (mode) => { + vi.spyOn(Date, 'now').mockReturnValue(10000) + const editor = createProductionEditor() + const initialPlugins = editor.state.plugins.length + const delay = pause() + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { + directMaxBytes: 1, + chunkMaxBytes: 2, + yieldToEventLoop: vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + }) + await flush() + expect(paragraphs(editor)[0]).toBe('hello AB') + const edit = editor.state.tr.insertText(mode === 'typing' ? 'T' : 'n', 8, 9) + editor.view.dispatch(mode === 'composition' ? edit.setMeta('composition', 7) : edit) + delay.resume() + await flush() + expect(editor.state.plugins.length).toBe(initialPlugins) + expect(toast.info).toHaveBeenCalledExactlyOnceWith('Large paste stopped before it finished.') + expect(toast.error).not.toHaveBeenCalled() + if (mode === 'typing') { + editor.view.dispatch(editor.state.tr.insertText('U')) + } else { + editor.view.dispatch(editor.state.tr.insertText('你', 8, 9).setMeta('composition', 7)) + } + expect(paragraphs(editor)[0]).toBe(mode === 'typing' ? 'hello ATU' : 'hello A你') + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)[0]).toBe('hello AB') + expect(editor.commands.undo()).toBe(true) + expect(paragraphs(editor)[0]).toBe('hello world') + } +) + +it('rejects the actual 16 MiB limit plus one emoji without changing the document or retaining plugins', async () => { + const editor = createProductionEditor() + const initial = editor.state.doc + const initialPlugins = editor.state.plugins.length + const payload = '😀'.repeat(4 * 1024 * 1024 + 1) + expect( + handleRichMarkdownLargeTextPaste(editor, pasteEvent(payload), { + yieldToEventLoop: async () => {} + }) + ).toBe(true) + await flush(1000) + expect(toast.error).toHaveBeenCalledExactlyOnceWith('Paste is too large.') + expect(toast.info).not.toHaveBeenCalled() + expect(editor.state.doc.eq(initial)).toBe(true) + expect(editor.state.plugins.length).toBe(initialPlugins) + editor.state.doc.check() +}) + +it.each(['earlier image', 'later image', 'ordinary typing'] as const)( + 'orders a pending text paste against %s without changing the live caret', + async (counterpart) => { + const editor = createProductionEditor() + editor.commands.setTextSelection(7) + const earlierOrder = + counterpart === 'earlier image' ? captureRichMarkdownClipboardInsertionOrder(editor) : null + const delay = pause() + handleRichMarkdownLargeTextPaste(editor, pasteEvent('ABCD'), { + directMaxBytes: 1, + chunkMaxBytes: 2, + measureYieldAfterCodeUnits: 1, + yieldToEventLoop: vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + }) + if (counterpart === 'ordinary typing') { + editor.commands.insertContent('typed ') + } else { + const order = earlierOrder ?? captureRichMarkdownClipboardInsertionOrder(editor) + editor.view.dispatch( + editor.state.tr + .insert(7, editor.schema.nodes.image.create({ src: 'counterpart.png' })) + .setMeta(richMarkdownClipboardInsertionOrderKey, order) + ) + } + const caret = editor.state.selection.from + delay.resume() + await flush() + const expected = + counterpart === 'earlier image' + ? 'hello ![](counterpart.png)ABCDworld' + : counterpart === 'later image' + ? 'hello ABCD![](counterpart.png)world' + : 'hello ABCDtyped world' + expect(editor.getMarkdown()).toBe(`${expected}\n\nsecond paragraph`) + expect(editor.state.selection.from).toBe(caret + 4) + expect(document.activeElement).toBe(editor.view.dom) + expect(toast.info).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + editor.state.doc.check() + } +) diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts new file mode 100644 index 00000000000..e871e7e02ed --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts @@ -0,0 +1,596 @@ +// @vitest-environment happy-dom +import { Editor, Extension } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { TableKit } from '@tiptap/extension-table' +import { CellSelection } from '@tiptap/pm/tables' +import { AllSelection, Plugin, TextSelection } from '@tiptap/pm/state' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste' +import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context' + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn() } })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +const editors: Editor[] = [] +const largeText = 'X'.repeat(70 * 1024) + +function createEditor( + content = '

hello world

second paragraph

', + extensions: Extension[] = [] +) { + const editor = new Editor({ extensions: [StarterKit, TableKit, ...extensions], content }) + document.body.append(editor.view.dom) + editor.view.dom.focus() + if (editor.state.doc.firstChild?.isTextblock) { + editor.commands.setTextSelection({ from: 7, to: 12 }) + } + editors.push(editor) + return editor +} + +function pasteEvent(text = largeText): ClipboardEvent { + const clipboardData = new DataTransfer() + clipboardData.setData('text/plain', text) + return new ClipboardEvent('paste', { clipboardData, cancelable: true }) +} + +function pause() { + let resume: () => void = () => {} + const promise = new Promise((resolve) => { + resume = resolve + }) + return { promise, resume: () => resume() } +} + +async function flush() { + for (let index = 0; index < 40; index += 1) { + await Promise.resolve() + } +} + +function textPosition(editor: Editor, text: string): number { + let position: number | undefined + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === text) { + position = pos + } + }) + if (position === undefined) { + throw new Error(`Missing text ${text}`) + } + return position +} + +afterEach(() => { + editors.splice(0).forEach((editor) => editor.destroy()) + document.body.replaceChildren() + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('large Markdown paste selection during yields', () => { + it('replaces the original selection while preserving a caret moved during production-size measurement', async () => { + const editor = createEditor() + const delay = pause() + const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + expect(handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop })).toBe(true) + expect(yieldToEventLoop).toHaveBeenCalledOnce() + editor.commands.setTextSelection(14) + delay.resume() + await flush() + expect(editor.state.doc.child(0).textContent === `hello ${largeText}`).toBe(true) + expect(editor.state.doc.child(1).textContent).toBe('second paragraph') + expect(editor.state.selection.from).toBe(editor.state.doc.child(0).nodeSize + 1) + expect(document.activeElement).toBe(editor.view.dom) + expect(toast.info).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('keeps every remaining production-size chunk at its insertion end after the caret moves', async () => { + const editor = createEditor() + const delay = pause() + const yieldToEventLoop = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockReturnValueOnce(delay.promise) + .mockResolvedValue(undefined) + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop }) + await flush() + expect(editor.state.doc.child(0).textContent.length).toBe(6 + 16 * 1024) + editor.commands.setTextSelection(editor.state.doc.child(0).nodeSize + 1) + delay.resume() + await flush() + expect(editor.state.doc.child(0).textContent === `hello ${largeText}`).toBe(true) + expect(editor.state.doc.child(1).textContent).toBe('second paragraph') + expect(editor.state.selection.from).toBe(editor.state.doc.child(0).nodeSize + 1) + }) + + it('maps the original selection through an earlier edit and its appended transaction', async () => { + const appendPrefix = Extension.create({ + name: 'pasteAppendPrefix', + addProseMirrorPlugins: () => [ + new Plugin({ + appendTransaction(transactions, _old, state) { + return transactions.some((tr) => tr.getMeta('appendPastePrefix')) + ? state.tr.insertText('next ', 1) + : null + } + }) + ] + }) + const editor = createEditor(undefined, [appendPrefix]) + const delay = pause() + const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop }) + editor.view.dispatch(editor.state.tr.insertText('first ', 1).setMeta('appendPastePrefix', true)) + delay.resume() + await flush() + expect(editor.state.doc.child(0).textContent === `next first hello ${largeText}`).toBe(true) + expect(editor.state.doc.child(1).textContent).toBe('second paragraph') + }) + + it.each(['replace', 'delete', 'insert'] as const)( + 'cancels when %s changes the pending selected text', + async (operation) => { + const editor = createEditor() + const delay = pause() + const yieldToEventLoop = vi + .fn() + .mockReturnValueOnce(delay.promise) + .mockResolvedValue(undefined) + const off = vi.spyOn(editor, 'off') + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop }) + const tr = editor.state.tr + if (operation === 'replace') { + tr.insertText('changed', 7, 12) + } + if (operation === 'delete') { + tr.delete(7, 12) + } + if (operation === 'insert') { + tr.insertText('typed', 9) + } + editor.view.dispatch(tr) + const changed = editor.getJSON() + delay.resume() + await flush() + expect(editor.getJSON()).toEqual(changed) + expect(off.mock.calls.map(([event]) => event)).toContain('transaction') + expect(off.mock.calls.map(([event]) => event)).toContain('destroy') + expect(toast.info).toHaveBeenCalledExactlyOnceWith( + 'Large paste cancelled because its original target changed.' + ) + expect(toast.error).not.toHaveBeenCalled() + } + ) + + it.each(['file', 'host'] as const)( + 'cancels when the original %s context changes', + async (change) => { + const editor = createEditor() + const context = { filePath: '/repo/note.md' } + setRichMarkdownImageResolverContext(editor, context) + const delay = pause() + const yieldToEventLoop = vi + .fn() + .mockReturnValueOnce(delay.promise) + .mockResolvedValue(undefined) + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop }) + setRichMarkdownImageResolverContext( + editor, + change === 'file' + ? { filePath: '/repo/other.md' } + : { + ...context, + runtimeContext: { + connectionId: 'another-target', + settings: null, + worktreeId: 'workspace', + worktreePath: '/repo' + } + } + ) + delay.resume() + await flush() + expect(editor.state.doc.child(0).textContent).toBe('hello world') + expect(toast.info).toHaveBeenCalledExactlyOnceWith( + 'Large paste cancelled because its original target changed.' + ) + expect(toast.error).not.toHaveBeenCalled() + } + ) + + it('uses native multi-cell replacement before continuing in the first inserted cell', async () => { + const content = + '
firstsecond
thirdfourth

outside

' + const editor = createEditor(content) + const cellPositions: number[] = [] + editor.state.doc.descendants((node, position) => { + if (node.type.spec.tableRole === 'cell') { + cellPositions.push(position) + } + }) + editor.view.dispatch( + editor.state.tr.setSelection( + CellSelection.create(editor.state.doc, cellPositions[0]!, cellPositions[3]!) + ) + ) + const original = editor.state.doc + const native = editor.state.tr.insertText(largeText).doc + const delay = pause() + const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop }) + const outside = textPosition(editor, 'outside') + editor.commands.setTextSelection(outside + 2) + delay.resume() + await flush() + expect(editor.state.doc.eq(native)).toBe(true) + expect(editor.state.doc.child(0).childCount).toBe(original.child(0).childCount) + expect(editor.state.selection.from).toBe(textPosition(editor, 'outside') + 2) + }) + + it('releases successful paste listeners and undoes all chunks together', async () => { + const editor = createEditor() + const original = editor.getJSON() + const off = vi.spyOn(editor, 'off') + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop: async () => {} }) + await flush() + expect(editor.commands.undo()).toBe(true) + expect(editor.getJSON()).toEqual(original) + expect(off.mock.calls.map(([event]) => event)).toContain('transaction') + expect(off.mock.calls.map(([event]) => event)).toContain('destroy') + }) + it('keeps the pending paste before ordinary typing at its original collapsed caret', async () => { + const editor = createEditor() + editor.commands.setTextSelection(7) + const delay = pause() + const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop }) + editor.commands.insertContent('typed ') + delay.resume() + await flush() + expect(editor.state.doc.child(0).textContent === `hello ${largeText}typed world`).toBe(true) + expect(editor.state.selection.from).toBe(7 + largeText.length + 'typed '.length) + }) + + it('excludes later typing at both ends of a reversed pending selection', async () => { + const editor = createEditor() + editor.commands.setTextSelection({ from: 12, to: 7 }) + const delay = pause() + const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop }) + editor.view.dispatch(editor.state.tr.insertText('start ', 7)) + editor.view.dispatch(editor.state.tr.insertText(' end', 18)) + delay.resume() + await flush() + expect(editor.state.doc.child(0).textContent === `hello start ${largeText} end`).toBe(true) + }) + + it('cancels when a new table row would expand the captured cell rectangle', async () => { + const editor = createEditor( + '
firstsecond
thirdfourth
' + ) + const positions: number[] = [] + editor.state.doc.descendants((node, pos) => { + if (node.type.spec.tableRole === 'cell') { + positions.push(pos) + } + }) + editor.view.dispatch( + editor.state.tr.setSelection( + CellSelection.create(editor.state.doc, positions[0]!, positions[3]!) + ) + ) + const delay = pause() + const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop }) + const row = editor.schema.nodeFromJSON({ + type: 'tableRow', + content: [ + { type: 'tableCell', content: [{ type: 'paragraph' }] }, + { type: 'tableCell', content: [{ type: 'paragraph' }] } + ] + }) + editor.view.dispatch( + editor.state.tr.insert(1 + editor.state.doc.child(0).child(0).nodeSize, row) + ) + const changed = editor.state.doc + delay.resume() + await flush() + expect(editor.state.doc.eq(changed)).toBe(true) + }) + + it('groups slow chunks as one undo step, separate from earlier and later typing', async () => { + const editor = createEditor() + const initial = editor.state.doc + editor.commands.insertContent('before') + const beforePaste = editor.state.doc + let now = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => now) + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { + yieldToEventLoop: async () => { + now += 1000 + } + }) + await flush() + const pasted = editor.state.doc + editor.commands.insertContent('after') + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.eq(pasted)).toBe(true) + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.eq(beforePaste)).toBe(true) + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.eq(initial)).toBe(true) + }) + + it('cancels remaining chunks when the user undoes the first chunk', async () => { + const editor = createEditor() + const initial = editor.state.doc + const delay = pause() + const yieldToEventLoop = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockReturnValueOnce(delay.promise) + .mockResolvedValue(undefined) + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop }) + await flush() + expect(editor.commands.undo()).toBe(true) + delay.resume() + await flush() + expect(editor.state.doc.eq(initial)).toBe(true) + }) + + it('stops and releases listeners when the first paste transaction is filtered', async () => { + const rejectPaste = Extension.create({ + name: 'rejectLargePaste', + addProseMirrorPlugins: () => [new Plugin({ filterTransaction: (tr) => !tr.docChanged })] + }) + const editor = createEditor(undefined, [rejectPaste]) + const initial = editor.state.doc + const off = vi.spyOn(editor, 'off') + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop: async () => {} }) + await flush() + expect(editor.state.doc.eq(initial)).toBe(true) + expect(off.mock.calls.map(([event]) => event)).toEqual(['transaction', 'destroy']) + expect(toast.info).toHaveBeenCalledExactlyOnceWith( + 'Large paste cancelled because its original target changed.' + ) + expect(toast.error).not.toHaveBeenCalled() + }) + + it.each(['disabled', 'disconnected', 'destroyed'] as const)( + 'cancels and releases listeners when the target is %s during measurement', + async (condition) => { + const editor = createEditor() + const initial = editor.state.doc + const delay = pause() + const off = vi.spyOn(editor, 'off') + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { + yieldToEventLoop: vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + }) + if (condition === 'disabled') { + editor.setEditable(false) + } + if (condition === 'disconnected') { + editor.view.dom.remove() + } + if (condition === 'destroyed') { + editor.destroy() + } + delay.resume() + await flush() + if (!editor.isDestroyed) { + expect(editor.state.doc.eq(initial)).toBe(true) + } + expect(off.mock.calls.map(([event]) => event)).toContain('transaction') + expect(off.mock.calls.map(([event]) => event)).toContain('destroy') + expect(toast.info).toHaveBeenCalledExactlyOnceWith( + 'Large paste cancelled because its original target changed.' + ) + expect(toast.error).not.toHaveBeenCalled() + } + ) + it('preserves marks armed at the original collapsed paste caret', async () => { + const editor = createEditor() + editor.commands.setTextSelection(7) + editor.commands.toggleBold() + expect(editor.state.storedMarks?.some((mark) => mark.type.name === 'bold')).toBe(true) + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop: async () => {} }) + await flush() + let pastedMarks: string[] = [] + editor.state.doc.descendants((node) => { + if (node.isText && node.text?.includes('X')) { + pastedMarks = node.marks.map((mark) => mark.type.name) + } + }) + expect(pastedMarks).toEqual(['bold']) + }) + + it('preserves armed marks at a moved live caret while writing the captured range', async () => { + const editor = createEditor() + const delay = pause() + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { + yieldToEventLoop: vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + }) + editor.commands.setTextSelection(14) + editor.commands.toggleItalic() + delay.resume() + await flush() + expect(editor.state.storedMarks?.some((mark) => mark.type.name === 'italic')).toBe(true) + expect(editor.state.selection.from).toBe(editor.state.doc.child(0).nodeSize + 1) + }) + it.each(['adjacent', 'other paragraph'] as const)( + 'keeps typing at %s separate between chronological paste segments', + async (position) => { + const editor = createEditor() + const initial = editor.state.doc + const delay = pause() + const yieldToEventLoop = vi + .fn() + .mockReturnValueOnce(delay.promise) + .mockResolvedValue(undefined) + handleRichMarkdownLargeTextPaste(editor, pasteEvent('ABCDE'), { + directMaxBytes: 1, + chunkMaxBytes: 2, + yieldToEventLoop + }) + await flush() + expect(editor.state.doc.child(0).textContent).toBe('hello AB') + if (position === 'other paragraph') { + editor.commands.setTextSelection(editor.state.doc.child(0).nodeSize + 1) + } + editor.commands.insertContent('T') + const typed = editor.state.doc + delay.resume() + await flush() + expect(editor.state.doc.child(0).textContent).toBe( + position === 'adjacent' ? 'hello ABCDET' : 'hello ABCDE' + ) + expect(editor.state.doc.child(1).textContent).toBe( + position === 'adjacent' ? 'second paragraph' : 'Tsecond paragraph' + ) + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.eq(typed)).toBe(true) + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.child(0).textContent).toBe('hello AB') + expect(editor.state.doc.child(1).textContent).toBe('second paragraph') + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.eq(initial)).toBe(true) + } + ) + + it('closes partial paste history after focus cancellation without reclaiming focus', async () => { + const editor = createEditor() + const initialPlugins = editor.state.plugins + const delay = pause() + const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + handleRichMarkdownLargeTextPaste(editor, pasteEvent('ABCDE'), { + directMaxBytes: 1, + chunkMaxBytes: 2, + yieldToEventLoop + }) + await flush() + expect(editor.state.doc.child(0).textContent).toBe('hello AB') + const input = document.createElement('input') + document.body.append(input) + input.focus() + delay.resume() + await flush() + expect(document.activeElement).toBe(input) + expect(toast.info).toHaveBeenCalledExactlyOnceWith('Large paste stopped before it finished.') + expect(toast.error).not.toHaveBeenCalled() + expect(editor.state.plugins).toEqual(initialPlugins) + editor.view.dom.focus() + editor.commands.insertContent('T') + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.child(0).textContent).toBe('hello AB') + }) + + it('cancels a full-document selection when a new paragraph appears outside its original range', async () => { + const editor = createEditor() + editor.view.dispatch(editor.state.tr.setSelection(new AllSelection(editor.state.doc))) + const initialPlugins = editor.state.plugins + const delay = pause() + const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + handleRichMarkdownLargeTextPaste(editor, pasteEvent('ABCDE'), { + directMaxBytes: 1, + measureYieldAfterCodeUnits: 1, + yieldToEventLoop + }) + const paragraph = editor.schema.nodeFromJSON({ + type: 'paragraph', + content: [{ type: 'text', text: 'new outside' }] + }) + editor.view.dispatch(editor.state.tr.insert(0, paragraph)) + const changed = editor.state.doc + delay.resume() + await flush() + expect(editor.state.doc.eq(changed)).toBe(true) + expect(editor.state.plugins).toEqual(initialPlugins) + expect(toast.info).toHaveBeenCalledExactlyOnceWith( + 'Large paste cancelled because its original target changed.' + ) + expect(toast.error).not.toHaveBeenCalled() + }) + + it('keeps an appended-only external edit separate from both paste segments in undo', async () => { + const appendTyping = Extension.create({ + name: 'appendExternalTyping', + addProseMirrorPlugins: () => [ + new Plugin({ + appendTransaction(transactions, _old, state) { + return transactions.some((tr) => tr.getMeta('appendExternalTyping')) + ? state.tr.insertText('T') + : null + } + }) + ] + }) + const editor = createEditor(undefined, [appendTyping]) + const initial = editor.state.doc + const delay = pause() + handleRichMarkdownLargeTextPaste(editor, pasteEvent('ABCDE'), { + directMaxBytes: 1, + chunkMaxBytes: 2, + yieldToEventLoop: vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + }) + await flush() + expect(editor.state.doc.child(0).textContent).toBe('hello AB') + editor.view.dispatch( + editor.state.tr + .setSelection( + TextSelection.create(editor.state.doc, editor.state.doc.child(0).nodeSize + 1) + ) + .setMeta('appendExternalTyping', true) + ) + const typed = editor.state.doc + expect(typed.child(1).textContent).toBe('Tsecond paragraph') + delay.resume() + await flush() + expect(editor.state.doc.child(0).textContent).toBe('hello ABCDE') + expect(editor.state.doc.child(1).textContent).toBe('Tsecond paragraph') + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.eq(typed)).toBe(true) + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.child(0).textContent).toBe('hello AB') + expect(editor.state.doc.child(1).textContent).toBe('second paragraph') + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.eq(initial)).toBe(true) + expect(toast.info).not.toHaveBeenCalled() + }) + + it('cancels when an external appended edit replaces the pending selected text', async () => { + const appendTargetEdit = Extension.create({ + name: 'appendTargetEdit', + addProseMirrorPlugins: () => [ + new Plugin({ + appendTransaction(transactions, _old, state) { + return transactions.some((tr) => tr.getMeta('appendTargetEdit')) + ? state.tr.insertText('changed', 7, 12) + : null + } + }) + ] + }) + const editor = createEditor(undefined, [appendTargetEdit]) + const delay = pause() + handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { + yieldToEventLoop: vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined) + }) + editor.view.dispatch( + editor.state.tr + .setSelection(TextSelection.create(editor.state.doc, 14)) + .setMeta('appendTargetEdit', true) + ) + const changed = editor.state.doc + expect(changed.child(0).textContent).toBe('hello changed') + delay.resume() + await flush() + expect(editor.state.doc.eq(changed)).toBe(true) + expect(toast.info).toHaveBeenCalledExactlyOnceWith( + 'Large paste cancelled because its original target changed.' + ) + expect(document.activeElement).toBe(editor.view.dom) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts index 23b3106150b..bf904cc1eed 100644 --- a/src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts @@ -1,31 +1,26 @@ // @vitest-environment happy-dom -import type { Editor } from '@tiptap/react' +import { Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' import { afterEach, describe, expect, it, vi } from 'vitest' import { toast } from 'sonner' import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste' vi.mock('sonner', () => ({ - toast: { error: vi.fn() } + toast: { error: vi.fn(), info: vi.fn() } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -type InsertTransaction = { text: string } +const editors: Editor[] = [] function makePasteEvent(text: string, html = ''): ClipboardEvent { - const event = new Event('paste', { - bubbles: true, - cancelable: true - }) as ClipboardEvent - Object.defineProperty(event, 'clipboardData', { - value: { - getData: (type: string) => (type === 'text/plain' ? text : type === 'text/html' ? html : '') - } - }) - return event + const clipboardData = new DataTransfer() + clipboardData.setData('text/plain', text) + clipboardData.setData('text/html', html) + return new ClipboardEvent('paste', { clipboardData, bubbles: true, cancelable: true }) } function makeEditor(): { @@ -34,39 +29,32 @@ function makeEditor(): { setDestroyed: (destroyed: boolean) => void setFocused: (focused: boolean) => void } { - const dom = document.createElement('div') - document.body.appendChild(dom) + const editor = new Editor({ extensions: [StarterKit], content: '

' }) + document.body.append(editor.view.dom) + editor.view.dom.focus() + editors.push(editor) const chunks: string[] = [] - let destroyed = false - let focused = true - const editor = { - get isDestroyed() { - return destroyed - }, - get state() { - return { - tr: { - insertText: (text: string): InsertTransaction => ({ text }) - } - } - }, - view: { - dom, - hasFocus: () => focused, - dispatch: (transaction: InsertTransaction): void => { - chunks.push(transaction.text) - } + editor.on('transaction', ({ transaction }) => { + if (transaction.docChanged) { + chunks.push(transaction.doc.textContent.slice(transaction.before.textContent.length)) } - } as unknown as Editor - + }) return { chunks, editor, - setDestroyed: (next) => { - destroyed = next + setDestroyed: (destroyed) => { + if (destroyed) { + editor.destroy() + } }, - setFocused: (next) => { - focused = next + setFocused: (focused) => { + if (focused) { + editor.view.dom.focus() + } else { + const input = document.createElement('input') + document.body.append(input) + input.focus() + } } } } @@ -78,7 +66,9 @@ async function flushPromises(count = 12): Promise { } afterEach(() => { + editors.splice(0).forEach((editor) => editor.destroy()) document.body.replaceChildren() + vi.restoreAllMocks() vi.clearAllMocks() }) @@ -96,6 +86,8 @@ describe('rich markdown large text paste', () => { expect(handleRichMarkdownLargeTextPaste(editor, handled, { directMaxBytes: 8 })).toBe(false) expect(small.defaultPrevented).toBe(false) expect(chunks).toEqual([]) + expect(toast.info).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() }) it('inserts large plain text through chunked ProseMirror transactions', async () => { @@ -118,6 +110,8 @@ describe('rich markdown large text paste', () => { expect(chunks.join('')).toBe(text) expect(chunks.some((chunk) => /[\uD800-\uDBFF]$/.test(chunk))).toBe(false) expect(yieldToEventLoop).toHaveBeenCalledTimes(chunks.length - 1) + expect(toast.info).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() }) it('claims large plain-text paste before yielded preflight inserts editor content', async () => { @@ -145,6 +139,8 @@ describe('rich markdown large text paste', () => { expect(chunks.join('')).toBe(text) expect(yieldToEventLoop).toHaveBeenCalled() + expect(toast.info).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() }) it('falls back to plain text when rich HTML is too large for synchronous parsing', async () => { @@ -167,6 +163,8 @@ describe('rich markdown large text paste', () => { expect(chunks.join('')).toBe(text) expect(chunks.join('')).not.toContain('hidden-token') expect(yieldToEventLoop).toHaveBeenCalledTimes(chunks.length - 1) + expect(toast.info).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() }) it('uses byte length, not string length, when deciding whether rich HTML is large', async () => { @@ -186,6 +184,8 @@ describe('rich markdown large text paste', () => { expect(event.defaultPrevented).toBe(true) expect(chunks.join('')).toBe('fallback') expect(yieldToEventLoop).toHaveBeenCalledTimes(1) + expect(toast.info).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() }) it('rejects large rich HTML without a plain-text fallback before editor parsing', () => { @@ -202,9 +202,8 @@ describe('rich markdown large text paste', () => { expect(event.defaultPrevented).toBe(true) expect(chunks).toEqual([]) expect(toast.error).toHaveBeenCalledWith('Paste is too large.') - expect( - JSON.stringify((toast.error as unknown as { mock: { calls: unknown[] } }).mock.calls) - ).not.toContain('hidden-token') + expect(JSON.stringify(vi.mocked(toast.error).mock.calls)).not.toContain('hidden-token') + expect(toast.info).not.toHaveBeenCalled() }) it('rejects oversized rich-editor paste without logging or inserting content', async () => { @@ -224,9 +223,8 @@ describe('rich markdown large text paste', () => { expect(event.defaultPrevented).toBe(true) expect(chunks).toEqual([]) expect(toast.error).toHaveBeenCalledWith('Paste is too large.') - expect( - JSON.stringify((toast.error as unknown as { mock: { calls: unknown[] } }).mock.calls) - ).not.toContain(secret) + expect(JSON.stringify(vi.mocked(toast.error).mock.calls)).not.toContain(secret) + expect(toast.info).not.toHaveBeenCalled() }) it('rejects oversized multibyte rich-editor paste before inserting content', async () => { @@ -245,6 +243,7 @@ describe('rich markdown large text paste', () => { expect(event.defaultPrevented).toBe(true) expect(chunks).toEqual([]) expect(toast.error).toHaveBeenCalledWith('Paste is too large.') + expect(toast.info).not.toHaveBeenCalled() }) it('stops chunking when the editor is destroyed between chunks', async () => { @@ -263,6 +262,8 @@ describe('rich markdown large text paste', () => { expect(event.defaultPrevented).toBe(true) expect(chunks).toEqual(['abcdef']) + expect(toast.info).toHaveBeenCalledExactlyOnceWith('Large paste stopped before it finished.') + expect(toast.error).not.toHaveBeenCalled() }) it('stops chunking when focus leaves the original editor target', async () => { @@ -281,5 +282,8 @@ describe('rich markdown large text paste', () => { expect(event.defaultPrevented).toBe(true) expect(chunks).toEqual(['abcdef']) + expect(toast.info).toHaveBeenCalledExactlyOnceWith('Large paste stopped before it finished.') + expect(toast.error).not.toHaveBeenCalled() + expect(document.activeElement).not.toBe(editor.view.dom) }) }) diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts index 815e4a27ed0..1e09a0612c4 100644 --- a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts @@ -1,4 +1,6 @@ import type { Editor } from '@tiptap/react' +import { TextSelection, type SelectionBookmark, type Transaction } from '@tiptap/pm/state' +import { closeHistory } from '@tiptap/pm/history' import { toast } from 'sonner' import { yieldToEventLoop } from '../../../../shared/event-loop-yield' import { @@ -10,6 +12,12 @@ import { measureTextControlPasteByteLengthWithYield } from '@/lib/text-control-paste' import { translate } from '@/i18n/i18n' +import { getRichMarkdownImageResolverContextVersion } from './rich-markdown-image-context' +import { trackRichMarkdownLargePasteHistory } from './rich-markdown-large-paste-history' +import { + captureRichMarkdownClipboardInsertionOrder, + richMarkdownClipboardInsertionOrderKey +} from './rich-markdown-clipboard-insertion-order' export const RICH_MARKDOWN_PASTE_DIRECT_MAX_BYTES = 64 * 1024 export const RICH_MARKDOWN_PASTE_CHUNK_MAX_BYTES = 16 * 1024 @@ -41,87 +49,198 @@ function isEditorAvailable( editor: Editor, canContinue: RichMarkdownLargeTextPasteOptions['canContinue'] ): boolean { - return !editor.isDestroyed && editor.view.dom.isConnected && (canContinue?.(editor) ?? true) + return ( + !editor.isDestroyed && + editor.isEditable && + editor.view.dom.isConnected && + (canContinue?.(editor) ?? true) + ) } function isEditorPasteTargetCurrent(editor: Editor, targetDom: HTMLElement): boolean { return ( - !editor.isDestroyed && - editor.view.dom === targetDom && - targetDom.isConnected && - editor.view.hasFocus() + isEditorAvailable(editor, undefined) && editor.view.dom === targetDom && editor.view.hasFocus() ) } -function readPlainText(event: ClipboardEvent): string { - return event.clipboardData?.getData('text/plain') ?? '' -} - -function readHtmlText(event: ClipboardEvent): string { - return event.clipboardData?.getData('text/html') ?? '' -} - -function shouldHandleLargeRichMarkdownPaste({ - plainTextByteLength, - plainTextExceededLimit, - htmlText, - maxDirect -}: { - plainTextByteLength: number - plainTextExceededLimit: boolean - htmlText: string - maxDirect: number -}): boolean { - if (plainTextExceededLimit || plainTextByteLength > maxDirect) { - return true - } - return !isUtf8ByteLengthWithinLimit(htmlText, maxDirect) -} - -async function insertRichMarkdownTextInChunks( - editor: Editor, - text: string, - byteLength: number, - options: RichMarkdownLargeTextPasteOptions -): Promise { - const chunkMaxBytes = options.chunkMaxBytes ?? RICH_MARKDOWN_PASTE_CHUNK_MAX_BYTES - let textIndex = 0 - let chunksWritten = 0 - - while (textIndex < text.length) { - if (!isEditorAvailable(editor, options.canContinue)) { - return { status: 'cancelled', reason: 'target-unavailable', byteLength, chunksWritten } - } - - const nextIndex = getUtf8ChunkEndIndex(text, textIndex, chunkMaxBytes) - const chunk = text.slice(textIndex, nextIndex) - editor.view.dispatch(editor.state.tr.insertText(chunk)) - textIndex = nextIndex - chunksWritten += 1 - - if (textIndex < text.length) { - await (options.yieldToEventLoop ?? yieldToEventLoop)() - } - } - - return { status: 'handled', chunksWritten, byteLength } -} - async function executeRichMarkdownLargeTextPaste( editor: Editor, text: string, options: RichMarkdownLargeTextPasteOptions ): Promise { - const maxBytes = options.maxBytes ?? RICH_MARKDOWN_PASTE_MAX_BYTES - const byteLengthMeasurement = await measureTextControlPasteByteLengthWithYield(text, { - stopAfterBytes: maxBytes, - yieldAfterCodeUnits: options.measureYieldAfterCodeUnits, - yieldToEventLoop: options.yieldToEventLoop + const chunkMaxBytes = Math.max(1, options.chunkMaxBytes ?? RICH_MARKDOWN_PASTE_CHUNK_MAX_BYTES) + const yieldBetweenBatches = options.yieldToEventLoop ?? yieldToEventLoop + const contextVersion = getRichMarkdownImageResolverContextVersion(editor) + const targetDom = editor.view.dom + const requestOrder = captureRichMarkdownClipboardInsertionOrder(editor) + let document = editor.state.doc + let bookmark = editor.state.selection.getBookmark() + const pasteMarks = editor.state.storedMarks + let insertionTransaction: Transaction | null = null + let insertionEnd: SelectionBookmark | null = null + let active = true + let pasteTime: number | null = null + let byteLength = 0 + let textIndex = 0 + let chunksWritten = 0 + const history = trackRichMarkdownLargePasteHistory(editor, () => { + pasteTime = null }) - if (byteLengthMeasurement.exceededLimit) { - return { status: 'rejected', reason: 'too-large', byteLength: byteLengthMeasurement.byteLength } + + const dispose = (): void => { + if (!active) { + return + } + active = false + editor.off('transaction', mapTransactions) + editor.off('destroy', dispose) + history.dispose() } - return insertRichMarkdownTextInChunks(editor, text, byteLengthMeasurement.byteLength, options) + const mapTransactions = ({ + transaction, + appendedTransactions + }: { + transaction: Transaction + appendedTransactions: Transaction[] + }): void => { + if (!transaction.before.eq(document)) { + dispose() + return + } + const ownChunk = transaction === insertionTransaction + const insertionOrder: unknown = transaction.getMeta(richMarkdownClipboardInsertionOrderKey) + const assoc = typeof insertionOrder === 'number' && insertionOrder < requestOrder ? 1 : -1 + for (const next of [transaction, ...appendedTransactions]) { + if (next === insertionTransaction && insertionEnd) { + bookmark = insertionEnd + insertionTransaction = null + } else if (!next.doc.eq(document)) { + const selection = bookmark.resolve(document) + let ranges = selection.ranges.map(({ $from, $to }) => ({ + from: $from.pos, + to: $to.pos + })) + for (const map of next.mapping.maps) { + const overlaps = ranges.some((range) => { + let changed = false + map.forEach((from, to) => { + changed ||= + range.from === range.to + ? to > from && from <= range.from && to >= range.to + : to > from + ? from < range.to && to > range.from + : from > range.from && from < range.to + }) + return changed + }) + if (overlaps && !ownChunk) { + dispose() + return + } + ranges = ranges.map((range) => ({ + from: map.map(range.from, range.from === range.to ? assoc : 1), + to: map.map(range.to, range.from === range.to ? assoc : -1) + })) + } + const range = ranges[0] + bookmark = + selection instanceof TextSelection && range + ? TextSelection.create( + next.doc, + selection.anchor <= selection.head ? range.from : range.to, + selection.anchor <= selection.head ? range.to : range.from + ).getBookmark() + : bookmark.map(next.mapping) + if (!(selection instanceof TextSelection)) { + const mapped = bookmark.resolve(next.doc) + const expectedRanges = new Set(ranges.map((range) => `${range.from}:${range.to}`)) + // Native selections must not expand to include content added during the paste. + if ( + mapped.constructor !== selection.constructor || + mapped.ranges.length !== ranges.length || + mapped.ranges.some(({ $from, $to }) => !expectedRanges.has(`${$from.pos}:${$to.pos}`)) + ) { + dispose() + return + } + } + } + document = next.doc + } + } + + editor.on('transaction', mapTransactions) + editor.on('destroy', dispose) + try { + const measurement = await measureTextControlPasteByteLengthWithYield(text, { + stopAfterBytes: options.maxBytes ?? RICH_MARKDOWN_PASTE_MAX_BYTES, + yieldAfterCodeUnits: options.measureYieldAfterCodeUnits, + yieldToEventLoop: yieldBetweenBatches + }) + byteLength = measurement.byteLength + if (measurement.exceededLimit) { + return { status: 'rejected', reason: 'too-large', byteLength } + } + while (textIndex < text.length) { + if ( + !active || + !isEditorPasteTargetCurrent(editor, targetDom) || + !(options.canContinue?.(editor) ?? true) || + getRichMarkdownImageResolverContextVersion(editor) !== contextVersion || + !editor.state.doc.eq(document) + ) { + return { status: 'cancelled', reason: 'target-unavailable', byteLength, chunksWritten } + } + const selection = bookmark.resolve(document) + const liveSelection = editor.state.selection + const liveMarks = editor.state.storedMarks + const nextIndex = getUtf8ChunkEndIndex(text, textIndex, chunkMaxBytes) + const tr = editor.state.tr + .setSelection(selection) + .setStoredMarks(pasteMarks) + .insertText(text.slice(textIndex, nextIndex)) + .setMeta(richMarkdownClipboardInsertionOrderKey, requestOrder) + if (pasteTime === null) { + closeHistory(tr) + pasteTime = tr.time + } + tr.setTime(pasteTime) + insertionEnd = tr.selection.getBookmark() + if (!liveSelection.eq(selection)) { + tr.setSelection(liveSelection.map(tr.doc, tr.mapping)) + } + tr.setStoredMarks(liveMarks) + // Own chunks advance the paste bookmark without following a moved live caret. + insertionTransaction = tr + history.dispatchChunk(tr) + if (insertionTransaction) { + return { status: 'cancelled', reason: 'target-unavailable', byteLength, chunksWritten } + } + textIndex = nextIndex + chunksWritten += 1 + if (textIndex < text.length) { + await yieldBetweenBatches() + } + } + return { status: 'handled', chunksWritten, byteLength } + } finally { + dispose() + if ( + chunksWritten && + !history.hasExternalBoundary() && + !editor.isDestroyed && + editor.view.dom === targetDom && + getRichMarkdownImageResolverContextVersion(editor) === contextVersion + ) { + editor.view.dispatch(closeHistory(editor.state.tr)) + } + } +} + +function showRichMarkdownLargePasteLimitError(): void { + toast.error( + translate('auto.components.editor.richMarkdownLargeTextPaste.tooLarge', 'Paste is too large.') + ) } export function handleRichMarkdownLargeTextPaste( @@ -129,58 +248,50 @@ export function handleRichMarkdownLargeTextPaste( event: ClipboardEvent, options: RichMarkdownLargeTextPasteOptions = {} ): boolean { - if (event.defaultPrevented) { - return false - } - if (!editor) { + if (event.defaultPrevented || !editor) { return false } - const text = options.plainTextOverride ?? readPlainText(event) - const html = options.htmlTextOverride ?? readHtmlText(event) + const text = options.plainTextOverride ?? event.clipboardData?.getData('text/plain') ?? '' + const html = options.htmlTextOverride ?? event.clipboardData?.getData('text/html') ?? '' const directMaxBytes = options.directMaxBytes ?? RICH_MARKDOWN_PASTE_DIRECT_MAX_BYTES const maxBytes = options.maxBytes ?? RICH_MARKDOWN_PASTE_MAX_BYTES const ownershipMeasurement = measureTextControlPasteByteLength(text, { stopAfterBytes: Math.min(directMaxBytes, maxBytes) }) if ( - !shouldHandleLargeRichMarkdownPaste({ - plainTextByteLength: ownershipMeasurement.byteLength, - plainTextExceededLimit: ownershipMeasurement.exceededLimit, - htmlText: html, - maxDirect: directMaxBytes - }) + !ownershipMeasurement.exceededLimit && + ownershipMeasurement.byteLength <= directMaxBytes && + isUtf8ByteLengthWithinLimit(html, directMaxBytes) ) { return false } event.preventDefault() if (!text || (maxBytes <= directMaxBytes && ownershipMeasurement.exceededLimit)) { - toast.error( - translate('auto.components.editor.richMarkdownLargeTextPaste.tooLarge', 'Paste is too large.') - ) + showRichMarkdownLargePasteLimitError() return true } if (!isEditorAvailable(editor, options.canContinue)) { return true } - const targetDom = editor.view.dom - const guardedOptions: RichMarkdownLargeTextPasteOptions = { - ...options, - canContinue: (candidate) => - isEditorPasteTargetCurrent(candidate, targetDom) && (options.canContinue?.(candidate) ?? true) - } - // Why: large rich-editor text or HTML paste bypasses ProseMirror's // synchronous parser and writes bounded plain-text fallback transactions. - void executeRichMarkdownLargeTextPaste(editor, text, guardedOptions).then((result) => { + void executeRichMarkdownLargeTextPaste(editor, text, options).then((result) => { if (result.status === 'rejected' && result.reason === 'too-large') { - toast.error( - translate( - 'auto.components.editor.richMarkdownLargeTextPaste.tooLarge', - 'Paste is too large.' - ) + showRichMarkdownLargePasteLimitError() + } else if (result.status === 'cancelled') { + toast.info( + result.chunksWritten + ? translate( + 'auto.components.editor.richMarkdownLargeTextPaste.stopped', + 'Large paste stopped before it finished.' + ) + : translate( + 'auto.components.editor.richMarkdownLargeTextPaste.cancelled', + 'Large paste cancelled because its original target changed.' + ) ) } }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0004adf383b..1a84ee8cad4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15670,7 +15670,9 @@ } }, "richMarkdownLargeTextPaste": { - "tooLarge": "Paste is too large." + "tooLarge": "Paste is too large.", + "stopped": "Large paste stopped before it finished.", + "cancelled": "Large paste cancelled because its original target changed." }, "ExternalFileChangeBanner": { "7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.", diff --git a/tests/e2e/markdown-large-paste-selection.spec.ts b/tests/e2e/markdown-large-paste-selection.spec.ts new file mode 100644 index 00000000000..62f7d29e690 --- /dev/null +++ b/tests/e2e/markdown-large-paste-selection.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from './helpers/orca-app' +import type { Locator } from '@stablyai/playwright-test' +import type { Editor } from '@tiptap/core' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' +import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' + +type RichMarkdownEditorElement = HTMLElement & { editor?: Editor } + +async function readParagraphs(editor: Locator): Promise { + return editor.evaluate(() => { + const instance = + document.querySelector('.rich-markdown-editor')?.editor + if (!instance) { + throw new Error('Document unavailable') + } + const texts: string[] = [] + instance.state.doc.forEach((node) => texts.push(node.textContent)) + return texts + }) +} + +test('keeps a large paste at its original selection after the caret moves', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-large-paste', + 'selection', + testInfo.workerIndex, + 'hello world\n\nother paragraph' + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + const payload = 'PASTE_SENTINEL '.repeat(5500) + await editor.evaluate((element, text) => { + const instance = + document.querySelector('.rich-markdown-editor')?.editor + if (!instance) { + throw new Error('Editor unavailable') + } + element.focus() + instance.commands.setTextSelection({ from: 7, to: 12 }) + const data = new DataTransfer() + data.setData('text/plain', text) + element.dispatchEvent( + new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }) + ) + // Move during the production byte-measurement yield in this same input task. + instance.commands.setTextSelection(29) + }, payload) + await expect + .poll(async () => (await readParagraphs(editor)).join('').length) + .toBeGreaterThan(payload.length) + await expect + .poll(async () => (await readParagraphs(editor)).join('').match(/PASTE_SENTINEL/g)?.length ?? 0) + .toBe(5500) + await orcaPage.keyboard.type('!') + await editor.evaluate((element) => element.querySelector('p')?.scrollIntoView({ block: 'start' })) + await orcaPage.screenshot({ path: testInfo.outputPath('large-paste-after-caret-move.png') }) + const paragraphs = await readParagraphs(editor) + const outcome = { + bytes: Buffer.byteLength(payload), + firstLength: paragraphs[0]?.length, + firstPrefix: paragraphs[0]?.slice(0, 40), + firstTextMatches: paragraphs[0] === `hello ${payload}`, + secondLength: paragraphs[1]?.length, + secondTextMatches: paragraphs[1] === 'other paragraph!' + } + await testInfo.attach('large-paste-outcome', { + body: JSON.stringify(outcome), + contentType: 'application/json' + }) + expect(outcome).toEqual({ + bytes: Buffer.byteLength(payload), + firstLength: payload.length + 6, + firstPrefix: `hello ${payload}`.slice(0, 40), + firstTextMatches: true, + secondLength: 16, + secondTextMatches: true + }) +})