@volter/world-console 3.0.3 → 3.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/console.css +20 -0
- package/client/console.tsx +208 -65
- package/dist/client/console.bundle.js +13 -13
- package/dist/client/console.css +20 -0
- package/dist/client/console.tsx +208 -65
- package/dist/client/main.bundle.js +87 -87
- package/package.json +3 -3
package/client/console.css
CHANGED
|
@@ -610,3 +610,23 @@ main.app.home { max-width: none; }
|
|
|
610
610
|
.ws-rule-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
|
|
611
611
|
.ws-rule-row select { width: auto; }
|
|
612
612
|
.ws-rules fieldset.choice { border: 0; padding: 0; margin: 4px 0; display: grid; gap: 6px; }
|
|
613
|
+
/* THE BOARD: sections round their frames as Figma's do, behind them; the boards as a canvas app's pages */
|
|
614
|
+
.canvas .react-flow__node-section { padding: 0; border: 0; background: none; pointer-events: none; }
|
|
615
|
+
.section-box { position: relative; border: 2px dashed var(--line-strong); border-radius: 28px; background: color-mix(in srgb, var(--surface) 40%, transparent); }
|
|
616
|
+
.section-label { position: absolute; top: 28px; left: 40px; display: inline-flex; align-items: center; gap: 14px; font: 600 40px/1.2 var(--sans); color: var(--muted); white-space: nowrap; }
|
|
617
|
+
.section-label .mark { width: 44px; height: 44px; }
|
|
618
|
+
.frame-none { height: 100%; display: grid; place-content: center; text-align: center; gap: 8px; padding: 40px; font-size: 24px; color: var(--muted); background: repeating-linear-gradient(135deg, var(--surface-2) 0 18px, var(--surface) 18px 36px); }
|
|
619
|
+
.frame-none p { margin: 0; }
|
|
620
|
+
.frame-label select, .frame-label button { font-size: 12px; padding: 1px 6px; }
|
|
621
|
+
.board-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 8px 0 0; padding: 8px 0 0; border-top: 1px solid var(--line); }
|
|
622
|
+
.board-tabs button { border: 1px solid var(--line); background: transparent; border-radius: 999px; padding: 2px 10px; font-size: 12.5px; color: var(--ink); }
|
|
623
|
+
.board-tabs button.on { background: var(--accent-soft); border-color: var(--accent); }
|
|
624
|
+
.board-acts { display: flex; gap: 4px; margin-top: 4px; }
|
|
625
|
+
.board-acts button { font-size: 12px; padding: 2px 6px; }
|
|
626
|
+
.layer-section { display: flex; align-items: center; gap: 6px; padding: 6px 8px 2px; font-size: 12px; font-weight: 600; color: var(--muted); }
|
|
627
|
+
.layer-section .mark { width: 16px; height: 16px; }
|
|
628
|
+
.layer-list.inner { border-top: 0; margin: 0; padding: 0 0 0 14px; }
|
|
629
|
+
.board-add { width: 100%; margin-top: 8px; font-size: 12.5px; }
|
|
630
|
+
.board-hidden { margin-top: 8px; font-size: 12.5px; }
|
|
631
|
+
.board-elsewhere { width: 100%; margin-top: 8px; display: flex; align-items: center; gap: 6px; font-size: 12.5px; border: 1px solid var(--line); background: transparent; border-radius: 8px; padding: 4px 8px; }
|
|
632
|
+
.pad-s { padding: 6px 8px; margin: 0; font-size: 12.5px; }
|
package/client/console.tsx
CHANGED
|
@@ -619,7 +619,7 @@ function WorldView({ name, token, twin, tab, arg, sub, admin, snapshot, parentTo
|
|
|
619
619
|
const retry = (): void => { setScope('pending'); setAttempt((a) => a + 1); };
|
|
620
620
|
// ALL SCREENS is a canvas app's own page: the whole window, the World's pages a click away (Figma's, tldraw's)
|
|
621
621
|
if (current === 'canvas' || (current === 'vendors' && !chosen) || (tab === 'screens' && !arg)) {
|
|
622
|
-
return <Canvas name={name} token={token} twins={twins} session={session} retry={retry} snapshot={snapshot} back={() => go(name.split('/'))} />;
|
|
622
|
+
return <Canvas name={name} token={token} twins={twins} session={session} retry={retry} snapshot={snapshot} back={() => go(name.split('/'))} canWrite={canWrite && !snapshot} />;
|
|
623
623
|
}
|
|
624
624
|
// EVERY World opens as its workspace: on this machine, on a host, and a snapshot of one (read only, a way back to live)
|
|
625
625
|
const tabOf = (t?: string): SettingsTab => (SETTINGS_TABS.find(([k]) => k === t)?.[0] ?? 'account');
|
|
@@ -1187,12 +1187,11 @@ function Workspace({ name, token, view, sub, arg, win, who, snapshot, onReturn,
|
|
|
1187
1187
|
|
|
1188
1188
|
<div className="ws-body">
|
|
1189
1189
|
<nav className="ws-side" aria-label={`${name} places`}>
|
|
1190
|
-
{/* the
|
|
1191
|
-
{
|
|
1192
|
-
<a href={`${BASE}/${name}/~/screens`} className={view === 'screen' ? 'ws-screens-entry on' : 'ws-screens-entry'} onClick={to(['screens'])} title={
|
|
1193
|
-
<Icon name="layout-grid" size={16} /><span>
|
|
1194
|
-
<span className="ws-screens-marks">{
|
|
1195
|
-
<span className="count">{withScreens.length}</span>
|
|
1190
|
+
{/* the board: one way in, saying whose things are on it; the board lists its frames along its left */}
|
|
1191
|
+
{vendors.length ? (
|
|
1192
|
+
<a href={`${BASE}/${name}/~/screens`} className={view === 'screen' ? 'ws-screens-entry on' : 'ws-screens-entry'} onClick={to(['screens'])} title={`The board: ${listed(vendors.map(vendorName))}`}>
|
|
1193
|
+
<Icon name="layout-grid" size={16} /><span>Board</span>
|
|
1194
|
+
<span className="ws-screens-marks">{vendors.slice(0, 3).map((v) => <Mark key={v} vendor={v} />)}{vendors.length > 3 ? <span className="more">+{vendors.length - 3}</span> : null}</span>
|
|
1196
1195
|
</a>
|
|
1197
1196
|
) : null}
|
|
1198
1197
|
<p className="side-group">Workspace</p>
|
|
@@ -1674,101 +1673,245 @@ type RequestReport = { hours: number; requests: number; journalFailures?: number
|
|
|
1674
1673
|
|
|
1675
1674
|
|
|
1676
1675
|
type ViewSession = { state: 'pending' } | { state: 'ok'; scope: string } | { state: 'error'; error: string };
|
|
1677
|
-
/** THE
|
|
1678
|
-
*
|
|
1679
|
-
* by
|
|
1680
|
-
*
|
|
1681
|
-
|
|
1682
|
-
|
|
1676
|
+
/** THE BOARD: the World's things as frames on one surface, the way a Figma file holds its frames (React Flow; Figma's
|
|
1677
|
+
* and tldraw's feel). Each vendor's pack lays out its default frames (a Worker's site, a repository's page), grouped in
|
|
1678
|
+
* sections by the vendor's own containers; the World says where each opens and how many changes it holds not yet cut
|
|
1679
|
+
* (`GET /-/<world>/board`). The first board is everything; others hold what they include, a section with its frames to
|
|
1680
|
+
* come. Moving, hiding and including are kept by the World (`PUT /-/<world>/board`), for everyone who opens it. Pan by
|
|
1681
|
+
* dragging the background, move a frame by its title bar, click a frame to use it and the background or Esc to leave. */
|
|
1682
|
+
const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160; const SECTION_PAD = 72; const SECTION_HEAD = 72;
|
|
1683
|
+
type BoardFrame = { vendor: string; id: string; kind: string; title: string; section: { id: string; title: string; aka?: string[] }; url: string; at: { site: string } | { screen: string } | null; unseen?: 'not-drawn' | 'sites-not-routed'; changes: number };
|
|
1684
|
+
type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }> };
|
|
1685
|
+
type BoardDoor = { frames: BoardFrame[]; boards: SavedBoard[]; version: string };
|
|
1686
|
+
const frameKey = (f: BoardFrame): string => `${f.vendor}:${f.id}`;
|
|
1687
|
+
const sectionKey = (f: BoardFrame): string => `${f.vendor}:section:${f.section.id}`;
|
|
1688
|
+
/** Every name a frame's section answers to on a board: its id, and those it was known by (an account a deploy adopted). */
|
|
1689
|
+
const sectionKeys = (f: BoardFrame): string[] => [f.section.id, ...(f.section.aka ?? [])].map((id) => `${f.vendor}:section:${id}`);
|
|
1690
|
+
/** Where a frame opens in this World: a site at its own origin, a vendor page at the twin's place, or nowhere yet. */
|
|
1691
|
+
const frameUrl = (name: string, f: BoardFrame): string | null => (f.at && 'site' in f.at ? f.at.site : f.at ? `${WORLDS}/${name}/${f.vendor}${f.at.screen}` : null);
|
|
1692
|
+
/** The frames a board shows: everything but what it hides, or what it includes (a frame, or a section's every frame). */
|
|
1693
|
+
const shownOn = (b: SavedBoard, all: BoardFrame[]): BoardFrame[] => b.include
|
|
1694
|
+
? all.filter((f) => b.include!.includes(frameKey(f)) || sectionKeys(f).some((k) => b.include!.includes(k)))
|
|
1695
|
+
: all.filter((f) => !(b.hidden ?? []).includes(frameKey(f)));
|
|
1696
|
+
type Screen = { world: string; frame: BoardFrame; url: string | null; active: boolean; boards: SavedBoard[]; addTo: (board: string, key: string) => void; remove: { label: string; act: (key: string) => void } | null };
|
|
1683
1697
|
function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
1684
|
-
const { world,
|
|
1698
|
+
const { world, frame, url, active, boards, addTo, remove } = data;
|
|
1699
|
+
const key = frameKey(frame);
|
|
1700
|
+
const others = boards.filter((b) => b.include && !b.include.includes(key) && !sectionKeys(frame).some((k) => b.include!.includes(k)));
|
|
1685
1701
|
return (
|
|
1686
1702
|
<>
|
|
1687
1703
|
<NodeToolbar isVisible position={Position.Top} align="start" className="frame-label">
|
|
1688
|
-
<Mark vendor={vendor} /><span>{vendorName(vendor)}</span>
|
|
1689
|
-
{
|
|
1704
|
+
<Mark vendor={frame.vendor} /><span>{vendorName(frame.vendor)} · {frame.kind}</span>
|
|
1705
|
+
{frame.changes ? <a className="frame-changed nodrag" href={`${BASE}/${world}/~/changes`} onClick={(e) => { e.preventDefault(); go([...world.split('/'), '~', 'changes']); }} title={`${counted(frame.changes, 'change')} here, not in a changeset: see them in Changes`}><Icon name="square-dot" size={14} />{frame.changes}</a> : null}
|
|
1706
|
+
{others.length ? <select className="nodrag frame-add" value="" aria-label={`Add ${frame.title} to a board`} onChange={(e) => { if (e.target.value) addTo(e.target.value, key); }}><option value="">Add to board…</option>{others.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}</select> : null}
|
|
1707
|
+
{remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
|
|
1690
1708
|
</NodeToolbar>
|
|
1691
1709
|
<div className={active ? 'frame on' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
|
|
1692
1710
|
<div className="frame-bar" title="Drag to move">
|
|
1693
|
-
<Mark vendor={vendor} /><strong>{
|
|
1694
|
-
<span className="frame-url">{url.replace(/^https?:\/\//, '')}</span>
|
|
1695
|
-
<a className="nodrag" href={
|
|
1696
|
-
<a className="nodrag" href={url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${vendorName(vendor)} in a new tab`}>↗</a>
|
|
1711
|
+
<Mark vendor={frame.vendor} /><strong>{frame.title}</strong>
|
|
1712
|
+
<span className="frame-url">{frame.url.replace(/^https?:\/\//, '')}</span>
|
|
1713
|
+
{url ? <a className="nodrag" href={url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${frame.title} in a new tab`}>↗</a> : null}
|
|
1697
1714
|
</div>
|
|
1698
1715
|
<div className={active ? 'frame-body nodrag nopan nowheel' : 'frame-body'}>
|
|
1699
|
-
|
|
1716
|
+
{url
|
|
1717
|
+
? <iframe title={frame.title} src={url} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
|
|
1718
|
+
: <div className="frame-none"><p><strong>{frame.unseen === 'sites-not-routed' ? `This World shows ${frame.title} only at its own local address.` : `${vendorName(frame.vendor)}'s twin draws no page for this ${frame.kind} yet.`}</strong></p><p className="mode">{frame.url}</p></div>}
|
|
1700
1719
|
</div>
|
|
1701
1720
|
</div>
|
|
1702
1721
|
</>
|
|
1703
1722
|
);
|
|
1704
1723
|
}
|
|
1705
|
-
|
|
1706
|
-
function
|
|
1724
|
+
type Section = { label: string; vendor: string; width: number; height: number };
|
|
1725
|
+
function SectionBox({ data }: NodeProps<Node<Section>>): React.JSX.Element {
|
|
1726
|
+
return <div className="section-box" style={{ width: data.width, height: data.height }}><span className="section-label"><Mark vendor={data.vendor} />{data.label}</span></div>;
|
|
1727
|
+
}
|
|
1728
|
+
const FRAME_TYPES = { screen: ScreenFrame, section: SectionBox };
|
|
1729
|
+
function Canvas(props: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session: ViewSession; retry: () => void; snapshot: Snapshot | null; back: () => void; /** a session that writes, never a snapshot: only it arranges the boards */ canWrite: boolean }): React.JSX.Element {
|
|
1707
1730
|
return <ReactFlowProvider><CanvasSurface {...props} /></ReactFlowProvider>;
|
|
1708
1731
|
}
|
|
1709
|
-
/** The canvas fills the window, as Figma's, tldraw's and Miro's do: the way back and the frames' list float at
|
|
1710
|
-
* left (a canvas app's layers), zoom at the bottom left, the minimap at the bottom right. */
|
|
1711
|
-
function CanvasSurface({ name, token,
|
|
1712
|
-
//
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
const
|
|
1721
|
-
const
|
|
1722
|
-
const
|
|
1732
|
+
/** The canvas fills the window, as Figma's, tldraw's and Miro's do: the way back, the boards and the frames' list float at
|
|
1733
|
+
* the top left (a canvas app's pages and layers), zoom at the bottom left, the minimap at the bottom right. */
|
|
1734
|
+
function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session: ViewSession; retry: () => void; snapshot: Snapshot | null; back: () => void; canWrite: boolean }): React.JSX.Element {
|
|
1735
|
+
// the boards as this page holds them: the World's as last read or saved, with this page's edits not yet saved on top.
|
|
1736
|
+
// Edits are saved one after another, each made from the version the last save left; one the World refuses (another
|
|
1737
|
+
// person saved first) is dropped and the World's boards are shown, said in a toast
|
|
1738
|
+
const held = useRef<{ boards: SavedBoard[]; version: string } | null>(null);
|
|
1739
|
+
const pending = useRef(0);
|
|
1740
|
+
const queue = useRef<Promise<void>>(Promise.resolve());
|
|
1741
|
+
// a save's generation: every save moves it when it starts and when it settles, and a read of the board takes its
|
|
1742
|
+
// boards only if none moved it while the read was out, so an older read never replaces a save's answer
|
|
1743
|
+
const generation = useRef(0);
|
|
1744
|
+
const [, rerender] = useState(0);
|
|
1745
|
+
const [loaded, setLoaded] = useState<Load<BoardDoor>>({ state: 'loading' });
|
|
1746
|
+
// the last read that did not answer, said beside the board it leaves on screen
|
|
1747
|
+
const [stale, setStale] = useState<string | null>(null);
|
|
1748
|
+
// reads in the order they went out: an earlier one answering after a later one is not taken
|
|
1749
|
+
const sent = useRef(0); const taken = useRef(0);
|
|
1750
|
+
/** The World's board read again (its frames always, its boards when no save moved since the read went out). */
|
|
1751
|
+
const readBoard = useCallback(async (): Promise<void> => {
|
|
1752
|
+
const at = generation.current; const mine = ++sent.current;
|
|
1753
|
+
const r = await door(token, `/-/${name}/board`, { signal: AbortSignal.timeout(15000) }).catch(() => null);
|
|
1754
|
+
const data = r?.ok ? ((await r.json().catch(() => null)) as BoardDoor | null) : null;
|
|
1755
|
+
if (mine < taken.current) return;
|
|
1756
|
+
taken.current = mine;
|
|
1757
|
+
if (!data?.frames || !data.boards || !data.version) {
|
|
1758
|
+
const error = r ? `the World answered ${r.status}` : 'the World did not answer';
|
|
1759
|
+
setLoaded((l) => (l.state === 'ok' ? l : { state: 'error', status: r?.status ?? 0, error }));
|
|
1760
|
+
setStale(error);
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
setStale(null);
|
|
1764
|
+
if (at === generation.current && pending.current === 0) held.current = { boards: data.boards, version: data.version };
|
|
1765
|
+
setLoaded({ state: 'ok', data });
|
|
1766
|
+
}, [token, name]);
|
|
1767
|
+
// read now and every ten seconds while the page is shown: another person's arrangement arrives as the World's changes do
|
|
1768
|
+
useEffect(() => { void readBoard(); const id = setInterval(() => { if (!document.hidden) void readBoard(); }, 10000); return () => clearInterval(id); }, [readBoard]);
|
|
1769
|
+
const all = loaded.state === 'ok' ? loaded.data.frames : [];
|
|
1770
|
+
const boards = held.current?.boards ?? [{ id: 'everything', name: 'Everything' }];
|
|
1771
|
+
const [boardId, setBoardId] = useState('everything');
|
|
1772
|
+
const board = boards.find((b) => b.id === boardId) ?? boards[0]!;
|
|
1773
|
+
const editable = canWrite && !snapshot;
|
|
1774
|
+
/** An edit of the boards: shown at once, saved after the edits before it, made from the boards as they then stand. */
|
|
1775
|
+
const save = useCallback((edit: (all: SavedBoard[]) => SavedBoard[]): Promise<void> => {
|
|
1776
|
+
if (!held.current) { toast('The board is being read again: make that change once it shows', 'error'); return Promise.resolve(); }
|
|
1777
|
+
held.current = { ...held.current, boards: edit(held.current.boards) };
|
|
1778
|
+
pending.current += 1; generation.current += 1; rerender((n) => n + 1);
|
|
1779
|
+
// each save settles whatever happens, so the next always runs: one the World refuses, or that never arrives, drops
|
|
1780
|
+
// this page's unsaved edits for the World's boards, read again
|
|
1781
|
+
const run = queue.current.then(async () => {
|
|
1782
|
+
const at = held.current;
|
|
1783
|
+
if (!at) return;
|
|
1784
|
+
try {
|
|
1785
|
+
// a save that never answers is a failed one, so the saves after it still run
|
|
1786
|
+
const r = await door(token, `/-/${name}/board`, { method: 'PUT', body: JSON.stringify({ boards: at.boards, version: at.version }), signal: AbortSignal.timeout(15000) });
|
|
1787
|
+
const answer = (await r.json().catch(() => null)) as (Partial<BoardDoor> & { error?: string }) | null;
|
|
1788
|
+
if (r.ok && answer?.boards && answer.version) held.current = { boards: pending.current > 1 && held.current ? held.current.boards : answer.boards, version: answer.version };
|
|
1789
|
+
else if (r.status === 409 && answer?.boards && answer.version) { held.current = { boards: answer.boards, version: answer.version }; toast('Someone else changed the board first: it is shown as they left it', 'error'); }
|
|
1790
|
+
else throw new Error(answer?.error ?? `the World answered ${r.status}`);
|
|
1791
|
+
} catch (e) {
|
|
1792
|
+
toast(`The board was not kept: ${e instanceof Error ? e.message : String(e)}`, 'error');
|
|
1793
|
+
// the World's boards as they stand now, read for this, never an older read the page still holds
|
|
1794
|
+
held.current = null;
|
|
1795
|
+
const r = await door(token, `/-/${name}/board`, { signal: AbortSignal.timeout(15000) }).catch(() => null);
|
|
1796
|
+
const now = r?.ok ? ((await r.json().catch(() => null)) as BoardDoor | null) : null;
|
|
1797
|
+
if (now?.boards && now.version) held.current = { boards: now.boards, version: now.version };
|
|
1798
|
+
}
|
|
1799
|
+
}).finally(() => {
|
|
1800
|
+
pending.current -= 1; generation.current += 1; rerender((n) => n + 1);
|
|
1801
|
+
// a page left without the boards (the recovery read failed too) reads them once the saves are done
|
|
1802
|
+
if (pending.current === 0 && !held.current) void readBoard();
|
|
1803
|
+
});
|
|
1804
|
+
queue.current = run;
|
|
1805
|
+
return run;
|
|
1806
|
+
}, [token, name, readBoard]);
|
|
1807
|
+
const change = (id: string, edit: (b: SavedBoard) => SavedBoard): void => { void save((list) => list.map((b) => (b.id === id ? edit(b) : b))); };
|
|
1808
|
+
const shown = shownOn(board, all);
|
|
1809
|
+
const elsewhere = all.filter((f) => f.changes && !shown.includes(f)).reduce((n, f) => n + f.changes, 0);
|
|
1723
1810
|
const [active, setActive] = useState<string | null>(null);
|
|
1724
|
-
const [layers, setLayers] = useState(true);
|
|
1725
1811
|
const flow = useReactFlow();
|
|
1726
|
-
|
|
1727
|
-
const cols = Math.max(1, Math.ceil(Math.sqrt(frames.length)));
|
|
1812
|
+
/** Each frame's place: where it was put on this board, else after its section's last frame, a new section under all. */
|
|
1728
1813
|
const placed = useCallback((): Array<Node<Screen>> => {
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1814
|
+
const at = new Map<string, { x: number; y: number }>();
|
|
1815
|
+
for (const f of shown) { const p = board.placed?.[frameKey(f)]; if (p) at.set(frameKey(f), p); }
|
|
1816
|
+
let bottom = Math.max(-FRAME_GAP, ...[...at.values()].map((p) => p.y + FRAME_H));
|
|
1817
|
+
const sections = [...new Set(shown.map(sectionKey))];
|
|
1818
|
+
for (const s of sections) {
|
|
1819
|
+
const members = shown.filter((f) => sectionKey(f) === s);
|
|
1820
|
+
const placedOnes = members.filter((f) => at.has(frameKey(f))).map((f) => at.get(frameKey(f))!);
|
|
1821
|
+
let x = placedOnes.length ? Math.max(...placedOnes.map((p) => p.x)) + FRAME_W + FRAME_GAP : 0;
|
|
1822
|
+
const y = placedOnes.length ? Math.min(...placedOnes.map((p) => p.y)) : bottom + FRAME_GAP + SECTION_PAD * 2 + SECTION_HEAD;
|
|
1823
|
+
for (const f of members) if (!at.has(frameKey(f))) { at.set(frameKey(f), { x, y }); x += FRAME_W + FRAME_GAP; }
|
|
1824
|
+
bottom = Math.max(bottom, y + FRAME_H);
|
|
1825
|
+
}
|
|
1826
|
+
const others = boards.filter((b) => b.include);
|
|
1827
|
+
const addTo = (id: string, key: string): void => change(id, (b) => ({ ...b, include: [...(b.include ?? []), key] }));
|
|
1828
|
+
const remove = { label: board.include ? 'Take it off this board' : 'Hide it', act: (key: string): void => change(board.id, (b) => (b.include ? { ...b, include: b.include.filter((k) => k !== key) } : { ...b, hidden: [...(b.hidden ?? []), key] })) };
|
|
1829
|
+
return shown.map((f) => ({ id: frameKey(f), type: 'screen', dragHandle: '.frame-bar', draggable: editable, position: at.get(frameKey(f))!, data: { world: name, frame: f, url: frameUrl(name, f), active: false, boards: editable ? others : [], addTo, remove: !editable || (board.include && !board.include.includes(frameKey(f))) ? null : remove } }));
|
|
1830
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the frames, the board and its places are what change it
|
|
1831
|
+
}, [JSON.stringify(shown), JSON.stringify(board), JSON.stringify(boards.map((b) => [b.id, b.name, b.include])), name, editable]);
|
|
1734
1832
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node<Screen>>([]);
|
|
1735
|
-
|
|
1736
|
-
|
|
1833
|
+
// a board opened is fitted to its own frames, once they are set: each board keeps its own layout
|
|
1834
|
+
const fitted = useRef('');
|
|
1835
|
+
useEffect(() => {
|
|
1836
|
+
const next = placed();
|
|
1837
|
+
setNodes(next.map((n) => ({ ...n, data: { ...n.data, active: n.id === active } })));
|
|
1838
|
+
if (next.length && fitted.current !== board.id) { fitted.current = board.id; requestAnimationFrame(() => { void flow.fitView({ padding: 0.08, duration: 0 }); }); }
|
|
1839
|
+
}, [placed, setNodes]);
|
|
1840
|
+
useEffect(() => { setNodes((ns) => ns.map((n) => (n.data.active === (n.id === active) ? n : { ...n, data: { ...n.data, active: n.id === active } }))); }, [active, setNodes]);
|
|
1737
1841
|
useEffect(() => { const on = (e: KeyboardEvent): void => { if (e.key === 'Escape') setActive(null); }; document.addEventListener('keydown', on); return () => document.removeEventListener('keydown', on); }, []);
|
|
1738
|
-
|
|
1842
|
+
// each section a box around its frames as they stand, behind them: it grows as a frame is moved
|
|
1843
|
+
const boxes = useMemo((): Array<Node<Section>> => {
|
|
1844
|
+
const by = new Map<string, Array<Node<Screen>>>();
|
|
1845
|
+
for (const n of nodes) { const k = sectionKey(n.data.frame); by.set(k, [...(by.get(k) ?? []), n]); }
|
|
1846
|
+
return [...by].map(([k, ns]) => {
|
|
1847
|
+
const x = Math.min(...ns.map((n) => n.position.x)) - SECTION_PAD; const y = Math.min(...ns.map((n) => n.position.y)) - SECTION_PAD - SECTION_HEAD;
|
|
1848
|
+
const f = ns[0]!.data.frame;
|
|
1849
|
+
const width = Math.max(...ns.map((n) => n.position.x)) + FRAME_W + SECTION_PAD - x; const height = Math.max(...ns.map((n) => n.position.y)) + FRAME_H + SECTION_PAD - y;
|
|
1850
|
+
// its size given, never measured: a box is derived from its frames, so React Flow's measurements of it are not kept
|
|
1851
|
+
return { id: `box:${k}`, type: 'section', position: { x, y }, width, height, zIndex: -1, draggable: false, selectable: false, data: { vendor: f.vendor, label: `${vendorName(f.vendor)} · ${f.section.title}`, width, height } };
|
|
1852
|
+
});
|
|
1853
|
+
}, [nodes]);
|
|
1854
|
+
// once one frame is moved the board's layout is its people's: every frame's place is kept, a new frame added after
|
|
1855
|
+
const keep = (): void => change(board.id, (b) => ({ ...b, placed: { ...(b.placed ?? {}), ...Object.fromEntries(nodes.map((n) => [n.id, { x: Math.round(n.position.x), y: Math.round(n.position.y) }])) } }));
|
|
1739
1856
|
/** A layer's row: bring that frame to the middle of the window, and use it. */
|
|
1740
|
-
const focus = (
|
|
1741
|
-
setActive(
|
|
1742
|
-
const n = flow.getNode(
|
|
1743
|
-
// the frame in the room the floating panels leave, at the largest zoom that shows it whole
|
|
1857
|
+
const focus = (key: string): void => {
|
|
1858
|
+
setActive(key);
|
|
1859
|
+
const n = flow.getNode(key); if (!n) return;
|
|
1744
1860
|
const zoom = Math.min((window.innerWidth - 300) / FRAME_W, (window.innerHeight - 120) / FRAME_H, 1);
|
|
1745
1861
|
void flow.setCenter(n.position.x + FRAME_W / 2 - 130 / zoom, n.position.y + FRAME_H / 2, { zoom, duration: 400 });
|
|
1746
1862
|
};
|
|
1863
|
+
const addBoard = (): void => {
|
|
1864
|
+
const id = `board-${Date.now().toString(36)}`;
|
|
1865
|
+
setBoardId(id);
|
|
1866
|
+
void save((list) => [...list, { id, name: `Board ${list.length}`, include: [] }]);
|
|
1867
|
+
};
|
|
1868
|
+
const rename = (): void => { const n = window.prompt('Name this board', board.name)?.trim(); if (n) change(board.id, (b) => ({ ...b, name: n.slice(0, 80) })); };
|
|
1869
|
+
const drop = (): void => { const gone = board.id; setBoardId('everything'); void save((list) => list.filter((b) => b.id !== gone)); };
|
|
1870
|
+
const sections = [...new Map(all.map((f) => [sectionKey(f), f])).values()];
|
|
1871
|
+
const missing = board.include ? [...sections.filter((f) => !sectionKeys(f).some((k) => board.include!.includes(k))).map((f) => ({ key: sectionKey(f), label: `${vendorName(f.vendor)} · ${f.section.title} (all)` })), ...all.filter((f) => !shown.includes(f)).map((f) => ({ key: frameKey(f), label: f.title }))] : [];
|
|
1872
|
+
const hidden = board.include ? [] : all.filter((f) => !shown.includes(f));
|
|
1747
1873
|
const dark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
1748
|
-
const ready = session.state === 'ok' &&
|
|
1874
|
+
const ready = session.state === 'ok' && loaded.state === 'ok';
|
|
1749
1875
|
return (
|
|
1750
|
-
<div className="canvas full" role="region" aria-label="
|
|
1751
|
-
<ReactFlow nodes={ready ? nodes : []} onNodesChange={onNodesChange} nodeTypes={FRAME_TYPES} onNodeDragStop={keep}
|
|
1752
|
-
onNodeClick={(_e, n) => setActive(n.id)} onPaneClick={() => setActive(null)}
|
|
1753
|
-
|
|
1876
|
+
<div className="canvas full" role="region" aria-label="The World's board">
|
|
1877
|
+
<ReactFlow nodes={ready ? [...boxes, ...nodes] as Node[] : []} onNodesChange={(cs) => onNodesChange(cs.filter((c) => !('id' in c) || !String(c.id).startsWith('box:')) as never)} nodeTypes={FRAME_TYPES} onNodeDragStop={keep}
|
|
1878
|
+
onNodeClick={(_e, n) => { if (!n.id.startsWith('box:')) setActive(n.id); }} onPaneClick={() => setActive(null)}
|
|
1879
|
+
minZoom={0.03} maxZoom={1.5} nodesConnectable={false} elementsSelectable={false}
|
|
1754
1880
|
panOnScroll={false} zoomOnDoubleClick={false} colorMode={dark ? 'dark' : 'light'} proOptions={{ hideAttribution: true }}>
|
|
1755
1881
|
<Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
|
|
1756
1882
|
<Panel position="top-left" className="float layers">
|
|
1757
1883
|
<div className="float-head">
|
|
1758
1884
|
<button type="button" className="back" onClick={back} aria-label={`Back to ${name}`}>←</button>
|
|
1759
1885
|
<span className="float-title"><strong>{name.split('/')[1]}</strong><span className="mode">{name.split('/')[0]}{snapshot ? ' · snapshot' : ''}</span></span>
|
|
1760
|
-
<button type="button" className="ghost" aria-expanded={layers} aria-label={layers ? 'Hide the screens list' : 'Show the screens list'} onClick={() => setLayers(!layers)}>{layers ? '▾' : '▸'}</button>
|
|
1761
1886
|
</div>
|
|
1762
|
-
|
|
1763
|
-
<
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
<
|
|
1887
|
+
<div className="board-tabs" role="tablist" aria-label="Boards">
|
|
1888
|
+
{boards.map((b) => <button key={b.id} type="button" role="tab" aria-selected={b.id === board.id} className={b.id === board.id ? 'on' : undefined} onClick={() => setBoardId(b.id)} onDoubleClick={editable && b.id === board.id && b.include ? rename : undefined}>{b.name}</button>)}
|
|
1889
|
+
{editable ? <button type="button" className="ghost" onClick={addBoard} aria-label="A new board">+</button> : null}
|
|
1890
|
+
</div>
|
|
1891
|
+
{board.include && editable ? <div className="board-acts"><button type="button" className="ghost" onClick={rename}>Rename</button><button type="button" className="ghost" onClick={drop}>Delete board</button></div> : null}
|
|
1892
|
+
<ul className="layer-list" aria-label="Frames">
|
|
1893
|
+
{[...new Set(shown.map(sectionKey))].map((s) => {
|
|
1894
|
+
const members = shown.filter((f) => sectionKey(f) === s);
|
|
1895
|
+
return (
|
|
1896
|
+
<li key={s}>
|
|
1897
|
+
<span className="layer-section"><Mark vendor={members[0]!.vendor} />{vendorName(members[0]!.vendor)} · {members[0]!.section.title}</span>
|
|
1898
|
+
<ul className="layer-list inner">
|
|
1899
|
+
{members.map((f) => <li key={frameKey(f)}><button type="button" className={active === frameKey(f) ? 'on' : undefined} onClick={() => focus(frameKey(f))}><span>{f.title}</span>{f.changes ? <span className="layer-changed" title={`${counted(f.changes, 'change')}, not in a changeset`}><Icon name="square-dot" size={14} />{f.changes}</span> : null}</button></li>)}
|
|
1900
|
+
</ul>
|
|
1901
|
+
</li>
|
|
1902
|
+
);
|
|
1903
|
+
})}
|
|
1904
|
+
</ul>
|
|
1905
|
+
{board.include && !shown.length ? <p className="mode pad-s">An empty board: add frames from Everything, or below.</p> : null}
|
|
1906
|
+
{missing.length && editable ? <select className="board-add" value="" aria-label="Add to this board" onChange={(e) => { const k = e.target.value; if (k) change(board.id, (b) => ({ ...b, include: [...(b.include ?? []), k] })); }}><option value="">Add to this board…</option>{missing.map((m) => <option key={m.key} value={m.key}>{m.label}</option>)}</select> : null}
|
|
1907
|
+
{hidden.length ? <details className="board-hidden"><summary>Hidden ({hidden.length})</summary><ul className="layer-list">{hidden.map((f) => <li key={frameKey(f)}>{editable ? <button type="button" onClick={() => change(board.id, (b) => ({ ...b, hidden: (b.hidden ?? []).filter((k) => k !== frameKey(f)) }))}><span>{f.title}</span><span className="mode">Show</span></button> : <span className="pad-s">{f.title}</span>}</li>)}</ul></details> : null}
|
|
1908
|
+
{elsewhere ? <button type="button" className="board-elsewhere" onClick={() => setBoardId('everything')}><Icon name="square-dot" size={14} />{counted(elsewhere, 'change')} not on this board</button> : null}
|
|
1909
|
+
<Pending load={loaded} what="the board" />
|
|
1910
|
+
{stale && loaded.state === 'ok' ? <p role="alert" className="pad-s">The board could not be read again ({stale}): it shows what was last read.</p> : null}
|
|
1768
1911
|
{session.state === 'pending' ? <p className="mode" role="status">Opening…</p> : null}
|
|
1769
1912
|
{session.state === 'error' ? <p role="alert">The World did not open its screens: {session.error} <button type="button" onClick={retry}>Try again</button></p> : null}
|
|
1770
1913
|
</Panel>
|
|
1771
|
-
<Panel position="top-center" className="float hint mode">{active ? `Using ${
|
|
1914
|
+
<Panel position="top-center" className="float hint mode">{active ? `Using ${shown.find((f) => frameKey(f) === active)?.title ?? active} · Esc to leave` : 'Drag to pan · scroll to zoom · click a frame to use it'}</Panel>
|
|
1772
1915
|
<Controls showInteractive={false} position="bottom-left" />
|
|
1773
1916
|
<MiniMap pannable zoomable nodeBorderRadius={8} position="bottom-right" />
|
|
1774
1917
|
</ReactFlow>
|