From c1b737d55fc5091e1c18604bfa7e37650a1195c5 Mon Sep 17 00:00:00 2001 From: John Traas Date: Mon, 25 May 2026 15:24:29 +0200 Subject: [PATCH] fix(text-editor): prevent content loss when pasting tables into cells --- .../plugins/table-paste-plugin.spec.ts | 293 ++++++++++++++++++ .../plugins/table-paste-plugin.ts | 87 ++++++ .../plugins/table-plugin.ts | 3 +- 3 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 src/components/text-editor/prosemirror-adapter/plugins/table-paste-plugin.spec.ts create mode 100644 src/components/text-editor/prosemirror-adapter/plugins/table-paste-plugin.ts diff --git a/src/components/text-editor/prosemirror-adapter/plugins/table-paste-plugin.spec.ts b/src/components/text-editor/prosemirror-adapter/plugins/table-paste-plugin.spec.ts new file mode 100644 index 0000000000..68aa0d0d36 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/table-paste-plugin.spec.ts @@ -0,0 +1,293 @@ +import { Schema, DOMParser, Slice, Node } from 'prosemirror-model'; +import { + EditorState, + TextSelection, + Transaction, +} from 'prosemirror-state'; +import { CellSelection, cellAround, tableNodes } from 'prosemirror-tables'; +import { schema as basicSchema } from 'prosemirror-schema-basic'; +import { addListNodes } from 'prosemirror-schema-list'; +import { createTablePastePlugin } from './table-paste-plugin'; + +function buildSchema(): Schema { + let nodes = basicSchema.spec.nodes; + nodes = addListNodes(nodes, 'paragraph block*', 'block'); + nodes = nodes.append( + tableNodes({ + tableGroup: 'block', + cellContent: 'block+', + cellAttributes: {}, + }) + ); + + return new Schema({ nodes: nodes, marks: basicSchema.spec.marks }); +} + +function htmlToDoc(html: string, schema: Schema): Node { + const div = document.createElement('div'); + div.innerHTML = html; + + return DOMParser.fromSchema(schema).parse(div); +} + +function htmlToSlice(html: string, schema: Schema): Slice { + const div = document.createElement('div'); + div.innerHTML = html; + + return DOMParser.fromSchema(schema).parseSlice(div); +} + +function findTextOffset(doc: Node, needle: string): number { + let result = -1; + doc.descendants((node, pos) => { + if (result !== -1) { + return false; + } + + if (node.isText && node.text?.includes(needle)) { + const offsetInText = node.text.indexOf(needle); + result = pos + offsetInText; + + return false; + } + }); + + return result; +} + +function countNodesOfType(doc: Node, typeName: string): number { + let count = 0; + doc.descendants((node) => { + if (node.type.name === typeName) { + count++; + } + }); + + return count; +} + +function extractAllText(doc: Node): string { + return doc.textBetween(0, doc.content.size, '\n', '\n'); +} + +function createMockView(state: EditorState): { + state: EditorState; + dispatch: (tr: Transaction) => void; +} { + const view = { + state: state, + dispatch(this: { state: EditorState }, tr: Transaction) { + this.state = this.state.apply(tr); + }, + }; + + return view; +} + +describe('table-paste-plugin', () => { + let schema: Schema; + + beforeEach(() => { + schema = buildSchema(); + }); + + describe('caret inside a cell with TextSelection', () => { + it('inserts a pasted table as a new sibling after the enclosing table', () => { + const doc = htmlToDoc( + '

existing text

', + schema + ); + const textPos = findTextOffset(doc, 'existing'); + const state = EditorState.create({ + doc: doc, + selection: TextSelection.create(doc, textPos + 4), + }); + const view = createMockView(state); + + const slice = htmlToSlice( + '
pasted row
', + schema + ); + + const plugin = createTablePastePlugin(); + const handled = plugin.props.handlePaste!( + view as any, + new Event('paste') as ClipboardEvent, + slice + ); + + expect(handled).toBe(true); + expect(countNodesOfType(view.state.doc, 'table')).toBe(2); + expect(extractAllText(view.state.doc)).toContain('existing text'); + expect(extractAllText(view.state.doc)).toContain('pasted row'); + }); + + it('preserves paragraphs and trailing content sibling to the enclosing table', () => { + const doc = htmlToDoc( + [ + '

cell content

', + '

paragraph one

', + '

paragraph two

', + ].join(''), + schema + ); + const textPos = findTextOffset(doc, 'cell content'); + const state = EditorState.create({ + doc: doc, + selection: TextSelection.create(doc, textPos), + }); + const view = createMockView(state); + + const slice = htmlToSlice( + '
new row
', + schema + ); + + const plugin = createTablePastePlugin(); + plugin.props.handlePaste!( + view as any, + new Event('paste') as ClipboardEvent, + slice + ); + + const topLevelTypes: string[] = []; + view.state.doc.forEach((child) => { + topLevelTypes.push(child.type.name); + }); + expect(topLevelTypes).toEqual([ + 'table', + 'table', + 'paragraph', + 'paragraph', + ]); + + const firstTable = view.state.doc.firstChild!; + const secondTable = view.state.doc.child(1); + expect(firstTable.textContent).toBe('cell content'); + expect(secondTable.textContent).toBe('new row'); + expect(view.state.doc.child(2).textContent).toBe('paragraph one'); + expect(view.state.doc.child(3).textContent).toBe('paragraph two'); + }); + + it('inserts a paragraph after the table when the table is the last block', () => { + const doc = htmlToDoc( + '

only cell

', + schema + ); + const textPos = findTextOffset(doc, 'only cell'); + const state = EditorState.create({ + doc: doc, + selection: TextSelection.create(doc, textPos), + }); + const view = createMockView(state); + + const slice = htmlToSlice( + '
pasted
', + schema + ); + + const plugin = createTablePastePlugin(); + const handled = plugin.props.handlePaste!( + view as any, + new Event('paste') as ClipboardEvent, + slice + ); + + expect(handled).toBe(true); + expect(extractAllText(view.state.doc)).toContain('only cell'); + expect(extractAllText(view.state.doc)).toContain('pasted'); + }); + + it('returns false when the pasted slice contains no table content', () => { + const doc = htmlToDoc( + '

cell text

', + schema + ); + const textPos = findTextOffset(doc, 'cell text'); + const state = EditorState.create({ + doc: doc, + selection: TextSelection.create(doc, textPos), + }); + const view = createMockView(state); + + const slice = htmlToSlice('

just a paragraph

', schema); + + const plugin = createTablePastePlugin(); + const handled = plugin.props.handlePaste!( + view as any, + new Event('paste') as ClipboardEvent, + slice + ); + + expect(handled).toBe(false); + }); + }); + + describe('selection is a CellSelection', () => { + it('returns false so the default tableEditing cell-grid replace runs', () => { + const doc = htmlToDoc( + [ + '', + '', + '', + '

a1

b1

a2

b2

', + ].join(''), + schema + ); + const a1Pos = findTextOffset(doc, 'a1'); + const b2Pos = findTextOffset(doc, 'b2'); + + const $a1Cell = cellAround(doc.resolve(a1Pos)); + const $b2Cell = cellAround(doc.resolve(b2Pos)); + if (!$a1Cell || !$b2Cell) { + throw new Error('failed to resolve cell anchors for test'); + } + + const state = EditorState.create({ + doc: doc, + selection: new CellSelection($a1Cell, $b2Cell), + }); + const view = createMockView(state); + + const slice = htmlToSlice( + '
x
', + schema + ); + + const plugin = createTablePastePlugin(); + const handled = plugin.props.handlePaste!( + view as any, + new Event('paste') as ClipboardEvent, + slice + ); + + expect(handled).toBe(false); + }); + }); + + describe('selection is outside any table', () => { + it('returns false so default ProseMirror paste behavior runs', () => { + const doc = htmlToDoc('

plain paragraph

', schema); + const textPos = findTextOffset(doc, 'plain'); + const state = EditorState.create({ + doc: doc, + selection: TextSelection.create(doc, textPos), + }); + const view = createMockView(state); + + const slice = htmlToSlice( + '
x
', + schema + ); + + const plugin = createTablePastePlugin(); + const handled = plugin.props.handlePaste!( + view as any, + new Event('paste') as ClipboardEvent, + slice + ); + + expect(handled).toBe(false); + }); + }); +}); diff --git a/src/components/text-editor/prosemirror-adapter/plugins/table-paste-plugin.ts b/src/components/text-editor/prosemirror-adapter/plugins/table-paste-plugin.ts new file mode 100644 index 0000000000..f6905c0461 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/table-paste-plugin.ts @@ -0,0 +1,87 @@ +import { + Plugin, + PluginKey, + Selection, + TextSelection, +} from 'prosemirror-state'; +import { Slice } from 'prosemirror-model'; +import { isInTable } from 'prosemirror-tables'; + +export const tablePastePluginKey = new PluginKey('tablePastePlugin'); + +// Narrow gate: only intercept when the pasted slice's top-level is a +// complete . Bare /
slices would not fit at block level +// (where the post-table insertion happens), so we let the default +// prosemirror-tables flow handle them. +const sliceStartsWithTable = (slice: Slice): boolean => { + return slice.content.firstChild?.type.spec.tableRole === 'table'; +}; + +export const createTablePastePlugin = (): Plugin => { + return new Plugin({ + key: tablePastePluginKey, + props: { + handlePaste: (view, _event, slice) => { + try { + const { state } = view; + + if (!(state.selection instanceof TextSelection)) { + return false; + } + + if (!isInTable(state)) { + return false; + } + + if (!sliceStartsWithTable(slice)) { + return false; + } + + const { $from } = state.selection; + let tableDepth = -1; + for (let depth = $from.depth; depth > 0; depth--) { + if ( + $from.node(depth).type.spec.tableRole === 'table' + ) { + tableDepth = depth; + break; + } + } + + if (tableDepth < 0) { + return false; + } + + // Force a closed slice so ProseMirror does not strip the + // table/row/cell wrappers when inserting at block level. + // parseSlice returns max-open slices (openStart/openEnd > 0), + // which would otherwise cause merging into surrounding context. + const closedSlice = new Slice(slice.content, 0, 0); + const posAfterTable = $from.after(tableDepth); + const tr = state.tr.replace( + posAfterTable, + posAfterTable, + closedSlice + ); + const $endPos = tr.doc.resolve( + posAfterTable + closedSlice.size + ); + tr.setSelection( + Selection.near($endPos, -1) + ).scrollIntoView(); + + view.dispatch(tr); + + return true; + } catch (error) { + console.error( + 'table-paste-plugin: failed to intercept paste, falling through to default', + error + ); + + return false; + } + }, + }, + }); +}; diff --git a/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts b/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts index b24408c537..8a4cd6adc2 100644 --- a/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts +++ b/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts @@ -1,9 +1,10 @@ import { tableNodes, tableEditing } from 'prosemirror-tables'; import { Plugin } from 'prosemirror-state'; +import { createTablePastePlugin } from './table-paste-plugin'; export const getTableEditingPlugins = (tablesEnabled: boolean): Plugin[] => { if (tablesEnabled) { - return [tableEditing()]; + return [createTablePastePlugin(), tableEditing()]; } return [];