/**
* 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 (