/**
* 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,
$isTableNode,
registerTableCellUnmergeTransform,
registerTablePlugin,
registerTableSelectionObserver,
} from "@lexical/table";
import { readOnly$ } from "@mdxeditor/editor";
import { useCellValue } from "@mdxeditor/gurx";
import { $getRoot, $getSelection, $isElementNode, $isRangeSelection, mergeRegister } from "lexical";
import { alignmentLabel, nextAlign } from "./alignment";
import { destination, dropSeam, gripBox, seamAt, seamBox, seams } from "./geometry";
import {
$addColumn,
$addRow,
$describe,
$moveColumn,
$moveRow,
$removeColumn,
$removeRow,
$setAlign,
} from "./ops";
import { canAddColumn, canAddRow, canRemoveColumn, canRemoveRow, HEADER } from "./shape";
import type { ElementNode, 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;
/** 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 };
/** Read every table out of the document. */
function collect(editor: LexicalEditor): Table[] {
let out: Table[] = [];
editor.getEditorState().read(() => {
let walk = (node: ElementNode) => {
for (let child of node.getChildren()) {
// A table cannot nest inside a table in this dialect, but a
// callout or a tab panel can hold one, so the walk cannot stop
// at the top level.
if ($isTableNode(child)) out.push($describe(child));
else if ($isElementNode(child)) walk(child);
}
};
walk($getRoot());
});
return out;
}
/**
* 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,
};
}
/**
* Project each column's alignment onto its cells.
*
* The alignment is recorded on the header cell, because the `| :--- |` row is
* the only place GFM has to put it, but it describes the whole column — and CSS
* cannot cascade from one cell to the others beneath it. So it is written to
* the elements rather than the document: the same arrangement as a tab panel's
* visibility, for the same reason, which is that how a column is drawn should
* not be an edit.
*
* Re-applied on every update, because Lexical rebuilds a block's element when
* the block changes and a style set on the old one goes with it.
*/
function paint(editor: LexicalEditor, tables: Table[]): void {
for (let table of tables) {
for (let row of table.cells) {
for (let column = 0; column < row.length; column++) {
let element = editor.getElementByKey(row[column]!);
if (!element) continue;
// Empty rather than "start", so clearing an alignment hands the
// column back to the stylesheet instead of pinning it to a
// value that would then win against any later change there.
element.style.textAlign = table.align[column] ?? "";
}
}
}
}
export function TableRails() {
let [editor] = useLexicalComposerContext();
let disabled = useCellValue(readOnly$);
let [tables, setTables] = useState
([]);
let [active, setActive] = useState();
let [metrics, setMetrics] = useState();
let [drag, setDrag] = useState();
/*
* Everything `@lexical/table` assumes is installed, and was not.
*
* Without `registerTablePlugin` a table has no keyboard navigation and none
* of the transforms that keep it rectangular. Without the unmerge transform
* a table pasted from HTML keeps its spans, which the dialect has no way to
* write — the export visitor ignores them, so the cells silently
* redistribute themselves at the next save.
*/
useEffect(
() =>
mergeRegister(
registerTablePlugin(editor),
registerTableSelectionObserver(editor),
registerTableCellUnmergeTransform(editor),
),
[editor],
);
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), []);
/*
* Read the document, and only say so when it has actually changed shape.
*
* Every keystroke fires an update, and `collect` allocates as it walks — so
* publishing unconditionally would re-render the rails and re-measure the
* table once per character typed anywhere in the plan. The measurement
* still has to happen on every update, because typing into a cell widens
* its column, but that is a rectangle read behind a frame rather than a
* pass over the whole tree.
*/
let last = useRef("");
useEffect(() => {
let refresh = () => {
// An update listener that throws takes every listener after it with
// it, including the one that syncs to Yjs. Losing the rails is the
// cheapest outcome available and the only acceptable one.
try {
let next = collect(editor);
let json = JSON.stringify(next);
if (json !== last.current) {
last.current = json;
setTables(next);
paint(editor, next);
}
schedule();
} catch (err) {
console.error("[plan] table rails could not read the document", err);
}
};
refresh();
return editor.registerUpdateListener(refresh);
}, [editor, schedule]);
/*
* Repaint the alignment after any render that could have replaced an
* element. Lexical rebuilds a block's element when the block changes, and
* an inline style set on the old one goes with it — so this cannot be left
* to the update above, which deliberately says nothing when the shape has
* not moved.
*/
useEffect(() => {
try {
paint(editor, tables);
} catch (err) {
console.error("[plan] column alignment could not be painted", err);
}
}, [editor, tables]);
// 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(() => {
return editor.registerUpdateListener(() => {
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);
});
});
}, [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;
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[];
};
function Rail({ axis, drag, metrics, onAct, onDrag, onHover, table, tracks }: 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 and the buttons in a lane beyond them, so
* the rail is as deep as the two together.
*
* 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 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) => {
// `TOOL` is the lane offset: the grips are the lane against the
// table, the buttons the one beyond it.
let box = gripBox(axis, track, origin, GRIP, TOOL);
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 (