feat: table enhancement (#2191)

This commit is contained in:
Philip Okugbe
2026-05-14 00:37:44 +01:00
committed by GitHub
parent 31ed0df3f7
commit cea9be7926
48 changed files with 3330 additions and 1133 deletions
@@ -1,76 +0,0 @@
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;
}
}
}
@@ -1,316 +1,393 @@
import { Editor, Extension } from "@tiptap/core";
import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state";
import { PluginKey, Plugin, PluginSpec, TextSelection, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { EditorProps, EditorView } from "@tiptap/pm/view";
import { columnResizingPluginKey } from "@tiptap/pm/tables";
import { cellAround } from "@tiptap/pm/tables";
import {
cellInfoFromResolvedCell,
DraggingDOMs,
getDndRelatedDOMs,
getHoveringCell,
HoveringCellInfo,
} from "./utils";
import { getDragOverColumn, getDragOverRow } from "./calc-drag-over";
import { findTable } from "../utils/query";
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");
export interface TableHandleState {
hoveringCell: HoveringCellInfo | null;
tableNode: ProseMirrorNode | null;
tablePos: number | null;
dragging: { orientation: "col" | "row"; index: number } | null;
frozen: boolean;
}
class TableDragHandlePluginSpec implements PluginSpec<void> {
const INITIAL_STATE: TableHandleState = {
hoveringCell: null,
tableNode: null,
tablePos: null,
dragging: null,
frozen: false,
};
export const TableDndKey = new PluginKey<TableHandleState>("table-handles");
class TableHandlePluginSpec implements PluginSpec<TableHandleState> {
key = TableDndKey;
props: EditorProps<Plugin<void>>;
props: EditorProps<Plugin<TableHandleState>>;
private _previewController: PreviewController;
private _dropIndicatorController: DropIndicatorController;
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;
private _draggingDOMs?: DraggingDOMs;
private _startCoords = { x: 0, y: 0 };
private _dragging = false;
state = {
init: (): TableHandleState => INITIAL_STATE,
apply: (tr: Transaction, prev: TableHandleState): TableHandleState => {
const meta = tr.getMeta(TableDndKey) as Partial<TableHandleState> | null;
if (!meta) return prev;
let changed = false;
for (const key in meta) {
if (!Object.is(prev[key as keyof TableHandleState], meta[key as keyof TableHandleState])) {
changed = true;
break;
}
}
return changed ? { ...prev, ...meta } : prev;
},
};
constructor(public editor: Editor) {
this.props = {
handleDOMEvents: {
pointerover: this._pointerOver,
pointermove: this._pointerMove,
// Force-unfreeze on any pointerdown that lands on the editor.
// Mantine's `Menu.onClose` doesn't always fire on outside click
// (the dropdown vanishes visually but the callback is skipped),
// which would otherwise leave `frozen=true` permanently.
pointerdown: this._pointerDown,
},
};
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;
//@ts-ignore
wrapper.appendChild(this._colDragHandle);
//@ts-ignore
wrapper.appendChild(this._rowDragHandle);
//@ts-ignore
// @ts-ignore
wrapper.appendChild(this._previewController.previewRoot);
//@ts-ignore
// @ts-ignore
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot);
// Track the cursor cell so handles follow keyboard nav and clicks too.
this.editor.on("selectionUpdate", this._onSelectionUpdate);
this._disposables.push(() =>
this.editor.off("selectionUpdate", this._onSelectionUpdate),
);
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());
this._disposables.forEach((d) => d());
};
private _pointerOver = (view: EditorView, event: PointerEvent) => {
if (this._dragging) return;
private _pointerDown = (view: EditorView, _event: PointerEvent): boolean => {
const current = TableDndKey.getState(view.state);
if (current?.frozen) this.editor.commands.unfreezeHandles();
return false;
};
private _pointerMove = (view: EditorView, event: PointerEvent) => {
const current = TableDndKey.getState(view.state);
if (current?.frozen || current?.dragging) return;
const resizeState = columnResizingPluginKey.getState(view.state);
if (resizeState?.dragging) return;
// Don't show drag handles in readonly mode
if (!this.editor.isEditable) {
this._dragHandleController.hide();
if (current?.hoveringCell == null && current?.tableNode == null && current?.tablePos == null) return;
this._dispatchMeta({ hoveringCell: null, tableNode: null, tablePos: null });
return;
}
const hoveringCell = getHoveringCell(view, event);
this._hoveringCell = hoveringCell;
if (!hoveringCell) {
this._dragHandleController.hide();
} else {
this._dragHandleController.show(this.editor, hoveringCell);
if (hoveringCell) {
if (current?.hoveringCell?.cellPos === hoveringCell.cellPos) return;
this._hoveringCell = hoveringCell;
const $cell = view.state.doc.resolve(hoveringCell.cellPos);
const tableInfo = findTable($cell);
this._dispatchMeta({
hoveringCell,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
return;
}
// Pointer isn't over a cell but may be transiting toward a handle that
// floats outside the cell — fall back to the selection's cell so the
// handles stay visible.
const $cellPos = cellAround(view.state.selection.$head);
if ($cellPos) {
const cellInfo = cellInfoFromResolvedCell($cellPos);
if (current?.hoveringCell?.cellPos === cellInfo.cellPos) return;
this._hoveringCell = cellInfo;
const tableInfo = findTable($cellPos);
this._dispatchMeta({
hoveringCell: cellInfo,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
return;
}
this._hoveringCell = undefined;
if (current?.hoveringCell == null && current?.tableNode == null && current?.tablePos == null) return;
this._dispatchMeta({ hoveringCell: null, tableNode: null, tablePos: null });
};
private _onDragColStart = (event: DragEvent) => {
this._onDragStart(event, "col");
private _onSelectionUpdate = () => {
if (!this.editor.isEditable) return;
const current = TableDndKey.getState(this.editor.state);
if (current?.frozen || current?.dragging) return;
const $cellPos = cellAround(this.editor.state.selection.$head);
if (!$cellPos) return;
const cellInfo = cellInfoFromResolvedCell($cellPos);
if (current?.hoveringCell?.cellPos === cellInfo.cellPos) return;
this._hoveringCell = cellInfo;
const tableInfo = findTable($cellPos);
this._dispatchMeta({
hoveringCell: cellInfo,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
};
private _onDraggingCol = (event: DragEvent) => {
private _dispatchMeta = (patch: Partial<TableHandleState>) => {
const tr = this.editor.state.tr.setMeta(TableDndKey, patch);
tr.setMeta("addToHistory", false);
this.editor.view.dispatch(tr);
};
// ---- Public API for the React handle layer ----
// Returns true if the drag was set up successfully.
startDragFromHandle = (
orientation: "col" | "row",
clientX: number,
clientY: number,
): boolean => {
if (!this._hoveringCell) return false;
this._dragging = true;
this._draggingDirection = orientation;
this._startCoords = { x: clientX, y: clientY };
const draggingIndex =
(orientation === "col"
? this._hoveringCell.colIndex
: this._hoveringCell.rowIndex) ?? 0;
this._draggingIndex = draggingIndex;
const relatedDoms = getDndRelatedDOMs(
this.editor.view,
this._hoveringCell.cellPos,
draggingIndex,
orientation,
);
if (!relatedDoms) {
this._dragging = false;
return false;
}
this._draggingDOMs = relatedDoms;
this._previewController.onDragStart(relatedDoms, draggingIndex, orientation);
this._dropIndicatorController.onDragStart(relatedDoms, orientation);
// Park the selection inside the dragged cell unless it's already in the
// same table. PM auto-maps `selection.from` through concurrent remote
// transactions, so commitDrop can resolve the table even if the doc
// shifted mid-drag — same trick the pre-pragmatic-dnd implementation
// relied on.
const state = this.editor.state;
const currentTable = findTable(state.selection.$from);
const hoverTable = (() => {
try {
return findTable(state.doc.resolve(this._hoveringCell.cellPos));
} catch {
return undefined;
}
})();
const tr = state.tr;
if (
hoverTable &&
(!currentTable || currentTable.pos !== hoverTable.pos)
) {
try {
const $inside = state.doc.resolve(this._hoveringCell.cellPos + 1);
tr.setSelection(TextSelection.near($inside, 1));
} catch {}
}
tr.setMeta(TableDndKey, {
dragging: { orientation, index: draggingIndex },
});
tr.setMeta("addToHistory", false);
this.editor.view.dispatch(tr);
return true;
};
updateDragPosition = (clientX: number, clientY: number) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs) return;
if (!draggingDOMs || !this._dragging) return;
this._draggingCoords = { x: event.clientX, y: event.clientY };
this._previewController.onDragging(
draggingDOMs,
this._draggingCoords.x,
this._draggingCoords.y,
"col",
);
if (this._draggingDirection === "col") {
this._previewController.onDragging(
draggingDOMs,
clientX,
clientY,
"col",
);
const direction = this._startCoords.x > clientX ? "left" : "right";
const dragOverColumn = getDragOverColumn(draggingDOMs.table, clientX);
if (!dragOverColumn) return;
const [col, index] = dragOverColumn;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(col, direction, "col");
return;
}
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,
);
this._previewController.onDragging(draggingDOMs, clientX, clientY, "row");
const direction = this._startCoords.y > clientY ? "up" : "down";
const dragOverRow = getDragOverRow(draggingDOMs.table, clientY);
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 = () => {
commitDrop = () => {
if (!this._dragging) return;
const direction = this._draggingDirection;
const from = this._draggingIndex;
const to = this._droppingIndex;
if (from < 0 || to < 0 || from === to) return;
// Use the live (auto-mapped) selection as the table anchor — PM has
// already mapped it through any concurrent remote transactions, so
// it's safe to resolve even if the doc shifted mid-drag.
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) {
if (moveColumn({ tr, originIndex: from, targetIndex: to, select: true, pos })) {
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;
if (moveRow({ tr, originIndex: from, targetIndex: to, select: true, pos })) {
this.editor.view.dispatch(tr);
}
};
endDrag = () => {
this._dragging = false;
this._draggingIndex = -1;
this._droppingIndex = -1;
this._startCoords = { x: 0, y: 0 };
this._draggingDOMs = undefined;
this._dropIndicatorController.onDragEnd();
this._previewController.onDragEnd();
this._dispatchMeta({ dragging: null });
};
}
export type { TableHandlePluginSpec };
// Resolve via plugin key, not a module singleton — survives StrictMode / HMR.
export function getTableHandlePluginSpec(
editor: Editor,
): TableHandlePluginSpec | null {
const plugin = TableDndKey.get(editor.state);
if (!plugin) return null;
return plugin.spec as unknown as TableHandlePluginSpec;
}
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];
const spec = new TableHandlePluginSpec(editor);
return [new Plugin(spec)];
},
});
export const TableHandleCommandsExtension = Extension.create({
name: "table-handle-commands",
addCommands() {
return {
freezeHandles:
() =>
({ tr, dispatch }) => {
if (dispatch) {
tr.setMeta(TableDndKey, { frozen: true });
tr.setMeta("addToHistory", false);
}
return true;
},
unfreezeHandles:
() =>
({ tr, state, dispatch }) => {
if (dispatch) {
// Re-sync `hoveringCell` to the cursor's cell as we unfreeze:
// `selectionUpdate` was gated while frozen, so the stored
// hoveringCell may be stale.
const patch: Partial<TableHandleState> = { frozen: false };
const $cellPos = cellAround(state.selection.$head);
if ($cellPos) {
const cellInfo = cellInfoFromResolvedCell($cellPos);
const tableInfo = findTable($cellPos);
patch.hoveringCell = cellInfo;
patch.tableNode = tableInfo?.node ?? null;
patch.tablePos = tableInfo?.pos ?? null;
} else {
patch.hoveringCell = null;
patch.tableNode = null;
patch.tablePos = null;
}
tr.setMeta(TableDndKey, patch);
tr.setMeta("addToHistory", false);
}
return true;
},
};
},
});
declare module "@tiptap/core" {
interface Commands<ReturnType> {
tableHandleCommands: {
freezeHandles: () => ReturnType;
unfreezeHandles: () => ReturnType;
};
}
}
@@ -1,105 +0,0 @@
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`,
});
})
}
}
@@ -1,21 +0,0 @@
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();
}
}
@@ -1 +1,7 @@
export * from './dnd-extension'
export {
TableDndExtension,
TableHandleCommandsExtension,
TableDndKey,
getTableHandlePluginSpec,
} from "./dnd-extension";
export type { TableHandleState, TableHandlePluginSpec } from "./dnd-extension";
@@ -99,4 +99,4 @@ export class DropIndicatorController {
});
}
}
}
@@ -1,4 +1,4 @@
import { computePosition, offset, ReferenceElement } from "@floating-ui/dom";
import { computePosition, offset, shift, ReferenceElement } from "@floating-ui/dom";
import { DraggingDOMs } from "../utils";
import { clearPreviewDOM, createPreviewDOM } from "./render-preview";
@@ -23,7 +23,7 @@ export class PreviewController {
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);
this._initPreviewPosition(relatedDoms.table, relatedDoms.cell, type);
}
onDragEnd = () => {
@@ -32,7 +32,7 @@ export class PreviewController {
}
onDragging = (relatedDoms: DraggingDOMs, x: number, y: number, type: 'col' | 'row') => {
this._updatePreviewPosition(x, y, relatedDoms.cell, type);
this._updatePreviewPosition(x, y, relatedDoms.table, relatedDoms.cell, type);
}
destroy = () => {
@@ -60,7 +60,7 @@ export class PreviewController {
}
}
private _initPreviewPosition(cell: HTMLElement, type: 'col' | 'row') {
private _initPreviewPosition(table: HTMLElement, cell: HTMLElement, type: 'col' | 'row') {
void computePosition(cell, this._preview, {
placement: type === 'row' ? 'right' : 'bottom',
middleware: [
@@ -70,6 +70,7 @@ export class PreviewController {
}
return -rects.reference.width
}),
shift({ boundary: table, padding: 0 }),
],
}).then(({ x, y }) => {
Object.assign(this._preview.style, {
@@ -79,11 +80,20 @@ export class PreviewController {
});
}
private _updatePreviewPosition(x: number, y: number, cell: HTMLElement, type: 'col' | 'row') {
// Clamp the preview to within the table's bounds via `shift({ boundary })`
// so it can't track the cursor past the table edge. Without the clamp,
// dragging near the viewport edge pushes the preview's `left` (or `top`)
// beyond the document's natural width/height, the browser extends the
// page to contain it, and the auto-scroll plugin then has a wider area
// to keep scrolling into — a feedback loop that grows the page forever.
private _updatePreviewPosition(x: number, y: number, table: HTMLElement, cell: HTMLElement, type: 'col' | 'row') {
computePosition(
getVirtualElement(cell, x, y),
this._preview,
{ placement: type === 'row' ? 'right' : 'bottom' },
{
placement: type === 'row' ? 'right' : 'bottom',
middleware: [shift({ boundary: table, padding: 0 })],
},
).then(({ x, y }) => {
if (type === 'row') {
Object.assign(this._preview.style, {
+23 -11
View File
@@ -1,4 +1,5 @@
import { cellAround, TableMap } from "@tiptap/pm/tables"
import { ResolvedPos } from "@tiptap/pm/model"
import { EditorView } from "@tiptap/pm/view"
export function getHoveringCell(
@@ -8,19 +9,30 @@ export function getHoveringCell(
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))
// Resolve directly from the cell DOM rather than via coords. The previous
// center-coords approach broke on tall merged cells — their visual center
// can land in empty space whose closest PM position resolves to an
// adjacent cell. `posAtDOM(td, 0)` is always inside this cell, regardless
// of rowspan/colspan.
let pos: number
try {
pos = view.posAtDOM(domCell, 0)
} catch {
return
}
const $cellPos = cellAround(view.state.doc.resolve(pos))
if (!$cellPos) return
return cellInfoFromResolvedCell($cellPos)
}
/**
* Build HoveringCellInfo from a resolved position whose parent is a
* table cell (i.e. the result of `cellAround` on some inner position).
*/
export function cellInfoFromResolvedCell(
$cellPos: ResolvedPos,
): HoveringCellInfo {
const map = TableMap.get($cellPos.node(-1))
const tableStart = $cellPos.start(-1)
const cellRect = map.findCell($cellPos.pos - tableStart)
@@ -0,0 +1,186 @@
// Per-table header-pin controller: native sticky when table fits its wrapper, transform fallback when it doesn't.
import { computePinTop, pinOffsetWatcher } from './offset';
const WRAPPER_NO_OVERFLOW = 'tableWrapperNoOverflow';
const HEADER_PINNED = 'tableHeaderPinned';
const PIN_OFFSET_VAR = '--table-pin-offset';
type PinMode = 'off' | 'native' | 'fallback';
function firstRowIsAllHeaders(row: HTMLTableRowElement | null): boolean {
if (!row) return false;
const cells = Array.from(row.cells);
return cells.length > 0 && cells.every((c) => c.tagName === 'TH');
}
function isNestedTable(wrapper: HTMLElement): boolean {
return wrapper.closest('table .tableWrapper') !== null;
}
function isLayoutInert(rect: DOMRectReadOnly): boolean {
return rect.width === 0 && rect.height === 0;
}
const fallbackControllers = new Set<TablePinController>();
let fallbackScrollListener: (() => void) | null = null;
let fallbackRafPending = false;
function ensureFallbackListener() {
if (fallbackScrollListener) return;
fallbackScrollListener = () => {
if (fallbackRafPending) return;
fallbackRafPending = true;
requestAnimationFrame(() => {
fallbackRafPending = false;
for (const ctrl of fallbackControllers) ctrl.updateFallbackOffset();
});
};
document.addEventListener('scroll', fallbackScrollListener, {
passive: true,
capture: true,
});
}
function maybeTeardownFallbackListener() {
if (!fallbackScrollListener || fallbackControllers.size > 0) return;
document.removeEventListener('scroll', fallbackScrollListener, {
capture: true,
});
fallbackScrollListener = null;
fallbackRafPending = false;
}
export class TablePinController {
private wrapper: HTMLElement;
private table: HTMLTableElement;
private fitsObserver?: IntersectionObserver;
private mode: PinMode = 'off';
private cachedHeaderRow: HTMLTableRowElement | null = null;
constructor(wrapper: HTMLElement, table: HTMLTableElement) {
this.wrapper = wrapper;
this.table = table;
pinOffsetWatcher.acquire();
this.fitsObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) this.evaluateFit(entry);
},
{ root: this.wrapper, threshold: 1 },
);
this.fitsObserver.observe(this.table);
}
private getHeaderRow(): HTMLTableRowElement | null {
if (this.cachedHeaderRow && this.table.contains(this.cachedHeaderRow)) {
return this.cachedHeaderRow;
}
this.cachedHeaderRow = this.table.querySelector('tr');
return this.cachedHeaderRow;
}
private evaluateFit(entry: IntersectionObserverEntry) {
if (!this.isEligible()) {
this.apply('off');
return;
}
if (isLayoutInert(entry.boundingClientRect)) return;
this.apply(entry.isIntersecting ? 'native' : 'fallback');
}
private isEligible(): boolean {
return (
!isNestedTable(this.wrapper) && firstRowIsAllHeaders(this.getHeaderRow())
);
}
private apply(next: PinMode) {
if (next === this.mode) return;
if (this.mode === 'fallback' && next !== 'fallback') {
fallbackControllers.delete(this);
maybeTeardownFallbackListener();
}
this.mode = next;
const cls = this.wrapper.classList;
if (next === 'off') {
cls.remove(HEADER_PINNED);
cls.remove(WRAPPER_NO_OVERFLOW);
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
} else if (next === 'native') {
cls.add(HEADER_PINNED);
cls.add(WRAPPER_NO_OVERFLOW);
// Native mode reads --editor-pin-offset from :root; clear stale per-wrapper var from fallback.
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
} else if (next === 'fallback') {
cls.add(HEADER_PINNED);
cls.remove(WRAPPER_NO_OVERFLOW);
fallbackControllers.add(this);
ensureFallbackListener();
// Avoid one stale-frame paint under translateY.
this.updateFallbackOffset();
}
}
updateFallbackOffset() {
const pinTop = computePinTop();
const tableRect = this.table.getBoundingClientRect();
const headerRow = this.getHeaderRow();
if (!headerRow) return;
const rowHeight = headerRow.getBoundingClientRect().height;
const active = tableRect.top < pinTop && tableRect.bottom > pinTop + rowHeight;
if (active) {
const offset = Math.min(pinTop - tableRect.top, tableRect.height - rowHeight);
this.wrapper.style.setProperty(PIN_OFFSET_VAR, `${offset}px`);
} else {
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
}
}
refresh() {
// The header <tr> may have been replaced by a PM transaction; drop
// the cached reference before checking eligibility.
this.cachedHeaderRow = null;
if (!this.isEligible()) {
this.apply('off');
return;
}
if (this.mode === 'off') {
// Eligibility just flipped back on; re-trigger the observer so it
// emits the current intersection state.
this.fitsObserver?.unobserve(this.table);
this.fitsObserver?.observe(this.table);
}
}
destroy() {
this.fitsObserver?.disconnect();
this.fitsObserver = undefined;
this.apply('off');
pinOffsetWatcher.release();
}
}
const controllers = new WeakMap<HTMLElement, TablePinController>();
export function attach(wrapper: HTMLElement) {
if (controllers.has(wrapper)) return;
const table = wrapper.querySelector(':scope > table') as HTMLTableElement | null;
if (!table) return;
controllers.set(wrapper, new TablePinController(wrapper, table));
}
export function detach(wrapper: HTMLElement) {
const ctrl = controllers.get(wrapper);
if (!ctrl) return;
ctrl.destroy();
controllers.delete(wrapper);
}
export function getController(wrapper: HTMLElement): TablePinController | undefined {
return controllers.get(wrapper);
}
@@ -0,0 +1,78 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { attach, detach, getController } from './controller';
const tableHeaderPinKey = new PluginKey('tableHeaderPin');
export const TableHeaderPin = Extension.create({
name: 'tableHeaderPin',
addProseMirrorPlugins() {
let editorRoot: HTMLElement | null = null;
let domObserver: MutationObserver | null = null;
const tracked = new Set<HTMLElement>();
let rafHandle: number | null = null;
const reconcile = () => {
rafHandle = null;
if (!editorRoot) return;
const current = new Set(
editorRoot.querySelectorAll<HTMLElement>('.tableWrapper'),
);
for (const w of tracked) {
if (!current.has(w)) {
detach(w);
tracked.delete(w);
}
}
for (const w of current) {
if (!tracked.has(w)) {
attach(w);
tracked.add(w);
}
}
};
const schedule = () => {
if (rafHandle !== null) return;
rafHandle = requestAnimationFrame(reconcile);
};
return [
new Plugin({
key: tableHeaderPinKey,
view(editorView) {
editorRoot = editorView.dom as HTMLElement;
schedule();
domObserver = new MutationObserver(schedule);
domObserver.observe(editorRoot, { subtree: true, childList: true });
return {
update(view, prevState) {
if (!editorRoot) return;
if (view.state.doc === prevState.doc) return;
editorRoot
.querySelectorAll<HTMLElement>('.tableWrapper')
.forEach((w) => getController(w)?.refresh());
},
destroy() {
if (rafHandle !== null) {
cancelAnimationFrame(rafHandle);
rafHandle = null;
}
domObserver?.disconnect();
domObserver = null;
for (const w of tracked) detach(w);
tracked.clear();
editorRoot = null;
},
};
},
}),
];
},
});
@@ -0,0 +1 @@
export { TableHeaderPin } from './extension';
@@ -0,0 +1,65 @@
// Pin-offset measurement and watcher used by the table header-pin controller.
// Fallback app-bar height (px) when no fixed surface is mounted; matches global-app-shell.tsx.
const APP_BAR_FALLBACK_HEIGHT = 45;
export const EDITOR_PIN_OFFSET_VAR = '--editor-pin-offset';
// Selectors for fixed surfaces between viewport top and editor content. Use data attributes —
// CSS module classes are build-time hashed and won't match.
const PIN_ANCHOR_SELECTORS = [
'[data-page-header]',
'[data-fixed-toolbar]',
] as const;
export function computePinTop(): number {
let bottom = APP_BAR_FALLBACK_HEIGHT;
for (const sel of PIN_ANCHOR_SELECTORS) {
const el = document.querySelector(sel) as HTMLElement | null;
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.height > 0 && rect.bottom > bottom) bottom = rect.bottom;
}
return bottom;
}
// Reference-counted watcher that publishes the editor's top offset to a CSS custom property.
export const pinOffsetWatcher = {
refs: 0,
resizeObserver: null as ResizeObserver | null,
rafPending: false,
lastValue: -1,
acquire() {
if (this.refs++ > 0) return;
this.publish();
const schedule = () => {
if (this.rafPending) return;
this.rafPending = true;
requestAnimationFrame(() => {
this.rafPending = false;
this.publish();
});
};
this.resizeObserver = new ResizeObserver(schedule);
this.resizeObserver.observe(document.body);
},
release() {
if (--this.refs > 0) return;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
document.documentElement.style.removeProperty(EDITOR_PIN_OFFSET_VAR);
this.lastValue = -1;
},
publish() {
const top = computePinTop();
if (top === this.lastValue) return;
this.lastValue = top;
document.documentElement.style.setProperty(
EDITOR_PIN_OFFSET_VAR,
`${top}px`,
);
},
};
+11 -1
View File
@@ -2,4 +2,14 @@ export * from "./row";
export * from "./cell";
export * from "./header";
export * from "./table";
export * from "./dnd";
export * from "./dnd";
export * from "./table-view";
export * from "./header-pin";
export * from "./table-readonly-sort";
export { moveColumn } from "./utils/move-column";
export type { MoveColumnParams } from "./utils/move-column";
export { moveRow } from "./utils/move-row";
export type { MoveRowParams } from "./utils/move-row";
export { convertTableNodeToArrayOfRows } from "./utils/convert-table-node-to-array-of-rows";
export { convertArrayOfRowsToTableNode } from "./utils/convert-array-of-rows-to-table-node";
export { transpose } from "./utils/transpose";
@@ -0,0 +1,233 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
type SortDirection = 'asc' | 'desc';
type SortState = {
col: number;
direction: SortDirection;
};
const CHEVRON_CLASS = 'tableReadonlySortChevron';
const tableReadonlySortKey = new PluginKey('tableReadonlySort');
const sortStates = new WeakMap<HTMLTableElement, SortState>();
const originalOrders = new WeakMap<HTMLTableElement, HTMLTableRowElement[]>();
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
function getColumnIndex(th: HTMLTableCellElement): number {
const row = th.parentElement as HTMLTableRowElement;
if (!row) return -1;
let col = 0;
for (let i = 0; i < row.cells.length; i++) {
if (row.cells[i] === th) return col;
col += row.cells[i].colSpan ?? 1;
}
return -1;
}
function getHeaderTh(target: EventTarget | null): HTMLTableCellElement | null {
if (!(target instanceof Element)) return null;
const th = target.closest('th') as HTMLTableCellElement | null;
if (!th) return null;
const row = th.parentElement;
if (!row) return null;
const tbody = row.parentElement;
if (!tbody) return null;
const table = tbody.closest('table');
if (!table) return null;
// th must be in the first row of the table (could be in thead or tbody)
const firstRow = table.querySelector('tr');
if (firstRow !== row) return null;
return th;
}
function getCellText(row: HTMLTableRowElement, colIndex: number): string {
let col = 0;
for (let i = 0; i < row.cells.length; i++) {
if (col === colIndex) return row.cells[i].textContent?.trim() ?? '';
col += row.cells[i].colSpan ?? 1;
}
return '';
}
function getOrSaveOriginalOrder(
table: HTMLTableElement,
dataRows: HTMLTableRowElement[],
): HTMLTableRowElement[] {
if (!originalOrders.has(table)) {
originalOrders.set(table, [...dataRows]);
}
return originalOrders.get(table)!;
}
function sortDataRows(
dataRows: HTMLTableRowElement[],
colIndex: number,
direction: SortDirection,
): HTMLTableRowElement[] {
return [...dataRows].sort((a, b) => {
const textA = getCellText(a, colIndex);
const textB = getCellText(b, colIndex);
const emptyA = textA === '';
const emptyB = textB === '';
if (emptyA && emptyB) return 0;
if (emptyA) return 1;
if (emptyB) return -1;
const cmp = collator.compare(textA, textB);
return direction === 'asc' ? cmp : -cmp;
});
}
function applySort(table: HTMLTableElement, colIndex: number): void {
const tbody = table.querySelector('tbody');
if (!tbody) return;
const allRows = Array.from(tbody.querySelectorAll<HTMLTableRowElement>(':scope > tr'));
if (allRows.length === 0) return;
const headerRow = allRows[0];
const dataRows = allRows.slice(1);
if (dataRows.length === 0) return;
const current = sortStates.get(table) ?? null;
const saved = getOrSaveOriginalOrder(table, dataRows);
let next: SortState | null;
if (!current || current.col !== colIndex) {
next = { col: colIndex, direction: 'asc' };
} else if (current.direction === 'asc') {
next = { col: colIndex, direction: 'desc' };
} else {
next = null;
}
if (next === null) {
sortStates.delete(table);
tbody.append(headerRow, ...saved);
} else {
sortStates.set(table, next);
const sorted = sortDataRows(saved, next.col, next.direction);
tbody.append(headerRow, ...sorted);
}
updateChevrons(table);
}
const CHEVRON_SVG =
'<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">' +
'<path d="M2.5 4.5 L6 8 L9.5 4.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />' +
'</svg>';
function ensureChevron(th: HTMLTableCellElement): HTMLSpanElement {
let chevron = th.querySelector<HTMLSpanElement>(`.${CHEVRON_CLASS}`);
if (!chevron) {
chevron = document.createElement('span');
chevron.className = CHEVRON_CLASS;
chevron.setAttribute('aria-hidden', 'true');
chevron.innerHTML = CHEVRON_SVG;
th.appendChild(chevron);
}
return chevron;
}
function updateChevrons(table: HTMLTableElement): void {
const firstRow = table.querySelector('tr');
if (!firstRow) return;
const state = sortStates.get(table) ?? null;
let col = 0;
for (let i = 0; i < firstRow.cells.length; i++) {
const cell = firstRow.cells[i];
if (cell.tagName !== 'TH') {
col += cell.colSpan ?? 1;
continue;
}
const chevron = ensureChevron(cell as HTMLTableCellElement);
let label: string;
if (state && state.col === col) {
chevron.setAttribute('data-sort', state.direction);
label = state.direction === 'asc' ? 'Sort descending' : 'Clear sort';
} else {
chevron.removeAttribute('data-sort');
label = 'Sort ascending';
}
chevron.setAttribute('data-tooltip', label);
chevron.setAttribute('aria-label', label);
chevron.title = label;
col += cell.colSpan ?? 1;
}
}
function addChevronsToAllTables(editorRoot: HTMLElement): void {
const tables = editorRoot.querySelectorAll<HTMLTableElement>('table');
tables.forEach((table) => updateChevrons(table));
}
function removeAllChevrons(editorRoot: HTMLElement): void {
editorRoot
.querySelectorAll<HTMLSpanElement>(`.${CHEVRON_CLASS}`)
.forEach((el) => el.remove());
}
export const TableReadonlySort = Extension.create({
name: 'tableReadonlySort',
addProseMirrorPlugins() {
const editor = this.editor;
let editorRoot: HTMLElement | null = null;
const onClick = (event: MouseEvent) => {
if (editor.isEditable) return;
// Only react to clicks on the chevron, not anywhere else in the header
// cell. This lets the user click into a header to select text without
// accidentally triggering a sort.
if (!(event.target instanceof Element)) return;
const chevron = event.target.closest(`.${CHEVRON_CLASS}`);
if (!chevron) return;
const th = getHeaderTh(chevron);
if (!th) return;
const table = th.closest('table') as HTMLTableElement | null;
if (!table) return;
const colIndex = getColumnIndex(th);
if (colIndex < 0) return;
applySort(table, colIndex);
};
return [
new Plugin({
key: tableReadonlySortKey,
view(editorView) {
editorRoot = editorView.dom as HTMLElement;
editorRoot.addEventListener('click', onClick);
if (!editor.isEditable) {
addChevronsToAllTables(editorRoot);
}
return {
update(view) {
const root = view.dom as HTMLElement;
if (!editor.isEditable) {
addChevronsToAllTables(root);
} else {
removeAllChevrons(root);
}
},
destroy() {
if (editorRoot) {
editorRoot.removeEventListener('click', onClick);
removeAllChevrons(editorRoot);
}
},
};
},
}),
];
},
});
@@ -0,0 +1,158 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import type { NodeView, ViewMutationRecord } from '@tiptap/pm/view';
import { getColStyleDeclaration } from './utils/col-style';
export function updateColumns(
node: ProseMirrorNode,
colgroup: HTMLElement,
table: HTMLTableElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: number,
) {
let totalWidth = 0;
let fixedWidth = true;
let nextDOM = colgroup.firstChild;
const row = node.firstChild;
if (row !== null) {
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs;
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth =
overrideCol === col
? overrideValue
: ((colwidth && colwidth[j]) as number | undefined);
const cssWidth = hasWidth ? `${hasWidth}px` : '';
totalWidth += hasWidth || cellMinWidth;
if (!hasWidth) {
fixedWidth = false;
}
if (!nextDOM) {
const colElement = document.createElement('col');
const [propertyKey, propertyValue] = getColStyleDeclaration(
cellMinWidth,
hasWidth,
);
colElement.style.setProperty(propertyKey, propertyValue);
colgroup.appendChild(colElement);
} else {
if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) {
const [propertyKey, propertyValue] = getColStyleDeclaration(
cellMinWidth,
hasWidth,
);
(nextDOM as HTMLTableColElement).style.setProperty(
propertyKey,
propertyValue,
);
}
nextDOM = nextDOM.nextSibling;
}
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling;
nextDOM.parentNode?.removeChild(nextDOM);
nextDOM = after;
}
const hasUserWidth =
node.attrs.style &&
typeof node.attrs.style === 'string' &&
/\bwidth\s*:/i.test(node.attrs.style);
if (fixedWidth && !hasUserWidth) {
table.style.width = `${totalWidth}px`;
table.style.minWidth = '';
} else {
table.style.width = '';
table.style.minWidth = `${totalWidth}px`;
}
}
export class TableView implements NodeView {
node: ProseMirrorNode;
cellMinWidth: number;
dom: HTMLDivElement;
table: HTMLTableElement;
colgroup: HTMLTableColElement;
contentDOM: HTMLTableSectionElement;
constructor(node: ProseMirrorNode, cellMinWidth: number) {
this.node = node;
this.cellMinWidth = cellMinWidth;
this.dom = document.createElement('div');
this.dom.className = 'tableWrapper';
this.table = this.dom.appendChild(document.createElement('table'));
if (node.attrs.style) {
this.table.style.cssText = node.attrs.style;
}
this.colgroup = this.table.appendChild(document.createElement('colgroup'));
updateColumns(node, this.colgroup, this.table, cellMinWidth);
this.contentDOM = this.table.appendChild(document.createElement('tbody'));
}
update(node: ProseMirrorNode) {
if (node.type !== this.node.type) return false;
this.node = node;
updateColumns(node, this.colgroup, this.table, this.cellMinWidth);
return true;
}
ignoreMutation(mutation: ViewMutationRecord) {
const target = mutation.target as Node;
const isInsideWrapper = this.dom.contains(target);
const isInsideContent = this.contentDOM.contains(target);
if (isInsideWrapper && !isInsideContent) {
if (
mutation.type === 'attributes' ||
mutation.type === 'childList' ||
mutation.type === 'characterData'
) {
return true;
}
}
// Chevron span (.tableReadonlySortChevron) added/removed by sort plugin.
if (mutation.type === 'childList') {
const nodes = [
...Array.from(mutation.addedNodes),
...Array.from(mutation.removedNodes),
];
if (
nodes.some(
(n) =>
n instanceof Element &&
n.classList.contains('tableReadonlySortChevron'),
)
) {
return true;
}
}
return false;
}
}
@@ -1,6 +1,8 @@
import { Table } from "@tiptap/extension-table";
import { Editor } from "@tiptap/core";
import { DOMOutputSpec } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state";
import { cellAround } from "@tiptap/pm/tables";
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
@@ -32,9 +34,36 @@ function handleListOutdent(editor: Editor): boolean {
}
export const CustomTable = Table.extend({
addKeyboardShortcuts() {
return {
...this.parent?.(),
"Mod-a": () => {
const { state, view } = this.editor;
const { selection, doc } = state;
const $cellPos = cellAround(selection.$anchor);
if (!$cellPos) return false;
const cellNode = doc.nodeAt($cellPos.pos);
// Empty cells have nothing useful to scope to — let the default
// Mod-a fall through and select the whole doc.
if (!cellNode || !cellNode.textContent) return false;
const from = $cellPos.pos + 1;
const to = $cellPos.pos + cellNode.nodeSize - 1;
if (from >= to) return true;
const nextSel = TextSelection.between(
doc.resolve(from),
doc.resolve(to),
1,
);
if (!nextSel || selection.eq(nextSel)) return true;
view.dispatch(state.tr.setSelection(nextSel));
return true;
},
Tab: () => {
// If we're in a list within a table, handle list indentation
if (isInList(this.editor) && this.editor.isActive("table")) {
@@ -0,0 +1,7 @@
export function getColStyleDeclaration(minWidth: number, width: number | undefined): [string, string] {
if (width) {
return ['width', `${Math.max(width, minWidth)}px`]
}
return ['min-width', `${minWidth}px`]
}