mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
Preserve large paste destinations and native Undo boundaries (#25177)
This commit is contained in:
@@ -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 ABCDworld'
|
||||
: counterpart === 'later image'
|
||||
? 'hello ABCDworld'
|
||||
: '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.'
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user