/**
* rgui HTML overlays — real DOM controls glued to nodes.
*
* Canvas cannot host interactive form controls, so rgui owns a positioned
* DOM layer above the canvas and keeps each registered element glued to its
* node's screen rect every frame (drag included). Size is screen-fixed
* (overlays do not scale with zoom); only position follows.
*
* Visibility reuses the native readability rule: an overlay hides (without
* being destroyed) whenever its node is collapsed into a pseudo-node,
* fully off-screen, or too small to read — so collapsed stacks show only
* boundary ports + the pseudo summary, never per-node config.
*/
import { nodeHeight, NODE_ROW_H, type Graph, type GraphNode } from "../core/graph.js";
import type { ViewTransform } from "../core/grid.js";
import type { RgRule } from "../core/rule.js";
export interface NodeHtmlOverlay {
el: HTMLElement;
/** where to glue relative to the node rect (default "right") */
anchor?: "right" | "below" | "over";
/**
* offset from the anchor point — screen px in "fixed" mode, WORLD units
* in "zoom" mode (it belongs to the node's local layout, so it scales)
*/
offset?: { x: number; y: number };
/**
* "fixed" (default): screen-constant size, position glued to the node.
* "zoom": scales with view.k like part of the node (lay out for k=1).
* "fit": rgui measures the element's natural size and applies
* scale = min(maxScale, node screen area / natural size) — the control fits
* the node's on-screen area, whatever the node type's size.
* In zoom/fit modes, when the applied scale drops below `minScale` the
* overlay hides and the native/summarized content takes over.
*/
scale?: "fixed" | "zoom" | "fit";
/**
* zoom/fit modes: hide when the applied scale drops below this
* (default 0.75) — an unreadable control yields to the summary.
* Hide always wins over scaling.
*/
minScale?: number;
/**
* fit mode: cap on the applied scale (default 1 — never upscale past the
* element's natural size, keeping it crisp). Raise above 1 to let a small
* overlay UPSCALE to fill a larger node (it grows past natural size, so it
* fills the node's screen rect instead of sitting at native size with the
* node showing around it — at the cost of some blur on a bitmap/canvas child).
*/
maxScale?: number;
/**
* "node": constrain the overlay to the node's screen rect (never larger
* than the node); overflowing content scrolls ("auto", default) or is
* cut ("hidden"). "viewport" (default): clipped by the viewport only.
*/
clip?: "node" | "viewport" | "none";
overflow?: "hidden" | "auto";
/**
* pointer-events mode (default true). When true, only actual CONTROLS
* inside the element receive pointer events (inputs, selects, buttons,
* links, [contenteditable], [data-rgui-interactive]) — the background is
* click-through so node drag / canvas pan keep working underneath.
* Mark custom widgets with data-rgui-interactive.
*/
interactive?: boolean;
/** called when the overlay is unmounted (replaced, node gone, destroy) */
destroy?: () => void;
}
export interface OverlayManager {
/** glue/refresh all overlays for the current frame */
sync(
graph: Graph,
visibleNodes: GraphNode[] | null,
view: ViewTransform,
rule: RgRule,
/**
* screen rects (CSS px) cut OUT of the overlay layer — canvas-native
* chrome (panels) that must stay visible and clickable above HTML
* overlays. Clipping also removes pointer hits, so presses over a
* cutout reach the canvas.
*/
cutouts?: Array<{ x: number; y: number; w: number; h: number }>,
): void;
destroy(): void;
}
export function createOverlayManager(
canvas: HTMLCanvasElement,
opts?: {
/**
* re-dispatch wheel events here (usually the rgui canvas) so pan/zoom
* keeps working over overlays instead of scrolling the page. A wheel is
* NOT forwarded when an ENGAGED overlay's inner scrollable can consume it.
*/
forwardWheelTo?: HTMLElement;
/**
* is this node engaged (selected)? An overlay only captures the wheel for
* its own scrolling once engaged — selected, or holding DOM focus. Merely
* hovering it must not steal the wheel, or a pan that sweeps the cursor
* across a node stalls mid-gesture.
*/
isNodeEngaged?: (nodeId: string) => boolean;
/** map view-space anchor points to raw screen (viewport rotation) */
transformPoint?: (x: number, y: number) => readonly [number, number];
},
): OverlayManager {
let layer: HTMLDivElement | null = null;
interface Mounted {
ov: NodeHtmlOverlay;
wrap: HTMLDivElement;
mo?: MutationObserver;
}
const mounted = new Map();
const CONTROLS =
'input,select,textarea,button,a,label,[contenteditable="true"],[data-rgui-interactive]';
/**
* click-through background: the element ignores pointers, its controls
* receive them — so pressing overlay whitespace drags the node beneath
*/
function applyControlPassthrough(el: HTMLElement) {
el.style.pointerEvents = "none";
if (el.matches?.(CONTROLS)) el.style.pointerEvents = "auto";
for (const c of el.querySelectorAll(CONTROLS))
c.style.pointerEvents = "auto";
}
/** can any scrollable between target and the layer consume this wheel? */
function scrollableConsumes(ev: WheelEvent): boolean {
let el = ev.target as HTMLElement | null;
while (el && el !== layer) {
const style = getComputedStyle(el);
const oy = style.overflowY;
if (
(oy === "auto" || oy === "scroll") &&
el.scrollHeight > el.clientHeight + 1
) {
const down = ev.deltaY > 0;
const canScroll = down
? el.scrollTop + el.clientHeight < el.scrollHeight - 1
: el.scrollTop > 0;
if (canScroll) return true;
}
el = el.parentElement;
}
return false;
}
/**
* an overlay owns the wheel only while engaged: its node is selected, or
* focus lives inside it (a clicked input / [data-rgui-interactive] widget).
*/
function engaged(ev: WheelEvent): boolean {
let el = ev.target as HTMLElement | null;
while (el && el !== layer) {
const id = el.dataset?.["nodeId"];
if (id !== undefined) {
const active = document.activeElement;
if (active && active !== document.body && el.contains(active)) return true;
return opts?.isNodeEngaged?.(id) ?? false;
}
el = el.parentElement;
}
return false;
}
const onLayerWheel = (ev: WheelEvent) => {
if (!opts?.forwardWheelTo) return;
if (engaged(ev) && scrollableConsumes(ev)) return; // let the control scroll natively
ev.preventDefault(); // never scroll/zoom the page
ev.stopPropagation();
opts.forwardWheelTo.dispatchEvent(new WheelEvent("wheel", ev));
};
function ensureLayer(): HTMLDivElement | null {
if (layer) return layer;
const parent = canvas.parentElement;
if (!parent) return null;
if (getComputedStyle(parent).position === "static")
parent.style.position = "relative";
layer = document.createElement("div");
layer.className = "rgui-overlay-layer";
// viewport clip by default; wrapper is a pass-through. overscroll
// containment stops scroll chaining out of overlay controls.
layer.style.cssText =
"position:absolute;inset:0;overflow:hidden;pointer-events:none;overscroll-behavior:contain;";
// rgui owns overlay stacking: always sit one above the canvas so HTML
// overlays are never buried — including after the WebGPU→canvas2d fallback,
// which leaves the canvas at z-index:1. (No consumer z-index workaround.)
const cz = parseInt(getComputedStyle(canvas).zIndex, 10);
layer.style.zIndex = String((Number.isFinite(cz) ? cz : 0) + 1);
layer.addEventListener("wheel", onLayerWheel, { passive: false });
parent.appendChild(layer);
return layer;
}
/**
* Wire the element's pointer-events mode. Re-runnable: `interactive` can
* flip on a rebuilt overlay object, so the observer is torn down first.
*/
function applyInteractive(m: Mounted) {
m.mo?.disconnect();
m.mo = undefined;
const { el, interactive } = m.ov;
if (interactive === false) {
el.style.pointerEvents = "none";
return;
}
applyControlPassthrough(el);
// hosts re-render controls dynamically (e.g. React) — keep the
// control passthrough fresh
m.mo = new MutationObserver(() => applyControlPassthrough(el));
m.mo.observe(el, { childList: true, subtree: true });
}
function unmount(id: string) {
const m = mounted.get(id);
if (!m) return;
m.mo?.disconnect();
m.wrap.remove();
try {
m.ov.destroy?.();
} catch (err) {
console.error("[rgui] overlay destroy failed:", err);
}
mounted.delete(id);
}
/** cut panel-shaped holes so canvas chrome shows through (and gets clicks) */
let lastClip = "";
function applyCutouts(
cutouts: Array<{ x: number; y: number; w: number; h: number }> | undefined,
W: number,
H: number,
) {
if (!layer) return;
// evenodd: outer viewport rect + one subpath per panel = holes
const holes = cutouts?.length
? cutouts
.map((c) => `M${c.x} ${c.y}H${c.x + c.w}V${c.y + c.h}H${c.x}Z`)
.join(" ")
: "";
const clip = holes ? `path(evenodd, "M0 0H${W}V${H}H0Z ${holes}")` : "";
// sync runs every frame — only touch style when the geometry changed,
// so idle frames skip the style-recalc entirely
if (clip === lastClip) return;
lastClip = clip;
layer.style.clipPath = clip;
}
function sync(
graph: Graph,
visibleNodes: GraphNode[] | null,
view: ViewTransform,
rule: RgRule,
cutouts?: Array<{ x: number; y: number; w: number; h: number }>,
) {
const want = new Map(
graph.nodes.filter((n) => n.overlay).map((n) => [n.id, n]),
);
// unmount overlays whose node vanished or whose element was replaced
for (const [id, m] of [...mounted]) {
const n = want.get(id);
if (!n || n.overlay!.el !== m.ov.el) unmount(id);
}
if (!want.size) return;
if (!ensureLayer()) return;
const visible = new Set((visibleNodes ?? graph.nodes).map((n) => n.id));
const W = canvas.clientWidth;
const H = canvas.clientHeight;
applyCutouts(cutouts, W, H);
let z = 0;
for (const [id, n] of want) {
const ov = n.overlay!;
let m = mounted.get(id);
if (!m) {
const wrap = document.createElement("div");
wrap.dataset["nodeId"] = id;
wrap.style.cssText =
"position:absolute;left:0;top:0;pointer-events:none;will-change:transform;";
wrap.appendChild(ov.el);
layer!.appendChild(wrap);
// clip:"node" turns the wrap into the scroller (pointer-events:auto),
// which would swallow presses on the overlay's click-through
// background — the node beneath could then only be dragged where the
// overlay isn't. Presses whose target is the WRAP itself are
// background presses (controls stop at themselves; the element and
// its non-control children are pointer-events:none), so re-dispatch
// them to the canvas: drag / select / context-menu / dblclick work
// through the overlay. Scrollbar presses (outside the client box)
// stay with the scroller.
const forwardPress = (ev: MouseEvent) => {
if (ev.target !== wrap) return;
if (
(wrap.clientWidth > 0 && ev.offsetX >= wrap.clientWidth) ||
(wrap.clientHeight > 0 && ev.offsetY >= wrap.clientHeight)
)
return;
if (ev.type === "contextmenu") ev.preventDefault();
const fwd =
typeof PointerEvent !== "undefined" && ev instanceof PointerEvent
? new PointerEvent(ev.type, ev)
: new MouseEvent(ev.type, ev);
// Chromium computes offsetX/Y for UNTRUSTED events without layout
// (halved under dpr 2, wrong under transforms), and the canvas
// pointer handlers read them — pin the correct canvas-relative
// values on the clone before dispatching.
const cr = canvas.getBoundingClientRect();
Object.defineProperty(fwd, "offsetX", { value: ev.clientX - cr.left });
Object.defineProperty(fwd, "offsetY", { value: ev.clientY - cr.top });
canvas.dispatchEvent(fwd);
};
wrap.addEventListener("pointerdown", forwardPress);
wrap.addEventListener("contextmenu", forwardPress);
wrap.addEventListener("dblclick", forwardPress);
m = { ov, wrap };
applyInteractive(m);
mounted.set(id, m);
} else if (m.ov !== ov) {
// The host rebuilt the overlay OBJECT around the same element — a
// graph re-map does this on every change. Only `el` decides remount,
// so adopt the new options here; otherwise a later anchor/offset/
// scale/minScale/clip change would never take effect, and unmount
// would call a destroy() the host has already replaced.
const prev = m.ov;
m.ov = ov;
if (prev.interactive !== ov.interactive) applyInteractive(m);
}
const k = view.k;
const h = nodeHeight(n);
const x0 = n.x * k + view.x;
const y0 = n.y * k + view.y;
const x1 = (n.x + n.w) * k + view.x;
const y1 = (n.y + h) * k + view.y;
// applied scale: zoom follows view.k; fit measures the element and fills
// the node's screen area, capped at maxScale (default 1 = no upscaling)
const el = m.ov.el;
let applied = 1;
if (m.ov.scale === "zoom") {
applied = k;
} else if (m.ov.scale === "fit") {
const nw = el.offsetWidth || 1;
const nh = el.offsetHeight || 1;
const anchor0 = m.ov.anchor ?? "right";
const cap = m.ov.maxScale ?? 1; // default: never upscale past natural size
applied =
anchor0 === "over"
? Math.min(cap, (x1 - x0) / nw, (y1 - y0) / nh)
: anchor0 === "right"
? Math.min(cap, (y1 - y0) / nh)
: Math.min(cap, (x1 - x0) / nw);
}
// readability gate: scaled controls hide below their readable scale
// (hide wins over scaling → the summarized content takes over);
// fixed overlays follow the node's field readability
const readable =
m.ov.scale === "zoom" || m.ov.scale === "fit"
? applied >= (m.ov.minScale ?? 0.75)
: NODE_ROW_H * k >= rule.fieldMinPx;
const offscreen = x1 < 0 || y1 < 0 || x0 > W || y0 > H;
const show = visible.has(id) && readable && !offscreen;
m.wrap.style.display = show ? "" : "none";
if (!show) continue;
const anchor = m.ov.anchor ?? "right";
const d =
m.ov.offset ??
(anchor === "right"
? { x: 8, y: 0 }
: anchor === "below"
? { x: 0, y: 8 }
: { x: 0, y: 0 });
const scaled = m.ov.scale === "zoom" || m.ov.scale === "fit";
// scaled modes: offsets belong to the node's local layout, so they
// scale with the element; anchored at its top-left (origin 0 0)
const dx = scaled ? d.x * applied : d.x;
const dy = scaled ? d.y * applied : d.y;
let tx = anchor === "right" ? x1 + dx : x0 + dx;
let ty = anchor === "below" ? y1 + dy : y0 + dy;
if (opts?.transformPoint) [tx, ty] = opts.transformPoint(tx, ty);
if (m.ov.clip === "node") {
// never larger than the node's on-screen rect; wrapper becomes the
// scroller (so wheel over it scrolls, per scrollableConsumes).
// NOTE: the `scale(applied)` transform below ALSO scales this box, so a
// literal (x1-x0) here becomes (x1-x0)×applied on screen — the clip window
// then shrinks to node×applied (double-scaled: node×k²), cutting the
// content off (worse as you zoom out). Pre-divide by applied so the box is
// exactly the node's screen rect AFTER the transform. (diagnosed via otoji)
const s = scaled && applied > 0 ? applied : 1;
m.wrap.style.width = `${Math.max(0, (x1 - x0) / s)}px`;
m.wrap.style.height = `${Math.max(0, (y1 - y0) / s)}px`;
m.wrap.style.overflow = m.ov.overflow ?? "auto";
m.wrap.style.pointerEvents = "auto";
} else {
// clip may have been "node" on a previous overlay object — the wrap
// keeps its styles, so hand it back its pass-through defaults
m.wrap.style.width = "";
m.wrap.style.height = "";
m.wrap.style.overflow = "";
m.wrap.style.pointerEvents = "none";
}
m.wrap.style.transformOrigin = "0 0";
m.wrap.style.transform = scaled
? `translate(${tx}px, ${ty}px) scale(${applied})`
: `translate(${tx}px, ${ty}px)`;
m.wrap.style.zIndex = String(z++);
}
}
return {
sync,
destroy() {
for (const id of [...mounted.keys()]) unmount(id);
layer?.removeEventListener("wheel", onLayerWheel);
layer?.remove();
layer = null;
},
};
}