diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd-ghost.ts b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd-ghost.ts new file mode 100644 index 00000000..e39aced2 --- /dev/null +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd-ghost.ts @@ -0,0 +1,243 @@ +import type {EditorView} from '#pm/view'; +import type {TableDescBinded} from 'src/table-utils/table-desc'; + +type Event = Pick; + +type BuildGhostResult = { + domElement: HTMLElement; + shiftX: number; + shiftY: number; +}; + +export type YfmTableDnDGhostParams = { + initial: Event; + type: 'row' | 'column'; + rangeIdx: number; + tableDesc: TableDescBinded; +}; + +export class YfmTableDnDGhost { + private _x: number; + private _y: number; + + private readonly _dndBackgroundElem: HTMLElement; + private readonly _ghostTable: HTMLElement; + private readonly _ghostButton: HTMLElement | null = null; + + private readonly _tblShiftX: number; + private readonly _tblShiftY: number; + + private readonly _btnShiftX: number = 0; + private readonly _btnShiftY: number = 0; + + private _rafId: number; + + constructor(view: EditorView, params: YfmTableDnDGhostParams) { + this._x = params.initial.clientX; + this._y = params.initial.clientY; + + const document = view.dom.ownerDocument; + + this._dndBackgroundElem = document.createElement('div'); + this._dndBackgroundElem.classList.add('g-md-yfm-table-dnd-cursor-background'); + + { + const res = this._buildGhostButton(params); + if (res) { + this._ghostButton = res.domElement; + this._btnShiftX = res.shiftX; + this._btnShiftY = res.shiftY; + this._dndBackgroundElem.appendChild(this._ghostButton); + } + } + + { + const {domElement, shiftX, shiftY} = + params.type === 'row' + ? this._buildRowGhost(view, params) + : this._buildColumnGhost(view, params); + + this._ghostTable = domElement; + this._tblShiftX = shiftX; + this._tblShiftY = shiftY; + this._dndBackgroundElem.appendChild(this._ghostTable); + } + + this._updatePositions(); + + this._rafId = requestAnimationFrame(() => { + document.body.append(this._dndBackgroundElem); + this._startAnimation(); + }); + } + + move(event: Event) { + this._x = event.clientX; + this._y = event.clientY; + } + + destroy() { + cancelAnimationFrame(this._rafId); + this._dndBackgroundElem.remove(); + } + + private _startAnimation() { + const self = this; + let last = {x: self._x, y: self._y}; + + self._rafId = requestAnimationFrame(function update() { + if (self._x !== last.x || self._y !== last.y) { + last = {x: self._x, y: self._y}; + self._updatePositions(); + } + self._rafId = requestAnimationFrame(update); + }); + } + + private _updatePositions() { + { + const tx = this._x + this._tblShiftX; + const ty = this._y + this._tblShiftY; + this._ghostTable.style.transform = `translate(${tx}px, ${ty}px)`; + } + + if (this._ghostButton) { + const tx = this._x + this._btnShiftX; + const ty = this._y + this._btnShiftY; + this._ghostButton.style.transform = `translate(${tx}px, ${ty}px)`; + } + } + + private _buildRowGhost( + view: EditorView, + {tableDesc, rangeIdx}: YfmTableDnDGhostParams, + ): BuildGhostResult { + let shiftX = 0; + let shiftY = 0; + + const document = view.dom.ownerDocument; + const container = this._buildGhostContainer(view); + + const table = container.appendChild(document.createElement('table')); + const tbody = table.appendChild(document.createElement('tbody')); + + { + const tablePos = tableDesc.pos; + const tableNode = view.domAtPos(tablePos + 1).node; + const rect = (tableNode as Element).getBoundingClientRect(); + table.style.width = rect.width + 'px'; + } + + const range = tableDesc.base.getRowRanges()[rangeIdx]; + for (let rowIdx = range.startIdx; rowIdx <= range.endIdx; rowIdx++) { + const tr = tbody.appendChild(document.createElement('tr')); + + for (let colIdx = 0; colIdx < tableDesc.cols; colIdx++) { + const cellPos = tableDesc.getPosForCell(rowIdx, colIdx); + if (cellPos.type === 'real') { + const origNode = view.domAtPos(cellPos.from + 1).node as HTMLElement; + const cloned = tr.appendChild(origNode.cloneNode(true)); + + const rect = origNode.getBoundingClientRect(); + (cloned as HTMLElement).style.width = rect.width + 'px'; + (cloned as HTMLElement).style.height = rect.height + 'px'; + + if (rowIdx === range.startIdx && colIdx === 0) { + shiftX = rect.left - this._x; + shiftY = rect.top - this._y; + } + } + } + } + + removeIdAttributes(table); + + return {domElement: container, shiftX, shiftY}; + } + + private _buildColumnGhost( + view: EditorView, + {tableDesc, rangeIdx}: YfmTableDnDGhostParams, + ): BuildGhostResult { + let shiftX = 0; + let shiftY = 0; + + const document = view.dom.ownerDocument; + const container = this._buildGhostContainer(view); + + { + const tablePos = tableDesc.pos; + const table = view.domAtPos(tablePos + 1).node; + const rect = (table as Element).getBoundingClientRect(); + container.style.height = rect.height + 'px'; + } + + const table = container.appendChild(document.createElement('table')); + const tbody = table.appendChild(document.createElement('tbody')); + + const range = tableDesc.base.getColumnRanges()[rangeIdx]; + for (let rowIdx = 0; rowIdx < tableDesc.rows; rowIdx++) { + const tr = tbody.appendChild(document.createElement('tr')); + + for (let colIdx = range.startIdx; colIdx <= range.endIdx; colIdx++) { + const cellPos = tableDesc.getPosForCell(rowIdx, colIdx); + if (cellPos.type === 'real') { + const origNode = view.domAtPos(cellPos.from + 1).node as HTMLElement; + const cloned = tr.appendChild(origNode.cloneNode(true)); + + const rect = origNode.getBoundingClientRect(); + (cloned as HTMLElement).style.width = rect.width + 'px'; + (cloned as HTMLElement).style.height = rect.height + 'px'; + + if (rowIdx === 0 && colIdx === range.startIdx) { + container.style.minWidth = rect.width + 'px'; + + shiftX = rect.left - this._x; + shiftY = rect.top - this._y; + } + } + } + } + + removeIdAttributes(table); + + return {domElement: container, shiftX, shiftY}; + } + + private _buildGhostButton({ + initial: {target}, + }: YfmTableDnDGhostParams): BuildGhostResult | null { + if (!(target instanceof Element)) return null; + + const button = target.closest('.g-button'); + if (!button) return null; + + const rect = button.getBoundingClientRect(); + const cloned = button.cloneNode(true) as HTMLElement; + + removeIdAttributes(cloned); + cloned.style.cursor = ''; + cloned.classList.add('g-md-yfm-table-dnd-ghost-button'); + + return { + domElement: cloned, + shiftX: rect.left - this._x, + shiftY: rect.top - this._y, + }; + } + + private _buildGhostContainer(view: EditorView): HTMLElement { + const container = view.dom.ownerDocument.createElement('div'); + container.setAttribute('aria-hidden', 'true'); + + const yfmClasses = Array.from(view.dom.classList).filter((val) => val.startsWith('yfm_')); + container.classList.add('g-md-yfm-table-dnd-ghost', 'yfm', ...yfmClasses); + + return container; + } +} + +function removeIdAttributes(elem: HTMLElement) { + elem.removeAttribute('id'); + elem.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id')); +} diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.scss b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.scss index 6c7a660a..e928769c 100644 --- a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.scss +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.scss @@ -10,6 +10,37 @@ background: transparent; } +.yfm.g-md-yfm-table-dnd-ghost, +.g-button.g-md-yfm-table-dnd-ghost-button { + position: fixed; + + cursor: grabbing; + pointer-events: none; + + transition: none; + will-change: transform; +} + +.yfm.g-md-yfm-table-dnd-ghost { + & > table { + border-color: var(--g-color-line-brand); + box-shadow: 0 8px 20px 1px var(--g-color-sfx-shadow); + + & > tbody > tr > td { + border-color: var(--g-color-line-brand); + } + } +} + +.g-button.g-md-yfm-table-dnd-ghost-button { + --g-button-background-color-hover: var(--g-color-base-background); + --g-button-background-color: var(--g-color-base-background); + --g-button-border-color: var(--g-color-line-brand); + --g-button-text-color: var(--g-color-text-brand); + + z-index: 2; +} + .yfm.ProseMirror { .g-md-yfm-table-dnd-dragged-row, .g-md-yfm-table-dnd-dragged-column-cell { diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.ts b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.ts index 95eb254a..019a596e 100644 --- a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.ts +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.ts @@ -23,6 +23,7 @@ import { DropCursor as RowDropCursor, TableColumnDropCursor, } from './dnd-drop-cursor'; +import {YfmTableDnDGhost} from './dnd-ghost'; import './dnd.scss'; @@ -124,7 +125,7 @@ abstract class YfmTableDnDAbstractHandler implements TableHandler, DnDControlHan if (!this._dragMouseDown || !isDragThresholdPassed(this._dragMouseDown, event)) return; if (this._editorView.dragging || this._dragging) return; - this._startDragging(); + this._startDragging(event); }; protected get _cellNode(): Node { @@ -147,7 +148,7 @@ abstract class YfmTableDnDAbstractHandler implements TableHandler, DnDControlHan return this.__dragMouseDown; } - protected abstract _startDragging(): void; + protected abstract _startDragging(event: React.MouseEvent): void; protected _getTableDescAndCellInfo() { const tcellPos = this._cellGetPos(); @@ -196,7 +197,7 @@ class YfmTableRowDnDHandler extends YfmTableDnDAbstractHandler { return rowRange.safeTopBoundary && rowRange.safeBottomBoundary; } - protected _startDragging = () => { + protected _startDragging = (event: React.MouseEvent) => { const info = this._getTableDescAndCellInfo(); if (!info) return; @@ -229,13 +230,16 @@ class YfmTableRowDnDHandler extends YfmTableDnDAbstractHandler { }; } - const dndBackground = document.createElement('div'); - dndBackground.classList.add('g-md-yfm-table-dnd-cursor-background'); - document.body.append(dndBackground); - const draggedRangeIdx = rowRanges.indexOf(currRowRange); - const onMove = debounce( + const ghost = new YfmTableDnDGhost(this._editorView, { + type: 'row', + initial: event, + rangeIdx: draggedRangeIdx, + tableDesc, + }); + + const onMoveDebounced = debounce( (event: MouseEvent) => { this._moveDragging(event, { rangeIdx: draggedRangeIdx, @@ -246,13 +250,18 @@ class YfmTableRowDnDHandler extends YfmTableDnDAbstractHandler { {maxWait: MOUSE_MOVE_DEBOUNCE}, ); + const onMove = (event: MouseEvent) => { + ghost.move(event); + onMoveDebounced(event); + }; + document.addEventListener('mousemove', onMove); document.addEventListener( 'mouseup', () => { - onMove.flush(); - dndBackground.remove(); + onMoveDebounced.flush(); + ghost.destroy(); document.removeEventListener('mousemove', onMove); this._endDragging(currRowRange, tableDesc); }, @@ -393,7 +402,7 @@ class YfmTableColumnDnDHandler extends YfmTableDnDAbstractHandler { return rowRange.safeLeftBoundary && rowRange.safeRightBoundary; } - protected _startDragging() { + protected _startDragging(event: React.MouseEvent) { const info = this._getTableDescAndCellInfo(); if (!info) return; @@ -424,13 +433,16 @@ class YfmTableColumnDnDHandler extends YfmTableDnDAbstractHandler { }; } - const dndBackground = document.createElement('div'); - dndBackground.classList.add('g-md-yfm-table-dnd-cursor-background'); - document.body.append(dndBackground); - const draggedRangeIdx = columnRanges.indexOf(currColumnRange); - const onMove = debounce( + const ghost = new YfmTableDnDGhost(this._editorView, { + type: 'column', + initial: event, + rangeIdx: draggedRangeIdx, + tableDesc, + }); + + const onMoveDebounced = debounce( (event: MouseEvent) => { this._moveDragging(event, { rangeIdx: draggedRangeIdx, @@ -441,13 +453,18 @@ class YfmTableColumnDnDHandler extends YfmTableDnDAbstractHandler { {maxWait: MOUSE_MOVE_DEBOUNCE}, ); + const onMove = (event: MouseEvent) => { + ghost.move(event); + onMoveDebounced(event); + }; + document.addEventListener('mousemove', onMove); document.addEventListener( 'mouseup', () => { - onMove.flush(); - dndBackground.remove(); + onMoveDebounced.flush(); + ghost.destroy(); document.removeEventListener('mousemove', onMove); this._endDragging(currColumnRange, tableDesc); },