feat: table row/column drag and drop (#1467)

* chore: add dev container

* feat: add drag handle when hovering cell

* feat: add column drag and drop

* feat: add support for row drag and drop

* refactor: extract preview controllers

* fix: hover issue

* refactor: add handle controller

* chore: f

* chore: remove log

* chore: remove dev files

* feat: hide other drop indicators when table dnd working

* feat: add auto scroll and bug fix

* chore: f

* fix: firefox
This commit is contained in:
Mirone
2025-09-01 02:53:27 +09:00
committed by GitHub
parent aa58e272d6
commit 7d1e5bce0d
30 changed files with 1652 additions and 1 deletions
@@ -0,0 +1,44 @@
import type { Node } from '@tiptap/pm/model'
import { TableMap } from '@tiptap/pm/tables'
/**
* Convert an array of rows to a table node.
*
* @internal
*/
export function convertArrayOfRowsToTableNode(
tableNode: Node,
arrayOfNodes: (Node | null)[][],
): Node {
const rowsPM = []
const map = TableMap.get(tableNode)
for (let rowIndex = 0; rowIndex < map.height; rowIndex++) {
const row = tableNode.child(rowIndex)
const rowCells = []
for (let colIndex = 0; colIndex < map.width; colIndex++) {
if (!arrayOfNodes[rowIndex][colIndex]) continue
const cellPos = map.map[rowIndex * map.width + colIndex]
const cell = arrayOfNodes[rowIndex][colIndex]!
const oldCell = tableNode.nodeAt(cellPos)!
const newCell = oldCell.type.createChecked(
Object.assign({}, cell.attrs),
cell.content,
cell.marks,
)
rowCells.push(newCell)
}
rowsPM.push(row.type.createChecked(row.attrs, rowCells, row.marks))
}
const newTable = tableNode.type.createChecked(
tableNode.attrs,
rowsPM,
tableNode.marks,
)
return newTable
}
@@ -0,0 +1,61 @@
import type { Node } from '@tiptap/pm/model'
import { TableMap } from '@tiptap/pm/tables'
/**
* This function will transform the table node into a matrix of rows and columns
* respecting merged cells, for example this table:
*
* ```
* ┌──────┬──────┬─────────────┐
* │ A1 │ B1 │ C1 │
* ├──────┼──────┴──────┬──────┤
* │ A2 │ B2 │ │
* ├──────┼─────────────┤ D1 │
* │ A3 │ B3 │ C3 │ │
* └──────┴──────┴──────┴──────┘
* ```
*
* will be converted to the below:
*
* ```javascript
* [
* [A1, B1, C1, null],
* [A2, B2, null, D1],
* [A3, B3, C3, null],
* ]
* ```
* @internal
*/
export function convertTableNodeToArrayOfRows(tableNode: Node): (Node | null)[][] {
const map = TableMap.get(tableNode)
const rows: (Node | null)[][] = []
const rowCount = map.height
const colCount = map.width
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
const row: (Node | null)[] = []
for (let colIndex = 0; colIndex < colCount; colIndex++) {
let cellIndex = rowIndex * colCount + colIndex
let cellPos = map.map[cellIndex]
if (rowIndex > 0) {
const topCellIndex = cellIndex - colCount
const topCellPos = map.map[topCellIndex]
if (cellPos === topCellPos) {
row.push(null)
continue
}
}
if (colIndex > 0) {
const leftCellIndex = cellIndex - 1
const leftCellPos = map.map[leftCellIndex]
if (cellPos === leftCellPos) {
row.push(null)
continue
}
}
row.push(tableNode.nodeAt(cellPos))
}
rows.push(row)
}
return rows
}
@@ -0,0 +1,36 @@
import type { Selection } from '@tiptap/pm/state'
import { TableMap } from '@tiptap/pm/tables'
import { findTable } from './query'
import type { CellPos } from './types'
/**
* Returns an array of cells in a column(s), where `columnIndex` could be a column index or an array of column indexes.
*
* @internal
*/
export function getCellsInColumn(columnIndexes: number | number[], selection: Selection): CellPos[] | undefined {
const table = findTable(selection.$from)
if (!table) {
return
}
const map = TableMap.get(table.node)
const indexes = Array.isArray(columnIndexes) ? columnIndexes : [columnIndexes]
return indexes
.filter((index) => index >= 0 && index <= map.width - 1)
.flatMap((index) => {
const cells = map.cellsInRect({
left: index,
right: index + 1,
top: 0,
bottom: map.height,
})
return cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos)!
const pos = nodePos + table.start
return { pos, start: pos + 1, node, depth: table.depth + 2 }
})
})
}
@@ -0,0 +1,36 @@
import type { Selection } from '@tiptap/pm/state'
import { TableMap } from '@tiptap/pm/tables'
import { findTable } from './query'
import type { CellPos } from './types'
/**
* Returns an array of cells in a row(s), where `rowIndex` could be a row index or an array of row indexes.
*
* @internal
*/
export function getCellsInRow(rowIndex: number | number[], selection: Selection): CellPos[] | undefined {
const table = findTable(selection.$from)
if (!table) {
return
}
const map = TableMap.get(table.node)
const indexes = Array.isArray(rowIndex) ? rowIndex : [rowIndex]
return indexes
.filter((index) => index >= 0 && index <= map.height - 1)
.flatMap((index) => {
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: index,
bottom: index + 1,
})
return cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos)!
const pos = nodePos + table.start
return { pos, start: pos + 1, node, depth: table.depth + 2 }
})
})
}
@@ -0,0 +1,91 @@
import type { Transaction } from '@tiptap/pm/state'
import { getCellsInColumn } from './get-cells-in-column'
import { getCellsInRow } from './get-cells-in-row'
import type { CellSelectionRange } from './types'
/**
* Returns a range of rectangular selection spanning all merged cells around a
* column at index `columnIndex`.
*
* Original implementation from Atlassian (Apache License 2.0)
*
* https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-column.ts#editor/editor-tables/src/utils/get-selection-range-in-column.ts
*
* @internal
*/
export function getSelectionRangeInColumn(tr: Transaction, startColIndex: number, endColIndex: number = startColIndex): CellSelectionRange | undefined {
let startIndex = startColIndex
let endIndex = endColIndex
// looking for selection start column (startIndex)
for (let i = startColIndex; i >= 0; i--) {
const cells = getCellsInColumn(i, tr.selection)
if (cells) {
cells.forEach((cell) => {
const maybeEndIndex = cell.node.attrs.colspan + i - 1
if (maybeEndIndex >= startIndex) {
startIndex = i
}
if (maybeEndIndex > endIndex) {
endIndex = maybeEndIndex
}
})
}
}
// looking for selection end column (endIndex)
for (let i = startColIndex; i <= endIndex; i++) {
const cells = getCellsInColumn(i, tr.selection)
if (cells) {
cells.forEach((cell) => {
const maybeEndIndex = cell.node.attrs.colspan + i - 1
if (cell.node.attrs.colspan > 1 && maybeEndIndex > endIndex) {
endIndex = maybeEndIndex
}
})
}
}
// filter out columns without cells (where all rows have colspan > 1 in the same column)
const indexes = []
for (let i = startIndex; i <= endIndex; i++) {
const maybeCells = getCellsInColumn(i, tr.selection)
if (maybeCells && maybeCells.length > 0) {
indexes.push(i)
}
}
startIndex = indexes[0]
endIndex = indexes[indexes.length - 1]
const firstSelectedColumnCells = getCellsInColumn(startIndex, tr.selection)
const firstRowCells = getCellsInRow(0, tr.selection)
if (!firstSelectedColumnCells || !firstRowCells) {
return
}
const $anchor = tr.doc.resolve(
firstSelectedColumnCells[firstSelectedColumnCells.length - 1].pos,
)
let headCell
for (let i = endIndex; i >= startIndex; i--) {
const columnCells = getCellsInColumn(i, tr.selection)
if (columnCells && columnCells.length > 0) {
for (let j = firstRowCells.length - 1; j >= 0; j--) {
if (firstRowCells[j].pos === columnCells[0].pos) {
headCell = columnCells[0]
break
}
}
if (headCell) {
break
}
}
}
if (!headCell) {
return
}
const $head = tr.doc.resolve(headCell.pos)
return { $anchor, $head, indexes }
}
@@ -0,0 +1,89 @@
import type { Transaction } from '@tiptap/pm/state'
import { getCellsInColumn } from './get-cells-in-column'
import { getCellsInRow } from './get-cells-in-row'
import type { CellSelectionRange } from './types'
/**
* Returns a range of rectangular selection spanning all merged cells around a
* row at index `rowIndex`.
*
* Original implementation from Atlassian (Apache License 2.0)
*
* https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-row.ts#editor/editor-tables/src/utils/get-selection-range-in-row.ts
*
* @internal
*/
export function getSelectionRangeInRow(tr: Transaction, startRowIndex: number, endRowIndex: number = startRowIndex): CellSelectionRange | undefined {
let startIndex = startRowIndex
let endIndex = endRowIndex
// looking for selection start row (startIndex)
for (let i = startRowIndex; i >= 0; i--) {
const cells = getCellsInRow(i, tr.selection)
if (cells) {
cells.forEach((cell) => {
const maybeEndIndex = cell.node.attrs.rowspan + i - 1
if (maybeEndIndex >= startIndex) {
startIndex = i
}
if (maybeEndIndex > endIndex) {
endIndex = maybeEndIndex
}
})
}
}
// looking for selection end row (endIndex)
for (let i = startRowIndex; i <= endIndex; i++) {
const cells = getCellsInRow(i, tr.selection)
if (cells) {
cells.forEach((cell) => {
const maybeEndIndex = cell.node.attrs.rowspan + i - 1
if (cell.node.attrs.rowspan > 1 && maybeEndIndex > endIndex) {
endIndex = maybeEndIndex
}
})
}
}
// filter out rows without cells (where all columns have rowspan > 1 in the same row)
const indexes = []
for (let i = startIndex; i <= endIndex; i++) {
const maybeCells = getCellsInRow(i, tr.selection)
if (maybeCells && maybeCells.length > 0) {
indexes.push(i)
}
}
startIndex = indexes[0]
endIndex = indexes[indexes.length - 1]
const firstSelectedRowCells = getCellsInRow(startIndex, tr.selection)
const firstColumnCells = getCellsInColumn(0, tr.selection)
if (!firstSelectedRowCells || !firstColumnCells) {
return
}
const $anchor = tr.doc.resolve(firstSelectedRowCells[firstSelectedRowCells.length - 1].pos)
let headCell
for (let i = endIndex; i >= startIndex; i--) {
const rowCells = getCellsInRow(i, tr.selection)
if (rowCells && rowCells.length > 0) {
for (let j = firstColumnCells.length - 1; j >= 0; j--) {
if (firstColumnCells[j].pos === rowCells[0].pos) {
headCell = rowCells[0]
break
}
}
if (headCell) {
break
}
}
}
if (!headCell) {
return
}
const $head = tr.doc.resolve(headCell.pos)
return { $anchor, $head, indexes }
}
@@ -0,0 +1,3 @@
export * from './move-column'
export * from './move-row'
export * from './query'
@@ -0,0 +1,81 @@
import type { Node } from '@tiptap/pm/model'
import type { Transaction } from '@tiptap/pm/state'
import {
CellSelection,
TableMap,
} from '@tiptap/pm/tables'
import { convertArrayOfRowsToTableNode } from './convert-array-of-rows-to-table-node'
import { convertTableNodeToArrayOfRows } from './convert-table-node-to-array-of-rows'
import { getSelectionRangeInColumn } from './get-selection-range-in-column'
import { moveRowInArrayOfRows } from './move-row-in-array-of-rows'
import { findTable } from './query'
import { transpose } from './transpose'
export interface MoveColumnParams {
tr: Transaction
originIndex: number
targetIndex: number
select: boolean
pos: number
}
/**
* Move a column from index `origin` to index `target`.
*
* @internal
*/
export function moveColumn(moveColParams: MoveColumnParams): boolean {
const { tr, originIndex, targetIndex, select, pos } = moveColParams
const $pos = tr.doc.resolve(pos)
const table = findTable($pos)
if (!table) return false
const indexesOriginColumn = getSelectionRangeInColumn(tr, originIndex)?.indexes
const indexesTargetColumn = getSelectionRangeInColumn(tr, targetIndex)?.indexes
if (!indexesOriginColumn || !indexesTargetColumn) return false
if (indexesOriginColumn.includes(targetIndex)) return false
const newTable = moveTableColumn(
table.node,
indexesOriginColumn,
indexesTargetColumn,
0,
)
tr.replaceWith(
table.pos,
table.pos + table.node.nodeSize,
newTable,
)
if (!select) return true
const map = TableMap.get(newTable)
const start = table.start
const index = targetIndex
const lastCell = map.positionAt(map.height - 1, index, newTable)
const $lastCell = tr.doc.resolve(start + lastCell)
const firstCell = map.positionAt(0, index, newTable)
const $firstCell = tr.doc.resolve(start + firstCell)
tr.setSelection(CellSelection.colSelection($lastCell, $firstCell))
return true
}
function moveTableColumn(
table: Node,
indexesOrigin: number[],
indexesTarget: number[],
direction: -1 | 1 | 0,
) {
let rows = transpose(convertTableNodeToArrayOfRows(table))
rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction)
rows = transpose(rows)
return convertArrayOfRowsToTableNode(table, rows)
}
@@ -0,0 +1,30 @@
/**
* Move a row in an array of rows.
*
* @internal
*/
export function moveRowInArrayOfRows<T>(
rows: T[],
indexesOrigin: number[],
indexesTarget: number[],
directionOverride: -1 | 1 | 0,
): T[] {
const direction = indexesOrigin[0] > indexesTarget[0] ? -1 : 1
const rowsExtracted = rows.splice(indexesOrigin[0], indexesOrigin.length)
const positionOffset = rowsExtracted.length % 2 === 0 ? 1 : 0
let target: number
if (directionOverride === -1 && direction === 1) {
target = indexesTarget[0] - 1
} else if (directionOverride === 1 && direction === -1) {
target = indexesTarget[indexesTarget.length - 1] - positionOffset + 1
} else {
target = direction === -1
? indexesTarget[0]
: indexesTarget[indexesTarget.length - 1] - positionOffset
}
rows.splice(target, 0, ...rowsExtracted)
return rows
}
@@ -0,0 +1,74 @@
import type { Node } from '@tiptap/pm/model'
import type { Transaction } from '@tiptap/pm/state'
import {
CellSelection,
TableMap,
} from '@tiptap/pm/tables'
import { convertArrayOfRowsToTableNode } from './convert-array-of-rows-to-table-node'
import { convertTableNodeToArrayOfRows } from './convert-table-node-to-array-of-rows'
import { getSelectionRangeInRow } from './get-selection-range-in-row'
import { moveRowInArrayOfRows } from './move-row-in-array-of-rows'
import { findTable } from './query'
export interface MoveRowParams {
tr: Transaction
originIndex: number
targetIndex: number
select: boolean
pos: number
}
/**
* Move a row from index `origin` to index `target`.
*
* @internal
*/
export function moveRow(moveRowParams: MoveRowParams): boolean {
const { tr, originIndex, targetIndex, select, pos } = moveRowParams
const $pos = tr.doc.resolve(pos)
const table = findTable($pos)
if (!table) return false
const indexesOriginRow = getSelectionRangeInRow(tr, originIndex)?.indexes
const indexesTargetRow = getSelectionRangeInRow(tr, targetIndex)?.indexes
if (!indexesOriginRow || !indexesTargetRow) return false
if (indexesOriginRow.includes(targetIndex)) return false
const newTable = moveTableRow(table.node, indexesOriginRow, indexesTargetRow, 0)
tr.replaceWith(
table.pos,
table.pos + table.node.nodeSize,
newTable,
)
if (!select) return true
const map = TableMap.get(newTable)
const start = table.start
const index = targetIndex
const lastCell = map.positionAt(index, map.width - 1, newTable)
const $lastCell = tr.doc.resolve(start + lastCell)
const firstCell = map.positionAt(index, 0, newTable)
const $firstCell = tr.doc.resolve(start + firstCell)
tr.setSelection(CellSelection.rowSelection($lastCell, $firstCell))
return true
}
function moveTableRow(
table: Node,
indexesOrigin: number[],
indexesTarget: number[],
direction: -1 | 1 | 0,
) {
let rows = convertTableNodeToArrayOfRows(table)
rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction)
return convertArrayOfRowsToTableNode(table, rows)
}
@@ -0,0 +1,122 @@
import type {
Node,
ResolvedPos,
} from '@tiptap/pm/model'
import type { Selection } from '@tiptap/pm/state'
import {
cellAround,
cellNear,
CellSelection,
inSameTable,
} from '@tiptap/pm/tables'
/**
* Checks if the given object is a `CellSelection` instance.
*
* @public
*/
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection
}
/**
* Find the closest table node.
*
* @internal
*/
export function findTable($pos: ResolvedPos): FindParentNodeResult | undefined {
return findParentNode((node) => node.type.spec.tableRole === 'table', $pos)
}
/**
* Try to find the anchor and head cell in the same table by using the given
* anchor and head as hit points, or fallback to the selection's anchor and
* head.
*
* @internal
*/
export function findCellRange(
selection: Selection,
anchorHit?: number,
headHit?: number,
): [ResolvedPos, ResolvedPos] | undefined {
if (anchorHit == null && headHit == null && isCellSelection(selection)) {
return [selection.$anchorCell, selection.$headCell]
}
const anchor: number = anchorHit ?? headHit ?? selection.anchor
const head: number = headHit ?? anchorHit ?? selection.head
const doc = selection.$head.doc
const $anchorCell = findCellPos(doc, anchor)
const $headCell = findCellPos(doc, head)
if ($anchorCell && $headCell && inSameTable($anchorCell, $headCell)) {
return [$anchorCell, $headCell]
}
}
/**
* Try to find a resolved pos of a cell by using the given pos as a hit point.
*
* @internal
*/
export function findCellPos(
doc: Node,
pos: number,
): ResolvedPos | undefined {
const $pos = doc.resolve(pos)
return cellAround($pos) || cellNear($pos)
}
/**
* @internal
*/
export interface FindParentNodeResult {
/**
* The closest parent node that satisfies the predicate.
*/
node: Node
/**
* The position directly before the node.
*/
pos: number
/**
* The position at the start of the node.
*/
start: number
/**
* The depth of the node.
*/
depth: number
}
/**
* Find the closest parent node that satisfies the predicate.
*
* @internal
*/
export function findParentNode(
/**
* The predicate to test the parent node.
*/
predicate: (node: Node) => boolean,
/**
* The position to start searching from.
*/
$pos: ResolvedPos,
): FindParentNodeResult | undefined {
for (let depth = $pos.depth; depth >= 0; depth -= 1) {
const node = $pos.node(depth)
if (predicate(node)) {
const pos = depth === 0 ? 0 : $pos.before(depth)
const start = $pos.start(depth)
return { node, pos, start, depth }
}
}
}
@@ -0,0 +1,29 @@
/**
* Transposes a 2D array by flipping columns to rows.
*
* Transposition is a familiar algebra concept where the matrix is flipped
* along its diagonal. For more details, see:
* https://en.wikipedia.org/wiki/Transpose
*
* @example
* ```javascript
* const arr = [
* ['a1', 'a2', 'a3'],
* ['b1', 'b2', 'b3'],
* ['c1', 'c2', 'c3'],
* ['d1', 'd2', 'd3'],
* ];
*
* const result = transpose(arr);
* result === [
* ['a1', 'b1', 'c1', 'd1'],
* ['a2', 'b2', 'c2', 'd2'],
* ['a3', 'b3', 'c3', 'd3'],
* ]
* ```
*/
export function transpose<T>(array: T[][]): T[][] {
return array[0].map((_, i) => {
return array.map((column) => column[i])
})
}
@@ -0,0 +1,18 @@
import type {
Node,
ResolvedPos,
} from '@tiptap/pm/model'
export type CellPos = {
pos: number
start: number
depth: number
node: Node
}
export type CellSelectionRange = {
$anchor: ResolvedPos
$head: ResolvedPos
// an array of column/row indexes
indexes: number[]
}