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:
Mirone
2025-09-01 02:53:27 +09:00
committed by GitHub
parent aa58e272d6
commit 7d1e5bce0d
30 changed files with 1652 additions and 1 deletions

View File

@ -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,
}),

View File

@ -375,7 +375,7 @@ export default function PageEditor({
}
return (
<div style={{ position: "relative" }}>
<div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />

View File

@ -45,6 +45,10 @@
display: none;
pointer-events: none;
}
&[data-direction='horizontal'] {
rotate: 90deg;
}
}
.dark .drag-handle {

View File

@ -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;
}
}

View File

@ -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",

View File

@ -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;
}
}
}

View File

@ -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')
}

View File

@ -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]
}
})

View File

@ -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`,
});
})
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1 @@
export * from './dnd-extension'

View File

@ -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`,
})
});
}
}

View File

@ -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,
}
},
}
}

View File

@ -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)
}
})
}
}

View File

@ -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 }
}

View File

@ -2,3 +2,4 @@ export * from "./row";
export * from "./cell";
export * from "./header";
export * from "./table";
export * from "./dnd";

View File

@ -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
}

View File

@ -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
}

View File

@ -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 }
})
})
}

View File

@ -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 }
})
})
}

View File

@ -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 }
}

View File

@ -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 }
}

View File

@ -0,0 +1,3 @@
export * from './move-column'
export * from './move-row'
export * from './query'

View File

@ -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)
}

View File

@ -0,0 +1,30 @@
/**
* Move a row in an array of rows.
*
* @internal
*/
export function moveRowInArrayOfRows<T>(
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
}

View File

@ -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)
}

View File

@ -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 }
}
}
}

View File

@ -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<T>(array: T[][]): T[][] {
return array[0].map((_, i) => {
return array.map((column) => column[i])
})
}

View File

@ -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[]
}

23
pnpm-lock.yaml generated
View File

@ -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':