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 ( + + +
{ + refs.setFloating(node); + setHandleEl(node); + }} + style={{ + ...floatingStyles, + ...(isReferenceHidden ? { visibility: "hidden" as const } : {}), + }} + className={clsx(classes.handle, classes.columnHandle)} + role="button" + tabIndex={0} + aria-label={t("Column actions")} + > + + + +
+
+ + + +
+ ); +}); + +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 ( + + +
{ + refs.setFloating(node); + setHandleEl(node); + }} + style={{ + ...floatingStyles, + ...(isReferenceHidden ? { visibility: "hidden" as const } : {}), + }} + className={clsx(classes.handle, classes.rowHandle)} + role="button" + tabIndex={0} + aria-label={t("Row actions")} + > + + + +
+
+ + + +
+ ); +}); + +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: