diff --git a/plugins/text-editor-assets/assets/icons.svg b/plugins/text-editor-assets/assets/icons.svg index 0de43e2d89..56514789e4 100644 --- a/plugins/text-editor-assets/assets/icons.svg +++ b/plugins/text-editor-assets/assets/icons.svg @@ -207,4 +207,11 @@ + + + + + + + \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/en.json b/plugins/text-editor-assets/lang/en.json index cceeb8ebd7..8677c3e156 100644 --- a/plugins/text-editor-assets/lang/en.json +++ b/plugins/text-editor-assets/lang/en.json @@ -46,9 +46,12 @@ "AddRowAfter": "Add after", "DeleteRow": "Delete", "DeleteTable": "Delete", + "MergeCells": "Merge cells", + "SplitCells": "Split cells", "Duplicate": "Duplicate", "CategoryRow": "Rows", "CategoryColumn": "Columns", + "CategoryCell": "Cells", "Table": "Table", "InsertTable": "Insert table", "TableOptions": "Customize table", diff --git a/plugins/text-editor-assets/lang/ru.json b/plugins/text-editor-assets/lang/ru.json index e1cea576aa..704ab81366 100644 --- a/plugins/text-editor-assets/lang/ru.json +++ b/plugins/text-editor-assets/lang/ru.json @@ -46,9 +46,12 @@ "AddRowAfter": "Добавить после", "DeleteRow": "Удалить", "DeleteTable": "Удалить", + "MergeCells": "Объединить ячейки", + "SplitCells": "Разделить ячейки", "Duplicate": "Дублировать", "CategoryRow": "Строки", "CategoryColumn": "Колонки", + "CategoryCell": "Ячейки", "Table": "Таблица", "InsertTable": "Добавить таблицу", "TableOptions": "Настроить таблицу", diff --git a/plugins/text-editor-assets/src/index.ts b/plugins/text-editor-assets/src/index.ts index 43a450ce20..0a4173dbd5 100644 --- a/plugins/text-editor-assets/src/index.ts +++ b/plugins/text-editor-assets/src/index.ts @@ -41,5 +41,7 @@ loadMetadata(textEditor.icon, { Download: `${icons}#download`, Note: `${icons}#note`, Comment: `${icons}#comment`, - SelectTable: `${icons}#move` + SelectTable: `${icons}#move`, + MergeCells: `${icons}#union`, + SplitCells: `${icons}#divide` }) diff --git a/plugins/text-editor-resources/src/components/extension/table/TableNodeView.svelte b/plugins/text-editor-resources/src/components/extension/table/TableNodeView.svelte index 48896c0afc..fb164977d2 100644 --- a/plugins/text-editor-resources/src/components/extension/table/TableNodeView.svelte +++ b/plugins/text-editor-resources/src/components/extension/table/TableNodeView.svelte @@ -175,7 +175,7 @@ } &__col { - right: -1.5rem; + right: calc(var(--table-offscreen-spacing) - 1.5rem); top: 0; bottom: 0; margin: 1.25rem 0; @@ -188,7 +188,7 @@ &__row { bottom: -0.25rem; left: var(--table-offscreen-spacing); - right: 0; + right: var(--table-offscreen-spacing); .table-button { height: 1.25rem; diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/actions.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/actions.ts index a7f022b089..81710e85f8 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/actions.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/actions.ts @@ -13,25 +13,76 @@ // limitations under the License. // -import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { Fragment, type Node, type Node as ProseMirrorNode } from '@tiptap/pm/model' import type { Transaction } from '@tiptap/pm/state' -import { TableMap } from '@tiptap/pm/tables' +import { type CellSelection, TableMap } from '@tiptap/pm/tables' import type { TableNodeLocation } from '../types' +import { type Editor } from '@tiptap/core' type TableRow = Array type TableRows = TableRow[] -export function moveColumn (table: TableNodeLocation, from: number, to: number, tr: Transaction): Transaction { - const cols = transpose(tableToCells(table)) - moveRowInplace(cols, from, to) - tableFromCells(table, transpose(cols), tr) +export function moveSelectedColumns ( + editor: Editor, + table: TableNodeLocation, + selection: CellSelection, + to: number, + tr: Transaction +): Transaction { + const tableMap = TableMap.get(table.node) + + let columnStart = -1 + let columnEnd = -1 + + selection.forEachCell((node, pos) => { + const cell = tableMap.findCell(pos - table.pos - 1) + for (let i = cell.left; i < cell.right; i++) { + columnStart = columnStart >= 0 ? Math.min(cell.left, columnStart) : cell.left + columnEnd = columnEnd >= 0 ? Math.max(cell.right, columnEnd) : cell.right + } + }) + + if (to < 0 || to > tableMap.width || (to >= columnStart && to < columnEnd)) return tr + + const rows = tableToCells(table) + for (const row of rows) { + const range = row.splice(columnStart, columnEnd - columnStart) + const offset = to > columnStart ? to - (columnEnd - columnStart - 1) : to + row.splice(offset, 0, ...range) + } + + tableFromCells(editor, table, rows, tr) return tr } -export function moveRow (table: TableNodeLocation, from: number, to: number, tr: Transaction): Transaction { +export function moveSelectedRows ( + editor: Editor, + table: TableNodeLocation, + selection: CellSelection, + to: number, + tr: Transaction +): Transaction { + const tableMap = TableMap.get(table.node) + + let rowStart = -1 + let rowEnd = -1 + + selection.forEachCell((node, pos) => { + const cell = tableMap.findCell(pos - table.pos - 1) + for (let i = cell.top; i < cell.bottom; i++) { + rowStart = rowStart >= 0 ? Math.min(cell.top, rowStart) : cell.top + rowEnd = rowEnd >= 0 ? Math.max(cell.bottom, rowEnd) : cell.bottom + } + }) + + if (to < 0 || to > tableMap.height || (to >= rowStart && to < rowEnd)) return tr + const rows = tableToCells(table) - moveRowInplace(rows, from, to) - tableFromCells(table, rows, tr) + const range = rows.splice(rowStart, rowEnd - rowStart) + const offset = to > rowStart ? to - (rowEnd - rowStart - 1) : to + rows.splice(offset, 0, ...range) + + tableFromCells(editor, table, rows, tr) return tr } @@ -78,23 +129,17 @@ export function duplicateColumns (table: TableNodeLocation, columnIndices: numbe return tr } -function moveRowInplace (rows: TableRows, from: number, to: number): void { - rows.splice(to, 0, rows.splice(from, 1)[0]) -} - -function transpose (rows: TableRows): TableRows { - return rows[0].map((_, colIdx) => rows.map((row) => row[colIdx])) -} - function tableToCells (table: TableNodeLocation): TableRows { const { map, width, height } = TableMap.get(table.node) + const visitedCells = new Set() const rows = [] for (let row = 0; row < height; row++) { const cells = [] for (let col = 0; col < width; col++) { const pos = map[row * width + col] - cells.push(table.node.nodeAt(pos)) + cells.push(!visitedCells.has(pos) ? table.node.nodeAt(pos) : null) + visitedCells.add(pos) } rows.push(cells) } @@ -102,23 +147,11 @@ function tableToCells (table: TableNodeLocation): TableRows { return rows } -function tableFromCells (table: TableNodeLocation, rows: TableRows, tr: Transaction): void { - const { map, width, height } = TableMap.get(table.node) - const mapStart = tr.mapping.maps.length - - for (let row = 0; row < height; row++) { - for (let col = 0; col < width; col++) { - const pos = map[row * width + col] - - const oldCell = table.node.nodeAt(pos) - const newCell = rows[row][col] - - if (oldCell !== null && newCell !== null && oldCell !== newCell) { - const start = tr.mapping.slice(mapStart).map(table.start + pos) - const end = start + oldCell.nodeSize - - tr.replaceWith(start, end, newCell) - } - } - } +function tableFromCells (editor: Editor, table: TableNodeLocation, rows: TableRows, tr: Transaction): void { + const schema = editor.schema.nodes + const newRowNodes = rows.map((row) => + schema.tableRow.create(null, row.filter((cell) => cell !== null) as readonly Node[]) + ) + const newTableNode = table.node.copy(Fragment.from(newRowNodes)) + tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTableNode) } diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts index e3cf2ae58c..762c4a3dad 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts @@ -15,14 +15,14 @@ import { type Editor } from '@tiptap/core' import { type EditorState } from '@tiptap/pm/state' -import { TableMap } from '@tiptap/pm/tables' +import { CellSelection, TableMap } from '@tiptap/pm/tables' import { Decoration } from '@tiptap/pm/view' import textEditor from '@hcengineering/text-editor' import { type TableNodeLocation } from '../types' import { findTable, getSelectedColumns, isColumnSelected, selectColumn } from '../utils' -import { duplicateColumns, moveColumn } from './actions' +import { duplicateColumns, moveSelectedColumns } from './actions' import DeleteCol from '../../../icons/table/DeleteCol.svelte' import Duplicate from '../../../icons/table/Duplicate.svelte' import { createCellsHandle, type OptionItem } from './cellsHandle' @@ -120,8 +120,10 @@ const handleMouseDown = ( if (col !== dropIndex) { let tr = editor.state.tr - tr = selectColumn(table, dropIndex, tr) - tr = moveColumn(table, col, dropIndex, tr) + const selection = editor.state.selection + if (selection instanceof CellSelection) { + tr = moveSelectedColumns(editor, table, selection, dropIndex, tr) + } editor.view.dispatch(tr) } window.removeEventListener('mouseup', handleFinish) diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts index 1f8b11a1a3..4ff95067f7 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts @@ -15,14 +15,14 @@ import { type Editor } from '@tiptap/core' import { type EditorState } from '@tiptap/pm/state' -import { TableMap } from '@tiptap/pm/tables' +import { CellSelection, TableMap } from '@tiptap/pm/tables' import { Decoration } from '@tiptap/pm/view' import textEditor from '@hcengineering/text-editor' import { type TableNodeLocation } from '../types' import { findTable, getSelectedRows, isRowSelected, selectRow } from '../utils' -import { duplicateRows, moveRow } from './actions' +import { duplicateRows, moveSelectedRows } from './actions' import DeleteRow from '../../../icons/table/DeleteRow.svelte' import Duplicate from '../../../icons/table/Duplicate.svelte' import { createCellsHandle, type OptionItem } from './cellsHandle' @@ -120,8 +120,10 @@ const handleMouseDown = ( if (row !== dropIndex) { let tr = editor.state.tr - tr = selectRow(table, dropIndex, tr) - tr = moveRow(table, row, dropIndex, tr) + const selection = editor.state.selection + if (selection instanceof CellSelection) { + tr = moveSelectedRows(editor, table, selection, dropIndex, tr) + } editor.view.dispatch(tr) } window.removeEventListener('mouseup', handleFinish) diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts index c6197e511c..6962b79414 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts @@ -60,10 +60,17 @@ function getTableCellBorders ( const { width, height } = tableMap const cellIndex = tableMap.map.indexOf(cell) + const rect = tableMap.findCell(cell) + const cellW = rect.right - rect.left + const cellH = rect.bottom - rect.top + + const testRight = cellW + const testBottom = width * cellH + const topCell = cellIndex >= width ? tableMap.map[cellIndex - width] : undefined - const bottomCell = cellIndex < width * height - width ? tableMap.map[cellIndex + width] : undefined - const leftCell = cellIndex % width !== 0 ? tableMap.map[cellIndex - 1] : undefined - const rightCell = cellIndex % width !== width - 1 ? tableMap.map[cellIndex + 1] : undefined + const bottomCell = cellIndex < width * height - testBottom ? tableMap.map[cellIndex + testBottom] : undefined + const leftCell = cellIndex % width > 0 ? tableMap.map[cellIndex - 1] : undefined + const rightCell = cellIndex % width < width - testRight ? tableMap.map[cellIndex + testRight] : undefined return { top: topCell === undefined || !selection.includes(topCell), diff --git a/plugins/text-editor-resources/src/components/extension/table/table.ts b/plugins/text-editor-resources/src/components/extension/table/table.ts index c130cdf7bd..0ce0cc0c54 100644 --- a/plugins/text-editor-resources/src/components/extension/table/table.ts +++ b/plugins/text-editor-resources/src/components/extension/table/table.ts @@ -116,6 +116,24 @@ export async function openTableOptions (editor: Editor, event: MouseEvent): Prom label: textEditor.string.CategoryRow } }, + { + id: '#mergeCells', + icon: textEditor.icon.MergeCells, + label: textEditor.string.MergeCells, + action: () => editor.commands.mergeCells(), + category: { + label: textEditor.string.CategoryCell + } + }, + { + id: '#splitCell', + icon: textEditor.icon.SplitCells, + label: textEditor.string.SplitCells, + action: () => editor.commands.splitCell(), + category: { + label: textEditor.string.CategoryCell + } + }, { id: '#deleteTable', icon: DeleteTable, diff --git a/plugins/text-editor-resources/src/components/extension/table/tableCell.ts b/plugins/text-editor-resources/src/components/extension/table/tableCell.ts index f34e8c603f..0bbf96dcce 100644 --- a/plugins/text-editor-resources/src/components/extension/table/tableCell.ts +++ b/plugins/text-editor-resources/src/components/extension/table/tableCell.ts @@ -18,17 +18,19 @@ import TiptapTableCell from '@tiptap/extension-table-cell' import { Plugin, PluginKey, type Selection } from '@tiptap/pm/state' import { DecorationSet } from '@tiptap/pm/view' -import { findTable } from './utils' +import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables' import { columnHandlerDecoration } from './decorations/columnHandlerDecoration' import { columnInsertDecoration } from './decorations/columnInsertDecoration' +import { rowHandlerDecoration } from './decorations/rowHandlerDecoration' import { rowInsertDecoration } from './decorations/rowInsertDecoration' import { tableDragMarkerDecoration } from './decorations/tableDragMarkerDecoration' import { tableSelectionDecoration } from './decorations/tableSelectionDecoration' -import { rowHandlerDecoration } from './decorations/rowHandlerDecoration' +import { findTable } from './utils' +import { type Node } from '@tiptap/pm/model' export const TableCell = TiptapTableCell.extend({ addProseMirrorPlugins () { - return [tableCellDecorationPlugin(this.editor)] + return [tableCellDecorationPlugin(this.editor), tableSelectionNormalizer()] } }) @@ -78,3 +80,80 @@ const tableCellDecorationPlugin = (editor: Editor): Plugin => { + return new Plugin({ + appendTransaction: (transactions, oldState, newState) => { + const selection = newState.selection + if (selection.eq(oldState.selection) || !(selection instanceof CellSelection)) return + + const table = findTable(newState.selection) + if (table === undefined) return + + const tableMap = TableMap.get(table.node) + + let rect: Rect | undefined + + const walkCell = (pos: number): void => { + const cell = tableMap.findCell(pos) + if (cell === undefined) return + + if (rect === undefined) { + rect = { ...cell } + } else { + rect.left = Math.min(rect.left, cell.left) + rect.top = Math.min(rect.top, cell.top) + + rect.right = Math.max(rect.right, cell.right) + rect.bottom = Math.max(rect.bottom, cell.bottom) + } + } + + selection.forEachCell((_node, pos) => { + walkCell(pos - table.pos - 1) + }) + if (rect === undefined) return + + const rectSelection: number[] = [] + for (let row = rect.top; row < rect.bottom; row++) { + for (let col = rect.left; col < rect.right; col++) { + rectSelection.push(tableMap.map[row * tableMap.width + col]) + } + } + rectSelection.forEach((pos) => { + walkCell(pos) + }) + + if (rect === undefined) return + + // Original promemirror implementation of TableMap.positionAt skips rowspawn cells, which leads to unpredictable selection behaviour + const firstCellOffset = cellPositionAt(tableMap, rect.bottom - 1, rect.right - 1, table.node) + const lastCellOffset = cellPositionAt(tableMap, rect.top, rect.left, table.node) + + const firstCellPos = newState.doc.resolve(table.start + firstCellOffset) + const lastCellPos = newState.doc.resolve(table.start + lastCellOffset) + + const reverseOrder = selection.$anchorCell.pos > selection.$headCell.pos + const $head = reverseOrder ? lastCellPos : firstCellPos + const $anchor = reverseOrder ? firstCellPos : lastCellPos + + const newSelection = new CellSelection($anchor, $head) + + if (newSelection.eq(newState.selection)) return + + return newState.tr.setSelection(new CellSelection($anchor, $head)) + } + }) +} + +function cellPositionAt (tableMap: TableMap, row: number, col: number, table: Node): number { + for (let i = 0, rowStart = 0; ; i++) { + const rowEnd = rowStart + table.child(i).nodeSize + if (i === row) { + const index = col + row * tableMap.width + const rowEndIndex = (row + 1) * tableMap.width + return index === rowEndIndex ? rowEnd - 1 : tableMap.map[index] + } + rowStart = rowEnd + } +} diff --git a/plugins/text-editor/src/plugin.ts b/plugins/text-editor/src/plugin.ts index c3e95c1692..408caec562 100644 --- a/plugins/text-editor/src/plugin.ts +++ b/plugins/text-editor/src/plugin.ts @@ -84,11 +84,14 @@ export default plugin(textEditorId, { DeleteColumn: '' as IntlString, AddRowBefore: '' as IntlString, AddRowAfter: '' as IntlString, + MergeCells: '' as IntlString, + SplitCells: '' as IntlString, DeleteRow: '' as IntlString, DeleteTable: '' as IntlString, Duplicate: '' as IntlString, CategoryRow: '' as IntlString, CategoryColumn: '' as IntlString, + CategoryCell: '' as IntlString, Table: '' as IntlString, TableOptions: '' as IntlString, SelectTable: '' as IntlString, @@ -125,6 +128,8 @@ export default plugin(textEditorId, { Download: '' as Asset, Note: '' as Asset, Comment: '' as Asset, - SelectTable: '' as Asset + SelectTable: '' as Asset, + MergeCells: '' as Asset, + SplitCells: '' as Asset } })