/** * 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. * * 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 } from "@lexical/table"; import { $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 = 14; const TOOL = 14; const DEPTH = GRIP + TOOL; /** How much of a seam can be aimed at. */ const SEAM = 16; /** One button in the tool lane, and how far each of a pair sits from centre. */ const BUTTON = 15; const PAIR = 8; 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[]; }; /** A drag in progress. */ type Drag = { axis: Axis; from: number; seam: number }; /** * 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, }; } 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 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 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(() => setActive(undefined)); }, []); useEffect(() => { let root = editor.getRootElement(); if (!root) return; let over = (event: PointerEvent) => { let target = event.target; if (!(target instanceof Node)) return; hover(tables.find(item => editor.getElementByKey(item.key)?.contains(target))?.key); }; let out = () => hover(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 caret = useRef(undefined); useEffect(() => { let sync = () => { editor.getEditorState().read(() => { let selection = $getSelection(); let found = $isRangeSelection(selection) ? $findTableNode(selection.anchor.getNode())?.getKey() : undefined; 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"; return ( <> ); } type RailProps = { axis: Axis; drag: Drag | undefined; metrics: Metrics; onAct: (op: () => void) => void; onDrag: (drag: Drag | undefined) => void; onHover: (key: NodeKey | undefined) => void; table: Table; tracks: Track[]; tools: boolean; }; function Rail({ axis, drag, metrics, onAct, onDrag, onHover, table, tracks, tools }: RailProps) { let column = axis === "column"; let key = table.key; let count = tracks.length; let lines = seams(tracks); // `Axis` is already the word for it. let noun = axis; /* * The track the pointer is on, which is the only one showing a remove * button. * * A cross against every row at once is a rail asking to be misread, and the * one that matters is always the one being pointed at. Every button is * rendered regardless, so they all keep their place in the tab order; this * only decides which of them is drawn. */ let [under, setUnder] = useState(); /* * Where the rail sits, in viewport coordinates. * * `overflow: hidden` so a column scrolled out of the table's own scroller * takes its grip with it rather than leaving one floating past the edge. * The grips lie against the table. A full rail adds the button lane beyond * them; a compact rail measures only the grips it actually draws. * * Half a seam of slack at each end, because the first and last seams sit * exactly on the table's edges and what is drawn on a seam straddles it: * without the slack the clip that keeps a scrolled-away grip hidden cuts * the outermost insert buttons in half, which are the two most likely to * be wanted. */ let pad = SEAM / 2; let depth = tools ? DEPTH : GRIP; let gripLane = tools ? TOOL : 0; let frame = column ? { left: metrics.left - pad, top: metrics.top - depth, width: metrics.width + pad * 2, height: depth, } : { left: metrics.left - depth, top: metrics.top - pad, width: depth, height: metrics.height + pad * 2, }; // Measured against the rail's own edge, slack included, so the two agree. let origin = column ? frame.left : frame.top; let mine = drag?.axis === axis ? drag : undefined; let at = mine ? dropSeam(axis, mine.seam) : undefined; let target = mine && at !== undefined ? destination(mine.from, at, count) : undefined; let move = (from: number, to: number) => onAct(() => (column ? $moveColumn(key, from, to) : $moveRow(key, from, to))); let insert = (seam: number) => onAct(() => (column ? $addColumn(key, seam) : $addRow(key, seam))); let remove = (index: number) => onAct(() => (column ? $removeColumn(key, index) : $removeRow(key, index))); let addable = column ? canAddColumn(table.shape) : canAddRow(table.shape); let removable = (index: number) => column ? canRemoveColumn(table.shape, index) : canRemoveRow(table.shape, index); // Every column may be taken hold of; the header row may not. let holdable = (index: number) => table.shape.simple && (column || index > HEADER); return (
onHover(key)} onPointerLeave={() => { onHover(undefined); setUnder(undefined); }} > {tracks.map((track, index) => { let box = gripBox(axis, track, origin, GRIP, gripLane); if (!holdable(index)) { // The header still gets a bar, so the rail does not have a // gap in it where the row everybody can see plainly is. return
; } return (