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; // Don't show drag handles in readonly mode if (!this.editor.isEditable) { this._dragHandleController.hide(); 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] } })