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-08-31 18:53:27 +01:00
committed by GitHub
parent aa58e272d6
commit 7d1e5bce0d
30 changed files with 1652 additions and 1 deletions
@@ -0,0 +1,76 @@
import { DraggingDOMs } from "./utils";
const EDGE_THRESHOLD = 100;
const SCROLL_SPEED = 10;
export class AutoScrollController {
private _autoScrollInterval?: number;
checkYAutoScroll = (clientY: number) => {
const scrollContainer = document.documentElement;
if (clientY < 0 + EDGE_THRESHOLD) {
this._startYAutoScroll(scrollContainer!, -1 * SCROLL_SPEED);
} else if (clientY > window.innerHeight - EDGE_THRESHOLD) {
this._startYAutoScroll(scrollContainer!, SCROLL_SPEED);
} else {
this._stopYAutoScroll();
}
}
checkXAutoScroll = (clientX: number, draggingDOMs: DraggingDOMs) => {
const table = draggingDOMs?.table;
if (!table) return;
const scrollContainer = table.closest<HTMLElement>('.tableWrapper');
const editorRect = scrollContainer.getBoundingClientRect();
if (!scrollContainer) return;
if (clientX < editorRect.left + EDGE_THRESHOLD) {
this._startXAutoScroll(scrollContainer!, -1 * SCROLL_SPEED);
} else if (clientX > editorRect.right - EDGE_THRESHOLD) {
this._startXAutoScroll(scrollContainer!, SCROLL_SPEED);
} else {
this._stopXAutoScroll();
}
}
stop = () => {
this._stopXAutoScroll();
this._stopYAutoScroll();
}
private _startXAutoScroll = (scrollContainer: HTMLElement, speed: number) => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
}
this._autoScrollInterval = window.setInterval(() => {
scrollContainer.scrollLeft += speed;
}, 16);
}
private _stopXAutoScroll = () => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
this._autoScrollInterval = undefined;
}
}
private _startYAutoScroll = (scrollContainer: HTMLElement, speed: number) => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
}
this._autoScrollInterval = window.setInterval(() => {
scrollContainer.scrollTop += speed;
}, 16);
}
private _stopYAutoScroll = () => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
this._autoScrollInterval = undefined;
}
}
}
@@ -0,0 +1,44 @@
function findDragOverElement(
elements: Element[],
pointer: number,
axis: 'x' | 'y',
): [Element, number] | undefined {
const startProp = axis === 'x' ? 'left' : 'top'
const endProp = axis === 'x' ? 'right' : 'bottom'
const lastIndex = elements.length - 1
const index = elements.findIndex((el, index) => {
const rect = el.getBoundingClientRect()
const boundaryStart = rect[startProp]
const boundaryEnd = rect[endProp]
// The pointer is within the boundary of the current element.
if (boundaryStart <= pointer && pointer <= boundaryEnd) return true
// The pointer is beyond the last element.
if (index === lastIndex && pointer > boundaryEnd) return true
// The pointer is before the first element.
if (index === 0 && pointer < boundaryStart) return true
return false
})
return index >= 0 ? [elements[index], index] : undefined
}
export function getDragOverColumn(
table: HTMLTableElement,
pointerX: number,
): [element: Element, index: number] | undefined {
const firstRow = table.querySelector('tr')
if (!firstRow) return
const cells = Array.from(firstRow.children)
return findDragOverElement(cells, pointerX, 'x')
}
export function getDragOverRow(
table: HTMLTableElement,
pointerY: number,
): [element: Element, index: number] | undefined {
const rows = Array.from(table.querySelectorAll('tr'))
return findDragOverElement(rows, pointerY, 'y')
}
@@ -0,0 +1,271 @@
import { Editor, Extension } from "@tiptap/core";
import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state";
import { EditorProps, EditorView } from "@tiptap/pm/view";
import { DraggingDOMs, getDndRelatedDOMs, getHoveringCell, HoveringCellInfo } from "./utils";
import { getDragOverColumn, getDragOverRow } from "./calc-drag-over";
import { moveColumn, moveRow } from "../utils";
import { PreviewController } from "./preview/preview-controller";
import { DropIndicatorController } from "./preview/drop-indicator-controller";
import { DragHandleController } from "./handle/drag-handle-controller";
import { EmptyImageController } from "./handle/empty-image-controller";
import { AutoScrollController } from "./auto-scroll-controller";
export const TableDndKey = new PluginKey('table-drag-and-drop')
class TableDragHandlePluginSpec implements PluginSpec<void> {
key = TableDndKey
props: EditorProps<Plugin<void>>
private _colDragHandle: HTMLElement;
private _rowDragHandle: HTMLElement;
private _hoveringCell?: HoveringCellInfo;
private _disposables: (() => void)[] = [];
private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 };
private _dragging = false;
private _draggingDirection: 'col' | 'row' = 'col';
private _draggingIndex = -1;
private _droppingIndex = -1;
private _draggingDOMs?: DraggingDOMs | undefined
private _startCoords: { x: number; y: number } = { x: 0, y: 0 };
private _previewController: PreviewController;
private _dropIndicatorController: DropIndicatorController;
private _dragHandleController: DragHandleController;
private _emptyImageController: EmptyImageController;
private _autoScrollController: AutoScrollController;
constructor(public editor: Editor) {
this.props = {
handleDOMEvents: {
pointerover: this._pointerOver,
}
}
this._dragHandleController = new DragHandleController();
this._colDragHandle = this._dragHandleController.colDragHandle;
this._rowDragHandle = this._dragHandleController.rowDragHandle;
this._previewController = new PreviewController();
this._dropIndicatorController = new DropIndicatorController();
this._emptyImageController = new EmptyImageController();
this._autoScrollController = new AutoScrollController();
this._bindDragEvents();
}
view = () => {
const wrapper = this.editor.options.element;
wrapper.appendChild(this._colDragHandle)
wrapper.appendChild(this._rowDragHandle)
wrapper.appendChild(this._previewController.previewRoot)
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot)
return {
update: this.update,
destroy: this.destroy,
}
}
update = () => {}
destroy = () => {
if (!this.editor.isDestroyed) return;
this._dragHandleController.destroy();
this._emptyImageController.destroy();
this._previewController.destroy();
this._dropIndicatorController.destroy();
this._autoScrollController.stop();
this._disposables.forEach(disposable => disposable());
}
private _pointerOver = (view: EditorView, event: PointerEvent) => {
if (this._dragging) return;
const hoveringCell = getHoveringCell(view, event)
this._hoveringCell = hoveringCell;
if (!hoveringCell) {
this._dragHandleController.hide();
} else {
this._dragHandleController.show(this.editor, hoveringCell);
}
}
private _onDragColStart = (event: DragEvent) => {
this._onDragStart(event, 'col');
}
private _onDraggingCol = (event: DragEvent) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs) return;
this._draggingCoords = { x: event.clientX, y: event.clientY };
this._previewController.onDragging(draggingDOMs, this._draggingCoords.x, this._draggingCoords.y, 'col');
this._autoScrollController.checkXAutoScroll(event.clientX, draggingDOMs);
const direction = this._startCoords.x > this._draggingCoords.x ? 'left' : 'right';
const dragOverColumn = getDragOverColumn(draggingDOMs.table, this._draggingCoords.x);
if (!dragOverColumn) return;
const [col, index] = dragOverColumn;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(col, direction, 'col');
}
private _onDragRowStart = (event: DragEvent) => {
this._onDragStart(event, 'row');
}
private _onDraggingRow = (event: DragEvent) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs) return;
this._draggingCoords = { x: event.clientX, y: event.clientY };
this._previewController.onDragging(draggingDOMs, this._draggingCoords.x, this._draggingCoords.y, 'row');
this._autoScrollController.checkYAutoScroll(event.clientY);
const direction = this._startCoords.y > this._draggingCoords.y ? 'up' : 'down';
const dragOverRow = getDragOverRow(draggingDOMs.table, this._draggingCoords.y);
if (!dragOverRow) return;
const [row, index] = dragOverRow;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(row, direction, 'row');
}
private _onDragEnd = () => {
this._dragging = false;
this._draggingIndex = -1;
this._droppingIndex = -1;
this._startCoords = { x: 0, y: 0 };
this._autoScrollController.stop();
this._dropIndicatorController.onDragEnd();
this._previewController.onDragEnd();
}
private _bindDragEvents = () => {
this._colDragHandle.addEventListener('dragstart', this._onDragColStart);
this._disposables.push(() => {
this._colDragHandle.removeEventListener('dragstart', this._onDragColStart);
})
this._colDragHandle.addEventListener('dragend', this._onDragEnd);
this._disposables.push(() => {
this._colDragHandle.removeEventListener('dragend', this._onDragEnd);
})
this._rowDragHandle.addEventListener('dragstart', this._onDragRowStart);
this._disposables.push(() => {
this._rowDragHandle.removeEventListener('dragstart', this._onDragRowStart);
})
this._rowDragHandle.addEventListener('dragend', this._onDragEnd);
this._disposables.push(() => {
this._rowDragHandle.removeEventListener('dragend', this._onDragEnd);
})
const ownerDocument = this.editor.view.dom?.ownerDocument
if (ownerDocument) {
// To make `drop` event work, we need to prevent the default behavior of the
// `dragover` event for drop zone. Here we set the whole document as the
// drop zone so that even the mouse moves outside the editor, the `drop`
// event will still be triggered.
ownerDocument.addEventListener('drop', this._onDrop);
ownerDocument.addEventListener('dragover', this._onDrag);
this._disposables.push(() => {
ownerDocument.removeEventListener('drop', this._onDrop);
ownerDocument.removeEventListener('dragover', this._onDrag);
});
}
}
private _onDragStart = (event: DragEvent, type: 'col' | 'row') => {
const dataTransfer = event.dataTransfer;
if (dataTransfer) {
dataTransfer.effectAllowed = 'move';
this._emptyImageController.hideDragImage(dataTransfer);
}
this._dragging = true;
this._draggingDirection = type;
this._startCoords = { x: event.clientX, y: event.clientY };
const draggingIndex = (type === 'col' ? this._hoveringCell?.colIndex : this._hoveringCell?.rowIndex) ?? 0;
this._draggingIndex = draggingIndex;
const relatedDoms = getDndRelatedDOMs(
this.editor.view,
this._hoveringCell?.cellPos,
draggingIndex,
type
)
this._draggingDOMs = relatedDoms;
const index = type === 'col' ? this._hoveringCell?.colIndex : this._hoveringCell?.rowIndex;
this._previewController.onDragStart(relatedDoms, index, type);
this._dropIndicatorController.onDragStart(relatedDoms, type);
}
private _onDrag = (event: DragEvent) => {
event.preventDefault()
if (!this._dragging) return;
if (this._draggingDirection === 'col') {
this._onDraggingCol(event);
} else {
this._onDraggingRow(event);
}
}
private _onDrop = () => {
if (!this._dragging) return;
const direction = this._draggingDirection;
const from = this._draggingIndex;
const to = this._droppingIndex;
const tr = this.editor.state.tr;
const pos = this.editor.state.selection.from;
if (direction === 'col') {
const canMove = moveColumn({
tr,
originIndex: from,
targetIndex: to,
select: true,
pos,
})
if (canMove) {
this.editor.view.dispatch(tr);
}
return;
}
if (direction === 'row') {
const canMove = moveRow({
tr,
originIndex: from,
targetIndex: to,
select: true,
pos,
})
if (canMove) {
this.editor.view.dispatch(tr);
}
return;
}
}
}
export const TableDndExtension = Extension.create({
name: 'table-drag-and-drop',
addProseMirrorPlugins() {
const editor = this.editor
const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor)
const dragHandlePlugin = new Plugin(dragHandlePluginSpec)
return [dragHandlePlugin]
}
})
@@ -0,0 +1,105 @@
import { Editor } from "@tiptap/core";
import { HoveringCellInfo } from "../utils";
import { computePosition, offset } from "@floating-ui/dom";
export class DragHandleController {
private _colDragHandle: HTMLElement;
private _rowDragHandle: HTMLElement;
constructor() {
this._colDragHandle = this._createDragHandleDom('col');
this._rowDragHandle = this._createDragHandleDom('row');
}
get colDragHandle() {
return this._colDragHandle;
}
get rowDragHandle() {
return this._rowDragHandle;
}
show = (editor: Editor, hoveringCell: HoveringCellInfo) => {
this._showColDragHandle(editor, hoveringCell);
this._showRowDragHandle(editor, hoveringCell);
}
hide = () => {
Object.assign(this._colDragHandle.style, {
display: 'none',
left: '-999px',
top: '-999px',
});
Object.assign(this._rowDragHandle.style, {
display: 'none',
left: '-999px',
top: '-999px',
});
}
destroy = () => {
this._colDragHandle.remove()
this._rowDragHandle.remove()
}
private _createDragHandleDom = (type: 'col' | 'row') => {
const dragHandle = document.createElement('div')
dragHandle.classList.add('drag-handle')
dragHandle.setAttribute('draggable', 'true')
dragHandle.setAttribute('data-direction', type === 'col' ? 'horizontal' : 'vertical')
dragHandle.setAttribute('data-drag-handle', '')
Object.assign(dragHandle.style, {
position: 'absolute',
top: '-999px',
left: '-999px',
display: 'none',
})
return dragHandle;
}
private _showColDragHandle(editor: Editor, hoveringCell: HoveringCellInfo) {
const referenceCell = editor.view.nodeDOM(hoveringCell.colFirstCellPos);
if (!referenceCell) return;
const yOffset = -1 * parseInt(getComputedStyle(this._colDragHandle).height) / 2;
computePosition(
referenceCell as HTMLElement,
this._colDragHandle,
{
placement: 'top',
middleware: [offset(yOffset)]
}
)
.then(({ x, y }) => {
Object.assign(this._colDragHandle.style, {
display: 'block',
top: `${y}px`,
left: `${x}px`,
});
})
}
private _showRowDragHandle(editor: Editor, hoveringCell: HoveringCellInfo) {
const referenceCell = editor.view.nodeDOM(hoveringCell.rowFirstCellPos);
if (!referenceCell) return;
const xOffset = -1 * parseInt(getComputedStyle(this._rowDragHandle).width) / 2;
computePosition(
referenceCell as HTMLElement,
this._rowDragHandle,
{
middleware: [offset(xOffset)],
placement: 'left'
}
)
.then(({ x, y}) => {
Object.assign(this._rowDragHandle.style, {
display: 'block',
top: `${y}px`,
left: `${x}px`,
});
})
}
}
@@ -0,0 +1,21 @@
export class EmptyImageController {
private _emptyImage: HTMLImageElement;
constructor() {
this._emptyImage = new Image(1, 1);
this._emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
get emptyImage() {
return this._emptyImage;
}
hideDragImage = (dataTransfer: DataTransfer) => {
dataTransfer.effectAllowed = 'move';
dataTransfer.setDragImage(this._emptyImage, 0, 0);
}
destroy = () => {
this._emptyImage.remove();
}
}
@@ -0,0 +1 @@
export * from './dnd-extension'
@@ -0,0 +1,102 @@
import { computePosition, offset } from "@floating-ui/dom";
import { DraggingDOMs } from "../utils";
const DROP_INDICATOR_WIDTH = 2;
export class DropIndicatorController {
private _dropIndicator: HTMLElement;
constructor() {
this._dropIndicator = document.createElement('div');
this._dropIndicator.classList.add('table-dnd-drop-indicator');
Object.assign(this._dropIndicator.style, {
position: 'absolute',
pointerEvents: 'none'
});
}
get dropIndicatorRoot() {
return this._dropIndicator;
}
onDragStart = (relatedDoms: DraggingDOMs, type: 'col' | 'row') => {
this._initDropIndicatorStyle(relatedDoms.table, type);
this._initDropIndicatorPosition(relatedDoms.cell, type);
this._dropIndicator.dataset.dragging = 'true';
}
onDragEnd = () => {
Object.assign(this._dropIndicator.style, { display: 'none' });
this._dropIndicator.dataset.dragging = 'false';
}
onDragging = (target: Element, direction: 'left' | 'right' | 'up' | 'down', type: 'col' | 'row') => {
if (type === 'col') {
void computePosition(target, this._dropIndicator, {
placement: direction === 'left' ? 'left' : 'right',
middleware: [offset((direction === 'left' ? -1 * DROP_INDICATOR_WIDTH : 0))],
}).then(({ x }) => {
Object.assign(this._dropIndicator.style, { left: `${x}px` });
})
return;
}
if (type === 'row') {
void computePosition(target, this._dropIndicator, {
placement: direction === 'up' ? 'top' : 'bottom',
middleware: [offset((direction === 'up' ? -1 * DROP_INDICATOR_WIDTH : 0))],
}).then(({ y }) => {
Object.assign(this._dropIndicator.style, { top: `${y}px` });
})
return;
}
}
destroy = () => {
this._dropIndicator.remove();
}
private _initDropIndicatorStyle = (table: HTMLElement, type: 'col' | 'row') => {
const tableRect = table.getBoundingClientRect();
if (type === 'col') {
Object.assign(this._dropIndicator.style, {
display: 'block',
width: `${DROP_INDICATOR_WIDTH}px`,
height: `${tableRect.height}px`,
});
return;
}
if (type === 'row') {
Object.assign(this._dropIndicator.style, {
display: 'block',
width: `${tableRect.width}px`,
height: `${DROP_INDICATOR_WIDTH}px`,
});
}
}
private _initDropIndicatorPosition = (cell: HTMLElement, type: 'col' | 'row') => {
void computePosition(cell, this._dropIndicator, {
placement: type === 'row' ? 'right' : 'bottom',
middleware: [
offset(({ rects }) => {
if (type === 'col') {
return -rects.reference.height
}
return -rects.reference.width
}),
],
}).then(({ x, y }) => {
Object.assign(this._dropIndicator.style, {
left: `${x}px`,
top: `${y}px`,
})
});
}
}
@@ -0,0 +1,122 @@
import { computePosition, offset, ReferenceElement } from "@floating-ui/dom";
import { DraggingDOMs } from "../utils";
import { clearPreviewDOM, createPreviewDOM } from "./render-preview";
export class PreviewController {
private _preview: HTMLElement;
constructor() {
this._preview = document.createElement('div');
this._preview.classList.add('table-dnd-preview');
this._preview.classList.add('ProseMirror');
Object.assign(this._preview.style, {
position: 'absolute',
pointerEvents: 'none',
display: 'none',
});
}
get previewRoot(): HTMLElement {
return this._preview;
}
onDragStart = (relatedDoms: DraggingDOMs, index: number | undefined, type: 'col' | 'row') => {
this._initPreviewStyle(relatedDoms.table, relatedDoms.cell, type);
createPreviewDOM(relatedDoms.table, this._preview, index, type)
this._initPreviewPosition(relatedDoms.cell, type);
}
onDragEnd = () => {
clearPreviewDOM(this._preview);
Object.assign(this._preview.style, { display: 'none' });
}
onDragging = (relatedDoms: DraggingDOMs, x: number, y: number, type: 'col' | 'row') => {
this._updatePreviewPosition(x, y, relatedDoms.cell, type);
}
destroy = () => {
this._preview.remove();
}
private _initPreviewStyle(table: HTMLTableElement, cell: HTMLTableCellElement, type: 'col' | 'row') {
const tableRect = table.getBoundingClientRect();
const cellRect = cell.getBoundingClientRect();
if (type === 'col') {
Object.assign(this._preview.style, {
display: 'block',
width: `${cellRect.width}px`,
height: `${tableRect.height}px`,
})
}
if (type === 'row') {
Object.assign(this._preview.style, {
display: 'block',
width: `${tableRect.width}px`,
height: `${cellRect.height}px`,
})
}
}
private _initPreviewPosition(cell: HTMLElement, type: 'col' | 'row') {
void computePosition(cell, this._preview, {
placement: type === 'row' ? 'right' : 'bottom',
middleware: [
offset(({ rects }) => {
if (type === 'col') {
return -rects.reference.height
}
return -rects.reference.width
}),
],
}).then(({ x, y }) => {
Object.assign(this._preview.style, {
left: `${x}px`,
top: `${y}px`,
})
});
}
private _updatePreviewPosition(x: number, y: number, cell: HTMLElement, type: 'col' | 'row') {
computePosition(
getVirtualElement(cell, x, y),
this._preview,
{ placement: type === 'row' ? 'right' : 'bottom' },
).then(({ x, y }) => {
if (type === 'row') {
Object.assign(this._preview.style, {
top: `${y}px`,
})
return
}
if (type === 'col') {
Object.assign(this._preview.style, {
left: `${x}px`,
})
return
}
})
}
}
function getVirtualElement(cell: HTMLElement, x: number, y: number): ReferenceElement {
return {
contextElement: cell,
getBoundingClientRect: () => {
const rect = cell.getBoundingClientRect()
return {
width: rect.width,
height: rect.height,
right: x + rect.width / 2,
bottom: y + rect.height / 2,
top: y - rect.height / 2,
left: x - rect.width / 2,
x: x - rect.width / 2,
y: y - rect.height / 2,
}
},
}
}
@@ -0,0 +1,37 @@
export function clearPreviewDOM(previewRoot: HTMLElement): void {
while (previewRoot.firstChild) {
previewRoot.removeChild(previewRoot.firstChild)
}
}
export function createPreviewDOM(
table: HTMLTableElement,
previewRoot: HTMLElement,
index: number,
direction: 'row' | 'col',
): void {
clearPreviewDOM(previewRoot)
const previewTable = document.createElement('table')
const previewTableBody = document.createElement('tbody')
previewTable.appendChild(previewTableBody)
previewRoot.appendChild(previewTable)
const rows = table.querySelectorAll('tr')
if (direction === 'row') {
const row = rows[index]
const rowDOM = row.cloneNode(true)
previewTableBody.appendChild(rowDOM)
} else {
rows.forEach((row) => {
const rowDOM = row.cloneNode(false)
const cells = row.querySelectorAll('th,td')
if (cells[index]) {
const cellDOM = cells[index].cloneNode(true)
rowDOM.appendChild(cellDOM)
previewTableBody.appendChild(rowDOM)
}
})
}
}
@@ -0,0 +1,107 @@
import { cellAround, TableMap } from "@tiptap/pm/tables"
import { EditorView } from "@tiptap/pm/view"
export function getHoveringCell(
view: EditorView,
event: MouseEvent,
): HoveringCellInfo | undefined {
const domCell = domCellAround(event.target as HTMLElement | null)
if (!domCell) return
const { left, top, width, height } = domCell.getBoundingClientRect()
const eventPos = view.posAtCoords({
// Use the center coordinates of the cell to ensure we're within the
// selected cell. This prevents potential issues when the mouse is on the
// border of two cells.
left: left + width / 2,
top: top + height / 2,
})
if (!eventPos) return
const $cellPos = cellAround(view.state.doc.resolve(eventPos.pos))
if (!$cellPos) return
const map = TableMap.get($cellPos.node(-1))
const tableStart = $cellPos.start(-1)
const cellRect = map.findCell($cellPos.pos - tableStart)
const rowIndex = cellRect.top
const colIndex = cellRect.left
return {
rowIndex,
colIndex,
cellPos: $cellPos.pos,
rowFirstCellPos: getCellPos(map, tableStart, rowIndex, 0),
colFirstCellPos: getCellPos(map, tableStart, 0, colIndex),
}
}
function domCellAround(target: HTMLElement | null): HTMLElement | null {
while (target && target.nodeName != 'TD' && target.nodeName != 'TH') {
target = target.classList?.contains('ProseMirror')
? null
: (target.parentNode as HTMLElement | null)
}
return target
}
export interface HoveringCellInfo {
rowIndex: number
colIndex: number
cellPos: number
rowFirstCellPos: number
colFirstCellPos: number
}
function getCellPos(
map: TableMap,
tableStart: number,
rowIndex: number,
colIndex: number,
) {
const cellIndex = getCellIndex(map, rowIndex, colIndex)
const posInTable = map.map[cellIndex]
return tableStart + posInTable
}
function getCellIndex(
map: TableMap,
rowIndex: number,
colIndex: number,
): number {
return map.width * rowIndex + colIndex
}
function getTableDOMByPos(view: EditorView, pos: number): HTMLTableElement | undefined {
const dom = view.domAtPos(pos).node
if (!dom) return
const element = dom instanceof HTMLElement ? dom : dom.parentElement
const table = element?.closest('table')
return table ?? undefined
}
function getTargetFirstCellDOM(table: HTMLTableElement, index: number, direction: 'row' | 'col'): HTMLTableCellElement | undefined {
if (direction === 'row') {
const row = table.querySelectorAll('tr')[index]
const cell = row?.querySelector<HTMLTableCellElement>('th,td')
return cell ?? undefined
} else {
const row = table.querySelector('tr')
const cell = row?.querySelectorAll<HTMLTableCellElement>('th,td')[index]
return cell ?? undefined
}
}
export type DraggingDOMs = {
table: HTMLTableElement
cell: HTMLTableCellElement
}
export function getDndRelatedDOMs(view: EditorView, cellPos: number | undefined, draggingIndex: number, direction: 'row' | 'col'): DraggingDOMs | undefined {
if (cellPos == null) return
const table = getTableDOMByPos(view, cellPos)
if (!table) return
const cell = getTargetFirstCellDOM(table, draggingIndex, direction)
if (!cell) return
return { table, cell }
}