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