diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 922bcd59..adfed7ed 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -38,6 +38,7 @@ import { Embed, SearchAndReplace, Mention, + TableDndExtension, } from "@docmost/editor-ext"; import { randomElement, @@ -168,6 +169,7 @@ export const mainExtensions = [ TableRow, TableCell, TableHeader, + TableDndExtension, MathInline.configure({ view: MathInlineView, }), diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 5d88b208..44aa403b 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -375,7 +375,7 @@ export default function PageEditor({ } return ( -
+
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':