/**
* 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
? (
)
: null}
>
);
}
function seamLine(axis: Axis, line: number, metrics: Metrics) {
let box = axis === "column"
? { left: line - 1, top: metrics.top, width: 2, height: metrics.height }
: { left: metrics.left, top: line - 1, width: metrics.width, height: 2 };
return { ...box, clipPath: clipTo(metrics.clip, box) };
}
type RailProps = {
/** The seam an insert is aimed at, on either rail. */
aim: Aim | undefined;
/** The track the pointer is on in the table; the caret alone does not count. */
aimed: number | undefined;
axis: Axis;
/** Whether the caret is in this table, which keeps every grip faintly drawn. */
caret: boolean;
drag: Drag | undefined;
/** The track being pointed at in the table, or holding the caret. */
hot: number | undefined;
metrics: Metrics;
onAct: (op: () => void) => void;
onAim: (aim: Aim | undefined) => void;
onDrag: (drag: Drag | undefined) => void;
onHover: (key: NodeKey | undefined) => void;
table: Table;
tracks: Track[];
tools: boolean;
};
function Rail(
{
aim,
aimed,
axis,
caret,
drag,
hot,
metrics,
onAct,
onAim,
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);
// A row rail is beside the table, so its tooltips go further out to the left;
// a column rail's already open above it.
let side = column ? undefined : "left";
/*
* Only the seams either side of the track being pointed at take the pointer.
* The rest of the button lane lets clicks through to whatever it overlaps —
* on a column rail, the last line of the paragraph above the table.
*/
let near = under ?? aimed;
let live = (seam: number) => near !== undefined && (seam === near || seam === near + 1);
return (
onHover(key)}
onPointerLeave={() => {
onHover(undefined);
setUnder(undefined);
}}
>
{tracks.map((track, index) => {
let box = gripBox(axis, track, origin, GRIP, gripLane);
if (!holdable(index)) {
// Undrawn, but still the rail's: a hole here would be a
// pointerleave on the way from the header to the first grip.
return ;
}
return (