Files
docmost/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts
2025-08-31 12:08:02 -07:00

278 lines
9.9 KiB
TypeScript

import { Editor, Extension } from "@tiptap/core";
import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state";
import { EditorProps, EditorView } from "@tiptap/pm/view";
import { DraggingDOMs, getDndRelatedDOMs, getHoveringCell, HoveringCellInfo } from "./utils";
import { getDragOverColumn, getDragOverRow } from "./calc-drag-over";
import { moveColumn, moveRow } from "../utils";
import { PreviewController } from "./preview/preview-controller";
import { DropIndicatorController } from "./preview/drop-indicator-controller";
import { DragHandleController } from "./handle/drag-handle-controller";
import { EmptyImageController } from "./handle/empty-image-controller";
import { AutoScrollController } from "./auto-scroll-controller";
export const TableDndKey = new PluginKey('table-drag-and-drop')
class TableDragHandlePluginSpec implements PluginSpec<void> {
key = TableDndKey
props: EditorProps<Plugin<void>>
private _colDragHandle: HTMLElement;
private _rowDragHandle: HTMLElement;
private _hoveringCell?: HoveringCellInfo;
private _disposables: (() => void)[] = [];
private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 };
private _dragging = false;
private _draggingDirection: 'col' | 'row' = 'col';
private _draggingIndex = -1;
private _droppingIndex = -1;
private _draggingDOMs?: DraggingDOMs | undefined
private _startCoords: { x: number; y: number } = { x: 0, y: 0 };
private _previewController: PreviewController;
private _dropIndicatorController: DropIndicatorController;
private _dragHandleController: DragHandleController;
private _emptyImageController: EmptyImageController;
private _autoScrollController: AutoScrollController;
constructor(public editor: Editor) {
this.props = {
handleDOMEvents: {
pointerover: this._pointerOver,
}
}
this._dragHandleController = new DragHandleController();
this._colDragHandle = this._dragHandleController.colDragHandle;
this._rowDragHandle = this._dragHandleController.rowDragHandle;
this._previewController = new PreviewController();
this._dropIndicatorController = new DropIndicatorController();
this._emptyImageController = new EmptyImageController();
this._autoScrollController = new AutoScrollController();
this._bindDragEvents();
}
view = () => {
const wrapper = this.editor.options.element;
wrapper.appendChild(this._colDragHandle)
wrapper.appendChild(this._rowDragHandle)
wrapper.appendChild(this._previewController.previewRoot)
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot)
return {
update: this.update,
destroy: this.destroy,
}
}
update = () => {}
destroy = () => {
if (!this.editor.isDestroyed) return;
this._dragHandleController.destroy();
this._emptyImageController.destroy();
this._previewController.destroy();
this._dropIndicatorController.destroy();
this._autoScrollController.stop();
this._disposables.forEach(disposable => disposable());
}
private _pointerOver = (view: EditorView, event: PointerEvent) => {
if (this._dragging) return;
// 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]
}
})