From 2b6239e960ead6b01e032814b7b53fd9de010f46 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 15 Oct 2026 17:57:13 +0200 Subject: [PATCH 1/5] fix(table): paste copied table cells into target cells Pasting copied table cells into a table cell was refused with a "nested tables are not supported" warning. Cells and rows copied from a ProseMirror editor (marked with data-pm-slice) now keep their type when parsed. Pasted header cells and rows are converted to body ones. A single copied cell is unwrapped and its content pasted at the cursor. Several cells are handed to the prosemirror-tables paste handler, which writes them into the target cells from the cursor onwards and adds rows and columns as needed. Tables pasted from spreadsheet apps go the same way, instead of keeping only the first cell's content. Pasting a table together with other content is still rejected as a nested table. Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-opus-5-5 --- src/nodes/Table/TableCell.ts | 73 +++++++++++++----- src/nodes/Table/TableRow.ts | 10 ++- src/tests/nodes/Table.spec.js | 99 +++++++++++++++++++++++++ src/tests/testHelpers/nextcloudMocks.js | 1 + 4 files changed, 162 insertions(+), 21 deletions(-) diff --git a/src/nodes/Table/TableCell.ts b/src/nodes/Table/TableCell.ts index 4e254597458..5519ddfecf8 100644 --- a/src/nodes/Table/TableCell.ts +++ b/src/nodes/Table/TableCell.ts @@ -3,12 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { Node as PMNode, Schema } from '@tiptap/pm/model' + import { showWarning } from '@nextcloud/dialogs' import { t } from '@nextcloud/l10n' import { mergeAttributes } from '@tiptap/core' import { TableCell } from '@tiptap/extension-table' import { Fragment, Slice } from '@tiptap/pm/model' import { Plugin, PluginKey } from '@tiptap/pm/state' +import { __pastedCells as pastedCells } from '@tiptap/pm/tables' export default TableCell.extend({ content: 'block+', @@ -19,6 +22,13 @@ export default TableCell.extend({ return [ { tag: 'td', preserveWhitespace: true }, { tag: 'th', preserveWhitespace: true }, + { + // Cells copied from a ProseMirror editor keep their type, even in the first row + tag: 'td', + priority: 90, + preserveWhitespace: true, + getAttrs: (el) => (el.closest('table[data-pm-slice]') ? null : false), + }, { tag: 'table thead ~ tbody th', priority: 70, @@ -75,7 +85,7 @@ export default TableCell.extend({ }, props: { // Prevent nested table when pasting to a table cell - transformPasted: (slice) => { + transformPasted: (slice, view) => { if (!this.editor.isActive(this.type.name)) { return slice } @@ -91,25 +101,18 @@ export default TableCell.extend({ return slice } - if ( - slice.content.childCount === 1 - && slice.content.firstChild?.type.name === 'table' - ) { - const tableChild = slice.content.firstChild.firstChild - if ( - (tableChild?.childCount === 1 - && tableChild.type.name === 'tableRow') - || tableChild?.type.name === 'tableHeadRow' - ) { - const rowChild = tableChild.firstChild - if ( - (rowChild?.childCount === 1 - && rowChild.type.name === 'tableCell') - || rowChild?.type.name === 'tableHeader' - ) { - return new Slice(rowChild.content, 0, 0) - } - } + const bodySlice = new Slice( + toBodyCells(slice.content, view.state.schema), + slice.openStart, + slice.openEnd, + ) + const cells = pastedCells(bodySlice) + if (cells?.width === 1 && cells.height === 1) { + return Slice.maxOpen(cells.rows[0].firstChild!.content) + } + if (cells) { + // Inserted into the target cells by the paste handler of prosemirror-tables + return bodySlice } // eslint-disable-next-line no-console @@ -127,3 +130,33 @@ export default TableCell.extend({ ] }, }) + +/** + * Convert header rows and cells to body rows and cells and drop captions, + * so that pasted cells fit into body rows. + * + * @param fragment - content of a pasted slice or of one of its table nodes + * @param schema - the editor schema + */ +function toBodyCells(fragment: Fragment, schema: Schema): Fragment { + const nodes: PMNode[] = [] + fragment.forEach((node) => { + switch (node.type.name) { + case 'tableCaption': + return + case 'tableHeader': + nodes.push(schema.nodes.tableCell.create(node.attrs, schema.nodes.paragraph.create(null, node.content))) + return + case 'tableHeadRow': + nodes.push(schema.nodes.tableRow.create(node.attrs, toBodyCells(node.content, schema))) + return + case 'table': + case 'tableRow': + nodes.push(node.copy(toBodyCells(node.content, schema))) + return + default: + nodes.push(node) + } + }) + return Fragment.from(nodes) +} diff --git a/src/nodes/Table/TableRow.ts b/src/nodes/Table/TableRow.ts index 5552f96dd9d..3c87f3c9378 100644 --- a/src/nodes/Table/TableRow.ts +++ b/src/nodes/Table/TableRow.ts @@ -12,6 +12,14 @@ export default TableRow.extend({ toMarkdown() {}, parseHTML() { - return [{ tag: 'tr', priority: 70 }] + return [ + { tag: 'tr', priority: 70 }, + { + tag: 'tr', + priority: 90, + // Body rows copied from a ProseMirror editor stay body rows, even as first row + getAttrs: (el) => (el.closest('table[data-pm-slice]') && el.querySelector(':scope > td') ? null : false), + }, + ] }, }) diff --git a/src/tests/nodes/Table.spec.js b/src/tests/nodes/Table.spec.js index c2e9ddac010..dd7ffc04a32 100644 --- a/src/tests/nodes/Table.spec.js +++ b/src/tests/nodes/Table.spec.js @@ -4,8 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { showWarning } from '@nextcloud/dialogs' import { TextSelection } from '@tiptap/pm/state' +import { CellSelection } from '@tiptap/pm/tables' import { builders } from 'prosemirror-test-builder' +import { vi } from 'vitest' import RichText from '../../extensions/RichText.js' import markdownit from '../../markdownit/index.js' import EditableTable from '../../nodes/EditableTable.ts' @@ -324,6 +327,62 @@ describe('Table extension', () => { const table = '| a | b | \\\n| c | |\n|---|---|\n| 1 | 2 |\n' expect(markdownThroughEditor(table)).toBe('| a c | b |\n|-----|---|\n| 1 | 2 |\n') }) + + describe('pasting cells into a table', () => { + const table = '' + + '' + + '
H1H2

a1

a2

b1

  • item

b2

' + + test('single copied cell is pasted as content of the target cell', ({ editor, serializeMarkdown }) => { + editor.commands.setContent(table) + const html = copyCells(editor, 4, 4) + placeCursorInCell(editor, 3) + editor.view.pasteHTML(html) + expect(serializeMarkdown()).toBe('| H1 | H2 |\n|--------|----------|\n| a1 | b1 | \\\n| | | \\\n| | - itema2 |\n| b1 | b2 | \\\n| | | \\\n| - item | |\n') + }) + + test('copied block of cells overwrites target cells and adds rows', ({ editor, serializeMarkdown }) => { + editor.commands.setContent(table) + const html = copyCells(editor, 2, 5) + placeCursorInCell(editor, 4) + editor.view.pasteHTML(html) + expect(serializeMarkdown()).toBe('| H1 | H2 |\n|--------|----|\n| a1 | a2 |\n| a1 | a2 |\n| b1 | b2 | \\\n| | | \\\n| - item | |\n') + }) + + test('copied row of cells adds columns', ({ editor, serializeMarkdown }) => { + editor.commands.setContent(table) + const html = copyCells(editor, 2, 3) + placeCursorInCell(editor, 5) + editor.view.pasteHTML(html) + expect(serializeMarkdown()).toBe('| H1 | H2 | |\n|--------|----|----|\n| a1 | a2 | |\n| b1 | a1 | a2 | \\\n| | | | \\\n| - item | | |\n') + }) + + test('copied header cell is pasted as body cell content', ({ editor, serializeMarkdown }) => { + editor.commands.setContent(table) + const html = copyCells(editor, 0, 2) + placeCursorInCell(editor, 5) + editor.view.pasteHTML(html) + expect(serializeMarkdown()).toBe('| H1 | H2 |\n|--------|----|\n| a1 | a2 |\n| b1 | H1 | \\\n| | | \\\n| - item | |\n| | a1 |\n') + }) + + test('table from a spreadsheet app is pasted cell by cell', ({ editor, serializeMarkdown }) => { + editor.commands.setContent(table) + placeCursorInCell(editor, 2) + editor.view.pasteHTML('' + + '' + + '
x1y1
x2y2
') + expect(serializeMarkdown()).toBe('| H1 | H2 |\n|----|--------|\n| x1 | y1 |\n| x2 | **y2** |\n') + }) + + test('table with other content is rejected as nested table', ({ editor, serializeMarkdown }) => { + vi.mocked(showWarning).mockClear() + editor.commands.setContent(table) + placeCursorInCell(editor, 2) + editor.view.pasteHTML('

text

x1
x2
') + expect(showWarning).toHaveBeenCalledOnce() + expect(serializeMarkdown()).toBe('| H1 | H2 |\n|--------|----|\n| a1 | a2 |\n| b1 | b2 | \\\n| | | \\\n| - item | |\n') + }) + }) }) function getHeaderCell(editor, targetIndex = 0) { @@ -359,3 +418,43 @@ function getBodyColumnValues(editor, columnIndex) { function formatHTML(html) { return html.replaceAll('><', '>\n<').replace(/\n$/, '') } + +/** + * Positions of all table cells in document order + * + * @param {object} editor - the tiptap editor + */ +function getCellPositions(editor) { + const positions = [] + editor.state.doc.descendants((node, pos) => { + if (['tableHeader', 'tableCell'].includes(node.type.name)) { + positions.push(pos) + } + }) + return positions +} + +/** + * Select cells and return the HTML that copying them puts into the clipboard + * + * @param {object} editor - the tiptap editor + * @param {number} anchor - index of the first selected cell + * @param {number} head - index of the last selected cell + */ +function copyCells(editor, anchor, head) { + const positions = getCellPositions(editor) + const selection = CellSelection.create(editor.state.doc, positions[anchor], positions[head]) + editor.view.dispatch(editor.state.tr.setSelection(selection)) + return editor.view.serializeForClipboard(selection.content()).dom.innerHTML +} + +/** + * Place the cursor at the start of a body cell + * + * @param {object} editor - the tiptap editor + * @param {number} index - index of the cell + */ +function placeCursorInCell(editor, index) { + const pos = getCellPositions(editor)[index] + 2 + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))) +} diff --git a/src/tests/testHelpers/nextcloudMocks.js b/src/tests/testHelpers/nextcloudMocks.js index e5bdb7e94a9..66312529d0b 100644 --- a/src/tests/testHelpers/nextcloudMocks.js +++ b/src/tests/testHelpers/nextcloudMocks.js @@ -26,6 +26,7 @@ vi.mock('@nextcloud/files', () => ({ vi.mock('@nextcloud/dialogs', () => ({ FilePickerType: {}, getFilePickerBuilder: () => {}, + showWarning: vi.fn(), })) vi.mock('@nextcloud/capabilities', async () => { From 301598619afa5f045567d6bdc1441976890b3277 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 16 Oct 2026 18:24:31 +0200 Subject: [PATCH 2/5] fix(table): paste text copied inside a table cell Text copied from inside a table cell is pasted wrapped in the table, row and cell it was copied from. The cell paste handling did not detect any cells in it and rejected it as a nested table. Unwrap the content and paste it at the cursor instead. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Jonas --- src/nodes/Table/TableCell.ts | 28 ++++++++++++++++++ src/tests/nodes/Table.spec.js | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/nodes/Table/TableCell.ts b/src/nodes/Table/TableCell.ts index 5519ddfecf8..fdd60265487 100644 --- a/src/nodes/Table/TableCell.ts +++ b/src/nodes/Table/TableCell.ts @@ -101,6 +101,11 @@ export default TableCell.extend({ return slice } + const cellContent = unwrapCellContent(slice) + if (cellContent) { + return cellContent + } + const bodySlice = new Slice( toBodyCells(slice.content, view.state.schema), slice.openStart, @@ -131,6 +136,29 @@ export default TableCell.extend({ }, }) +/** + * Content copied from inside a table cell is pasted wrapped in the table, + * row and cell it was copied from. Return the content without them. + * + * @param slice - the pasted slice + */ +function unwrapCellContent(slice: Slice): Slice | null { + let { content, openStart, openEnd } = slice + while (content.childCount === 1 && openStart > 0 && openEnd > 0) { + const node = content.firstChild! + if (!node.type.name.startsWith('table')) { + return null + } + content = node.content + openStart-- + openEnd-- + if (['tableCell', 'tableHeader'].includes(node.type.name)) { + return new Slice(content, openStart, openEnd) + } + } + return null +} + /** * Convert header rows and cells to body rows and cells and drop captions, * so that pasted cells fit into body rows. diff --git a/src/tests/nodes/Table.spec.js b/src/tests/nodes/Table.spec.js index dd7ffc04a32..ef43cc6f4aa 100644 --- a/src/tests/nodes/Table.spec.js +++ b/src/tests/nodes/Table.spec.js @@ -382,6 +382,30 @@ describe('Table extension', () => { expect(showWarning).toHaveBeenCalledOnce() expect(serializeMarkdown()).toBe('| H1 | H2 |\n|--------|----|\n| a1 | a2 |\n| b1 | b2 | \\\n| | | \\\n| - item | |\n') }) + + test('text copied inside a cell is pasted as text', ({ editor, serializeMarkdown }) => { + editor.commands.setContent(table) + const html = copyText(editor, 'a1', 'a1') + placeCursorInCell(editor, 5) + editor.view.pasteHTML(html) + expect(serializeMarkdown()).toBe('| H1 | H2 |\n|--------|------|\n| a1 | a2 |\n| b1 | a1b2 | \\\n| | | \\\n| - item | |\n') + }) + + test('blocks copied inside a cell are pasted as blocks', ({ editor, serializeMarkdown }) => { + editor.commands.setContent(table) + const html = copyText(editor, 'b1', 'item') + placeCursorInCell(editor, 3) + editor.view.pasteHTML(html) + expect(serializeMarkdown()).toBe('| H1 | H2 |\n|--------|----------|\n| a1 | b1 | \\\n| | | \\\n| | - itema2 |\n| b1 | b2 | \\\n| | | \\\n| - item | |\n') + }) + + test('text copied inside a header cell is pasted as text', ({ editor, serializeMarkdown }) => { + editor.commands.setContent(table) + const html = copyText(editor, 'H1', 'H1') + placeCursorInCell(editor, 5) + editor.view.pasteHTML(html) + expect(serializeMarkdown()).toBe('| H1 | H2 |\n|--------|------|\n| a1 | a2 |\n| b1 | H1b2 | \\\n| | | \\\n| - item | |\n') + }) }) }) @@ -448,6 +472,38 @@ function copyCells(editor, anchor, head) { return editor.view.serializeForClipboard(selection.content()).dom.innerHTML } +/** + * Select text and return the HTML that copying it puts into the clipboard + * + * @param {object} editor - the tiptap editor + * @param {string} first - text at the start of the selection + * @param {string} last - text at the end of the selection + */ +function copyText(editor, first, last) { + const from = findText(editor, first) + const to = findText(editor, last) + last.length + const selection = TextSelection.create(editor.state.doc, from, to) + editor.view.dispatch(editor.state.tr.setSelection(selection)) + return editor.view.serializeForClipboard(selection.content()).dom.innerHTML +} + +/** + * Position of the first occurrence of a text in the document + * + * @param {object} editor - the tiptap editor + * @param {string} text - the text to find + */ +function findText(editor, text) { + let found + editor.state.doc.descendants((node, pos) => { + if (found === undefined && node.isText && node.text.includes(text)) { + found = pos + node.text.indexOf(text) + } + return found === undefined + }) + return found +} + /** * Place the cursor at the start of a body cell * From 97ba1041d9eedf0152f4f02ca97604e3b145b95b Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 16 Oct 2026 18:27:42 +0200 Subject: [PATCH 3/5] fix(table): keep lists intact when pasting a single copied cell A single copied cell was pasted with its content opened as deep as possible. Text after the cursor was pulled into a list at the end of the cell, and a list at the start of the cell was merged into the paragraph at the cursor. Only merge with the text at the cursor where the cell content starts or ends with a paragraph. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Jonas --- src/nodes/Table/TableCell.ts | 16 +++++++++++++++- src/tests/nodes/Table.spec.js | 12 +++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/nodes/Table/TableCell.ts b/src/nodes/Table/TableCell.ts index fdd60265487..66e9f605881 100644 --- a/src/nodes/Table/TableCell.ts +++ b/src/nodes/Table/TableCell.ts @@ -113,7 +113,7 @@ export default TableCell.extend({ ) const cells = pastedCells(bodySlice) if (cells?.width === 1 && cells.height === 1) { - return Slice.maxOpen(cells.rows[0].firstChild!.content) + return openParagraphEdges(cells.rows[0].firstChild!.content) } if (cells) { // Inserted into the target cells by the paste handler of prosemirror-tables @@ -159,6 +159,20 @@ function unwrapCellContent(slice: Slice): Slice | null { return null } +/** + * Slice of the given blocks that only merges with the text around the cursor + * where the blocks start or end with a paragraph. + * + * @param content - the blocks to paste + */ +function openParagraphEdges(content: Fragment): Slice { + return new Slice( + content, + content.firstChild?.isTextblock ? 1 : 0, + content.lastChild?.isTextblock ? 1 : 0, + ) +} + /** * Convert header rows and cells to body rows and cells and drop captions, * so that pasted cells fit into body rows. diff --git a/src/tests/nodes/Table.spec.js b/src/tests/nodes/Table.spec.js index ef43cc6f4aa..c076dfe091f 100644 --- a/src/tests/nodes/Table.spec.js +++ b/src/tests/nodes/Table.spec.js @@ -338,7 +338,17 @@ describe('Table extension', () => { const html = copyCells(editor, 4, 4) placeCursorInCell(editor, 3) editor.view.pasteHTML(html) - expect(serializeMarkdown()).toBe('| H1 | H2 |\n|--------|----------|\n| a1 | b1 | \\\n| | | \\\n| | - itema2 |\n| b1 | b2 | \\\n| | | \\\n| - item | |\n') + expect(serializeMarkdown()).toBe('| H1 | H2 |\n|--------|--------|\n| a1 | b1 | \\\n| | | \\\n| | - item | \\\n| | | \\\n| | a2 |\n| b1 | b2 | \\\n| | | \\\n| - item | |\n') + }) + + test('single copied cell starting with a list keeps the list', ({ editor, serializeMarkdown }) => { + editor.commands.setContent('' + + '
H1H2
  • item

a1

xy

') + const html = copyCells(editor, 2, 2) + const pos = findText(editor, 'xy') + 1 + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))) + editor.view.pasteHTML(html) + expect(serializeMarkdown()).toBe('| H1 | H2 |\n|--------|--------|\n| - item | x | \\\n| | | \\\n| a1 | - item | \\\n| | | \\\n| | a1y |\n') }) test('copied block of cells overwrites target cells and adds rows', ({ editor, serializeMarkdown }) => { From 0d5e1b5d89640f60abadb3422e24e1ae1c46641d Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 16 Oct 2026 18:41:14 +0200 Subject: [PATCH 4/5] fix(table): keep add row button at table width Use the wide prop of NcButton instead, which disables the forced width. Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-opus-5-5 --- src/nodes/Table/TableView.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nodes/Table/TableView.vue b/src/nodes/Table/TableView.vue index 196952339de..a1f4e44d7c9 100644 --- a/src/nodes/Table/TableView.vue +++ b/src/nodes/Table/TableView.vue @@ -45,6 +45,7 @@ v-if="isEditable" class="table-add-row" size="small" + wide :aria-label="t('text', 'Add row below')" :title="t('text', 'Add row below')" @click="addRowAfter"> @@ -215,7 +216,7 @@ export default { // Needed to overwrite forced button width with nextcloud-vue v9 &.editable .table-add-row.button-vue { - width: calc(100% - var(--clickable-area-small) - 4px) !important; + width: calc(100% - var(--clickable-area-small) - 4px); } } From beb1386e9dbf2e9ab7a6c96c7b0e7ffcdf40c227 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 16 Oct 2026 19:12:45 +0200 Subject: [PATCH 5/5] fix(text): place cursor inside new cell after adding row or column The "Add row below" and "Add column after" buttons put the cursor at a position that has no inline content: in the new row's first cell before its paragraph, or in the header row before the new header cell. Typing there made ProseMirror wrap the text in a new node. A new body cell ended up with an extra empty paragraph below the typed text, and a new header row got an extra header cell. Move the cursor into the new cell's inline content instead. Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-opus-5-5 --- src/nodes/Table/TableView.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nodes/Table/TableView.vue b/src/nodes/Table/TableView.vue index a1f4e44d7c9..fe826b9f475 100644 --- a/src/nodes/Table/TableView.vue +++ b/src/nodes/Table/TableView.vue @@ -122,7 +122,7 @@ export default { .focus() .setTextSelection(this.getPos() + headerRowNode.nodeSize - 1) .addColumnAfter() - .setTextSelection(this.getPos() + headerRowNode.nodeSize) + .setTextSelection(this.getPos() + headerRowNode.nodeSize + 1) .run() }, @@ -133,7 +133,7 @@ export default { .focus() .setTextSelection(this.getPos() + this.node.nodeSize - lastRowNode.nodeSize + 1) .addRowAfter() - .setTextSelection(this.getPos() + this.node.nodeSize + 1) + .setTextSelection(this.getPos() + this.node.nodeSize + 2) .run() },