diff --git a/apps/client/package.json b/apps/client/package.json
index f85c008e1..a53974b80 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -10,6 +10,8 @@
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
},
"dependencies": {
+ "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
+ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5",
"@casl/react": "^5.0.1",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 3f357b258..11e40822d 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -276,6 +276,7 @@
"Align left": "Align left",
"Align right": "Align right",
"Align center": "Align center",
+ "Text alignment": "Text alignment",
"Justify": "Justify",
"Merge cells": "Merge cells",
"Split cell": "Split cell",
@@ -286,6 +287,20 @@
"Add row above": "Add row above",
"Add row below": "Add row below",
"Delete table": "Delete table",
+ "Add column left": "Add column left",
+ "Add column right": "Add column right",
+ "Clear cell": "Clear cell",
+ "Clear cells": "Clear cells",
+ "Distribute columns": "Distribute columns",
+ "Toggle header cell": "Toggle header cell",
+ "Toggle header column": "Toggle header column",
+ "Toggle header row": "Toggle header row",
+ "Move column left": "Move column left",
+ "Move column right": "Move column right",
+ "Move row down": "Move row down",
+ "Move row up": "Move row up",
+ "Sort A → Z": "Sort A → Z",
+ "Sort Z → A": "Sort Z → A",
"Info": "Info",
"Note": "Note",
"Success": "Success",
@@ -997,5 +1012,8 @@
"No pages with this label": "No pages with this label",
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
"No pages match your search.": "No pages match your search.",
- "Updated {{date}}": "Updated {{date}}"
+ "Updated {{date}}": "Updated {{date}}",
+ "Cell actions": "Cell actions",
+ "Column actions": "Column actions",
+ "Row actions": "Row actions"
}
diff --git a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.module.css b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.module.css
index f5cf09cbb..ef5595ea1 100644
--- a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.module.css
+++ b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.module.css
@@ -3,7 +3,7 @@
top: calc(var(--app-shell-header-offset, 0rem) + 45px);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
- z-index: 50;
+ z-index: 99;
display: flex;
align-items: center;
background: var(--mantine-color-body);
diff --git a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx
index 2a2135e8c..d72db0c7d 100644
--- a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx
+++ b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx
@@ -28,6 +28,7 @@ export const FixedToolbar: FC = () => {
<>
e.preventDefault()}
diff --git a/apps/client/src/features/editor/components/table/handle/cell-chevron.tsx b/apps/client/src/features/editor/components/table/handle/cell-chevron.tsx
new file mode 100644
index 000000000..db79844e8
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/cell-chevron.tsx
@@ -0,0 +1,126 @@
+import React, { useCallback, useEffect } from "react";
+import type { Editor } from "@tiptap/react";
+import { useEditorState } from "@tiptap/react";
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { TextSelection } from "@tiptap/pm/state";
+import { columnResizingPluginKey } from "@tiptap/pm/tables";
+import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
+import { Menu, UnstyledButton } from "@mantine/core";
+import { IconChevronDown } from "@tabler/icons-react";
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+import { isCellSelection } from "@docmost/editor-ext";
+import { CellChevronMenu } from "./menus/cell-chevron-menu";
+import classes from "./handle.module.css";
+
+interface CellChevronProps {
+ editor: Editor;
+ cellPos: number;
+ tableNode: ProseMirrorNode;
+ tablePos: number;
+}
+
+export const CellChevron = React.memo(function CellChevron({
+ editor,
+ cellPos,
+ tableNode,
+ tablePos,
+}: CellChevronProps) {
+ const { t } = useTranslation();
+ const cellDom = editor.view.nodeDOM(cellPos) as HTMLElement | null;
+
+ const { refs, floatingStyles, middlewareData } = useFloating({
+ placement: "top-end",
+ // crossAxis pulls the chevron INWARD from the cell's right edge. We need
+ // enough inset that we don't overlap PM-tables' column-resize hot zone
+ // (~5px wide around the column boundary). Without this, hovering near the
+ // column edge picks up the chevron's `cursor: pointer` instead of
+ // `col-resize`, and a drag near the edge clicks the chevron.
+ middleware: [offset({ mainAxis: -22, crossAxis: -10 }), hide()],
+ whileElementsMounted: autoUpdate,
+ strategy: "absolute",
+ });
+ const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
+
+ useEffect(() => {
+ refs.setReference(cellDom);
+ }, [cellDom, refs]);
+
+ // Hide the chevron while the user is resizing a column. PM-tables sets
+ // `activeHandle > -1` whenever the mouse is near a column boundary OR
+ // actively dragging it. Either way we don't want the chevron in the way.
+ const isResizingColumn = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) return false;
+ const state = columnResizingPluginKey.getState(ctx.editor.state) as
+ | { activeHandle: number }
+ | undefined;
+ return !!state && state.activeHandle > -1;
+ },
+ });
+
+ const onOpen = useCallback(() => {
+ const current = editor.state.selection;
+
+ // Preserve an existing multi-cell CellSelection that already covers
+ // this cell so merge etc. operate on the user's whole range.
+ let preserveExisting = false;
+ if (isCellSelection(current)) {
+ current.forEachCell((_node, pos) => {
+ if (pos === cellPos) preserveExisting = true;
+ });
+ }
+
+ if (!preserveExisting) {
+ // Drop a collapsed cursor inside the cell rather than a single-cell
+ // CellSelection — PM-tables paints the latter as a text-range
+ // highlight on the cell content.
+ try {
+ const $inside = editor.state.doc.resolve(cellPos + 1);
+ const sel = TextSelection.near($inside, 1);
+ editor.view.dispatch(editor.state.tr.setSelection(sel));
+ } catch {}
+ }
+ editor.commands.freezeHandles();
+ }, [editor, cellPos]);
+
+ const onClose = useCallback(() => {
+ editor.commands.unfreezeHandles();
+ }, [editor]);
+
+ if (!cellDom) return null;
+ if (isResizingColumn) return null;
+
+ return (
+
+ );
+});
diff --git a/apps/client/src/features/editor/components/table/handle/column-handle.tsx b/apps/client/src/features/editor/components/table/handle/column-handle.tsx
new file mode 100644
index 000000000..e634a8518
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/column-handle.tsx
@@ -0,0 +1,122 @@
+import React, { useEffect, useRef, useState } from "react";
+import type { Editor } from "@tiptap/react";
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
+import { Menu } from "@mantine/core";
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
+import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
+import { ColumnHandleMenu } from "./menus/column-handle-menu";
+import classes from "./handle.module.css";
+
+interface ColumnHandleProps {
+ editor: Editor;
+ index: number;
+ anchorPos: number;
+ tableNode: ProseMirrorNode;
+ tablePos: number;
+}
+
+export const ColumnHandle = React.memo(function ColumnHandle({
+ editor,
+ index,
+ anchorPos,
+ tableNode,
+ tablePos,
+}: ColumnHandleProps) {
+ const { t } = useTranslation();
+ // Hold the cell DOM in a ref-backed state so we never unmount the handle
+ // mid-drag. A remote edit can transiently flip `nodeDOM(anchorPos)` to null
+ // (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
+ // unmounting the source element here would make pragmatic-dnd silently
+ // abort the active drag.
+ const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
+ const [cellDom, setCellDom] = useState
(lookupCellDom);
+ const lastCellDomRef = useRef(lookupCellDom);
+ useEffect(() => {
+ if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
+ lastCellDomRef.current = lookupCellDom;
+ setCellDom(lookupCellDom);
+ }
+ }, [lookupCellDom]);
+
+ const [handleEl, setHandleEl] = useState(null);
+
+ const { refs, floatingStyles, middlewareData } = useFloating({
+ placement: "top",
+ middleware: [offset(-4), hide()],
+ whileElementsMounted: autoUpdate,
+ });
+ const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
+
+ useEffect(() => {
+ refs.setReference(cellDom);
+ }, [cellDom, refs]);
+
+ // `cellDom` is inside the table, so `closest('.tableWrapper')` finds the
+ // wrapper for this drag's auto-scroll. The handle itself lives in a
+ // floating layer outside the editor DOM, so we can't walk up from it.
+ const wrapper = cellDom?.closest(".tableWrapper") ?? null;
+ useTableHandleDrag(editor, "col", handleEl, wrapper);
+
+ const { onOpen, onClose } = useColumnRowMenuLifecycle({
+ editor,
+ orientation: "col",
+ index,
+ tableNode,
+ tablePos,
+ });
+
+ if (!cellDom) return null;
+
+ return (
+
+ );
+});
+
+function GripIcon() {
+ return (
+
+ );
+}
diff --git a/apps/client/src/features/editor/components/table/handle/handle.module.css b/apps/client/src/features/editor/components/table/handle/handle.module.css
new file mode 100644
index 000000000..e7d9ac124
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/handle.module.css
@@ -0,0 +1,108 @@
+.handle {
+ position: absolute;
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ color: rgba(55, 53, 47, 0.45);
+ background: var(--mantine-color-body);
+ border: 1px solid rgba(55, 53, 47, 0.12);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
+ cursor: grab;
+ padding: 0;
+ transition: background-color 120ms ease, color 120ms ease;
+
+ @mixin dark {
+ color: rgba(255, 255, 255, 0.55);
+ background: var(--mantine-color-dark-7);
+ border-color: rgba(255, 255, 255, 0.12);
+ }
+}
+
+.handle:hover {
+ background: light-dark(
+ var(--mantine-color-gray-1),
+ var(--mantine-color-dark-5)
+ );
+ color: light-dark(
+ var(--mantine-color-gray-7),
+ var(--mantine-color-dark-0)
+ );
+}
+
+.handle:active {
+ cursor: grabbing;
+}
+
+.columnHandle {
+ width: 28px;
+ height: 16px;
+}
+
+.columnHandle svg {
+ transform: rotate(90deg);
+}
+
+.rowHandle {
+ width: 16px;
+ height: 28px;
+}
+
+@media (max-width: 600px) {
+ .handle {
+ display: none;
+ }
+}
+
+.cellChevron {
+ position: absolute;
+ z-index: 50;
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: light-dark(
+ var(--mantine-color-gray-7),
+ var(--mantine-color-dark-1)
+ );
+ background: light-dark(
+ var(--mantine-color-gray-1),
+ var(--mantine-color-dark-5)
+ );
+ border: 1px solid rgba(55, 53, 47, 0.12);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
+ cursor: pointer;
+ padding: 0;
+ transition: background-color 120ms ease, color 120ms ease;
+
+ @mixin dark {
+ border-color: rgba(255, 255, 255, 0.12);
+ }
+}
+
+.cellChevron:hover {
+ background: light-dark(
+ var(--mantine-color-gray-2),
+ var(--mantine-color-dark-4)
+ );
+ color: light-dark(
+ var(--mantine-color-gray-8),
+ var(--mantine-color-dark-0)
+ );
+}
+
+@media (max-width: 600px) {
+ .cellChevron {
+ display: none;
+ }
+}
+
+@media print {
+ .handle,
+ .cellChevron {
+ display: none !important;
+ }
+}
diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts
new file mode 100644
index 000000000..a30595597
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts
@@ -0,0 +1,40 @@
+import { useCallback } from "react";
+import type { Editor } from "@tiptap/react";
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
+
+interface Args {
+ editor: Editor;
+ orientation: Orientation;
+ index: number;
+ tableNode: ProseMirrorNode;
+ tablePos: number;
+}
+
+export function useColumnRowMenuLifecycle({
+ editor,
+ orientation,
+ index,
+ tableNode,
+ tablePos,
+}: Args) {
+ const onOpen = useCallback(() => {
+ const selection = buildRowOrColumnSelection(
+ editor.state,
+ tableNode,
+ tablePos,
+ orientation,
+ index,
+ );
+ const tr = editor.state.tr;
+ if (selection) tr.setSelection(selection);
+ editor.view.dispatch(tr);
+ editor.commands.freezeHandles();
+ }, [editor, orientation, index, tableNode, tablePos]);
+
+ const onClose = useCallback(() => {
+ editor.commands.unfreezeHandles();
+ }, [editor]);
+
+ return { onOpen, onClose };
+}
diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-table-clear.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-table-clear.ts
new file mode 100644
index 000000000..1bd4cb209
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/hooks/use-table-clear.ts
@@ -0,0 +1,54 @@
+import { useCallback } from "react";
+import type { Editor } from "@tiptap/react";
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { TableMap } from "@tiptap/pm/tables";
+
+type Scope =
+ | { kind: "col"; index: number }
+ | { kind: "row"; index: number }
+ | { kind: "cell"; cellPos: number };
+
+export function useTableClear(
+ editor: Editor,
+ tableNode: ProseMirrorNode,
+ tablePos: number,
+ scope: Scope,
+) {
+ return useCallback(() => {
+ const tr = editor.state.tr;
+ const tableStart = tablePos + 1;
+ const map = TableMap.get(tableNode);
+ const paragraph = editor.schema.nodes.paragraph;
+ if (!paragraph) return;
+
+ const cellOffsets: number[] = [];
+
+ if (scope.kind === "col") {
+ for (let row = 0; row < map.height; row++) {
+ cellOffsets.push(map.map[row * map.width + scope.index]);
+ }
+ } else if (scope.kind === "row") {
+ for (let col = 0; col < map.width; col++) {
+ cellOffsets.push(map.map[scope.index * map.width + col]);
+ }
+ }
+
+ const targets =
+ scope.kind === "cell"
+ ? [scope.cellPos]
+ : Array.from(new Set(cellOffsets)).map((o) => tableStart + o);
+
+ // Process in reverse position order so earlier replacements don't shift later ones.
+ targets.sort((a, b) => b - a);
+
+ for (const cellPos of targets) {
+ const node = tr.doc.nodeAt(cellPos);
+ if (!node) continue;
+ const start = cellPos + 1;
+ const end = cellPos + node.nodeSize - 1;
+ tr.replaceWith(start, end, paragraph.create());
+ }
+
+ if (tr.docChanged) editor.view.dispatch(tr);
+ }, [editor, tableNode, tablePos, scope]);
+}
diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-drag.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-drag.ts
new file mode 100644
index 000000000..b6e52d803
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-drag.ts
@@ -0,0 +1,75 @@
+import { useEffect } from "react";
+import type { Editor } from "@tiptap/react";
+import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
+import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import { disableNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview";
+import {
+ autoScrollForElements,
+ autoScrollWindowForElements,
+} from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
+import { getTableHandlePluginSpec } from "@docmost/editor-ext";
+
+// Uses pragmatic-drag-and-drop instead of native HTML5 DnD because the native
+// dragstart→dragover→drop lifecycle was being silently cancelled in this app.
+export function useTableHandleDrag(
+ editor: Editor,
+ orientation: "col" | "row",
+ element: HTMLElement | null,
+ wrapper: HTMLElement | null,
+) {
+ useEffect(() => {
+ if (!element) return;
+
+ return combine(
+ draggable({
+ element,
+ getInitialData: () => ({ type: `table-${orientation}` }),
+ onGenerateDragPreview: ({ nativeSetDragImage }) => {
+ // We render our own floating preview via PreviewController, so hide
+ // the native drag image entirely.
+ disableNativeDragPreview({ nativeSetDragImage });
+ },
+ onDragStart: ({ location }) => {
+ const spec = getTableHandlePluginSpec(editor);
+ if (!spec) return;
+ const { clientX, clientY } = location.initial.input;
+ spec.startDragFromHandle(orientation, clientX, clientY);
+ },
+ onDrag: ({ location }) => {
+ const spec = getTableHandlePluginSpec(editor);
+ if (!spec) return;
+ const { clientX, clientY } = location.current.input;
+ spec.updateDragPosition(clientX, clientY);
+ },
+ onDrop: ({ location }) => {
+ const spec = getTableHandlePluginSpec(editor);
+ if (!spec) return;
+ const { clientX, clientY } = location.current.input;
+ // Make sure the final position is recorded before committing the drop.
+ spec.updateDragPosition(clientX, clientY);
+ spec.commitDrop();
+ spec.endDrag();
+ },
+ }),
+ // Wrapper owns horizontal auto-scroll (it has `overflow-x: auto`);
+ // window owns vertical. Locking each axis prevents the window's
+ // horizontal auto-scroll from running when the cursor approaches
+ // the viewport edge — without the cap, the preview's `left` follows
+ // the cursor past the viewport, the page widens to contain it, the
+ // plugin scrolls the now-wider page further, and the loop never
+ // ends.
+ // Only the column handle registers wrapper auto-scroll (rows can't
+ // scroll horizontally) — registering twice on the same wrapper
+ // triggers a dev-mode warning from pragmatic-dnd-auto-scroll.
+ orientation === "col" &&
+ wrapper &&
+ !wrapper.classList.contains("tableWrapperNoOverflow")
+ ? autoScrollForElements({
+ element: wrapper,
+ getAllowedAxis: () => "horizontal",
+ })
+ : () => {},
+ autoScrollWindowForElements({ getAllowedAxis: () => "vertical" }),
+ );
+ }, [editor, orientation, element, wrapper]);
+}
diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-state.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-state.ts
new file mode 100644
index 000000000..ab8893566
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-state.ts
@@ -0,0 +1,23 @@
+import type { Editor } from "@tiptap/react";
+import { useEditorState } from "@tiptap/react";
+import { TableDndKey, TableHandleState } from "@docmost/editor-ext";
+
+const FALLBACK: TableHandleState = {
+ hoveringCell: null,
+ tableNode: null,
+ tablePos: null,
+ dragging: null,
+ frozen: false,
+};
+
+export function useTableHandleState(editor: Editor | null): TableHandleState {
+ const state = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) return null;
+ return TableDndKey.getState(ctx.editor.state) ?? null;
+ },
+ });
+
+ return state ?? FALLBACK;
+}
diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-table-move-row-column.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-table-move-row-column.ts
new file mode 100644
index 000000000..476c68f8d
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/hooks/use-table-move-row-column.ts
@@ -0,0 +1,50 @@
+import { useCallback, useMemo } from "react";
+import type { Editor } from "@tiptap/react";
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { TableMap } from "@tiptap/pm/tables";
+import { moveColumn, moveRow } from "@docmost/editor-ext";
+
+export type MoveDirection = "left" | "right" | "up" | "down";
+
+export function useTableMoveRowColumn(
+ editor: Editor,
+ orientation: "col" | "row",
+ index: number,
+ direction: MoveDirection,
+ tableNode: ProseMirrorNode,
+ tablePos: number,
+) {
+ const target =
+ direction === "left" || direction === "up" ? index - 1 : index + 1;
+
+ const maxIndex = useMemo(() => {
+ const map = TableMap.get(tableNode);
+ return orientation === "col" ? map.width - 1 : map.height - 1;
+ }, [tableNode, orientation]);
+
+ const canMove = target >= 0 && target <= maxIndex;
+
+ const handleMove = useCallback(() => {
+ if (!canMove) return;
+ const tr = editor.state.tr;
+ const moved =
+ orientation === "col"
+ ? moveColumn({
+ tr,
+ originIndex: index,
+ targetIndex: target,
+ select: true,
+ pos: tablePos + 1,
+ })
+ : moveRow({
+ tr,
+ originIndex: index,
+ targetIndex: target,
+ select: true,
+ pos: tablePos + 1,
+ });
+ if (moved) editor.view.dispatch(tr);
+ }, [editor, orientation, index, target, tablePos, canMove]);
+
+ return { canMove, handleMove };
+}
diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-table-sort.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-table-sort.ts
new file mode 100644
index 000000000..afc6a2774
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/hooks/use-table-sort.ts
@@ -0,0 +1,100 @@
+import { useCallback, useMemo } from "react";
+import type { Editor } from "@tiptap/react";
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import {
+ convertArrayOfRowsToTableNode,
+ convertTableNodeToArrayOfRows,
+ transpose,
+} from "@docmost/editor-ext";
+import {
+ getCellSortText,
+ isCellEmpty,
+ isHeaderCell,
+ type SortDirection,
+ type SortableItem,
+ sortItems,
+ weaveItems,
+} from "../lib/sort-cells";
+
+interface Args {
+ editor: Editor;
+ orientation: "col" | "row";
+ index: number;
+ tableNode: ProseMirrorNode;
+ tablePos: number;
+ direction: SortDirection;
+}
+
+function tableHasMergedCells(tableNode: ProseMirrorNode): boolean {
+ for (let r = 0; r < tableNode.childCount; r++) {
+ const row = tableNode.child(r);
+ for (let c = 0; c < row.childCount; c++) {
+ const { colspan = 1, rowspan = 1 } = row.child(c).attrs;
+ if (colspan > 1 || rowspan > 1) return true;
+ }
+ }
+ return false;
+}
+
+function isAllHeader(cells: (ProseMirrorNode | null)[]): boolean {
+ return cells.every((c) => c !== null && isHeaderCell(c));
+}
+
+export function useTableSort({
+ editor,
+ orientation,
+ index,
+ tableNode,
+ tablePos,
+ direction,
+}: Args) {
+ const canSort = useMemo(() => {
+ if (tableHasMergedCells(tableNode)) return false;
+
+ const rows = convertTableNodeToArrayOfRows(tableNode);
+ const axes = orientation === "col" ? rows : transpose(rows);
+ if (axes.length < 2) return false;
+
+ return axes.some((cells) => {
+ if (isAllHeader(cells)) return false;
+ const sortCell = cells[index];
+ return !!sortCell && !isCellEmpty(sortCell);
+ });
+ }, [tableNode, orientation, index]);
+
+ const handleSort = useCallback(() => {
+ if (!canSort) return;
+
+ const rows = convertTableNodeToArrayOfRows(tableNode);
+ const axes = orientation === "col" ? rows : transpose(rows);
+
+ const items: SortableItem<(ProseMirrorNode | null)[]>[] = axes.map(
+ (cells, originalOrder) => {
+ const sortCell = cells[index];
+ return {
+ payload: cells,
+ text: sortCell ? getCellSortText(sortCell) : "",
+ isHeader: isAllHeader(cells),
+ isEmpty: !sortCell || isCellEmpty(sortCell),
+ originalOrder,
+ };
+ },
+ );
+
+ const dataItems = items.filter((it) => !it.isHeader);
+ const sortedData = sortItems(dataItems, direction);
+ const woven = weaveItems(items, sortedData);
+
+ const newAxes = woven.map((it) => it.payload);
+ const newRows = orientation === "col" ? newAxes : transpose(newAxes);
+
+ const newTable = convertArrayOfRowsToTableNode(tableNode, newRows);
+
+ const tr = editor.state.tr;
+ tr.replaceWith(tablePos, tablePos + tableNode.nodeSize, newTable);
+
+ if (tr.docChanged) editor.view.dispatch(tr);
+ }, [editor, tableNode, tablePos, orientation, index, direction, canSort]);
+
+ return { canSort, handleSort };
+}
diff --git a/apps/client/src/features/editor/components/table/handle/lib/select-row-column.ts b/apps/client/src/features/editor/components/table/handle/lib/select-row-column.ts
new file mode 100644
index 000000000..5ef315cf1
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/lib/select-row-column.ts
@@ -0,0 +1,34 @@
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import type { EditorState } from "@tiptap/pm/state";
+import { CellSelection, TableMap } from "@tiptap/pm/tables";
+
+export type Orientation = "col" | "row";
+
+export function buildRowOrColumnSelection(
+ state: EditorState,
+ tableNode: ProseMirrorNode,
+ tablePos: number,
+ orientation: Orientation,
+ index: number,
+): CellSelection | null {
+ const map = TableMap.get(tableNode);
+ const tableStart = tablePos + 1;
+
+ if (orientation === "col") {
+ if (index < 0 || index >= map.width) return null;
+ const firstCellPos = tableStart + map.map[index];
+ const lastCellPos =
+ tableStart + map.map[(map.height - 1) * map.width + index];
+ const $first = state.doc.resolve(firstCellPos);
+ const $last = state.doc.resolve(lastCellPos);
+ return CellSelection.colSelection($first, $last);
+ }
+
+ if (index < 0 || index >= map.height) return null;
+ const firstCellPos = tableStart + map.map[index * map.width];
+ const lastCellPos =
+ tableStart + map.map[index * map.width + (map.width - 1)];
+ const $first = state.doc.resolve(firstCellPos);
+ const $last = state.doc.resolve(lastCellPos);
+ return CellSelection.rowSelection($first, $last);
+}
diff --git a/apps/client/src/features/editor/components/table/handle/lib/sort-cells.ts b/apps/client/src/features/editor/components/table/handle/lib/sort-cells.ts
new file mode 100644
index 000000000..ffd039c2b
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/lib/sort-cells.ts
@@ -0,0 +1,57 @@
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+
+export type SortDirection = "asc" | "desc";
+
+export interface SortableItem {
+ payload: T;
+ text: string;
+ isHeader: boolean;
+ isEmpty: boolean;
+ originalOrder: number;
+}
+
+const HEADER_TYPE_NAMES = new Set(["tableHeader", "table_header"]);
+
+export function isHeaderCell(node: ProseMirrorNode): boolean {
+ if (HEADER_TYPE_NAMES.has(node.type.name)) return true;
+ return node.attrs?.header === true;
+}
+
+export function getCellSortText(node: ProseMirrorNode): string {
+ let text = "";
+ node.descendants((child) => {
+ if (child.isText) text += child.text ?? "";
+ return true;
+ });
+ return text.trim().toLowerCase();
+}
+
+export function isCellEmpty(node: ProseMirrorNode): boolean {
+ return getCellSortText(node) === "";
+}
+
+export const collator = new Intl.Collator(undefined, {
+ sensitivity: "base",
+ numeric: true,
+});
+
+export function sortItems(
+ data: SortableItem[],
+ direction: SortDirection,
+): SortableItem[] {
+ return [...data].sort((a, b) => {
+ if (a.isEmpty && !b.isEmpty) return 1;
+ if (!a.isEmpty && b.isEmpty) return -1;
+ if (a.isEmpty && b.isEmpty) return a.originalOrder - b.originalOrder;
+ const cmp = collator.compare(a.text, b.text);
+ return direction === "asc" ? cmp : -cmp;
+ });
+}
+
+export function weaveItems(
+ all: SortableItem[],
+ sortedData: SortableItem[],
+): SortableItem[] {
+ const dataQueue = [...sortedData];
+ return all.map((item) => (item.isHeader ? item : dataQueue.shift()!));
+}
diff --git a/apps/client/src/features/editor/components/table/handle/menus/alignment-submenu.tsx b/apps/client/src/features/editor/components/table/handle/menus/alignment-submenu.tsx
new file mode 100644
index 000000000..c58f5a967
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/menus/alignment-submenu.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+import type { Editor } from "@tiptap/react";
+import { Menu } from "@mantine/core";
+import {
+ IconAlignCenter,
+ IconAlignLeft,
+ IconAlignRight,
+} from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+
+interface AlignmentSubmenuProps {
+ editor: Editor;
+}
+
+export const AlignmentSubmenu = React.memo(function AlignmentSubmenu({
+ editor,
+}: AlignmentSubmenuProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ }>
+ {t("Text alignment")}
+
+
+
+ }
+ onClick={() => editor.chain().focus().setTextAlign("left").run()}
+ >
+ {t("Align left")}
+
+ }
+ onClick={() => editor.chain().focus().setTextAlign("center").run()}
+ >
+ {t("Align center")}
+
+ }
+ onClick={() => editor.chain().focus().setTextAlign("right").run()}
+ >
+ {t("Align right")}
+
+
+
+ );
+});
diff --git a/apps/client/src/features/editor/components/table/handle/menus/cell-chevron-menu.tsx b/apps/client/src/features/editor/components/table/handle/menus/cell-chevron-menu.tsx
new file mode 100644
index 000000000..84f904ca7
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/menus/cell-chevron-menu.tsx
@@ -0,0 +1,154 @@
+import React from "react";
+import type { Editor } from "@tiptap/react";
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { ColorSwatch, Menu } from "@mantine/core";
+import {
+ IconBoxMargin,
+ IconColumnInsertRight,
+ IconColumnRemove,
+ IconEraser,
+ IconPalette,
+ IconRowInsertBottom,
+ IconRowRemove,
+ IconSquareToggle,
+ IconTableRow,
+} from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { useTableClear } from "../hooks/use-table-clear";
+import { TABLE_COLORS } from "../../table-background-color";
+import { AlignmentSubmenu } from "./alignment-submenu";
+
+interface CellChevronMenuProps {
+ editor: Editor;
+ cellPos: number;
+ tableNode: ProseMirrorNode;
+ tablePos: number;
+}
+
+export const CellChevronMenu = React.memo(function CellChevronMenu({
+ editor,
+ cellPos,
+ tableNode,
+ tablePos,
+}: CellChevronMenuProps) {
+ const { t } = useTranslation();
+
+ const clearCell = useTableClear(editor, tableNode, tablePos, {
+ kind: "cell",
+ cellPos,
+ });
+
+ const setBackground = (color: string, name: string) => {
+ editor
+ .chain()
+ .focus()
+ .updateAttributes("tableCell", {
+ backgroundColor: color || null,
+ backgroundColorName: color ? name : null,
+ })
+ .updateAttributes("tableHeader", {
+ backgroundColor: color || null,
+ backgroundColorName: color ? name : null,
+ })
+ .run();
+ };
+
+ return (
+ <>
+
+
+ }>
+ {t("Background color")}
+
+
+
+
+ {TABLE_COLORS.map((c) => (
+
+ ))}
+
+
+
+
+
+
+ }
+ onClick={() => editor.chain().focus().mergeCells().run()}
+ disabled={!editor.can().mergeCells()}
+ >
+ {t("Merge cells")}
+
+ }
+ onClick={() => editor.chain().focus().splitCell().run()}
+ disabled={!editor.can().splitCell()}
+ >
+ {t("Split cell")}
+
+ }
+ onClick={() => editor.chain().focus().toggleHeaderCell().run()}
+ >
+ {t("Toggle header cell")}
+
+
+
+
+ }
+ onClick={() => editor.chain().focus().addColumnAfter().run()}
+ >
+ {t("Add column right")}
+
+ }
+ onClick={() => editor.chain().focus().addRowAfter().run()}
+ >
+ {t("Add row below")}
+
+
+ } onClick={clearCell}>
+ {t("Clear cell")}
+
+ }
+ onClick={() => editor.chain().focus().deleteColumn().run()}
+ >
+ {t("Delete column")}
+
+ }
+ onClick={() => editor.chain().focus().deleteRow().run()}
+ >
+ {t("Delete row")}
+
+ >
+ );
+});
diff --git a/apps/client/src/features/editor/components/table/handle/menus/column-handle-menu.tsx b/apps/client/src/features/editor/components/table/handle/menus/column-handle-menu.tsx
new file mode 100644
index 000000000..8dbe9d326
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/menus/column-handle-menu.tsx
@@ -0,0 +1,177 @@
+import React from "react";
+import type { Editor } from "@tiptap/react";
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { ColorSwatch, Menu } from "@mantine/core";
+import { TABLE_COLORS } from "../../table-background-color";
+import {
+ IconArrowLeft,
+ IconArrowRight,
+ IconColumnInsertLeft,
+ IconColumnInsertRight,
+ IconColumnRemove,
+ IconEraser,
+ IconPalette,
+ IconSortAscendingLetters,
+ IconSortDescendingLetters,
+} from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column";
+import { useTableClear } from "../hooks/use-table-clear";
+import { useTableSort } from "../hooks/use-table-sort";
+import { AlignmentSubmenu } from "./alignment-submenu";
+
+interface ColumnHandleMenuProps {
+ editor: Editor;
+ index: number;
+ tableNode: ProseMirrorNode;
+ tablePos: number;
+}
+
+export const ColumnHandleMenu = React.memo(function ColumnHandleMenu({
+ editor,
+ index,
+ tableNode,
+ tablePos,
+}: ColumnHandleMenuProps) {
+ const { t } = useTranslation();
+
+ const moveLeft = useTableMoveRowColumn(editor, "col", index, "left", tableNode, tablePos);
+ const moveRight = useTableMoveRowColumn(editor, "col", index, "right", tableNode, tablePos);
+ const clearCol = useTableClear(editor, tableNode, tablePos, {
+ kind: "col",
+ index,
+ });
+
+ const setBackground = (color: string, name: string) => {
+ editor
+ .chain()
+ .focus()
+ .updateAttributes("tableCell", {
+ backgroundColor: color || null,
+ backgroundColorName: color ? name : null,
+ })
+ .updateAttributes("tableHeader", {
+ backgroundColor: color || null,
+ backgroundColorName: color ? name : null,
+ })
+ .run();
+ };
+
+ const sortAsc = useTableSort({
+ editor,
+ orientation: "col",
+ index,
+ tableNode,
+ tablePos,
+ direction: "asc",
+ });
+ const sortDesc = useTableSort({
+ editor,
+ orientation: "col",
+ index,
+ tableNode,
+ tablePos,
+ direction: "desc",
+ });
+
+ return (
+ <>
+ }
+ onClick={sortAsc.handleSort}
+ disabled={!sortAsc.canSort}
+ >
+ {t("Sort A → Z")}
+
+ }
+ onClick={sortDesc.handleSort}
+ disabled={!sortDesc.canSort}
+ >
+ {t("Sort Z → A")}
+
+
+
+
+
+ }>
+ {t("Background color")}
+
+
+
+
+ {TABLE_COLORS.map((c) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ }
+ onClick={() => editor.chain().focus().addColumnBefore().run()}
+ >
+ {t("Add column left")}
+
+ }
+ onClick={() => editor.chain().focus().addColumnAfter().run()}
+ >
+ {t("Add column right")}
+
+
+
+
+ }
+ onClick={clearCol}
+ >
+ {t("Clear cells")}
+
+ }
+ onClick={() => editor.chain().focus().deleteColumn().run()}
+ >
+ {t("Delete column")}
+
+
+
+
+ }
+ onClick={moveLeft.handleMove}
+ disabled={!moveLeft.canMove}
+ >
+ {t("Move column left")}
+
+ }
+ onClick={moveRight.handleMove}
+ disabled={!moveRight.canMove}
+ >
+ {t("Move column right")}
+
+ >
+ );
+});
diff --git a/apps/client/src/features/editor/components/table/handle/menus/row-handle-menu.tsx b/apps/client/src/features/editor/components/table/handle/menus/row-handle-menu.tsx
new file mode 100644
index 000000000..13b968b76
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/menus/row-handle-menu.tsx
@@ -0,0 +1,138 @@
+import React from "react";
+import type { Editor } from "@tiptap/react";
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { ColorSwatch, Menu } from "@mantine/core";
+import { TABLE_COLORS } from "../../table-background-color";
+import {
+ IconArrowDown,
+ IconArrowUp,
+ IconEraser,
+ IconPalette,
+ IconRowInsertBottom,
+ IconRowInsertTop,
+ IconRowRemove,
+} from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column";
+import { useTableClear } from "../hooks/use-table-clear";
+import { AlignmentSubmenu } from "./alignment-submenu";
+
+interface RowHandleMenuProps {
+ editor: Editor;
+ index: number;
+ tableNode: ProseMirrorNode;
+ tablePos: number;
+}
+
+export const RowHandleMenu = React.memo(function RowHandleMenu({
+ editor,
+ index,
+ tableNode,
+ tablePos,
+}: RowHandleMenuProps) {
+ const { t } = useTranslation();
+
+ const setBackground = (color: string, name: string) => {
+ editor
+ .chain()
+ .focus()
+ .updateAttributes("tableCell", {
+ backgroundColor: color || null,
+ backgroundColorName: color ? name : null,
+ })
+ .updateAttributes("tableHeader", {
+ backgroundColor: color || null,
+ backgroundColorName: color ? name : null,
+ })
+ .run();
+ };
+
+ const moveUp = useTableMoveRowColumn(editor, "row", index, "up", tableNode, tablePos);
+ const moveDown = useTableMoveRowColumn(editor, "row", index, "down", tableNode, tablePos);
+ const clearRow = useTableClear(editor, tableNode, tablePos, {
+ kind: "row",
+ index,
+ });
+
+ return (
+ <>
+
+
+ }>
+ {t("Background color")}
+
+
+
+
+ {TABLE_COLORS.map((c) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ }
+ onClick={() => editor.chain().focus().addRowBefore().run()}
+ >
+ {t("Add row above")}
+
+ }
+ onClick={() => editor.chain().focus().addRowAfter().run()}
+ >
+ {t("Add row below")}
+
+
+
+
+ } onClick={clearRow}>
+ {t("Clear cells")}
+
+ }
+ onClick={() => editor.chain().focus().deleteRow().run()}
+ >
+ {t("Delete row")}
+
+
+
+
+ }
+ onClick={moveUp.handleMove}
+ disabled={!moveUp.canMove}
+ >
+ {t("Move row up")}
+
+ }
+ onClick={moveDown.handleMove}
+ disabled={!moveDown.canMove}
+ >
+ {t("Move row down")}
+
+ >
+ );
+});
diff --git a/apps/client/src/features/editor/components/table/handle/row-handle.tsx b/apps/client/src/features/editor/components/table/handle/row-handle.tsx
new file mode 100644
index 000000000..107c303f1
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/row-handle.tsx
@@ -0,0 +1,117 @@
+import React, { useEffect, useRef, useState } from "react";
+import type { Editor } from "@tiptap/react";
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
+import { Menu } from "@mantine/core";
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
+import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
+import { RowHandleMenu } from "./menus/row-handle-menu";
+import classes from "./handle.module.css";
+
+interface RowHandleProps {
+ editor: Editor;
+ index: number;
+ anchorPos: number;
+ tableNode: ProseMirrorNode;
+ tablePos: number;
+}
+
+export const RowHandle = React.memo(function RowHandle({
+ editor,
+ index,
+ anchorPos,
+ tableNode,
+ tablePos,
+}: RowHandleProps) {
+ const { t } = useTranslation();
+ // See ColumnHandle for the rationale: keep the last valid cell DOM cached
+ // so the handle div stays mounted across stale-anchor renders, otherwise
+ // pragmatic-dnd silently aborts an in-flight drag.
+ const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
+ const [cellDom, setCellDom] = useState(lookupCellDom);
+ const lastCellDomRef = useRef(lookupCellDom);
+ useEffect(() => {
+ if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
+ lastCellDomRef.current = lookupCellDom;
+ setCellDom(lookupCellDom);
+ }
+ }, [lookupCellDom]);
+
+ const [handleEl, setHandleEl] = useState(null);
+
+ const { refs, floatingStyles, middlewareData } = useFloating({
+ placement: "left",
+ middleware: [offset(-4), hide()],
+ whileElementsMounted: autoUpdate,
+ });
+ const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
+
+ useEffect(() => {
+ refs.setReference(cellDom);
+ }, [cellDom, refs]);
+
+ const wrapper = cellDom?.closest(".tableWrapper") ?? null;
+ useTableHandleDrag(editor, "row", handleEl, wrapper);
+
+ const { onOpen, onClose } = useColumnRowMenuLifecycle({
+ editor,
+ orientation: "row",
+ index,
+ tableNode,
+ tablePos,
+ });
+
+ if (!cellDom) return null;
+
+ return (
+
+ );
+});
+
+function GripIcon() {
+ return (
+
+ );
+}
diff --git a/apps/client/src/features/editor/components/table/handle/table-handles-layer.tsx b/apps/client/src/features/editor/components/table/handle/table-handles-layer.tsx
new file mode 100644
index 000000000..e40c7baac
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/table-handles-layer.tsx
@@ -0,0 +1,44 @@
+import React from "react";
+import type { Editor } from "@tiptap/react";
+import { useTableHandleState } from "./hooks/use-table-handle-state";
+import { ColumnHandle } from "./column-handle";
+import { RowHandle } from "./row-handle";
+import { CellChevron } from "./cell-chevron";
+
+interface TableHandlesLayerProps {
+ editor: Editor | null;
+}
+
+export const TableHandlesLayer = React.memo(function TableHandlesLayer({
+ editor,
+}: TableHandlesLayerProps) {
+ const state = useTableHandleState(editor);
+
+ if (!editor || !editor.isEditable) return null;
+ if (!state.hoveringCell || !state.tableNode || state.tablePos == null) return null;
+
+ return (
+ <>
+
+
+
+ >
+ );
+});
diff --git a/apps/client/src/features/editor/components/table/table-background-color.tsx b/apps/client/src/features/editor/components/table/table-background-color.tsx
index 3e4ce6168..c0df52d81 100644
--- a/apps/client/src/features/editor/components/table/table-background-color.tsx
+++ b/apps/client/src/features/editor/components/table/table-background-color.tsx
@@ -22,7 +22,7 @@ interface TableBackgroundColorProps {
editor: Editor | null;
}
-const TABLE_COLORS: TableColorItem[] = [
+export const TABLE_COLORS: TableColorItem[] = [
{ name: "Default", color: "" },
{ name: "Blue", color: "#b4d5ff" },
{ name: "Green", color: "#acf5d2" },
diff --git a/apps/client/src/features/editor/components/table/table-menu.tsx b/apps/client/src/features/editor/components/table/table-menu.tsx
index 4adafb206..3be7ec539 100644
--- a/apps/client/src/features/editor/components/table/table-menu.tsx
+++ b/apps/client/src/features/editor/components/table/table-menu.tsx
@@ -104,12 +104,12 @@ export const TableMenu = React.memo(
element.style.zIndex = "99";
}}
options={{
- placement: "top",
+ placement: "bottom",
offset: {
mainAxis: 15,
},
flip: {
- fallbackPlacements: ["top", "bottom"],
+ fallbackPlacements: ["bottom", "top"],
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
boundary: editor.options.element as HTMLElement,
},
diff --git a/apps/client/src/features/editor/extensions/drag-handle.ts b/apps/client/src/features/editor/extensions/drag-handle.ts
index a4843ed67..6b10678a1 100644
--- a/apps/client/src/features/editor/extensions/drag-handle.ts
+++ b/apps/client/src/features/editor/extensions/drag-handle.ts
@@ -60,6 +60,23 @@ function nodeDOMAtCoords(
options: GlobalDragHandleOptions,
view: EditorView,
) {
+ // Custom nodes (transclusion, …) render via tiptap's React node-view
+ // renderer, which emits `class="react-renderer node-${name}"` on the
+ // live wrapper — the `data-type` attribute is for static HTML
+ // serialization only. Match both so we cover live and parsed DOM.
+ // Inside a custom node, also match plain `p` so the first paragraph
+ // (which doesn't match `:not(:first-child)`) still gets its own
+ // handle; only hovers on the custom node's padding/border fall
+ // through to the wrapper.
+ const customSelectors = options.customNodes.flatMap((node) => [
+ `[data-type=${node}]`,
+ `.node-${node}`,
+ ]);
+ const customParagraphSelectors = options.customNodes.flatMap((node) => [
+ `[data-type=${node}] p`,
+ `.node-${node} p`,
+ ]);
+
const selectors = [
"li",
"p:not(:first-child)",
@@ -71,7 +88,13 @@ function nodeDOMAtCoords(
"h4",
"h5",
"h6",
- ...options.customNodes.map((node) => `[data-type=${node}]`),
+ // Tables nested in another block (toggle, transclusion, …) have a
+ // wrapper that isn't a direct child of .ProseMirror, so the
+ // parent-check below skips it. Match the wrapper explicitly so the
+ // handle shows up even with empty cells.
+ ".tableWrapper",
+ ...customParagraphSelectors,
+ ...customSelectors,
].join(", ");
return document
.elementsFromPoint(coords.x, coords.y)
@@ -99,6 +122,22 @@ function nodePosAtDOM(
})?.inside;
}
+function isCustomNodeDOM(
+ elem: Element | null | undefined,
+ options: GlobalDragHandleOptions,
+): boolean {
+ if (!elem) return false;
+ for (const name of options.customNodes) {
+ if (
+ elem.getAttribute("data-type") === name ||
+ elem.classList.contains(`node-${name}`)
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
@@ -137,7 +176,6 @@ export function DragHandlePlugin(
const nodePos = view.state.doc.resolve(fromSelectionPos);
- // Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(
@@ -166,14 +204,46 @@ export function DragHandlePlugin(
} else {
selection = NodeSelection.create(view.state.doc, draggedNodePos);
- // if inline node is selected, e.g mention -> go to the parent node to select the whole node
- // if table row is selected, go to the parent node to select the whole node
- if (
- (selection as NodeSelection).node.type.isInline ||
- (selection as NodeSelection).node.type.name === "tableRow"
- ) {
- let $pos = view.state.doc.resolve(selection.from);
- selection = NodeSelection.create(view.state.doc, $pos.before());
+ const $sel = view.state.doc.resolve(selection.from);
+
+ if (isCustomNodeDOM(node, options)) {
+ // The drag landed on a custom-node container (transclusion etc.).
+ // Walk up to the matching node so the drag moves the whole
+ // container, not whatever inner element the click landed on.
+ const customTypes = new Set(options.customNodes);
+ for (let d = $sel.depth; d > 0; d--) {
+ if (customTypes.has($sel.node(d).type.name)) {
+ selection = NodeSelection.create(
+ view.state.doc,
+ $sel.before(d),
+ );
+ break;
+ }
+ }
+ } else {
+ // If the selected node lives inside a table (at any nesting
+ // depth), promote to the whole table — the global drag handle is
+ // meant to move the table as a single block, not a row/cell. The
+ // earlier tableRow-only check only worked when the table sat at
+ // the doc root; once wrapped in another node (toggle, layout,
+ // etc.) the selection lands on a cell/paragraph and that check
+ // never fired.
+ let tableDepth = -1;
+ for (let d = $sel.depth; d > 0; d--) {
+ if ($sel.node(d).type.name === "table") {
+ tableDepth = d;
+ break;
+ }
+ }
+ if (tableDepth > 0) {
+ selection = NodeSelection.create(
+ view.state.doc,
+ $sel.before(tableDepth),
+ );
+ } else if ((selection as NodeSelection).node.type.isInline) {
+ // Inline node (e.g. mention): walk up to the parent block.
+ selection = NodeSelection.create(view.state.doc, $sel.before());
+ }
}
}
view.dispatch(view.state.tr.setSelection(selection));
@@ -313,6 +383,27 @@ export function DragHandlePlugin(
return;
}
+ const isCustomNode = isCustomNodeDOM(node, options);
+
+ // Custom nodes pin the handle to the inner NodeViewWrapper's top-left:
+ // the natural anchor sits in transient/empty space outside the visible block.
+ if (isCustomNode) {
+ // tiptap React node-views emit an outer `.react-renderer` whose first
+ // child is the visible NodeViewWrapper; walk to that outer first since
+ // `node` may be either the outer or an inner element with data-type.
+ const rendererOuter =
+ (node.closest(".react-renderer") as HTMLElement | null) ?? node;
+ const inner =
+ (rendererOuter.firstElementChild as HTMLElement | null) ??
+ rendererOuter;
+ const innerRect = absoluteRect(inner);
+ if (!dragHandleElement) return;
+ dragHandleElement.style.left = `${innerRect.left + 4}px`;
+ dragHandleElement.style.top = `${innerRect.top + 4}px`;
+ showDragHandle();
+ return;
+ }
+
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const lineHeight = isNaN(parsedLineHeight)
@@ -328,6 +419,13 @@ export function DragHandlePlugin(
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= options.dragHandleWidth;
}
+ // Tables: clear the table's own row-drag handle so the two
+ // grips don't stack on each other. `nodeDOMAtCoords` returns
+ // the wrapper for top-level hovers (wrapper is direct child of
+ // .ProseMirror) and a descendant for deeper hovers — cover both.
+ if (node.closest(".tableWrapper")) {
+ rect.left -= options.dragHandleWidth;
+ }
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts
index 095adecb9..1f09bef37 100644
--- a/apps/client/src/features/editor/extensions/extensions.ts
+++ b/apps/client/src/features/editor/extensions/extensions.ts
@@ -45,6 +45,9 @@ import {
SearchAndReplace,
Mention,
TableDndExtension,
+ TableHandleCommandsExtension,
+ TableHeaderPin,
+ TableReadonlySort,
Subpages,
Heading,
Highlight,
@@ -260,12 +263,16 @@ export const mainExtensions = [
resizable: true,
lastColumnResizable: true,
allowTableNodeSelection: true,
+ cellMinWidth: 49,
View: TableView,
}),
TableRow,
TableCell,
TableHeader,
TableDndExtension,
+ TableHandleCommandsExtension,
+ TableHeaderPin,
+ TableReadonlySort,
MathInline.configure({
view: MathInlineView,
}),
diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx
index 57aab5bb0..4e2fcccf6 100644
--- a/apps/client/src/features/editor/page-editor.tsx
+++ b/apps/client/src/features/editor/page-editor.tsx
@@ -44,6 +44,7 @@ import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubbl
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
+import { TableHandlesLayer } from "@/features/editor/components/table/handle/table-handles-layer";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
@@ -424,7 +425,7 @@ export default function PageEditor({
-
+
diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css
index 34ddaca3c..077570fb5 100644
--- a/apps/client/src/features/editor/styles/core.css
+++ b/apps/client/src/features/editor/styles/core.css
@@ -203,7 +203,8 @@
}
}
- .resize-cursor {
+ &.resize-cursor,
+ &.resize-cursor * {
cursor: ew-resize;
cursor: col-resize;
}
diff --git a/apps/client/src/features/editor/styles/table.css b/apps/client/src/features/editor/styles/table.css
index 9926d0bc0..5d802e4ab 100644
--- a/apps/client/src/features/editor/styles/table.css
+++ b/apps/client/src/features/editor/styles/table.css
@@ -15,7 +15,8 @@
}
.table-dnd-drop-indicator {
- background-color: #adf;
+ background-color: var(--mantine-color-blue-5);
+ z-index: 3;
}
.ProseMirror {
@@ -57,13 +58,14 @@
}
.column-resize-handle {
- background-color: #adf;
+ background-color: var(--mantine-color-blue-5);
bottom: -1px;
position: absolute;
- right: -2px;
+ right: -1px;
pointer-events: none;
top: 0;
- width: 4px;
+ width: 2px;
+ z-index: 3;
}
.selectedCell:after {
@@ -129,6 +131,139 @@
}
}
+
+/* Header-row pinning. Two CSS paths, picked by `header-pin/controller.ts`:
+ - native sticky (preferred): wrapper drops its overflow constraint so
+ `position: sticky` on the row can resolve against the document scroll.
+ - transform fallback: wrapper keeps `overflow-x: auto` for horizontal
+ scrolling; the row is positioned imperatively per scroll frame.
+
+ `--editor-pin-offset` is published to :root by `pinOffsetWatcher` in
+ `header-pin/offset.ts`, measured against the lowest fixed surface above
+ the editor (app shell header, page header, fixed toolbar). */
+
+.tableWrapper.tableWrapperNoOverflow,
+.tableWrapper.tableWrapperNoOverflow table {
+ overflow: visible;
+}
+
+.tableWrapper.tableHeaderPinned table tr:first-child {
+ z-index: 2;
+}
+
+.tableWrapper.tableWrapperNoOverflow.tableHeaderPinned table tr:first-child {
+ position: sticky;
+ top: var(--editor-pin-offset, 90px);
+}
+
+.tableWrapper.tableHeaderPinned:not(.tableWrapperNoOverflow) table tr:first-child {
+ position: relative;
+ transform: translateY(var(--table-pin-offset, 0px));
+}
+
+@media print {
+ .tableWrapper.tableHeaderPinned table tr:first-child {
+ position: static;
+ transform: none;
+ }
+}
+
+.tableReadonlySortChevron {
+ /* Anchor to the cell's right edge, vertically centered with the cell
+ content. The cell content (a ) is block-level so an inline chevron
+ would wrap to a new line; absolute positioning takes it out of flow. */
+ position: absolute;
+ top: 50%;
+ right: 6px;
+ transform: translateY(-50%);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ background: light-dark(
+ rgba(55, 53, 47, 0.08),
+ rgba(255, 255, 255, 0.08)
+ );
+ color: light-dark(
+ rgba(55, 53, 47, 0.55),
+ rgba(255, 255, 255, 0.55)
+ );
+ user-select: none;
+ cursor: pointer;
+ z-index: 1;
+ /* Hidden by default; revealed on header-cell hover or when this column is
+ the active sort (see selectors below). */
+ opacity: 0;
+ transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
+}
+
+.ProseMirror table th:hover .tableReadonlySortChevron,
+.tableReadonlySortChevron[data-sort] {
+ opacity: 1;
+}
+
+.ProseMirror table th:has(.tableReadonlySortChevron) {
+ padding-right: 30px;
+}
+
+.tableReadonlySortChevron:hover {
+ background: light-dark(
+ rgba(55, 53, 47, 0.16),
+ rgba(255, 255, 255, 0.16)
+ );
+}
+
+/* Immediate tooltip on the chevron — same style language as the rest of the
+ app (small, dark, rounded), unlike the native `title` tooltip which only
+ appears after a long delay. */
+.tableReadonlySortChevron::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ /* Below the chevron — placing it above the cell hits the table's
+ overflow clipping (the wrapper has `overflow-x: auto` which forces
+ `overflow-y: auto` per spec). */
+ top: calc(100% + 6px);
+ right: 0;
+ padding: 4px 8px;
+ border-radius: 4px;
+ background: var(--mantine-color-dark-7);
+ color: var(--mantine-color-white);
+ font-size: 12px;
+ font-weight: 400;
+ line-height: 1.4;
+ white-space: nowrap;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease;
+ z-index: 10;
+}
+
+.tableReadonlySortChevron:hover::after {
+ opacity: 1;
+}
+
+.tableReadonlySortChevron svg {
+ display: block;
+}
+
+.tableReadonlySortChevron[data-sort="asc"],
+.tableReadonlySortChevron[data-sort="desc"] {
+ background: light-dark(
+ var(--mantine-color-blue-1),
+ var(--mantine-color-blue-9)
+ );
+ color: light-dark(
+ var(--mantine-color-blue-7),
+ var(--mantine-color-blue-2)
+ );
+}
+
+.tableReadonlySortChevron[data-sort="asc"] svg {
+ transform: rotate(180deg);
+}
+
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
.prosemirror-dropcursor-block {
display: none;
diff --git a/apps/client/src/features/page/components/header/page-header.tsx b/apps/client/src/features/page/components/header/page-header.tsx
index 12f131b8d..0614cf0bd 100644
--- a/apps/client/src/features/page/components/header/page-header.tsx
+++ b/apps/client/src/features/page/components/header/page-header.tsx
@@ -8,7 +8,7 @@ interface Props {
}
export default function PageHeader({ readOnly }: Props) {
return (
-
+
diff --git a/packages/editor-ext/src/lib/table/dnd/auto-scroll-controller.ts b/packages/editor-ext/src/lib/table/dnd/auto-scroll-controller.ts
deleted file mode 100644
index 9b8304d54..000000000
--- a/packages/editor-ext/src/lib/table/dnd/auto-scroll-controller.ts
+++ /dev/null
@@ -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('.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;
- }
- }
-}
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts
index 1ad57ec1f..b4ca516ae 100644
--- a/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts
+++ b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts
@@ -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 {
+const INITIAL_STATE: TableHandleState = {
+ hoveringCell: null,
+ tableNode: null,
+ tablePos: null,
+ dragging: null,
+ frozen: false,
+};
+
+export const TableDndKey = new PluginKey("table-handles");
+
+class TableHandlePluginSpec implements PluginSpec {
key = TableDndKey;
- props: EditorProps>;
+ props: EditorProps>;
+
+ 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 | 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) => {
+ 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 = { 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 {
+ tableHandleCommands: {
+ freezeHandles: () => ReturnType;
+ unfreezeHandles: () => ReturnType;
+ };
+ }
+}
diff --git a/packages/editor-ext/src/lib/table/dnd/handle/drag-handle-controller.ts b/packages/editor-ext/src/lib/table/dnd/handle/drag-handle-controller.ts
deleted file mode 100644
index 33137e91f..000000000
--- a/packages/editor-ext/src/lib/table/dnd/handle/drag-handle-controller.ts
+++ /dev/null
@@ -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`,
- });
- })
- }
-}
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/dnd/handle/empty-image-controller.ts b/packages/editor-ext/src/lib/table/dnd/handle/empty-image-controller.ts
deleted file mode 100644
index 8848a6b04..000000000
--- a/packages/editor-ext/src/lib/table/dnd/handle/empty-image-controller.ts
+++ /dev/null
@@ -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();
- }
-}
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/table/dnd/index.ts b/packages/editor-ext/src/lib/table/dnd/index.ts
index cb21bec14..eaeade987 100644
--- a/packages/editor-ext/src/lib/table/dnd/index.ts
+++ b/packages/editor-ext/src/lib/table/dnd/index.ts
@@ -1 +1,7 @@
-export * from './dnd-extension'
\ No newline at end of file
+export {
+ TableDndExtension,
+ TableHandleCommandsExtension,
+ TableDndKey,
+ getTableHandlePluginSpec,
+} from "./dnd-extension";
+export type { TableHandleState, TableHandlePluginSpec } from "./dnd-extension";
diff --git a/packages/editor-ext/src/lib/table/dnd/preview/drop-indicator-controller.ts b/packages/editor-ext/src/lib/table/dnd/preview/drop-indicator-controller.ts
index 0f0798282..a42c632f7 100644
--- a/packages/editor-ext/src/lib/table/dnd/preview/drop-indicator-controller.ts
+++ b/packages/editor-ext/src/lib/table/dnd/preview/drop-indicator-controller.ts
@@ -99,4 +99,4 @@ export class DropIndicatorController {
});
}
-}
\ No newline at end of file
+}
diff --git a/packages/editor-ext/src/lib/table/dnd/preview/preview-controller.ts b/packages/editor-ext/src/lib/table/dnd/preview/preview-controller.ts
index b7a0ea40d..9884f00f6 100644
--- a/packages/editor-ext/src/lib/table/dnd/preview/preview-controller.ts
+++ b/packages/editor-ext/src/lib/table/dnd/preview/preview-controller.ts
@@ -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, {
diff --git a/packages/editor-ext/src/lib/table/dnd/utils.ts b/packages/editor-ext/src/lib/table/dnd/utils.ts
index d184368f4..9b00769d3 100644
--- a/packages/editor-ext/src/lib/table/dnd/utils.ts
+++ b/packages/editor-ext/src/lib/table/dnd/utils.ts
@@ -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)
diff --git a/packages/editor-ext/src/lib/table/header-pin/controller.ts b/packages/editor-ext/src/lib/table/header-pin/controller.ts
new file mode 100644
index 000000000..318d4145d
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/header-pin/controller.ts
@@ -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();
+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 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();
+
+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);
+}
diff --git a/packages/editor-ext/src/lib/table/header-pin/extension.ts b/packages/editor-ext/src/lib/table/header-pin/extension.ts
new file mode 100644
index 000000000..8e5157ede
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/header-pin/extension.ts
@@ -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();
+ let rafHandle: number | null = null;
+
+ const reconcile = () => {
+ rafHandle = null;
+ if (!editorRoot) return;
+ const current = new Set(
+ editorRoot.querySelectorAll('.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('.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;
+ },
+ };
+ },
+ }),
+ ];
+ },
+});
diff --git a/packages/editor-ext/src/lib/table/header-pin/index.ts b/packages/editor-ext/src/lib/table/header-pin/index.ts
new file mode 100644
index 000000000..b45e01aee
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/header-pin/index.ts
@@ -0,0 +1 @@
+export { TableHeaderPin } from './extension';
diff --git a/packages/editor-ext/src/lib/table/header-pin/offset.ts b/packages/editor-ext/src/lib/table/header-pin/offset.ts
new file mode 100644
index 000000000..89cc6bf9e
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/header-pin/offset.ts
@@ -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`,
+ );
+ },
+};
diff --git a/packages/editor-ext/src/lib/table/index.ts b/packages/editor-ext/src/lib/table/index.ts
index 784b71928..ed06582e3 100644
--- a/packages/editor-ext/src/lib/table/index.ts
+++ b/packages/editor-ext/src/lib/table/index.ts
@@ -4,3 +4,12 @@ export * from "./header";
export * from "./table";
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";
diff --git a/packages/editor-ext/src/lib/table/table-readonly-sort.ts b/packages/editor-ext/src/lib/table/table-readonly-sort.ts
new file mode 100644
index 000000000..3e246a411
--- /dev/null
+++ b/packages/editor-ext/src/lib/table/table-readonly-sort.ts
@@ -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();
+const originalOrders = new WeakMap();
+
+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(':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 =
+ '';
+
+function ensureChevron(th: HTMLTableCellElement): HTMLSpanElement {
+ let chevron = th.querySelector(`.${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('table');
+ tables.forEach((table) => updateChevrons(table));
+}
+
+function removeAllChevrons(editorRoot: HTMLElement): void {
+ editorRoot
+ .querySelectorAll(`.${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);
+ }
+ },
+ };
+ },
+ }),
+ ];
+ },
+});
diff --git a/packages/editor-ext/src/lib/table/table-view.ts b/packages/editor-ext/src/lib/table/table-view.ts
index 733b04089..7e410918e 100644
--- a/packages/editor-ext/src/lib/table/table-view.ts
+++ b/packages/editor-ext/src/lib/table/table-view.ts
@@ -4,7 +4,7 @@ import { getColStyleDeclaration } from './utils/col-style';
export function updateColumns(
node: ProseMirrorNode,
- colgroup: HTMLTableColElement, // has the same prototype as
+ colgroup: HTMLElement,
table: HTMLTableElement,
cellMinWidth: number,
overrideCol?: number,
@@ -69,7 +69,6 @@ export function updateColumns(
nextDOM = after;
}
- // Check if user has set a width style on the table node
const hasUserWidth =
node.attrs.style &&
typeof node.attrs.style === 'string' &&
@@ -104,7 +103,6 @@ export class TableView implements NodeView {
this.dom.className = 'tableWrapper';
this.table = this.dom.appendChild(document.createElement('table'));
- // Apply user styles to the table element
if (node.attrs.style) {
this.table.style.cssText = node.attrs.style;
}
@@ -115,9 +113,7 @@ export class TableView implements NodeView {
}
update(node: ProseMirrorNode) {
- if (node.type !== this.node.type) {
- return false;
- }
+ if (node.type !== this.node.type) return false;
this.node = node;
updateColumns(node, this.colgroup, this.table, this.cellMinWidth);
@@ -140,6 +136,23 @@ export class TableView implements NodeView {
}
}
+ // 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;
}
}
diff --git a/packages/editor-ext/src/lib/table/table.ts b/packages/editor-ext/src/lib/table/table.ts
index e93a1836c..e87048a46 100644
--- a/packages/editor-ext/src/lib/table/table.ts
+++ b/packages/editor-ext/src/lib/table/table.ts
@@ -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"];
@@ -36,6 +38,32 @@ 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")) {
diff --git a/packages/editor-ext/src/lib/table/utils/col-style.ts b/packages/editor-ext/src/lib/table/utils/col-style.ts
index d54a259fd..8060962fd 100644
--- a/packages/editor-ext/src/lib/table/utils/col-style.ts
+++ b/packages/editor-ext/src/lib/table/utils/col-style.ts
@@ -1,9 +1,7 @@
export function getColStyleDeclaration(minWidth: number, width: number | undefined): [string, string] {
if (width) {
- // apply the stored width unless it is below the configured minimum cell width
return ['width', `${Math.max(width, minWidth)}px`]
}
- // set the minimum with on the column if it has no stored width
return ['min-width', `${minWidth}px`]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f54985075..b872a3009 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -250,6 +250,12 @@ importers:
apps/client:
dependencies:
+ '@atlaskit/pragmatic-drag-and-drop':
+ specifier: ^1.8.1
+ version: 1.8.1
+ '@atlaskit/pragmatic-drag-and-drop-auto-scroll':
+ specifier: ^2.1.5
+ version: 2.1.5
'@casl/react':
specifier: ^5.0.1
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
@@ -909,6 +915,12 @@ packages:
'@asamuzakjp/css-color@2.8.3':
resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==}
+ '@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.5':
+ resolution: {integrity: sha512-InLvVhZAHPBfv3CxuG4AfOQuhNJjaFy69YBfodPMWtRFQNQAKa9Yb3vL9Ho6qsD9qKUBuJa4A5k7QddaXQ4Eyw==}
+
+ '@atlaskit/pragmatic-drag-and-drop@1.8.1':
+ resolution: {integrity: sha512-uXWNPpL8n4OmTVbduH7nq8pk8htqGo/prR5cYEE8sVCPJGAUMWn6lzvWTfI+4VCeQvHiDRODVz4YzH06OVAxhw==}
+
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -5540,6 +5552,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
+ bind-event-listener@3.0.0:
+ resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==}
+
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -8938,6 +8953,9 @@ packages:
'@types/react-dom':
optional: true
+ raf-schd@4.0.3:
+ resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
+
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -10557,6 +10575,17 @@ snapshots:
'@csstools/css-tokenizer': 3.0.3
lru-cache: 10.4.3
+ '@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.5':
+ dependencies:
+ '@atlaskit/pragmatic-drag-and-drop': 1.8.1
+ '@babel/runtime': 7.29.2
+
+ '@atlaskit/pragmatic-drag-and-drop@1.8.1':
+ dependencies:
+ '@babel/runtime': 7.29.2
+ bind-event-listener: 3.0.0
+ raf-schd: 4.0.3
+
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
@@ -15978,6 +16007,8 @@ snapshots:
binary-extensions@2.3.0: {}
+ bind-event-listener@3.0.0: {}
+
bl@4.1.0:
dependencies:
buffer: 5.7.1
@@ -19987,6 +20018,8 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
+ raf-schd@4.0.3: {}
+
range-parser@1.2.1: {}
raw-body@3.0.2: