diff --git a/src/nodes/Table/TableCell.ts b/src/nodes/Table/TableCell.ts index 4e254597458..66e9f605881 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,23 @@ 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 cellContent = unwrapCellContent(slice) + if (cellContent) { + return cellContent + } + + 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 openParagraphEdges(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 +135,70 @@ 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 +} + +/** + * 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. + * + * @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/nodes/Table/TableView.vue b/src/nodes/Table/TableView.vue index 196952339de..fe826b9f475 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"> @@ -121,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() }, @@ -132,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() }, @@ -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); } } diff --git a/src/tests/nodes/Table.spec.js b/src/tests/nodes/Table.spec.js index c2e9ddac010..c076dfe091f 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,96 @@ 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| | - 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 }) => { + 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') + }) + + 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') + }) + }) }) function getHeaderCell(editor, targetIndex = 0) { @@ -359,3 +452,75 @@ 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 +} + +/** + * 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 + * + * @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 () => {