{links.map((item, idx) => (
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..a46ac50d5
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/column-handle.tsx
@@ -0,0 +1,132 @@
+import React, { useCallback, 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.
+ // `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
+ // an external drop reflows the doc before the plugin re-emits
+ // hoveringCell), it can resolve to a Text node, on which `.closest` is
+ // undefined. Filter to HTMLElement so downstream consumers stay safe.
+ const lookupDom = editor.view.nodeDOM(anchorPos);
+ const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : 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;
+
+ const [menuOpened, setMenuOpened] = useState(false);
+ const closeMenu = useCallback(() => setMenuOpened(false), []);
+ useTableHandleDrag(editor, "col", handleEl, wrapper, closeMenu);
+
+ 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..30b179689
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-drag.ts
@@ -0,0 +1,79 @@
+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
+export function useTableHandleDrag(
+ editor: Editor,
+ orientation: "col" | "row",
+ element: HTMLElement | null,
+ wrapper: HTMLElement | null,
+ onDragStart?: () => void,
+) {
+ 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 }) => {
+ // The menu (if open from a prior click on the handle) won't dismiss
+ // on its own — pragmatic-dnd swallows the events Mantine listens for.
+ onDragStart?.();
+ 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, onDragStart]);
+}
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..1f3e3cc51
--- /dev/null
+++ b/apps/client/src/features/editor/components/table/handle/row-handle.tsx
@@ -0,0 +1,127 @@
+import React, { useCallback, 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.
+ // `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
+ // an external drop reflows the doc before the plugin re-emits
+ // hoveringCell), it can resolve to a Text node, on which `.closest` is
+ // undefined. Filter to HTMLElement so downstream consumers stay safe.
+ const lookupDom = editor.view.nodeDOM(anchorPos);
+ const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : 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;
+
+ const [menuOpened, setMenuOpened] = useState(false);
+ const closeMenu = useCallback(() => setMenuOpened(false), []);
+ useTableHandleDrag(editor, "row", handleEl, wrapper, closeMenu);
+
+ const { onOpen, onClose } = useColumnRowMenuLifecycle({
+ editor,
+ orientation: "row",
+ index,
+ tableNode,
+ tablePos,
+ });
+
+ if (!cellDom) return null;
+
+ return (
+