import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; import { useParam } from '../router'; import { BridgeError, fetchCss, fetchSet, fetchSvg, fetchText, health, type FigmaNode, type NodeAnswer, } from './bridge'; import type { FigmaTextStyle } from './css-diff'; /** * React's side of the bridge: is it there, and what does it say about a set. * * Everything here is optional decoration. With the bridge down the panel falls * back to `figma-spec.json`, which is why the status is a first-class value * rather than an error state. */ export type BridgeStatus = /** Not probed yet, or probing is switched off. */ | 'idle' /** The bridge answered and the plugin window is open. */ | 'live' /** The bridge is up but nothing is polling it from Figma. */ | 'no-plugin' /** Nothing is listening on the port. */ | 'off'; export interface BridgeState { status: BridgeStatus; file: string | null; page: string | null; } // ------------------------------------------------------------- status store const IDLE: BridgeState = { status: 'idle', file: null, page: null }; let state: BridgeState = IDLE; const listeners = new Set<() => void>(); let subscribers = 0; let timer: ReturnType | undefined; /* A new object on every probe would re-render every subscriber twice a minute for nothing, and `useSyncExternalStore` compares by identity. */ function emit(next: BridgeState) { if ( next.status === state.status && next.file === state.file && next.page === state.page ) { return; } state = next; for (const l of listeners) l(); } async function probe() { try { const h = await health(); emit({ status: h.plugin.connected ? 'live' : 'no-plugin', file: h.plugin.file, page: h.plugin.page, }); } catch { emit({ status: 'off', file: null, page: null }); } } /* The plugin is considered gone after 45s of not polling, so a slower beat than that would report `live` for a window that was closed a while ago. */ const BEAT = 20_000; function subscribeActive(listener: () => void) { listeners.add(listener); if (++subscribers === 1) { void probe(); timer = setInterval(() => void probe(), BEAT); } return () => { listeners.delete(listener); if (--subscribers === 0) { clearInterval(timer); timer = undefined; } }; } /* Registered but silent: a page with the panel switched off makes no requests at all, and still re-renders if something else in the app turns it back on. */ function subscribeIdle(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); } const getSnapshot = () => state; /** * `?figma=live|off` in the hash, defaulting to on in development only. * * `showcase:build` produces a static site that may end up hosted somewhere; it * has no business probing a visitor's localhost. In `pnpm showcase` the whole * point is that it does. */ export function useFigmaMode() { return useParam('figma', import.meta.env.DEV ? 'live' : 'off'); } export function useBridge(): BridgeState & { enabled: boolean } { const [mode] = useFigmaMode(); /* `import.meta.env.DEV` first, and it is not decoration: the default above only decides what the *absent* parameter means, so a published site still honoured `#/button?figma=live` and went back to polling — and then wrote whatever `localhost:7332` answered into the document, SVG included (`figma/figma-panel.tsx`). Loopback keeps a remote attacker out, but the visitor's own machine is not ours to reach into from a hosted page. The same guard, in the same shape, as `chrome/hooks/use-a11y.ts`. */ const enabled = import.meta.env.DEV && mode !== 'off'; const current = useSyncExternalStore(enabled ? subscribeActive : subscribeIdle, getSnapshot); return { ...(enabled ? current : IDLE), enabled }; } // -------------------------------------------------------------- node lookups export interface Async { status: 'idle' | 'loading' | 'ready' | 'error'; data?: T; error?: string; } const IDLE_ASYNC = { status: 'idle' } as const; const LOADING = { status: 'loading' } as const; function useNodeCall( id: string | null, enabled: boolean, /* Module-level constants (`fetchCss`, `fetchSvg`, …), so the effect below re-runs on the id and nothing else. */ fn: (id: string) => Promise ): Async { /* The answer is stored with the id it belongs to, so idle and loading are *derived* below rather than written from the effect. Setting them from inside the effect would be a second render on every id change, for a value that is already known during the first one. */ const [answer, setAnswer] = useState<{ id: string; result: Async }>({ id: '', result: IDLE_ASYNC, }); const key = id && enabled ? id : ''; useEffect(() => { if (!key) return; let live = true; fn(key).then( (data) => { if (live) setAnswer({ id: key, result: { status: 'ready', data } }); }, (err: unknown) => { if (live) { setAnswer({ id: key, result: { status: 'error', error: err instanceof BridgeError ? err.message : String(err), }, }); } } ); return () => { live = false; }; }, [key, fn]); if (!key) return IDLE_ASYNC; return answer.id === key ? answer.result : LOADING; } /** One variant of a component set, with its axis values already parsed out. */ export interface VariantNode { id: string; name: string; /** `{ size: 'md', variant: 'solid' }`, lower-cased keys and values. */ axes: Record; width?: number; height?: number; } export interface SetIndex { id: string; name: string; variants: VariantNode[]; /** Canonical `axis=value` key → variant, for an O(1) match. */ byKey: Map; } /** `"size=md, variant=solid"` → `{ size: 'md', variant: 'solid' }`. */ function parseVariantName(name: string): Record { const axes: Record = {}; for (const part of name.split(',')) { const at = part.indexOf('='); if (at < 0) continue; axes[part.slice(0, at).trim().toLowerCase()] = part.slice(at + 1).trim().toLowerCase(); } return axes; } /** * The lookup key for a set of axis values. * * Sorted, because a variant's name lists its axes in the set's own order and * nothing guarantees the caller uses the same one. */ export function variantKey(axes: Record) { return Object.entries(axes) .map(([k, v]) => `${k.trim().toLowerCase()}=${String(v).trim().toLowerCase()}`) .sort() .join(','); } function indexSet(answer: NodeAnswer, id: string): SetIndex { const doc: FigmaNode | undefined = answer.nodes[id]?.document; const variants: VariantNode[] = (doc?.children ?? []).map((child) => ({ id: child.id, name: child.name, axes: parseVariantName(child.name), width: child.absoluteBoundingBox?.width, height: child.absoluteBoundingBox?.height, })); const byKey = new Map(); for (const v of variants) byKey.set(variantKey(v.axes), v); return { id, name: doc?.name ?? '', variants, byKey }; } const loadSet = async (id: string) => indexSet(await fetchSet(id), id); /** Every variant of a component set, from one `node --depth 2` call. */ export const useFigmaSet = (id: string | null, enabled: boolean) => useNodeCall(id, enabled, loadSet); const loadCss = async (id: string) => (await fetchCss(id)).css; /** Figma's own `getCSSAsync()` for a node — what Dev Mode shows. */ export const useFigmaCss = (id: string | null, enabled: boolean) => useNodeCall(id, enabled, loadCss); const loadSvg = async (id: string) => (await fetchSvg(id)).svg; export const useFigmaSvg = (id: string | null, enabled: boolean) => useNodeCall(id, enabled, loadSvg); /* The first TEXT under the variant is its label; a Figma control that has two is drawing a helper line under the main one, and the main one is the type scale worth comparing. */ const loadTextStyle = async (id: string): Promise => (await fetchText(id)).texts[0]?.style; export const useFigmaTextStyle = (id: string | null, enabled: boolean) => useNodeCall(id, enabled, loadTextStyle); /** A one-shot action with its own pending/error state — used by "open in Figma". */ export function useBridgeAction(fn: (id: string) => Promise) { const [state, setState] = useState>(IDLE_ASYNC); const run = useCallback( (id: string) => { setState({ status: 'loading' }); fn(id).then( (data) => setState({ status: 'ready', data }), (err: unknown) => setState({ status: 'error', error: err instanceof BridgeError ? err.message : String(err), }) ); }, [fn] ); return [state, run] as const; }