@volter/world-console 3.0.16 → 3.0.18
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 +15 -2
- package/client/console.tsx +81 -35
- package/dist/client/console.bundle.js +13 -13
- package/dist/client/console.css +15 -2
- package/dist/client/console.tsx +81 -35
- package/dist/client/main.bundle.js +89 -89
- package/package.json +3 -3
package/client/console.css
CHANGED
|
@@ -127,7 +127,8 @@ h2 .badge.read-only { vertical-align: middle; color: var(--warn); border-color:
|
|
|
127
127
|
.canvas .react-flow__node-screen { padding: 0; border: 0; background: none; }
|
|
128
128
|
.frame { display: grid; grid-template-rows: 48px 1fr; background: var(--surface); border: 1px solid var(--line-strong); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; }
|
|
129
129
|
.frame.on { outline: 4px solid var(--accent); outline-offset: 3px; }
|
|
130
|
-
.
|
|
130
|
+
.canvas .react-flow__node-screen.draggable .frame:not(.on) { cursor: grab; }
|
|
131
|
+
.frame-bar { display: flex; align-items: center; gap: 12px; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--surface-2); font-size: 17px; }
|
|
131
132
|
.frame-bar .mark { width: 26px; height: 26px; }
|
|
132
133
|
.frame-bar .mark.logo svg { width: 16px; height: 16px; }
|
|
133
134
|
.frame-url { flex: 1; min-width: 0; margin: 0 8px; padding: 5px 12px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); color: var(--muted); font: 14px var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -147,6 +148,10 @@ h2 .badge.read-only { vertical-align: middle; color: var(--warn); border-color:
|
|
|
147
148
|
.float button.back:hover, .float button.ghost:hover { background: var(--surface-2); }
|
|
148
149
|
.layer-list { list-style: none; margin: 6px 0 0; padding: 6px 0 0; border-top: 1px solid var(--line); display: grid; gap: 1px; }
|
|
149
150
|
.layer-list li { margin: 0; }
|
|
151
|
+
.layer-list li:has(> .layer-open) { display: flex; flex-wrap: wrap; align-items: center; }
|
|
152
|
+
.layer-list li:has(> .layer-open) > button:first-child { flex: 1; width: auto; min-width: 0; }
|
|
153
|
+
.layer-list button.layer-open { width: auto; flex: none; font-size: 11.5px; padding: 2px 6px; color: var(--ink-2, var(--ink)); }
|
|
154
|
+
.layer-list li > .layer-list { flex-basis: 100%; }
|
|
150
155
|
.layer-list button { width: 100%; display: flex; align-items: center; gap: 8px; border: 0; background: transparent; padding: 5px 8px; text-align: left; color: var(--ink); }
|
|
151
156
|
.layer-list button:hover { background: var(--surface-2); color: var(--ink); }
|
|
152
157
|
.layer-list button.on { background: var(--accent-soft); }
|
|
@@ -615,10 +620,18 @@ main.app.home { max-width: none; }
|
|
|
615
620
|
.canvas .react-flow__node-section { padding: 0; border: 0; background: none; }
|
|
616
621
|
.section-box { width: 100%; height: 100%; border: 1px solid var(--line-strong); border-radius: 4px; background: color-mix(in srgb, var(--surface) 55%, transparent); }
|
|
617
622
|
.section-box.on { outline: 2px solid var(--accent); outline-offset: 0; }
|
|
618
|
-
.section-tag { display: flex; align-items: center; gap: 2px; }
|
|
623
|
+
.section-tag { display: flex; align-items: center; gap: 2px; margin-left: 6px; }
|
|
619
624
|
.section-tag button { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border: 0; border-radius: 4px; background: var(--surface-2); color: var(--ink); font: 600 12px/1.4 var(--sans); }
|
|
620
625
|
.section-tag button.on { background: var(--accent); color: var(--surface); }
|
|
621
626
|
.section-tag .mark { width: 14px; height: 14px; }
|
|
627
|
+
.name-field { font: 600 12px/1.4 var(--sans); padding: 2px 8px; border: 1px solid var(--accent); border-radius: 4px; background: var(--surface); color: var(--ink); outline: none; }
|
|
628
|
+
.board-tabs .name-field { border-radius: 999px; font-weight: 400; font-size: 12.5px; }
|
|
629
|
+
/* a section's edges are grabbed as wide on screen at every zoom (xyflow scales only its corner handles); the selected
|
|
630
|
+
section's outline is what shows them */
|
|
631
|
+
.canvas .react-flow__resize-control.line { border-width: 0; }
|
|
632
|
+
.canvas .react-flow__resize-control.line.left, .canvas .react-flow__resize-control.line.right { width: calc(12px / var(--zoom, 1)); }
|
|
633
|
+
.canvas .react-flow__resize-control.line.top, .canvas .react-flow__resize-control.line.bottom { height: calc(12px / var(--zoom, 1)); }
|
|
634
|
+
.canvas .react-flow__resize-control.handle { width: 10px; height: 10px; border-radius: 2px; }
|
|
622
635
|
.frame.picked { outline: 4px solid var(--accent-soft); outline-offset: 3px; }
|
|
623
636
|
.float.board-tools { display: flex; gap: 4px; padding: 4px; }
|
|
624
637
|
.float.board-tools button { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 13px; }
|
package/client/console.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// person typed, from the same origin the host serves. The client holds no state a door did not
|
|
4
4
|
// answer, and offers nothing `volter world` or `curl` could not do. Locators are roles and text.
|
|
5
5
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
-
import { Background, BackgroundVariant, Controls, MiniMap, NodeResizer, NodeToolbar, Panel, Position, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, type Node, type NodeProps } from '@xyflow/react';
|
|
6
|
+
import { Background, BackgroundVariant, Controls, MiniMap, NodeResizer, NodeToolbar, Panel, Position, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, useStore, type Node, type NodeProps } from '@xyflow/react';
|
|
7
7
|
import { applyTheme, storedTheme, type Theme, At, Brand, ConfirmHost, MenuDismiss, confirmAct, CopyField, counted, Dialog, DrawerButton, Empty, failure as doorWords, listed, Mark, Menu, Pending, Skeleton, Toaster, toast, useDoor as useKitDoor, vendorName, when, ago, type Load } from './kit/kit.tsx';
|
|
8
8
|
import { agentPrompt } from '../../../packages/cli/src/agent-prompt.ts';
|
|
9
9
|
import { ICONS } from './brand/icons.ts';
|
|
@@ -363,6 +363,8 @@ function App(): React.JSX.Element {
|
|
|
363
363
|
</main>
|
|
364
364
|
);
|
|
365
365
|
}
|
|
366
|
+
// a World's own origin whose World refused the page's credential (WorldView's onEnded): the gate, in its own words
|
|
367
|
+
const endedHere = (): void => setError('This link or key opens no World here: it was revoked, or it has ended.');
|
|
366
368
|
const signOut = (): void => {
|
|
367
369
|
// a local World has no sign-in to leave: its page opens itself again
|
|
368
370
|
if (local) { window.location.reload(); return; }
|
|
@@ -433,7 +435,7 @@ function App(): React.JSX.Element {
|
|
|
433
435
|
{name || local ? null : <button onClick={signOut}>Sign out</button>}
|
|
434
436
|
</header>}
|
|
435
437
|
{name
|
|
436
|
-
? <WorldView key={name} name={name} token={tokenOf(name)} twin={twin} tab={tabbed ? tabPart ?? 'overview' : 'overview'} arg={tabbed ? tabArg : undefined} {...(tabbed && tabSub ? { sub: tabSub } : {})} admin={snapshot ? null : mode.kind === 'admin' ? token : null} snapshot={snapshot} parentToken={parentToken} who={{ local, person: who || null, admin: mode.kind === 'admin' }} onSignOut={signOut} onReturn={returnToLive} opens={(n) => !!snapshots()[n] || (mode.kind === 'admin' && mode.worlds.some((w) => w.name === n))} onRemoved={() => { void load(token); go([]); }} onWho={setWho} worlds={mode.worlds.map((w) => w.name)} />
|
|
438
|
+
? <WorldView key={name} name={name} token={tokenOf(name)} twin={twin} tab={tabbed ? tabPart ?? 'overview' : 'overview'} arg={tabbed ? tabArg : undefined} {...(tabbed && tabSub ? { sub: tabSub } : {})} admin={snapshot ? null : mode.kind === 'admin' ? token : null} snapshot={snapshot} parentToken={parentToken} who={{ local, person: who || null, admin: mode.kind === 'admin' }} onSignOut={signOut} onReturn={returnToLive} opens={(n) => !!snapshots()[n] || (mode.kind === 'admin' && mode.worlds.some((w) => w.name === n))} onRemoved={() => { void load(token); go([]); }} onWho={setWho} onEnded={endedHere} worlds={mode.worlds.map((w) => w.name)} />
|
|
437
439
|
: <Worlds mode={mode} admin={mode.kind === 'admin' ? token : null} reload={() => load(token)} />}
|
|
438
440
|
</main>
|
|
439
441
|
);
|
|
@@ -562,7 +564,7 @@ function said(e: Entry): { what: string; label: string | null } {
|
|
|
562
564
|
/** A secret's id is never shown whole (a token, a password, a key): its last four characters. */
|
|
563
565
|
const shown = (e: Entry): string => (/token|password|secret|key|credential/i.test(e.subject.type) && e.subject.id.length > 6 ? `••••${e.subject.id.slice(-4)}` : e.subject.id);
|
|
564
566
|
|
|
565
|
-
function WorldView({ name, token, twin, tab, arg, sub, admin, snapshot, parentToken, who, onSignOut, onReturn, opens, onRemoved, onWho, worlds = [] }: { /** the Worlds this page opens, for the World menu's switcher */ worlds?: string[]; /** who is at this page (the account block) */ who: { local: boolean; person: string | null; admin: boolean }; onSignOut: () => void; name: string; token: string; twin?: string; tab: string; arg?: string; /** with a snapshot in view: the credential this page holds for the World it is a snapshot of, or null */ parentToken?: string | null; /** a page under a tab's argument: a changeset under Changes › Changesets */ sub?: string; admin: string | null; snapshot: Snapshot | null; onReturn: (s: Snapshot) => Promise<void>; opens: (name: string) => boolean; onRemoved: () => void; onWho?: (who: string) => void }): React.JSX.Element {
|
|
567
|
+
function WorldView({ name, token, twin, tab, arg, sub, admin, snapshot, parentToken, who, onSignOut, onReturn, opens, onRemoved, onWho, onEnded, worlds = [] }: { /** on a World's own origin, the World refused the credential this page opened it with (its session ended, its tokens were rotated): the page goes to its gate */ onEnded?: () => void; /** the Worlds this page opens, for the World menu's switcher */ worlds?: string[]; /** who is at this page (the account block) */ who: { local: boolean; person: string | null; admin: boolean }; onSignOut: () => void; name: string; token: string; twin?: string; tab: string; arg?: string; /** with a snapshot in view: the credential this page holds for the World it is a snapshot of, or null */ parentToken?: string | null; /** a page under a tab's argument: a changeset under Changes › Changesets */ sub?: string; admin: string | null; snapshot: Snapshot | null; onReturn: (s: Snapshot) => Promise<void>; opens: (name: string) => boolean; onRemoved: () => void; onWho?: (who: string) => void }): React.JSX.Element {
|
|
566
568
|
// the session: its scope says which controls this token may use, and its cookie is what a screen reads with
|
|
567
569
|
const [scope, setScope] = useState<Scope>('pending');
|
|
568
570
|
// why the session was not opened (the wall says it, with a way to try again), and that try
|
|
@@ -602,6 +604,10 @@ function WorldView({ name, token, twin, tab, arg, sub, admin, snapshot, parentTo
|
|
|
602
604
|
useEffect(() => { const id = setInterval(() => { if (!document.hidden) setTick((t) => t + 1); }, 5000); return () => clearInterval(id); }, []);
|
|
603
605
|
const [clockVersion, setClockVersion] = useState(0);
|
|
604
606
|
const clock = useDoor<Clock>(token, `/-/${name}/clock`, tick + clockVersion);
|
|
607
|
+
// on a World's own origin the page holds one credential for one World: the World's clock refusing it (401: nothing it
|
|
608
|
+
// issued) is the page's credential gone, and the page stops reading and asks again rather than polling refusals
|
|
609
|
+
// beside what it last read. A host's page, which holds Worlds it may not open, keeps its own session wall
|
|
610
|
+
useEffect(() => { if (ON_WORLD && !snapshot && clock.state === 'error' && clock.status === 401) onEnded?.(); }, [clock, snapshot, onEnded]);
|
|
605
611
|
const twins = useDoor<{ twins: Twin[] }>(token, `/-/${name}/twins`);
|
|
606
612
|
const branches = useDoor<BranchesDoor>(token, `/-/${name}/branches`, clockVersion);
|
|
607
613
|
const map = useDoor<WorldMap>(token, `/-/${name}/map`, Math.floor(tick / 2));
|
|
@@ -1679,10 +1685,13 @@ type ViewSession = { state: 'pending' } | { state: 'ok'; scope: string } | { sta
|
|
|
1679
1685
|
* (`GET /-/<world>/board`). The first board is everything; others hold what they include, a section with its frames to
|
|
1680
1686
|
* come. Moving, hiding and including are kept by the World (`PUT /-/<world>/board`), for everyone who opens it. Pan by
|
|
1681
1687
|
* 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 =
|
|
1688
|
+
const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160; const SECTION_PAD = 72; const SECTION_HEAD = 120;
|
|
1689
|
+
/** The screen pixels a frame's name needs in its section's head: the section's own name inside the head's top, then the
|
|
1690
|
+
* frame's label and the gap NodeToolbar leaves above the frame. */
|
|
1691
|
+
const FRAME_NAME_ROOM = 72;
|
|
1683
1692
|
/** A change behind a frame's count: the writes one request made, as words read them, and when. */
|
|
1684
1693
|
type BoardAct = { at: string; writes: number; entries: Array<{ op: string; operation?: string; subject: { type: string; id: string }; fields: Record<string, string> }> };
|
|
1685
|
-
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; /** when its first change not yet cut was made */ since?: string | null };
|
|
1694
|
+
type BoardFrame = { vendor: string; id: string; kind: string; title: string; section: { id: string; title: string; aka?: string[] }; url: string; /** the page it sits under at the vendor (its id there) */ parent?: string; /** the World's own */ ours?: boolean; at: { site: string } | { screen: string } | null; unseen?: 'not-drawn' | 'sites-not-routed'; changes: number; /** when its first change not yet cut was made */ since?: string | null };
|
|
1686
1695
|
/** A section of a board: its region and name, or a pack's group removed from the board (`gone`). */
|
|
1687
1696
|
type SavedSection = { x: number; y: number; w: number; h: number; name?: string; gone?: true };
|
|
1688
1697
|
type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }>; sections?: Record<string, SavedSection> };
|
|
@@ -1702,10 +1711,13 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
|
1702
1711
|
const { frame, url, active, picked, boards, addTo, remove, compare } = data;
|
|
1703
1712
|
const key = frameKey(frame);
|
|
1704
1713
|
const others = boards.filter((b) => b.include && !b.include.includes(key) && !sectionKeys(frame).some((k) => b.include!.includes(k)));
|
|
1714
|
+
// its name is drawn the same size at every zoom while the head above it shrinks: zoomed out past the point the name fits
|
|
1715
|
+
// there, it is left out, as a canvas app leaves out a frame name that does not fit; selected or in use, it is always shown
|
|
1716
|
+
const roomy = useStore((st) => st.transform[2] * (SECTION_PAD + SECTION_HEAD) >= FRAME_NAME_ROOM);
|
|
1705
1717
|
return (
|
|
1706
1718
|
<>
|
|
1707
|
-
{/* its name
|
|
1708
|
-
<NodeToolbar isVisible position={Position.Top} align="start" className="frame-label">
|
|
1719
|
+
{/* its name, as a canvas app labels its frames; what can be done to it once it is selected */}
|
|
1720
|
+
<NodeToolbar isVisible={roomy || picked || active} position={Position.Top} align="start" className="frame-label">
|
|
1709
1721
|
<Mark vendor={frame.vendor} /><span>{frame.title}</span>
|
|
1710
1722
|
{frame.changes ? <button type="button" className="frame-changed nodrag" onClick={() => compare(key)} title={`${counted(frame.changes, 'change')} here, not in a changeset: compare before and now`}><Icon name="square-dot" size={14} />{frame.changes}</button>
|
|
1711
1723
|
: picked ? <button type="button" className="ghost nodrag" onClick={() => compare(key)} title="Compare with a branch of this World" aria-label={`Compare ${frame.title} with a branch`}><Icon name="file-diff" size={14} /></button> : null}
|
|
@@ -1713,7 +1725,7 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
|
1713
1725
|
{picked && remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
|
|
1714
1726
|
</NodeToolbar>
|
|
1715
1727
|
<div className={active ? 'frame on' : picked ? 'frame picked' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
|
|
1716
|
-
<div className="frame-bar"
|
|
1728
|
+
<div className="frame-bar">
|
|
1717
1729
|
<Mark vendor={frame.vendor} /><strong>{vendorName(frame.vendor)} · {frame.kind}</strong>
|
|
1718
1730
|
<span className="frame-url">{frame.url.replace(/^https?:\/\//, '')}</span>
|
|
1719
1731
|
{url ? <a className="nodrag" href={url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${frame.title} in a new tab`}>↗</a> : null}
|
|
@@ -1727,18 +1739,28 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
|
1727
1739
|
</>
|
|
1728
1740
|
);
|
|
1729
1741
|
}
|
|
1742
|
+
/** A name edited where it is shown, as a canvas app renames a section or a page: Enter or leaving it keeps the name, Esc
|
|
1743
|
+
* keeps the old one. Never the browser's own prompt, which a page the browser is driven in never shows. */
|
|
1744
|
+
function NameField({ name, label, done }: { name: string; label: string; done: (next: string | null) => void }): React.JSX.Element {
|
|
1745
|
+
const settled = useRef(false);
|
|
1746
|
+
const end = (next: string | null): void => { if (settled.current) return; settled.current = true; done(next?.trim() && next.trim() !== name ? next.trim().slice(0, 80) : null); };
|
|
1747
|
+
return <input className="nodrag nopan name-field" aria-label={label} defaultValue={name} size={Math.max(8, name.length + 2)} autoFocus onFocus={(e) => e.currentTarget.select()}
|
|
1748
|
+
onKeyDown={(e) => { if (e.key === 'Enter') end(e.currentTarget.value); else if (e.key === 'Escape') end(null); }} onBlur={(e) => end(e.currentTarget.value)} />;
|
|
1749
|
+
}
|
|
1730
1750
|
/** A section, as a canvas app's: a region of the board with a name, holding whatever frames sit in it. Its name stays the
|
|
1731
1751
|
* same size at every zoom; selected, it can be resized from its edges, renamed (double-click the name) or removed. */
|
|
1732
|
-
type SectionData = { key: string; name: string; vendor: string | null; /** renamed by someone, so its name is kept */ named: boolean; picked: boolean; editable: boolean; pick: (key: string) => void; rename: (key: string) => void; remove: (key: string) => void; resized: () => void };
|
|
1752
|
+
type SectionData = { key: string; name: string; vendor: string | null; /** renamed by someone, so its name is kept */ named: boolean; picked: boolean; editable: boolean; renaming: boolean; pick: (key: string) => void; rename: (key: string) => void; renamed: (key: string, next: string | null) => void; remove: (key: string) => void; resized: () => void };
|
|
1733
1753
|
function SectionBox({ data }: NodeProps<Node<SectionData>>): React.JSX.Element {
|
|
1734
1754
|
return (
|
|
1735
1755
|
<>
|
|
1736
1756
|
<NodeResizer isVisible={data.picked && data.editable} minWidth={480} minHeight={360} color="var(--accent)" onResizeEnd={data.resized} />
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1757
|
+
{/* inside its top-left corner, as a canvas app names a section: it never reaches a neighbouring section at any zoom */}
|
|
1758
|
+
<NodeToolbar isVisible position={Position.Top} align="start" offset={-30} className="section-tag">
|
|
1759
|
+
{data.renaming ? <NameField name={data.name} label="Name this section" done={(next) => data.renamed(data.key, next)} />
|
|
1760
|
+
: <button type="button" className={data.picked ? 'nodrag on' : 'nodrag'} onClick={() => data.pick(data.key)} onDoubleClick={() => { if (data.editable) data.rename(data.key); }} title={data.editable ? 'Double-click to rename' : undefined}>
|
|
1761
|
+
{data.vendor ? <Mark vendor={data.vendor} /> : null}<span>{data.name}</span>
|
|
1762
|
+
</button>}
|
|
1763
|
+
{data.picked && data.editable && !data.renaming ? <button type="button" className="ghost nodrag" onClick={() => data.remove(data.key)} aria-label={`Remove the section ${data.name}`} title="Remove the section (its frames stay)">✕</button> : null}
|
|
1742
1764
|
</NodeToolbar>
|
|
1743
1765
|
<div className={data.picked ? 'section-box on' : 'section-box'} />
|
|
1744
1766
|
</>
|
|
@@ -1906,8 +1928,15 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1906
1928
|
useEffect(() => { void readBoard(); const id = setInterval(() => { if (!document.hidden) void readBoard(); }, 10000); return () => clearInterval(id); }, [readBoard]);
|
|
1907
1929
|
const all = loaded.state === 'ok' ? loaded.data.frames : [];
|
|
1908
1930
|
const boards = held.current?.boards ?? [{ id: 'everything', name: 'Everything' }];
|
|
1909
|
-
|
|
1910
|
-
|
|
1931
|
+
// what is shown (architecture, "The board"): the pages whose records changed, everything, or a board people keep.
|
|
1932
|
+
// Changes is no board of its own: it is the first board's places and sections, holding only the pages that changed
|
|
1933
|
+
const [boardId, setBoardId] = useState<string | null>(null);
|
|
1934
|
+
const anyChanged = all.some((f) => f.changes);
|
|
1935
|
+
// a board opened with nothing chosen shows what changed, else everything
|
|
1936
|
+
const lens = boardId ?? (anyChanged ? 'changes' : 'everything');
|
|
1937
|
+
const board = (lens === 'changes' ? boards[0] : boards.find((b) => b.id === lens)) ?? boards[0]!;
|
|
1938
|
+
// the pages opened from the ones they sit under (Everything shows a vendor's top pages; a page's own open from it)
|
|
1939
|
+
const [opened, setOpened] = useState<Set<string>>(new Set());
|
|
1911
1940
|
const editable = canWrite && !snapshot;
|
|
1912
1941
|
/** An edit of the boards: shown at once, saved after the edits before it, made from the boards as they then stand. */
|
|
1913
1942
|
const save = useCallback((edit: (all: SavedBoard[]) => SavedBoard[]): Promise<void> => {
|
|
@@ -1943,7 +1972,13 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1943
1972
|
return run;
|
|
1944
1973
|
}, [token, name, readBoard]);
|
|
1945
1974
|
const change = (id: string, edit: (b: SavedBoard) => SavedBoard): void => { void save((list) => list.map((b) => (b.id === id ? edit(b) : b))); };
|
|
1946
|
-
const
|
|
1975
|
+
const parentKey = (f: BoardFrame): string | null => (f.parent ? `${f.vendor}:${f.parent}` : null);
|
|
1976
|
+
const keys = new Set(all.map(frameKey));
|
|
1977
|
+
// a page under one that is not on the list stands at the top
|
|
1978
|
+
const top = (f: BoardFrame): boolean => { const p = parentKey(f); return !p || !keys.has(p); };
|
|
1979
|
+
const shown = lens === 'changes' ? all.filter((f) => f.changes)
|
|
1980
|
+
: board.include ? shownOn(board, all)
|
|
1981
|
+
: shownOn(board, all).filter((f) => top(f) || opened.has(parentKey(f)!));
|
|
1947
1982
|
const elsewhere = all.filter((f) => f.changes && !shown.includes(f)).reduce((n, f) => n + f.changes, 0);
|
|
1948
1983
|
// a frame in use takes the pointer (double-click, as a canvas app enters a frame); one selected shows what can be done to it
|
|
1949
1984
|
const [active, setActive] = useState<string | null>(null);
|
|
@@ -1989,7 +2024,8 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1989
2024
|
const own = kept[keyed];
|
|
1990
2025
|
if (own?.gone) continue;
|
|
1991
2026
|
const ps = shown.filter((x) => sectionKey(x) === s).map((x) => at.get(frameKey(x))!);
|
|
1992
|
-
|
|
2027
|
+
// a section's head is the room above its frames where each frame's name sits, clear of the section's own name above it
|
|
2028
|
+
const around = { x: Math.min(...ps.map((p) => p.x)) - SECTION_PAD, y: Math.min(...ps.map((p) => p.y)) - SECTION_PAD - SECTION_HEAD, w: 0, h: 0 };
|
|
1993
2029
|
around.w = Math.max(...ps.map((p) => p.x)) + FRAME_W + SECTION_PAD - around.x; around.h = Math.max(...ps.map((p) => p.y)) + FRAME_H + SECTION_PAD - around.y;
|
|
1994
2030
|
// a group people arranged still takes its new frames: its region reaches over the ones placed after it
|
|
1995
2031
|
const unplacedOf = shown.filter((x) => sectionKey(x) === s && !board.placed?.[frameKey(x)]).map((x) => at.get(frameKey(x))!);
|
|
@@ -1999,18 +2035,21 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1999
2035
|
}
|
|
2000
2036
|
for (const [k, r] of Object.entries(kept)) if (k.startsWith('user:') && !r.gone) sections.push(section(k, r, r.name ?? 'Section', null, true));
|
|
2001
2037
|
// frames above the sections they sit in; a section's name tag above the board, where it can be clicked
|
|
2002
|
-
// a frame's size given, never left to be measured again: a board laid out anew keeps showing what did not change size
|
|
2003
|
-
|
|
2038
|
+
// a frame's size given, never left to be measured again: a board laid out anew keeps showing what did not change size.
|
|
2039
|
+
// A frame moves from anywhere on it, as a canvas app's does, until it is in use (its page then takes the pointer)
|
|
2040
|
+
const frames: Node[] = shown.map((f) => ({ id: frameKey(f), type: 'screen', zIndex: 1, width: FRAME_W, height: FRAME_H, draggable: editable, position: at.get(frameKey(f))!, data: { world: name, frame: f, url: frameUrl(name, f) } }));
|
|
2004
2041
|
return [...sections, ...frames];
|
|
2005
2042
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the frames, the board and its places are what change it
|
|
2006
2043
|
}, [JSON.stringify(shown), JSON.stringify(board), name, editable]);
|
|
2007
2044
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
|
2045
|
+
// the canvas's zoom, for what is drawn the same size on screen at every zoom (a section's edges, where it is resized)
|
|
2046
|
+
const zoom = useStore((st) => st.transform[2]);
|
|
2008
2047
|
// a board opened is fitted to its own frames, once they are set: each board keeps its own layout
|
|
2009
2048
|
const fitted = useRef('');
|
|
2010
2049
|
useEffect(() => {
|
|
2011
2050
|
const next = layout();
|
|
2012
2051
|
setNodes(next);
|
|
2013
|
-
if (next.length && fitted.current !==
|
|
2052
|
+
if (next.length && fitted.current !== lens) { fitted.current = lens; requestAnimationFrame(() => { void flow.fitView({ padding: { top: '72px', left: '272px', right: '40px', bottom: '72px' }, duration: 0 }); }); }
|
|
2014
2053
|
}, [layout, setNodes]);
|
|
2015
2054
|
/** What was just arranged, kept on this board beside what was kept before: the frames moved, and the sections moved,
|
|
2016
2055
|
* resized or renamed (a pack's group is kept only once someone arranges it; its name only once someone renames it). */
|
|
@@ -2064,10 +2103,12 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
2064
2103
|
/** A pack's group's frames where they now stand: kept with its section when the section is arranged, so they stay in it. */
|
|
2065
2104
|
const groupFrames = (key: string): Record<string, { x: number; y: number }> =>
|
|
2066
2105
|
Object.fromEntries(flow.getNodes().filter((n) => n.type === 'screen' && sectionKeys((n.data as Screen).frame).includes(key)).map((n) => [n.id, { x: n.position.x, y: n.position.y }]));
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2106
|
+
// the name being edited where it is shown: a section's (its key) or this board's (`board:<id>`)
|
|
2107
|
+
const [renaming, setRenaming] = useState<string | null>(null);
|
|
2108
|
+
const renameSection = (key: string): void => { setPicked(`sec:${key}`); setRenaming(key); };
|
|
2109
|
+
const renamedSection = (key: string, next: string | null): void => {
|
|
2110
|
+
setRenaming(null); if (!next) return;
|
|
2111
|
+
setNodes((ns) => ns.map((x) => (x.id === `sec:${key}` ? { ...x, data: { ...x.data, name: next, named: true } } : x)));
|
|
2071
2112
|
requestAnimationFrame(() => keep(groupFrames(key), [key], { [key]: next }));
|
|
2072
2113
|
};
|
|
2073
2114
|
/** A section removed: its frames stay where they are; a pack's group is kept as removed, so it does not come back. */
|
|
@@ -2091,7 +2132,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
2091
2132
|
};
|
|
2092
2133
|
// what each node shows: in use, selected, and what can be done to it
|
|
2093
2134
|
const shownNodes = nodes.map((n) => (n.type === 'section'
|
|
2094
|
-
? { ...n, data: { ...n.data, picked: picked === n.id, editable, pick: (k: string) => setPicked(`sec:${k}`), rename: renameSection, remove: removeSection, resized: () => requestAnimationFrame(() => { const k = (n.data as { key: string }).key; keep(groupFrames(k), [k]); }) } }
|
|
2135
|
+
? { ...n, data: { ...n.data, picked: picked === n.id, editable, renaming: editable && renaming === (n.data as { key: string }).key, pick: (k: string) => setPicked(`sec:${k}`), rename: renameSection, renamed: renamedSection, remove: removeSection, resized: () => requestAnimationFrame(() => { const k = (n.data as { key: string }).key; keep(groupFrames(k), [k]); }) } }
|
|
2095
2136
|
: { ...n, data: { ...n.data, active: active === n.id, picked: picked === n.id, boards: editable ? others : [], addTo, remove: !editable || (board.include && !board.include.includes(n.id)) ? null : removeFrame, compare: (k: string) => { setPicked(k); setComparing(k); } } }));
|
|
2096
2137
|
// the keys a canvas app answers: Esc leaves a frame, then the selection; Delete removes what is selected; ⇧S draws a section
|
|
2097
2138
|
useEffect(() => {
|
|
@@ -2116,34 +2157,39 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
2116
2157
|
setBoardId(id);
|
|
2117
2158
|
void save((list) => [...list, { id, name: `Board ${list.length}`, include: [] }]);
|
|
2118
2159
|
};
|
|
2119
|
-
const rename = (): void =>
|
|
2120
|
-
const drop = (): void => { const gone = board.id; setBoardId(
|
|
2160
|
+
const rename = (): void => setRenaming(`board:${board.id}`);
|
|
2161
|
+
const drop = (): void => { const gone = board.id; setBoardId(null); void save((list) => list.filter((b) => b.id !== gone)); };
|
|
2121
2162
|
const groups = [...new Map(all.map((f) => [sectionKey(f), f])).values()];
|
|
2122
2163
|
const missing = board.include ? [...groups.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 }))] : [];
|
|
2123
|
-
const hidden = board.include ? [] : all.filter((f) =>
|
|
2164
|
+
const hidden = board.include ? [] : all.filter((f) => (board.hidden ?? []).includes(frameKey(f)));
|
|
2124
2165
|
// the layers as the canvas has them: each section with the frames in it, then the frames in none
|
|
2125
2166
|
const sectionNodes = nodes.filter((n) => n.type === 'section');
|
|
2126
2167
|
const frameNodes = nodes.filter((n) => n.type === 'screen');
|
|
2127
2168
|
const inSection = new Map(frameNodes.map((n) => [n.id, sectionOf(n, nodes)?.id ?? null]));
|
|
2128
|
-
|
|
2169
|
+
// how many pages sit under each page, opened from it in Everything
|
|
2170
|
+
const under = new Map<string, number>(); for (const f of all) { const p = parentKey(f); if (p && keys.has(p)) under.set(p, (under.get(p) ?? 0) + 1); }
|
|
2171
|
+
const frameRow = (n: Node): React.JSX.Element => { const f = (n.data as Screen).frame; return <li key={n.id}><button type="button" className={picked === n.id ? 'on' : undefined} onClick={() => focus(n.id)} onDoubleClick={() => setActive(n.id)}><Mark vendor={f.vendor} /><span>{f.title}</span>{f.changes ? <span className="layer-changed" title={`${counted(f.changes, 'change')}, not in a changeset: its mark on the board compares before and now`}><Icon name="square-dot" size={14} />{f.changes}</span> : null}</button>{under.get(n.id) && lens !== 'changes' && !board.include ? <button type="button" className="ghost layer-open" aria-expanded={opened.has(n.id)} onClick={() => setOpened((o) => { const x = new Set(o); if (x.has(n.id)) x.delete(n.id); else x.add(n.id); return x; })} title={opened.has(n.id) ? 'Close the pages under it' : 'Open the pages under it'}>{opened.has(n.id) ? '−' : '+'}{under.get(n.id)}</button> : null}</li>; };
|
|
2129
2172
|
const dark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
2130
2173
|
const ready = session.state === 'ok' && loaded.state === 'ok';
|
|
2131
2174
|
return (
|
|
2132
|
-
<div className="canvas full" role="region" aria-label="The World's board">
|
|
2175
|
+
<div className="canvas full" role="region" aria-label="The World's board" style={{ '--zoom': zoom } as React.CSSProperties}>
|
|
2133
2176
|
<ReactFlow nodes={ready ? shownNodes : []} onNodesChange={onNodesChange} nodeTypes={FRAME_TYPES}
|
|
2134
2177
|
onNodeDragStart={onDragStart} onNodeDrag={onDrag} onNodeDragStop={onDragStop}
|
|
2135
2178
|
onNodeClick={(_e, n) => { if (n.id !== active) setPicked(n.id); }} onNodeDoubleClick={(_e, n) => { if (n.type === 'screen') { setPicked(n.id); setActive(n.id); } }}
|
|
2136
2179
|
onPaneClick={() => { setActive(null); setPicked(null); }}
|
|
2137
2180
|
minZoom={0.03} maxZoom={1.5} nodesConnectable={false} elementsSelectable={false}
|
|
2138
2181
|
panOnScroll zoomOnPinch zoomOnScroll={false} zoomOnDoubleClick={false} colorMode={dark ? 'dark' : 'light'} proOptions={{ hideAttribution: true }}>
|
|
2139
|
-
|
|
2182
|
+
{/* dots the same size on screen at every zoom, their spacing doubling as the board zooms out (as a canvas app's
|
|
2183
|
+
grid steps), so the board shows where it is and how far it moved at any zoom */}
|
|
2184
|
+
<Background variant={BackgroundVariant.Dots} gap={28 * 2 ** Math.max(0, Math.ceil(Math.log2(16 / (28 * zoom))))} size={2 / zoom} />
|
|
2140
2185
|
<Panel position="top-left" className="float layers">
|
|
2141
2186
|
<div className="float-head">
|
|
2142
2187
|
<button type="button" className="back" onClick={back} aria-label={`Back to ${name}`}>←</button>
|
|
2143
2188
|
<span className="float-title"><strong>{name.split('/')[1]}</strong><span className="mode">{name.split('/')[0]}{snapshot ? ' · snapshot' : ''}</span></span>
|
|
2144
2189
|
</div>
|
|
2145
2190
|
<div className="board-tabs" role="tablist" aria-label="Boards">
|
|
2146
|
-
|
|
2191
|
+
<button type="button" role="tab" aria-selected={lens === 'changes'} className={lens === 'changes' ? 'on' : undefined} onClick={() => { setPicked(null); setActive(null); setBoardId('changes'); }} title="The pages whose records changed and are not yet in a changeset">Changes{anyChanged ? ` (${all.filter((f) => f.changes).length})` : ''}</button>
|
|
2192
|
+
{boards.map((b) => (renaming === `board:${b.id}` ? <NameField key={b.id} name={b.name} label="Name this board" done={(next) => { setRenaming(null); if (next) change(b.id, (x) => ({ ...x, name: next })); }} /> : <button key={b.id} type="button" role="tab" aria-selected={lens !== 'changes' && b.id === board.id} className={lens !== 'changes' && b.id === board.id ? 'on' : undefined} onClick={() => { setPicked(null); setActive(null); setBoardId(b.id); }} onDoubleClick={editable && b.id === board.id && b.include ? rename : undefined}>{b.name}</button>))}
|
|
2147
2193
|
{editable ? <button type="button" className="ghost" onClick={addBoard} aria-label="A new board">+</button> : null}
|
|
2148
2194
|
</div>
|
|
2149
2195
|
{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}
|
|
@@ -2152,7 +2198,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
2152
2198
|
const d = s.data as SectionData;
|
|
2153
2199
|
return (
|
|
2154
2200
|
<li key={s.id}>
|
|
2155
|
-
<button type="button" className={picked === s.id ? 'layer-section on' : 'layer-section'} onClick={() => focus(s.id)} onDoubleClick={() => { if (editable) renameSection(d.key); }}>{d.vendor ? <Mark vendor={d.vendor} /> : <Icon name="layout-grid" size={14} />}<span>{d.name}</span></button>
|
|
2201
|
+
<button type="button" className={picked === s.id ? 'layer-section on' : 'layer-section'} onClick={() => focus(s.id)} onDoubleClick={() => { if (editable) { focus(s.id); renameSection(d.key); } }}>{d.vendor ? <Mark vendor={d.vendor} /> : <Icon name="layout-grid" size={14} />}<span>{d.name}</span></button>
|
|
2156
2202
|
<ul className="layer-list inner">{frameNodes.filter((n) => inSection.get(n.id) === s.id).map(frameRow)}</ul>
|
|
2157
2203
|
</li>
|
|
2158
2204
|
);
|
|
@@ -2162,7 +2208,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
2162
2208
|
{board.include && !shown.length ? <p className="mode pad-s">An empty board: add frames from Everything, or below.</p> : null}
|
|
2163
2209
|
{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}
|
|
2164
2210
|
{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}
|
|
2165
|
-
{elsewhere ? <button type="button" className="board-elsewhere" onClick={() => setBoardId('
|
|
2211
|
+
{elsewhere ? <button type="button" className="board-elsewhere" onClick={() => setBoardId('changes')}><Icon name="square-dot" size={14} />{counted(elsewhere, 'change')} not on this board</button> : null}
|
|
2166
2212
|
<Pending load={loaded} what="the board" />
|
|
2167
2213
|
{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}
|
|
2168
2214
|
{session.state === 'pending' ? <p className="mode" role="status">Opening…</p> : null}
|