/** * The rails a table is reshaped from. * * A grip per column above the table and per row beside it: the handle a drag * starts from, the button a row or column is removed by, and — between two of * them — where a new one is inserted. * * They are drawn as a fixed overlay measured from cell rectangles rather than * as chrome inside the node, because a `` cannot contain a `
`. The * `data-plan-chrome` slot that callouts and tabs portal into is not available * at any price here: the browser hoists a stray div straight back out of a * table, so the choice is between measuring and subclassing `TableNode`. This * is the technique the selection bubble already uses. * * Only one table has rails at a time — whichever the pointer is over, or the * caret is in. Rails on every table at once would be a page of grey furniture * around prose that is mostly not tables. Within that table only the row and * column being pointed at, or holding the caret, draw a grip; an insert button * shows only while its seam is aimed at. * * There is no test for this file, on purpose: it is rectangles and pointers all * the way down and the test runtime has no DOM. What can be decided without one * — which seam a drop means, what a rail's boxes are, what the table permits — * lives in `geometry.ts` and `shape.ts`, which are tested. */ import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $findTableNode, $isTableCellNode } from "@lexical/table"; import { $findMatchingParent, $getSelection, $isRangeSelection } from "lexical"; import { alignmentLabel, nextAlign } from "./alignment"; import { destination, dropSeam, gripBox, seamAt, seamBox, seams } from "./geometry"; import { $addColumn, $addRow, $moveColumn, $moveRow, $removeColumn, $removeRow, $setAlign, } from "./ops"; import { canAddColumn, canAddRow, canRemoveColumn, canRemoveRow, HEADER } from "./shape"; import type { LexicalEditor, NodeKey } from "lexical"; import type { Axis, Track } from "./geometry"; import type { Table } from "./ops"; /* * A rail is two lanes: grips against the table, buttons beyond them. * * They are separate because they were not, once. A cross in the middle of a * grip is where a drag is most naturally begun, so it swallowed the gesture it * was drawn beside — and only sometimes, since whether it had become live yet * depended on a re-render landing between the pointer arriving and the button * going down. Nothing in the two lanes overlaps now, so neither can take the * other's pointer. */ const GRIP = 12; const TOOL = 24; const DEPTH = GRIP + TOOL; /** How much of a seam can be aimed at. */ const SEAM = 24; /** One button in the tool lane, and how far each of a pair sits from centre. */ const BUTTON = 24; const PAIR = 13; export type RailMode = "full" | "grips"; /** Where a measured table is on the screen, and where its tracks are. */ type Metrics = { /** * The table these were taken from. * * Carried so a render that has already switched tables can tell that the * measurements have not caught up, and draw nothing for the one frame * before the layout effect runs. Without it the rails appear at the last * table's coordinates first, which reads as them jumping into place. */ key: NodeKey; left: number; top: number; width: number; height: number; columns: Track[]; rows: Track[]; /** The visible rect of the scroller the table is in, which rails never draw past. */ clip: { top: number; bottom: number }; }; /** The nearest ancestor that scrolls vertically, or the viewport. */ function visibleRect(element: HTMLElement): Metrics["clip"] { for (let node = element.parentElement; node; node = node.parentElement) { if (!/auto|scroll/.test(getComputedStyle(node).overflowY)) continue; let { top, bottom } = node.getBoundingClientRect(); return { top, bottom }; } return { top: 0, bottom: innerHeight }; } /** A fixed overlay's `clip-path`, cutting off whatever falls outside `clip`. */ function clipTo( clip: Metrics["clip"], box: { left: number; top: number; width: number; height: number }, ) { // Vertical only: a row rail lives in the gutter, which can be outside the // scroller's own box, and the chrome a rail must not cover is above and below. let inset = [clip.top - box.top, 0, box.top + box.height - clip.bottom, 0] .map(edge => `${Math.max(edge, 0)}px`); return `inset(${inset.join(" ")})`; } /** A drag in progress. */ type Drag = { axis: Axis; from: number; seam: number }; /** A cell of the active table, by row and column. */ type Cell = { table: NodeKey; row: number; column: number }; /** The seam an insert button is aimed at, drawn across the table. */ type Aim = { axis: Axis; seam: number }; function same(a: Cell | undefined, b: Cell | undefined): boolean { return a?.table === b?.table && a?.row === b?.row && a?.column === b?.column; } function cellAt(tables: Table[], key: NodeKey): Cell | undefined { for (let table of tables) { for (let [row, keys] of table.cells.entries()) { let column = keys.indexOf(key); if (column >= 0) return { table: table.key, row, column }; } } return undefined; } /** * Measure one table. * * Cells are found by key rather than by `querySelector`, so nothing here * depends on the DOM `@lexical/table` happens to build — whether rows sit * under a `tbody`, whether a `colgroup` comes first. * * Returns nothing when the table is not rendered, which is the case for one * inside a tab panel that is not the open tab. */ function measure(editor: LexicalEditor, table: Table): Metrics | undefined { let element = editor.getElementByKey(table.key); if (!element) return undefined; let frame = element.getBoundingClientRect(); if (frame.width === 0 && frame.height === 0) return undefined; let box = (key: NodeKey | undefined) => key ? editor.getElementByKey(key)?.getBoundingClientRect() : undefined; // Columns from the header row, rows from the first column: one pass along // each edge rather than over the whole grid. let columns: Track[] = []; for (let key of table.cells[HEADER] ?? []) { let rect = box(key); if (!rect) return undefined; columns.push({ start: rect.left, end: rect.right }); } let rows: Track[] = []; for (let row of table.cells) { let rect = box(row[0]); if (!rect) return undefined; rows.push({ start: rect.top, end: rect.bottom }); } return { key: table.key, left: frame.left, top: frame.top, width: frame.width, height: frame.height, columns, rows, clip: visibleRect(element), }; } export function TableRails( { disabled, mode = "full", tables }: { disabled?: boolean; mode?: RailMode; tables: Table[]; }, ) { let [editor] = useLexicalComposerContext(); let [active, setActive] = useState(); let [metrics, setMetrics] = useState(); let [drag, setDrag] = useState(); let [pointed, setPointed] = useState(); let [caretCell, setCaretCell] = useState(); let [aim, setAim] = useState(); let table = tables.find(item => item.key === active); let remeasure = useCallback(() => { setMetrics(table ? measure(editor, table) : undefined); }, [editor, table]); // A ref so `schedule` can stay stable across renders while still calling // the current measurement, which changes whenever the active table does. let latest = useRef(remeasure); latest.current = remeasure; let pending = useRef(0); let schedule = useCallback(() => { cancelAnimationFrame(pending.current); pending.current = requestAnimationFrame(() => latest.current()); }, []); useEffect(() => () => cancelAnimationFrame(pending.current), []); useEffect(() => { return editor.registerUpdateListener(schedule); }, [editor, schedule]); // Synchronously on a change of table, so switching between two never draws // one frame of rails at the other's coordinates. useLayoutEffect(() => { remeasure(); }, [remeasure, tables]); /* * Re-measure whenever anything could have moved the table. * * Rectangles go stale for more reasons than the document changing: the pane * resizes, the document scrolls, and the table scrolls inside itself, which * is a scroll the document never hears about. Scroll is taken on the * capture phase rather than from any one element, because which element * scrolls is not fixed. */ useEffect(() => { if (!table) return; window.addEventListener("scroll", schedule, { capture: true, passive: true }); window.addEventListener("resize", schedule, { passive: true }); let element = editor.getElementByKey(table.key); let observer = new ResizeObserver(schedule); if (element) observer.observe(element); return () => { window.removeEventListener("scroll", schedule, { capture: true }); window.removeEventListener("resize", schedule); observer.disconnect(); }; }, [editor, table, schedule]); /* * Which table the rails belong to. * * Pointer rather than caret alone, so a table can be reshaped without first * clicking into it — and caret as well, so it can be reshaped with no * pointer at all. */ let dragging = useRef(false); dragging.current = drag !== undefined; let leaving = useRef(0); let caret = useRef(undefined); let hover = useCallback((key: NodeKey | undefined) => { cancelAnimationFrame(leaving.current); if (key) return setActive(key); // A drag holds the pointer captured well outside the rail it started // from, so a leave during one is not a leave. if (dragging.current) return; /* * Otherwise deferred by a frame: moving from the last cell onto a grip * leaves the table before it reaches the rail, and clearing at once * would take the rails out from under a pointer on its way to them. * The rail's own handler cancels this before it runs. */ leaving.current = requestAnimationFrame(() => { // Back to the table holding the caret, if any, rather than none. setActive(caret.current); setAim(undefined); }); }, []); useEffect(() => { let root = editor.getRootElement(); if (!root) return; let over = (event: PointerEvent) => { let target = event.target; if (!(target instanceof Node)) return; let found = tables.find(item => editor.getElementByKey(item.key)?.contains(target)); hover(found?.key); let element = target instanceof Element ? target : target.parentElement; let cell = element?.closest("td, th"); let key = found && cell ? found.cells.flat().find(item => editor.getElementByKey(item) === cell) : undefined; let at = key ? cellAt([found!], key) : undefined; setPointed(last => same(last, at) ? last : at); }; let out = () => { hover(undefined); setPointed(undefined); }; root.addEventListener("pointerover", over); root.addEventListener("pointerleave", out); return () => { root.removeEventListener("pointerover", over); root.removeEventListener("pointerleave", out); }; }, [editor, tables, hover]); /* * The caret, so a table can be reshaped with no pointer at all. * * Only when it moves into a *different* table, not on every update that * happens to leave it in one. Otherwise typing in a table would keep * re-asserting it against a pointer resting somewhere else, and the rails * would flick between the two as long as somebody kept writing. */ let tablesRef = useRef(tables); tablesRef.current = tables; useEffect(() => { let sync = () => { editor.getEditorState().read(() => { let selection = $getSelection(); let node = $isRangeSelection(selection) ? selection.anchor.getNode() : undefined; let found = node ? $findTableNode(node)?.getKey() : undefined; let cell = node ? $findMatchingParent(node, $isTableCellNode)?.getKey() : undefined; let at = cell ? cellAt(tablesRef.current, cell) : undefined; setCaretCell(last => same(last, at) ? last : at); if (found === caret.current) return; caret.current = found; if (found) setActive(found); }); }; sync(); return editor.registerUpdateListener(sync); }, [editor]); let act = useCallback((op: () => void) => editor.update(op), [editor]); // Metrics lag the active table by a frame; drawing against the previous // table's rectangles would put the rails somewhere they do not belong. if (disabled || !table || !metrics || metrics.key !== table.key) return null; let tools = mode === "full"; let aimed = pointed?.table === table.key ? pointed : undefined; let hot = aimed ?? (caretCell?.table === table.key ? caretCell : undefined); let holding = caretCell?.table === table.key; let line = aim ? seams(aim.axis === "column" ? metrics.columns : metrics.rows)[aim.seam] : undefined; return ( <> {aim && line !== undefined ? (