Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 95 additions & 20 deletions src/nodes/Table/TableCell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+',
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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)
}
10 changes: 9 additions & 1 deletion src/nodes/Table/TableRow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
]
},
})
7 changes: 4 additions & 3 deletions src/nodes/Table/TableView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down Expand Up @@ -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()
},

Expand All @@ -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()
},

Expand Down Expand Up @@ -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);
}
}
</style>
165 changes: 165 additions & 0 deletions src/tests/nodes/Table.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = '<table><tr><th>H1</th><th>H2</th></tr>'
+ '<tr><td><p>a1</p></td><td><p>a2</p></td></tr>'
+ '<tr><td><p>b1</p><ul><li><p>item</p></li></ul></td><td><p>b2</p></td></tr></table>'

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('<table><tr><th>H1</th><th>H2</th></tr>'
+ '<tr><td><ul><li><p>item</p></li></ul><p>a1</p></td><td><p>xy</p></td></tr></table>')
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('<table><colgroup><col width="85"></colgroup>'
+ '<tr><td>x1</td><td>y1</td></tr>'
+ '<tr><td>x2</td><td><b>y2</b></td></tr></table>')
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('<p>text</p><table><tr><td>x1</td></tr><tr><td>x2</td></tr></table>')
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) {
Expand Down Expand Up @@ -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)))
}
1 change: 1 addition & 0 deletions src/tests/testHelpers/nextcloudMocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ vi.mock('@nextcloud/files', () => ({
vi.mock('@nextcloud/dialogs', () => ({
FilePickerType: {},
getFilePickerBuilder: () => {},
showWarning: vi.fn(),
}))

vi.mock('@nextcloud/capabilities', async () => {
Expand Down
Loading