import {
createContext,
useContext,
useEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type ReactNode,
type RefObject,
} from "react";
import rough from "roughjs";
import type {
WireframeElName,
WireframeNode,
WireframeTone,
} from "./wireframe.config.js";
/**
* Shared wireframe "kit" — hand-drawn low-fi primitives, the el → component node
* registry, the rough.js sketch overlay, and the viewer-level sketchy/clean
* style preference. Ported verbatim (geometry-wise) from the plan template's
* `app/components/plan/wireframe/kit/*` so any app can render wireframe blocks.
*
* DECOUPLING: the only behavioral change from the plan copy is theme detection —
* core blocks read `document.documentElement.classList.contains("dark")` (the
* MermaidBlock precedent) instead of importing `next-themes`. Everything else
* (the `.plan-wf` / `.wf-*` / `[data-rough]` class contract the rough overlay
* measures, the `--wf-*` / `--ink` / `--paper` token names every primitive
* reads) is preserved exactly, so the kit looks identical in plan and renders
* correctly in any app once the matching tokens exist in `core/styles/blocks.css`.
*/
/* ========================================================================== */
/* Viewer-level wireframe style preference (localStorage) */
/* ========================================================================== */
export type WireframeStyle = "sketchy" | "clean";
const STYLE_STORAGE_KEY = "plan-wireframe-style";
const styleListeners = new Set<() => void>();
function readStoredStyle(): WireframeStyle {
if (typeof localStorage === "undefined") return "sketchy";
try {
return localStorage.getItem(STYLE_STORAGE_KEY) === "clean"
? "clean"
: "sketchy";
} catch {
return "sketchy";
}
}
let currentStyle: WireframeStyle = readStoredStyle();
export function setWireframeStyle(style: WireframeStyle): void {
if (style === currentStyle) return;
currentStyle = style;
try {
localStorage.setItem(STYLE_STORAGE_KEY, style);
} catch {
// ignore (private mode / disabled storage)
}
for (const listener of styleListeners) listener();
}
export function toggleWireframeStyle(): void {
setWireframeStyle(currentStyle === "sketchy" ? "clean" : "sketchy");
}
function subscribeStyle(callback: () => void): () => void {
styleListeners.add(callback);
const onStorage = (event: StorageEvent) => {
if (event.key === STYLE_STORAGE_KEY) {
currentStyle = readStoredStyle();
callback();
}
};
if (typeof window !== "undefined") {
window.addEventListener("storage", onStorage);
}
return () => {
styleListeners.delete(callback);
if (typeof window !== "undefined") {
window.removeEventListener("storage", onStorage);
}
};
}
export function useWireframeStyle(): WireframeStyle {
return useSyncExternalStore(
subscribeStyle,
() => currentStyle,
() => "sketchy",
);
}
/** Read the live dark-mode flag from the document root (next-themes-free). */
export function useIsDark(): boolean {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
if (typeof document === "undefined") return;
const root = document.documentElement;
const read = () => setIsDark(root.classList.contains("dark"));
read();
const observer = new MutationObserver(read);
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return isDark;
}
/* ========================================================================== */
/* Primitives (ported from kit/primitives.tsx) */
/* ========================================================================== */
/**
* Frame-level config threaded to every Screen so skeleton / theme / sketch-vs-
* clean reach the kit no matter which path renders the root Screen.
*/
export const KitConfigContext = createContext<{
skeleton?: boolean;
flushFrame?: boolean;
sketch?: number;
theme?: "light" | "dark";
style?: "sketchy" | "clean";
}>({});
const V = {
ink: "var(--ink)",
soft: "var(--ink-soft)",
line: "var(--line)",
paper: "var(--paper)",
card: "var(--card)",
accent: "var(--accent)",
accentSoft: "var(--accent-soft)",
warn: "var(--warn)",
warnSoft: "var(--warn-soft)",
ok: "var(--ok)",
okSoft: "var(--ok-soft)",
stroke: "var(--stroke)",
radius: "var(--radius)",
gap: "var(--gap)",
pad: "var(--pad)",
fs: "var(--fs)",
hand: "var(--font-hand)",
script: "var(--font-script)",
} as const;
type ToneColors = { fg: string; bg: string; bd: string };
function toneColors(tone: WireframeTone = "default"): ToneColors {
switch (tone) {
case "accent":
return { fg: V.accent, bg: V.accentSoft, bd: V.accent };
case "warn":
return { fg: V.warn, bg: V.warnSoft, bd: V.warn };
case "ok":
return { fg: V.ok, bg: V.okSoft, bd: V.ok };
case "muted":
return { fg: V.soft, bg: "transparent", bd: V.soft };
default:
return { fg: V.ink, bg: "transparent", bd: V.ink };
}
}
function toneInk(tone?: WireframeTone): string {
return toneColors(tone).fg;
}
function fontWeight(weight?: "normal" | "medium" | "bold"): number {
if (weight === "bold") return 700;
if (weight === "medium") return 600;
return 400;
}
export function Screen({
children,
pad = 0,
sketch,
density,
theme,
skeleton = false,
style = {},
}: {
children?: ReactNode;
pad?: number | string;
sketch?: number;
density?: "compact" | "regular" | "roomy";
theme?: "light" | "dark";
skeleton?: boolean;
style?: CSSProperties;
}) {
const cfg = useContext(KitConfigContext);
const isSkeleton = skeleton || Boolean(cfg.skeleton);
const wfStyle = cfg.style ?? "sketchy";
const wfTheme = theme ?? cfg.theme ?? "light";
const effectivePad = cfg.flushFrame ? 0 : pad;
void sketch;
return (
{children}
);
}
export function Hand({
children,
size,
weight = 400,
color = V.ink,
script = false,
style = {},
}: {
children?: ReactNode;
size?: number | string;
weight?: number;
color?: string;
script?: boolean;
style?: CSSProperties;
}) {
return (
{children}
);
}
export function Bar({
w = 80,
h,
color = V.line,
r = 4,
style = {},
}: {
w?: number | string;
h?: number | string;
color?: string;
r?: number | string;
style?: CSSProperties;
}) {
return (
);
}
export function Lines({
n = 2,
gap = 6,
widths,
color = V.line,
style = {},
}: {
n?: number;
gap?: number;
widths?: Array;
color?: string;
style?: CSSProperties;
}) {
const ws =
widths ??
Array.from({ length: n }, (_, i) => (i === n - 1 ? "55%" : "100%"));
return (
{ws.map((w, i) => (
))}
);
}
export function Box({
children,
pad = V.pad,
fill = V.card,
dashed = false,
style = {},
}: {
children?: ReactNode;
pad?: number | string;
fill?: string;
dashed?: boolean;
style?: CSSProperties;
}) {
return (
{children}
);
}
export function Check({
done = false,
shape = "square",
size = 18,
}: {
done?: boolean;
shape?: "square" | "circle";
size?: number;
}) {
const r = shape === "circle" ? "50%" : "calc(var(--radius) * 0.5)";
return (
);
}
export function Pill({
children,
tone = "default",
style = {},
}: {
children?: ReactNode;
tone?: WireframeTone;
style?: CSSProperties;
}) {
const c = toneColors(tone);
return (
{children}
);
}
export function Prio({ level = 2, label }: { level?: number; label?: string }) {
const fill = level === 1 ? V.warn : level === 2 ? V.soft : "transparent";
const bd = level === 3 ? V.soft : "transparent";
return (
{label && (
{label}
)}
);
}
export function Btn({
children,
solid = false,
full = false,
size = "md",
tone = "default",
style = {},
}: {
children?: ReactNode;
solid?: boolean;
full?: boolean;
size?: "sm" | "md" | "lg";
tone?: WireframeTone;
style?: CSSProperties;
}) {
const pad =
size === "sm" ? "4px 10px" : size === "lg" ? "10px 18px" : "7px 14px";
const c = toneColors(tone === "default" ? "accent" : tone);
return (
{children}
);
}
export function Chip({
children,
active = false,
style = {},
}: {
children?: ReactNode;
active?: boolean;
style?: CSSProperties;
}) {
return (
{children}
);
}
export function Field({
label,
value,
placeholder,
h,
area = false,
right,
style = {},
}: {
label?: string;
value?: string;
placeholder?: number | string;
h?: number | string;
area?: boolean;
right?: ReactNode;
style?: CSSProperties;
}) {
return (
{label && (
{label}
)}
{value ? (
{value}
) : area ? (
) : (
)}
{right}
);
}
export function StatusBar() {
return (
);
}
export function Fab({ icon = "+" }: { icon?: string }) {
return (
{icon}
);
}
export function BrowserBar({
title = "todo",
children,
}: {
title?: string;
children?: ReactNode;
}) {
return (
{[0, 1, 2].map((i) => (
))}
{title}.app
{children}
);
}
export function SectionLabel({
children,
right,
tone = "muted",
}: {
children?: ReactNode;
right?: ReactNode;
tone?: WireframeTone;
}) {
return (
{children}
{right}
);
}
export function Avatar({ size = 26 }: { size?: number }) {
return (
);
}
export function IconSquare({
size = 18,
active = false,
}: {
size?: number;
active?: boolean;
}) {
return (
);
}
export function NavItem({
label,
count,
active = false,
dot = false,
tone = "accent",
}: {
label?: string;
count?: number;
active?: boolean;
dot?: boolean;
tone?: WireframeTone;
}) {
const dotColor = toneInk(tone);
return (
{dot ? (
) : (
)}
{label}
{count != null && (
{count}
)}
);
}
export function Sidebar({
children,
width = 196,
style = {},
}: {
children?: ReactNode;
width?: number;
style?: CSSProperties;
}) {
return (
{children}
);
}
export function Main({
children,
style = {},
}: {
children?: ReactNode;
style?: CSSProperties;
}) {
return (
{children}
);
}
export function Row({
children,
full = false,
style = {},
}: {
children?: ReactNode;
full?: boolean;
style?: CSSProperties;
}) {
return (
{children}
);
}
export function Col({
children,
full = false,
style = {},
}: {
children?: ReactNode;
full?: boolean;
style?: CSSProperties;
}) {
return (
{children}
);
}
export function TaskRow({
title,
note,
due,
dueTone = "default",
prio,
done = false,
}: {
title?: string;
note?: string;
due?: string;
dueTone?: WireframeTone;
prio?: number;
done?: boolean;
}) {
return (
{title}
{note && (
{note}
)}
{due &&
{due}}
{prio ?
: null}
);
}
export function Card({
children,
style = {},
}: {
children?: ReactNode;
style?: CSSProperties;
}) {
return (
{children}
);
}
export function Column({
title,
count,
tone = "muted",
width = 232,
children,
}: {
title?: string;
count?: number;
tone?: WireframeTone;
width?: number;
children?: ReactNode;
}) {
return (
{title}
{count != null && (
{count}
)}
+
{children}
);
}
export function Toolbar({
children,
style = {},
}: {
children?: ReactNode;
style?: CSSProperties;
}) {
return (
{children}
);
}
export function Tabs({
items = [],
}: {
items?: Array<{ label: string; active?: boolean }>;
}) {
return (
{items.map((item, i) => (
{item.label}
))}
);
}
export function KV({ rows = [] }: { rows?: Array<{ k: string; v: string }> }) {
return (
{rows.map((row, i) => (
{row.k}
{row.v}
))}
);
}
export function SearchBar({
placeholder = "Search",
}: {
placeholder?: string;
}) {
return (
{placeholder}
);
}
export function Divider({ style = {} }: { style?: CSSProperties }) {
return (
);
}
export function Title({
text,
script = false,
size = "calc(var(--fs) * 2)",
color = V.ink,
}: {
text?: string;
script?: boolean;
size?: number | string;
color?: string;
}) {
return (
{text}
);
}
export function Text({
value,
color,
weight,
script = false,
}: {
value?: string;
color?: WireframeTone;
weight?: "normal" | "medium" | "bold";
script?: boolean;
}) {
return (
{value}
);
}
/* ========================================================================== */
/* Node registry (ported from kit/registry.tsx) */
/* ========================================================================== */
type NodeRenderer = (node: WireframeNode, children: ReactNode) => ReactNode;
const REGISTRY: Record = {
// --- Frame / structure -------------------------------------------------
screen: (n) => {
const kids = n.children ?? [];
const lead = kids[0]?.el;
if (lead === "browserBar") {
return (
{renderNode(kids[0], kids[0].id ?? "browserbar")}
{renderScreenBodyNodes(kids.slice(1))}
);
}
if (lead === "statusBar") {
return (
{renderNode(kids[0], kids[0].id ?? "statusbar")}
{renderScreenBodyNodes(kids.slice(1))}
);
}
return (
{renderScreenBodyNodes(kids)}
);
},
browserBar: (n, children) => (
{children}
),
statusBar: () => ,
toolbar: (_n, children) => {children},
row: (n, children) => {children}
,
col: (n, children) => {children},
sidebar: (_n, children) => {children},
main: (_n, children) => {children},
box: (n, children) => {children},
card: (_n, children) => {children},
column: (n, children) => (
{children}
),
divider: () => ,
// --- Text --------------------------------------------------------------
title: (n) => ,
text: (n) => (
),
lines: (n) => ,
section: (n) => (
{n.label ?? n.text}
),
// --- List / task -------------------------------------------------------
navItem: (n) => (
),
taskRow: (n) => (
),
// --- Controls ----------------------------------------------------------
chips: (n) => ,
chip: (n) => {n.label ?? n.text},
pill: (n) => {n.label ?? n.text},
check: (n) => ,
field: (n) => (
),
btn: (n) => (
{n.label ?? n.text}
),
fab: (n) => ,
searchBar: (n) => ,
// --- Atoms -------------------------------------------------------------
avatar: () => ,
iconSquare: (n) => ,
kv: (n) => ,
};
function renderScreenBodyNode(
node: WireframeNode,
key?: string | number,
): ReactNode {
const shouldFill =
node.full !== false &&
(node.el === "row" || node.el === "col" || node.el === "main");
return renderNode(shouldFill ? { ...node, full: true } : node, key);
}
function renderScreenBodyNodes(nodes: WireframeNode[]): ReactNode {
return nodes.map((node, i) =>
renderScreenBodyNode(node, node.id ?? `screen-body-${i}`),
);
}
/** Render a single kit-tree node (and its children, recursively). */
export function renderNode(
node: WireframeNode,
key?: string | number,
): ReactNode {
const renderer = REGISTRY[node.el];
const children = node.children?.length ? renderNodes(node.children) : null;
if (!renderer) {
return children ? {children}
: null;
}
const rendered = renderer(node, children);
return {rendered};
}
/** Render an array of nodes. */
export function renderNodes(nodes: WireframeNode[]): ReactNode {
return nodes.map((node, i) => renderNode(node, node.id ?? i));
}
/** Lightweight keyed wrapper that does not introduce extra DOM. */
function KeyedNode({ children }: { children: ReactNode }) {
return <>{children}>;
}
/** Whether an `el` name has a registered renderer. */
export function hasRenderer(el: string): el is WireframeElName {
return el in REGISTRY;
}
export { REGISTRY as NODE_REGISTRY, V as WFV, toneColors, toneInk, fontWeight };
/* ========================================================================== */
/* Rough overlay (ported from kit/rough.tsx) */
/* ========================================================================== */
const gen = rough.generator();
type RoughPath = { d: string; stroke: string; strokeWidth: number };
/** The default selector used for HTML mockups: standard wireframe primitives plus explicit opt-ins. */
export const HTML_ROUGH_SELECTOR =
"[data-rough],button,input,textarea,select,hr,.wf-btn,.wf-card,.wf-box,.wf-pill,.wf-chip,.wf-icon-fallback,[style*='border:'],[style*='border-top:'],[style*='border-right:'],[style*='border-bottom:'],[style*='border-left:']";
/** Stable per-element seed so a frame doesn't re-wobble on every measure. */
function seedFrom(...parts: Array): number {
const value = parts.join(":");
let hash = 2166136261;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return ((hash >>> 0) % 2147483646) + 1;
}
/** Map the 0–100 sketch slider to a rough.js roughness (calm + legible). */
export function sketchRoughness(sketch: number): number {
const s = Math.max(0, Math.min(100, Number.isFinite(sketch) ? sketch : 0));
return Number((0.32 + (s / 100) * 1.15).toFixed(2));
}
function sketchBowing(sketch: number): number {
const s = Math.max(0, Math.min(100, Number.isFinite(sketch) ? sketch : 0));
return Number((0.4 + (s / 100) * 0.5).toFixed(2));
}
function readVar(el: Element, name: string): string {
return getComputedStyle(el).getPropertyValue(name).trim();
}
/** Normalize a CSS color (hex or rgb[a]) to "r,g,b" for equality comparison. */
function toRgbKey(color: string): string | null {
const c = color.trim();
const hex = c.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hex) {
const h = hex[1];
const full =
h.length === 3
? h
.split("")
.map((d) => d + d)
.join("")
: h;
const n = parseInt(full, 16);
return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`;
}
const rgb = c.match(/rgba?\(([^)]+)\)/i);
if (rgb) {
const [r, g, b] = rgb[1].split(",").map((v) => parseInt(v.trim(), 10));
return `${r},${g},${b}`;
}
return null;
}
/** True when two CSS colors resolve to the same RGB (hex vs rgb tolerant). */
function sameColor(a: string, b: string): boolean {
const ka = toRgbKey(a);
const kb = toRgbKey(b);
return ka !== null && ka === kb;
}
/** A rounded-rect SVG path (so the frame stroke follows the artboard radius). */
function roundedRectPath(
x: number,
y: number,
w: number,
h: number,
r: number,
): string {
const rad = Math.max(0, Math.min(r, w / 2, h / 2));
return [
`M${x + rad},${y}`,
`H${x + w - rad}`,
`A${rad},${rad} 0 0 1 ${x + w},${y + rad}`,
`V${y + h - rad}`,
`A${rad},${rad} 0 0 1 ${x + w - rad},${y + h}`,
`H${x + rad}`,
`A${rad},${rad} 0 0 1 ${x},${y + h - rad}`,
`V${y + rad}`,
`A${rad},${rad} 0 0 1 ${x + rad},${y}`,
"Z",
].join(" ");
}
/** Rough.js cannot safely render paths whose inset consumes the whole box. */
export function hasDrawableRoughBounds(
width: number,
height: number,
inset: number,
): boolean {
return (
Number.isFinite(width) &&
Number.isFinite(height) &&
width > inset * 2 &&
height > inset * 2
);
}
function elementStroke(node: Element, fallback: string): string {
const explicit = readVar(node, "--rough-stroke");
if (explicit) return explicit;
const cs = getComputedStyle(node);
for (const side of [
"borderTopColor",
"borderLeftColor",
"borderBottomColor",
"borderRightColor",
] as const) {
const width = parseFloat(
cs.getPropertyValue(side.replace("Color", "Width")),
);
const color = cs[side];
if (width > 0 && color && color !== "rgba(0, 0, 0, 0)") return color;
}
return fallback;
}
function visibleBorderSides(
node: Element,
): Array<"top" | "right" | "bottom" | "left"> {
const cs = getComputedStyle(node);
const sides: Array<"top" | "right" | "bottom" | "left"> = [];
for (const [side, widthProp, colorProp] of [
["top", "borderTopWidth", "borderTopColor"],
["right", "borderRightWidth", "borderRightColor"],
["bottom", "borderBottomWidth", "borderBottomColor"],
["left", "borderLeftWidth", "borderLeftColor"],
] as const) {
const width = parseFloat(cs[widthProp]);
const color = cs[colorProp];
if (
width > 0 &&
color &&
color !== "rgba(0, 0, 0, 0)" &&
color !== "transparent"
) {
sides.push(side);
}
}
return sides;
}
function inferRoughKind(node: HTMLElement): string {
const explicit = node.getAttribute("data-rough");
if (explicit) return explicit;
if (node.tagName === "HR") return "line:middle";
const sides = visibleBorderSides(node);
if (sides.length !== 1) return "rect";
switch (sides[0]) {
case "top":
return "line:top";
case "right":
return "line:right";
case "bottom":
return "line:bottom";
case "left":
return "line:left";
}
return "rect";
}
function build(
scope: HTMLElement,
opts: {
roughness: number;
bowing: number;
frameRadius: number;
drawFrame: boolean;
selector: string;
},
): { paths: RoughPath[]; w: number; h: number } {
const base = scope.getBoundingClientRect();
const layoutW = scope.offsetWidth;
const layoutH = scope.offsetHeight;
if (!layoutW || !layoutH) return { paths: [], w: 0, h: 0 };
const zoom = base.width / layoutW || 1;
const themed =
(scope.matches(".plan-wf, .plan-html-frame, .plan-diagram-frame")
? scope
: scope.querySelector(
".plan-wf, .plan-html-frame, .plan-diagram-frame",
)) ?? scope;
const ink =
readVar(themed, "--ink") || readVar(themed, "--wf-ink") || "#34322e";
const sketch = readVar(themed, "--wf-sketch") || ink;
const line = readVar(themed, "--wf-line") || readVar(themed, "--line") || "";
const paths: RoughPath[] = [];
let index = 0;
const push = (drawable: unknown, stroke: string, sw: number) => {
for (const p of gen.toPaths(
drawable as Parameters[0],
)) {
paths.push({
d: p.d,
stroke: p.stroke && p.stroke !== "none" ? p.stroke : stroke,
strokeWidth: p.strokeWidth || sw,
});
}
};
const makeOpts = (stroke: string, sw: number, seed: number) => ({
seed,
roughness: opts.roughness,
bowing: opts.bowing,
stroke,
strokeWidth: sw,
preserveVertices: true,
});
if (opts.drawFrame && hasDrawableRoughBounds(layoutW, layoutH, 2)) {
const sw = 2;
push(
gen.path(
roundedRectPath(2, 2, layoutW - 4, layoutH - 4, opts.frameRadius),
{
...makeOpts(sketch, sw, seedFrom("frame", layoutW, layoutH)),
roughness: opts.roughness + 0.35,
bowing: opts.bowing + 0.18,
},
),
sketch,
sw,
);
}
scope.querySelectorAll(opts.selector).forEach((node) => {
if (node.getAttribute("data-rough") === "none") return;
if (readVar(node, "--rough-skip") === "1") return;
const r = node.getBoundingClientRect();
const x = (r.left - base.left) / zoom;
const y = (r.top - base.top) / zoom;
const w = r.width / zoom;
const h = r.height / zoom;
if (!hasDrawableRoughBounds(w, h, 1)) return;
const kind = inferRoughKind(node);
const rawStroke = elementStroke(node, sketch);
const stroke =
sameColor(rawStroke, ink) || (line !== "" && sameColor(rawStroke, line))
? sketch
: rawStroke;
const sw = Number(readVar(node, "--rough-w")) || 1.4;
const seed = seedFrom(
kind,
Math.round(x),
Math.round(y),
Math.round(w),
Math.round(h),
index++,
);
const o = makeOpts(stroke, sw, seed);
let drawable: unknown;
if (kind === "ellipse") {
drawable = gen.ellipse(x + w / 2, y + h / 2, w, h, o);
} else if (kind === "line:left") {
drawable = gen.line(x, y, x, y + h, o);
} else if (kind === "line:right") {
drawable = gen.line(x + w, y, x + w, y + h, o);
} else if (kind === "line:bottom") {
drawable = gen.line(x, y + h, x + w, y + h, o);
} else if (kind === "line:top") {
drawable = gen.line(x, y, x + w, y, o);
} else if (kind === "line:middle") {
drawable = gen.line(x, y + h / 2, x + w, y + h / 2, o);
} else {
const cr = parseFloat(getComputedStyle(node).borderTopLeftRadius) || 0;
const radius = Math.min(cr / zoom, w / 2, h / 2);
drawable =
radius > 1
? gen.path(roundedRectPath(x + 1, y + 1, w - 2, h - 2, radius), o)
: gen.rectangle(x + 1, y + 1, w - 2, h - 2, o);
}
push(drawable, stroke, sw);
});
return { paths, w: layoutW, h: layoutH };
}
/**
* Renders the rough overlay for a frame. `scopeRef` points at the frame root.
* When `enabled` is false (skeleton / clean register) it renders nothing and the
* crisp CSS borders stay visible.
*/
export function RoughOverlay({
scopeRef,
sketch = 52,
enabled = true,
drawFrame = true,
frameRadius = 14,
selector = "[data-rough]",
}: {
scopeRef: RefObject;
sketch?: number;
enabled?: boolean;
drawFrame?: boolean;
frameRadius?: number;
selector?: string;
}) {
const [state, setState] = useState<{
paths: RoughPath[];
w: number;
h: number;
}>({ paths: [], w: 0, h: 0 });
const rafRef = useRef(0);
useEffect(() => {
const el = scopeRef.current;
if (!el || !enabled) {
el?.removeAttribute("data-rough-ready");
el?.querySelector(
".plan-wf, .plan-html-frame, .plan-diagram-frame",
)?.removeAttribute("data-rough-ready");
setState({ paths: [], w: 0, h: 0 });
return;
}
const roughness = sketchRoughness(sketch);
const bowing = sketchBowing(sketch);
const measure = () => {
clearTimeout(rafRef.current);
rafRef.current = window.setTimeout(() => {
const next = build(el, {
roughness,
bowing,
frameRadius,
drawFrame,
selector,
});
if (next.w && next.h) {
el.setAttribute("data-rough-ready", "true");
(
el.querySelector(
".plan-wf, .plan-html-frame, .plan-diagram-frame",
) ?? el
).setAttribute("data-rough-ready", "true");
setState(next);
}
}, 0);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
el.querySelectorAll(selector).forEach((node) => ro.observe(node));
const mo = new MutationObserver((mutations) => {
if (mutations.every(isRoughOverlayMutation)) return;
measure();
});
mo.observe(el, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
});
el.addEventListener("plan-prototype-runtime:rendered", measure);
let cancelled = false;
if (typeof document !== "undefined" && "fonts" in document) {
void document.fonts.ready.then(() => {
if (!cancelled) measure();
});
}
return () => {
cancelled = true;
ro.disconnect();
mo.disconnect();
el.removeEventListener("plan-prototype-runtime:rendered", measure);
clearTimeout(rafRef.current);
el.removeAttribute("data-rough-ready");
el.querySelector(
".plan-wf, .plan-html-frame, .plan-diagram-frame",
)?.removeAttribute("data-rough-ready");
};
}, [scopeRef, sketch, enabled, drawFrame, frameRadius, selector]);
if (!enabled || !state.paths.length) return null;
return (
);
}
function isRoughOverlayMutation(mutation: MutationRecord) {
if (nodeIsRoughOverlay(mutation.target)) return true;
const changedNodes = [
...Array.from(mutation.addedNodes),
...Array.from(mutation.removedNodes),
];
return changedNodes.length > 0 && changedNodes.every(nodeIsRoughOverlay);
}
function nodeIsRoughOverlay(node: Node) {
const element = node instanceof Element ? node : node.parentElement;
return Boolean(
element?.classList.contains("plan-rough-overlay") ||
element?.closest(".plan-rough-overlay"),
);
}