diff --git a/apps/client/src/features/editor/styles/drag-handle.css b/apps/client/src/features/editor/styles/drag-handle.css
index e1d3ae4b..f77c9630 100644
--- a/apps/client/src/features/editor/styles/drag-handle.css
+++ b/apps/client/src/features/editor/styles/drag-handle.css
@@ -45,6 +45,10 @@
display: none;
pointer-events: none;
}
+
+ &[data-direction='horizontal'] {
+ rotate: 90deg;
+ }
}
.dark .drag-handle {
diff --git a/apps/client/src/features/editor/styles/table.css b/apps/client/src/features/editor/styles/table.css
index d60a299c..9926d0bc 100644
--- a/apps/client/src/features/editor/styles/table.css
+++ b/apps/client/src/features/editor/styles/table.css
@@ -8,6 +8,16 @@
}
}
+.table-dnd-preview {
+ padding: 0;
+ background-color: rgba(255, 255, 255, 0.3);
+ backdrop-filter: blur(2px);
+}
+
+.table-dnd-drop-indicator {
+ background-color: #adf;
+}
+
.ProseMirror {
table {
border-collapse: collapse;
@@ -118,3 +128,13 @@
}
}
}
+
+.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
+ .prosemirror-dropcursor-block {
+ display: none;
+ }
+
+ .prosemirror-dropcursor-inline {
+ display: none;
+ }
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index 4b14083e..707c262f 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"dependencies": {
"@braintree/sanitize-url": "^7.1.0",
"@docmost/editor-ext": "workspace:*",
+ "@floating-ui/dom": "^1.7.3",
"@hocuspocus/extension-redis": "^2.15.2",
"@hocuspocus/provider": "^2.15.2",
"@hocuspocus/server": "^2.15.2",
diff --git a/packages/editor-ext/src/lib/table/dnd/auto-scroll-controller.ts b/packages/editor-ext/src/lib/table/dnd/auto-scroll-controller.ts
new file mode 100644
index 00000000..9b8304d5
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/dnd/auto-scroll-controller.ts
@@ -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
('.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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/dnd/calc-drag-over.ts b/packages/editor-ext/src/lib/table/dnd/calc-drag-over.ts
new file mode 100644
index 00000000..8bdb68ad
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/dnd/calc-drag-over.ts
@@ -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')
+}
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts
new file mode 100644
index 00000000..a29476ca
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts
@@ -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 {
+ key = TableDndKey
+ props: EditorProps>
+
+ 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]
+ }
+})
diff --git a/packages/editor-ext/src/lib/table/dnd/handle/drag-handle-controller.ts b/packages/editor-ext/src/lib/table/dnd/handle/drag-handle-controller.ts
new file mode 100644
index 00000000..33137e91
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/dnd/handle/drag-handle-controller.ts
@@ -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`,
+ });
+ })
+ }
+}
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/dnd/handle/empty-image-controller.ts b/packages/editor-ext/src/lib/table/dnd/handle/empty-image-controller.ts
new file mode 100644
index 00000000..8848a6b0
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/dnd/handle/empty-image-controller.ts
@@ -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();
+ }
+}
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/dnd/index.ts b/packages/editor-ext/src/lib/table/dnd/index.ts
new file mode 100644
index 00000000..cb21bec1
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/dnd/index.ts
@@ -0,0 +1 @@
+export * from './dnd-extension'
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/dnd/preview/drop-indicator-controller.ts b/packages/editor-ext/src/lib/table/dnd/preview/drop-indicator-controller.ts
new file mode 100644
index 00000000..0f079828
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/dnd/preview/drop-indicator-controller.ts
@@ -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`,
+ })
+ });
+ }
+
+}
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/dnd/preview/preview-controller.ts b/packages/editor-ext/src/lib/table/dnd/preview/preview-controller.ts
new file mode 100644
index 00000000..b7a0ea40
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/dnd/preview/preview-controller.ts
@@ -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,
+ }
+ },
+ }
+}
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/dnd/preview/render-preview.ts b/packages/editor-ext/src/lib/table/dnd/preview/render-preview.ts
new file mode 100644
index 00000000..fa251f8f
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/dnd/preview/render-preview.ts
@@ -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)
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/dnd/utils.ts b/packages/editor-ext/src/lib/table/dnd/utils.ts
new file mode 100644
index 00000000..d184368f
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/dnd/utils.ts
@@ -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('th,td')
+ return cell ?? undefined
+ } else {
+ const row = table.querySelector('tr')
+ const cell = row?.querySelectorAll('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 }
+}
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/index.ts b/packages/editor-ext/src/lib/table/index.ts
index fd1d8019..9e5a9265 100644
--- a/packages/editor-ext/src/lib/table/index.ts
+++ b/packages/editor-ext/src/lib/table/index.ts
@@ -2,3 +2,4 @@ export * from "./row";
export * from "./cell";
export * from "./header";
export * from "./table";
+export * from "./dnd";
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/utils/convert-array-of-rows-to-table-node.ts b/packages/editor-ext/src/lib/table/utils/convert-array-of-rows-to-table-node.ts
new file mode 100644
index 00000000..c959ce50
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/convert-array-of-rows-to-table-node.ts
@@ -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
+}
diff --git a/packages/editor-ext/src/lib/table/utils/convert-table-node-to-array-of-rows.ts b/packages/editor-ext/src/lib/table/utils/convert-table-node-to-array-of-rows.ts
new file mode 100644
index 00000000..5d85b68b
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/convert-table-node-to-array-of-rows.ts
@@ -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
+}
diff --git a/packages/editor-ext/src/lib/table/utils/get-cells-in-column.ts b/packages/editor-ext/src/lib/table/utils/get-cells-in-column.ts
new file mode 100644
index 00000000..0f9d6534
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/get-cells-in-column.ts
@@ -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 }
+ })
+ })
+}
diff --git a/packages/editor-ext/src/lib/table/utils/get-cells-in-row.ts b/packages/editor-ext/src/lib/table/utils/get-cells-in-row.ts
new file mode 100644
index 00000000..75e73546
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/get-cells-in-row.ts
@@ -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 }
+ })
+ })
+}
diff --git a/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.ts b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.ts
new file mode 100644
index 00000000..6fda934d
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.ts
@@ -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 }
+}
diff --git a/packages/editor-ext/src/lib/table/utils/get-selection-range-in-row.ts b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-row.ts
new file mode 100644
index 00000000..ece61d7f
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-row.ts
@@ -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 }
+}
diff --git a/packages/editor-ext/src/lib/table/utils/index.ts b/packages/editor-ext/src/lib/table/utils/index.ts
new file mode 100644
index 00000000..23169970
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/index.ts
@@ -0,0 +1,3 @@
+export * from './move-column'
+export * from './move-row'
+export * from './query'
diff --git a/packages/editor-ext/src/lib/table/utils/move-column.ts b/packages/editor-ext/src/lib/table/utils/move-column.ts
new file mode 100644
index 00000000..d73e06bf
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/move-column.ts
@@ -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)
+}
diff --git a/packages/editor-ext/src/lib/table/utils/move-row-in-array-of-rows.ts b/packages/editor-ext/src/lib/table/utils/move-row-in-array-of-rows.ts
new file mode 100644
index 00000000..529df254
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/move-row-in-array-of-rows.ts
@@ -0,0 +1,30 @@
+/**
+ * Move a row in an array of rows.
+ *
+ * @internal
+ */
+export function moveRowInArrayOfRows(
+ 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
+}
diff --git a/packages/editor-ext/src/lib/table/utils/move-row.ts b/packages/editor-ext/src/lib/table/utils/move-row.ts
new file mode 100644
index 00000000..e69e870a
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/move-row.ts
@@ -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)
+}
diff --git a/packages/editor-ext/src/lib/table/utils/query.ts b/packages/editor-ext/src/lib/table/utils/query.ts
new file mode 100644
index 00000000..3d0a542b
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/query.ts
@@ -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 }
+ }
+ }
+}
diff --git a/packages/editor-ext/src/lib/table/utils/transpose.ts b/packages/editor-ext/src/lib/table/utils/transpose.ts
new file mode 100644
index 00000000..dffc6e26
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/transpose.ts
@@ -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(array: T[][]): T[][] {
+ return array[0].map((_, i) => {
+ return array.map((column) => column[i])
+ })
+}
diff --git a/packages/editor-ext/src/lib/table/utils/types.ts b/packages/editor-ext/src/lib/table/utils/types.ts
new file mode 100644
index 00000000..a45f5be1
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/utils/types.ts
@@ -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[]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b9fdc892..1bb9b885 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -22,6 +22,9 @@ importers:
'@docmost/editor-ext':
specifier: workspace:*
version: link:packages/editor-ext
+ '@floating-ui/dom':
+ specifier: ^1.7.3
+ version: 1.7.3
'@hocuspocus/extension-redis':
specifier: ^2.15.2
version: 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
@@ -2189,9 +2192,15 @@ packages:
'@floating-ui/core@1.5.3':
resolution: {integrity: sha512-O0WKDOo0yhJuugCx6trZQj5jVJ9yR0ystG2JaNAemYUWce+pmM6WUEFIibnWyEJKdrDxhm75NoSRME35FNaM/Q==}
+ '@floating-ui/core@1.7.3':
+ resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
+
'@floating-ui/dom@1.6.3':
resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==}
+ '@floating-ui/dom@1.7.3':
+ resolution: {integrity: sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==}
+
'@floating-ui/react-dom@2.1.2':
resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==}
peerDependencies:
@@ -2204,6 +2213,9 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
+ '@floating-ui/utils@0.2.10':
+ resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
+
'@floating-ui/utils@0.2.8':
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
@@ -11848,11 +11860,20 @@ snapshots:
dependencies:
'@floating-ui/utils': 0.2.8
+ '@floating-ui/core@1.7.3':
+ dependencies:
+ '@floating-ui/utils': 0.2.10
+
'@floating-ui/dom@1.6.3':
dependencies:
'@floating-ui/core': 1.5.3
'@floating-ui/utils': 0.2.8
+ '@floating-ui/dom@1.7.3':
+ dependencies:
+ '@floating-ui/core': 1.7.3
+ '@floating-ui/utils': 0.2.10
+
'@floating-ui/react-dom@2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/dom': 1.6.3
@@ -11867,6 +11888,8 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
tabbable: 6.2.0
+ '@floating-ui/utils@0.2.10': {}
+
'@floating-ui/utils@0.2.8': {}
'@hocuspocus/common@2.15.2':