Preserve large paste destinations and native Undo boundaries (#25177)

This commit is contained in:
Neil
2026-10-04 16:22:23 -07:00
committed by GitHub
parent cecb62158a
commit d3afb5c5a9
9 changed files with 1422 additions and 142 deletions
@@ -0,0 +1,13 @@
import type { Editor } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
export const richMarkdownClipboardInsertionOrderKey = new PluginKey<number>(
'richMarkdownClipboardInsertionOrder'
)
const requestOrders = new WeakMap<Editor, number>()
export function captureRichMarkdownClipboardInsertionOrder(editor: Editor): number {
const requestOrder = (requestOrders.get(editor) ?? 0) + 1
requestOrders.set(editor, requestOrder)
return requestOrder
}
@@ -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,
@@ -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<boolean>('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)
}
}
}
}
@@ -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<void>((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()
}
)
@@ -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 = '<p>hello world</p><p>second paragraph</p>',
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<void>((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 =
'<table><tbody><tr><td>first</td><td>second</td></tr><tr><td>third</td><td>fourth</td></tr></tbody></table><p>outside</p>'
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(
'<table><tbody><tr><td>first</td><td>second</td></tr><tr><td>third</td><td>fourth</td></tr></tbody></table>'
)
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)
})
})
@@ -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: '<p></p>' })
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<void> {
}
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)
})
})
@@ -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<RichMarkdownLargeTextPasteResult> {
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<RichMarkdownLargeTextPasteResult> {
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.'
)
)
}
})
+3 -1
View File
@@ -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.",
@@ -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<string[]> {
return editor.evaluate(() => {
const instance =
document.querySelector<RichMarkdownEditorElement>('.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<RichMarkdownEditorElement>('.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
})
})