@volter/world-console 3.0.25 → 3.0.26
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.tsx +57 -106
- package/dist/client/console.bundle.js +12 -12
- package/dist/client/console.tsx +57 -106
- package/dist/client/main.bundle.js +104 -104
- package/package.json +3 -3
package/dist/client/console.tsx
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
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
|
+
import { boardFrameKey } from '../../../packages/world-core/src/board-keys.ts';
|
|
8
9
|
import { agentPrompt } from '../../../packages/cli/src/agent-prompt.ts';
|
|
9
10
|
import { ICONS } from './brand/icons.ts';
|
|
10
11
|
|
|
@@ -1691,17 +1692,31 @@ const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160; const SECTION_
|
|
|
1691
1692
|
const FRAME_NAME_ROOM = 72;
|
|
1692
1693
|
/** A change behind a frame's count: the writes one request made, as words read them, and when. */
|
|
1693
1694
|
type BoardAct = { at: string; writes: number; entries: Array<{ op: string; operation?: string; subject: { type: string; id: string }; fields: Record<string, string> }> };
|
|
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' | 'branch-sites'; changes: number; /** when its first change not yet cut was made */ since?: string | null };
|
|
1695
|
+
type BoardFrame = { browser?: string; 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' | 'branch-sites'; changes: number; /** when its first change not yet cut was made */ since?: string | null };
|
|
1695
1696
|
/** A section of a board: its region and name, or a pack's group removed from the board (`gone`). */
|
|
1696
1697
|
type SavedSection = { x: number; y: number; w: number; h: number; name?: string; gone?: true };
|
|
1697
1698
|
type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }>; sections?: Record<string, SavedSection> };
|
|
1698
1699
|
type BoardBranch = { name: string; origin?: string; sites?: string; at: { instant?: string; label?: string; live?: boolean } | null; expiresAt: string | null };
|
|
1699
1700
|
const BRANCH_SITES_UNSEEN = "This World shows its branches' sites only on this machine.";
|
|
1700
1701
|
type BoardDoor = { frames: BoardFrame[]; boards: SavedBoard[]; version: string };
|
|
1701
|
-
const frameKey =
|
|
1702
|
-
const sectionKey = (f: BoardFrame): string =>
|
|
1703
|
-
|
|
1704
|
-
const
|
|
1702
|
+
const frameKey = boardFrameKey;
|
|
1703
|
+
const sectionKey = (f: BoardFrame): string => frameKey({ ...f, id: `section:${f.section.id}` });
|
|
1704
|
+
type BoardAliases = { first?: string; frames: Set<string>; sections: Set<string> };
|
|
1705
|
+
const boardAliases = (all: BoardFrame[]): BoardAliases => ({
|
|
1706
|
+
first: all.flatMap(f => f.browser ? [f.browser] : []).sort()[0],
|
|
1707
|
+
frames: new Set(all.filter(f => !f.browser).map(frameKey)),
|
|
1708
|
+
sections: new Set(all.filter(f => !f.browser).flatMap(f => [f.section.id, ...(f.section.aka ?? [])].map(id => `${f.vendor}:section:${id}`))),
|
|
1709
|
+
});
|
|
1710
|
+
const frameKeys = (f: BoardFrame, aliases: BoardAliases): string[] => {
|
|
1711
|
+
const plain = `${f.vendor}:${f.id}`;
|
|
1712
|
+
return f.browser === aliases.first && !aliases.frames.has(plain) ? [frameKey(f), plain] : [frameKey(f)];
|
|
1713
|
+
};
|
|
1714
|
+
/** A section's current and adopted ids; saved plain keys apply only to the first browser copy. */
|
|
1715
|
+
const sectionKeys = (f: BoardFrame, aliases: BoardAliases): string[] => [...new Set([f.section.id, ...(f.section.aka ?? [])].flatMap(id => {
|
|
1716
|
+
const key = frameKey({ ...f, id: `section:${id}` });
|
|
1717
|
+
const plain = `${f.vendor}:section:${id}`;
|
|
1718
|
+
return f.browser === aliases.first && !aliases.sections.has(plain) ? [key, plain] : [key];
|
|
1719
|
+
}))];
|
|
1705
1720
|
/** Where a frame opens in this World: a site at its own origin, a vendor page at the twin's place, or nowhere yet. */
|
|
1706
1721
|
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);
|
|
1707
1722
|
/** A vendor page on a branch always opens through its parent's read-pass door. */
|
|
@@ -1709,44 +1724,15 @@ const branchFrameUrl = (name: string, branch: string, f: BoardFrame): string | n
|
|
|
1709
1724
|
? `${WORLDS}/-/${name}/branches/${branch}/pages?${new URLSearchParams({ land: `/${branch}/${f.vendor}${f.at.screen}` }).toString()}`
|
|
1710
1725
|
: frameUrl(branch, f);
|
|
1711
1726
|
/** The frames a board shows: everything but what it hides, or what it includes (a frame, or a section's every frame). */
|
|
1712
|
-
const shownOn = (b: SavedBoard, all: BoardFrame[]): BoardFrame[] => b.include
|
|
1713
|
-
? all.filter((f) => b.include!.includes(
|
|
1714
|
-
: all.filter((f) => !(b.hidden ?? []).includes(
|
|
1715
|
-
type
|
|
1716
|
-
type ViewAsMenuData = { world: string; token: string; vendor: string; label?: string; busy: boolean; choose: (person: string | null) => void };
|
|
1717
|
-
type ViewAsNavigation = { frame: string; url: string; pass: string; done: (viewed?: ViewedPerson, error?: string) => void };
|
|
1718
|
-
/** The choice lives in the vendor's cookie; a menu reads people, never password material. */
|
|
1719
|
-
function ViewAsMenu({ data }: { data: ViewAsMenuData }): React.JSX.Element {
|
|
1720
|
-
const [reads, setReads] = useState(0);
|
|
1721
|
-
const people = useDoor<{ viewAs: boolean; people: Array<{ id: string; label: string }> }>(data.token, `/-/${data.world}/twins/${data.vendor}/people`, reads);
|
|
1722
|
-
const offered = useRef(false);
|
|
1723
|
-
if (people.state === 'ok') offered.current = people.data.viewAs;
|
|
1724
|
-
if (!offered.current) return <></>;
|
|
1725
|
-
return <select className="nodrag nopan frame-add" value="" aria-label={`View ${vendorName(data.vendor)} as`} disabled={data.busy}
|
|
1726
|
-
onFocus={() => setReads((n) => n + 1)} onChange={(e) => { if (e.target.value) data.choose(e.target.value === 'signed-out' ? null : e.target.value); }}>
|
|
1727
|
-
<option value="">{data.busy ? 'Signing in…' : data.label ? `View as: ${data.label}` : 'View as…'}</option>
|
|
1728
|
-
<option value="signed-out">Signed out</option>
|
|
1729
|
-
{people.state === 'ok' ? people.data.people.map((p) => <option key={p.id} value={p.id}>{p.label}</option>) : <option disabled>{people.state === 'error' ? people.error : 'Loading people…'}</option>}
|
|
1730
|
-
</select>;
|
|
1731
|
-
}
|
|
1732
|
-
type Screen = { viewAs?: ViewAsMenuData; navigation?: ViewAsNavigation; revision?: number; world: string; branch: boolean; frame: BoardFrame; url: string | null; active: boolean; picked: boolean; boards: SavedBoard[]; addTo: (board: string, key: string) => void; remove: { label: string; act: (key: string) => void } | null; compare: (key: string) => void; /** its name moves it, as a canvas app's frame name does */ grab: (e: React.PointerEvent) => void };
|
|
1727
|
+
const shownOn = (b: SavedBoard, all: BoardFrame[], aliases: BoardAliases): BoardFrame[] => b.include
|
|
1728
|
+
? all.filter((f) => frameKeys(f, aliases).some(k => b.include!.includes(k)) || sectionKeys(f, aliases).some((k) => b.include!.includes(k)))
|
|
1729
|
+
: all.filter((f) => !frameKeys(f, aliases).some(k => (b.hidden ?? []).includes(k)));
|
|
1730
|
+
type Screen = { aliases: BoardAliases; world: string; branch: boolean; frame: BoardFrame; url: string | null; active: boolean; picked: boolean; boards: SavedBoard[]; addTo: (board: string, key: string) => void; remove: { label: string; act: (key: string) => void } | null; compare: (key: string) => void; /** its name moves it, as a canvas app's frame name does */ grab: (e: React.PointerEvent) => void };
|
|
1733
1731
|
function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
1734
|
-
const { frame, url, active, picked, boards, addTo, remove, compare, grab, branch } = data;
|
|
1732
|
+
const { aliases, frame, url, active, picked, boards, addTo, remove, compare, grab, branch } = data;
|
|
1735
1733
|
const key = frameKey(frame);
|
|
1736
1734
|
const iframe = useRef<HTMLIFrameElement>(null);
|
|
1737
|
-
const
|
|
1738
|
-
useEffect(() => {
|
|
1739
|
-
if (!navigation) return;
|
|
1740
|
-
const origin = new URL(navigation.url).origin;
|
|
1741
|
-
const on = (event: MessageEvent): void => {
|
|
1742
|
-
if (event.origin !== origin || event.source !== iframe.current?.contentWindow || event.data?.type !== 'volter-view-as' || event.data.pass !== navigation.pass) return;
|
|
1743
|
-
if (event.data.viewed?.vendor !== frame.vendor && !event.data.error) return;
|
|
1744
|
-
navigation.done(event.data.viewed, event.data.error);
|
|
1745
|
-
};
|
|
1746
|
-
window.addEventListener('message', on);
|
|
1747
|
-
return () => window.removeEventListener('message', on);
|
|
1748
|
-
}, [navigation, frame.vendor]);
|
|
1749
|
-
const others = boards.filter((b) => b.include && !b.include.includes(key) && !sectionKeys(frame).some((k) => b.include!.includes(k)));
|
|
1735
|
+
const others = boards.filter((b) => b.include && !frameKeys(frame, aliases).some(k => b.include!.includes(k)) && !sectionKeys(frame, aliases).some((k) => b.include!.includes(k)));
|
|
1750
1736
|
// its name is drawn the same size at every zoom while the head above it shrinks: zoomed out past the point the name fits
|
|
1751
1737
|
// 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
|
|
1752
1738
|
const roomy = useStore((st) => st.transform[2] * (SECTION_PAD + SECTION_HEAD) >= FRAME_NAME_ROOM);
|
|
@@ -1757,19 +1743,18 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
|
1757
1743
|
<span className="frame-name" onPointerDown={grab}><Mark vendor={frame.vendor} /><span>{frame.title}</span></span>
|
|
1758
1744
|
{frame.changes ? <button type="button" className="frame-changed nodrag" onClick={() => compare(key)} title={`${counted(frame.changes, 'change')} ${branch ? 'on this branch' : 'here, not in a changeset'}: compare`}><Icon name="square-dot" size={14} />{frame.changes}</button>
|
|
1759
1745
|
: 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}
|
|
1760
|
-
{picked && data.viewAs ? <ViewAsMenu data={data.viewAs} /> : null}
|
|
1761
1746
|
{picked && 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}
|
|
1762
1747
|
{picked && remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
|
|
1763
1748
|
</NodeToolbar>
|
|
1764
1749
|
<div className={active ? 'frame on' : picked ? 'frame picked' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
|
|
1765
1750
|
<div className="frame-bar">
|
|
1766
|
-
<Mark vendor={frame.vendor} /><strong>{vendorName(frame.vendor)} · {frame.kind}</strong>
|
|
1751
|
+
<Mark vendor={frame.vendor} /><strong>{frame.browser ? `${frame.browser} · ` : ''}{vendorName(frame.vendor)} · {frame.kind}</strong>
|
|
1767
1752
|
<span className="frame-url">{frame.url.replace(/^https?:\/\//, '')}</span>
|
|
1768
1753
|
{url ? <a className="nodrag" href={url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${frame.title} in a new tab`}>↗</a> : null}
|
|
1769
1754
|
</div>
|
|
1770
1755
|
<div className={active ? 'frame-body nodrag nopan nowheel' : 'frame-body'}>
|
|
1771
1756
|
{url
|
|
1772
|
-
? <iframe ref={iframe}
|
|
1757
|
+
? <iframe ref={iframe} title={frame.title} src={url} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
|
|
1773
1758
|
: <div className="frame-none"><p><strong>{frame.unseen === 'branch-sites' ? BRANCH_SITES_UNSEEN : 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>}
|
|
1774
1759
|
</div>
|
|
1775
1760
|
</div>
|
|
@@ -1786,7 +1771,7 @@ function NameField({ name, label, done }: { name: string; label: string; done: (
|
|
|
1786
1771
|
}
|
|
1787
1772
|
/** 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
|
|
1788
1773
|
* same size at every zoom; selected, it can be resized from its edges, renamed (double-click the name) or removed. */
|
|
1789
|
-
type SectionData = {
|
|
1774
|
+
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; /** its name moves it, with what it holds */ grab: (e: React.PointerEvent) => void };
|
|
1790
1775
|
function SectionBox({ data }: NodeProps<Node<SectionData>>): React.JSX.Element {
|
|
1791
1776
|
return (
|
|
1792
1777
|
<>
|
|
@@ -1797,7 +1782,6 @@ function SectionBox({ data }: NodeProps<Node<SectionData>>): React.JSX.Element {
|
|
|
1797
1782
|
: <button type="button" className={data.picked ? 'nodrag on' : 'nodrag'} onPointerDown={data.grab} onClick={() => data.pick(data.key)} onDoubleClick={() => { if (data.editable) data.rename(data.key); }} title={data.editable ? 'Double-click to rename' : undefined}>
|
|
1798
1783
|
{data.vendor ? <Mark vendor={data.vendor} /> : null}<span>{data.name}</span>
|
|
1799
1784
|
</button>}
|
|
1800
|
-
{data.viewAs ? <ViewAsMenu data={data.viewAs} /> : null}
|
|
1801
1785
|
{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}
|
|
1802
1786
|
</NodeToolbar>
|
|
1803
1787
|
<div className={data.picked ? 'section-box on' : 'section-box'} />
|
|
@@ -1995,7 +1979,8 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
1995
1979
|
}, [token, name, viewing]);
|
|
1996
1980
|
// read now and every ten seconds while the page is shown: another person's arrangement arrives as the World's changes do
|
|
1997
1981
|
useEffect(() => { void readBoard(); const id = setInterval(() => { if (!document.hidden) void readBoard(); }, 10000); return () => clearInterval(id); }, [readBoard]);
|
|
1998
|
-
const all = loaded.state === 'ok' ? loaded.data.frames.map((f): BoardFrame => viewing && f.at && 'site' in f.at && !/^https?:\/\/\{host\}\./.test(branch?.sites ?? '') ? { ...f, at: null, unseen: 'branch-sites' } : f) : [];
|
|
1982
|
+
const all = loaded.state === 'ok' ? loaded.data.frames.map((f): BoardFrame => viewing && (f.browser || (f.at && 'site' in f.at && !/^https?:\/\/\{host\}\./.test(branch?.sites ?? ''))) ? { ...f, at: null, unseen: 'branch-sites' } : f) : [];
|
|
1983
|
+
const aliases = boardAliases(all);
|
|
1999
1984
|
const boards = held.current?.boards ?? [{ id: 'everything', name: 'Everything' }];
|
|
2000
1985
|
// what is shown (architecture, "The board"): the pages whose records changed, everything, or a board people keep.
|
|
2001
1986
|
// Changes is no board of its own: it is the first board's places and sections, holding only the pages that changed
|
|
@@ -2007,13 +1992,6 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
2007
1992
|
// the pages opened from the ones they sit under (Everything shows a vendor's top pages; a page's own open from it)
|
|
2008
1993
|
const [opened, setOpened] = useState<Set<string>>(new Set());
|
|
2009
1994
|
const editable = canWrite && !snapshot && !viewing;
|
|
2010
|
-
const [viewedAs, setViewedAs] = useState<Record<string, { label: string; revision: number }>>({});
|
|
2011
|
-
const [signingAs, setSigningAs] = useState<Record<string, ViewAsNavigation>>({});
|
|
2012
|
-
const busyAs = useRef(new Set<string>());
|
|
2013
|
-
const [askingAs, setAskingAs] = useState<string[]>([]);
|
|
2014
|
-
const viewAsTimers = useRef(new Set<ReturnType<typeof setTimeout>>());
|
|
2015
|
-
const viewAsMounted = useRef(true);
|
|
2016
|
-
useEffect(() => { viewAsMounted.current = true; return () => { viewAsMounted.current = false; for (const timer of viewAsTimers.current) clearTimeout(timer); }; }, []);
|
|
2017
1995
|
/** An edit of the boards: shown at once, saved after the edits before it, made from the boards as they then stand. */
|
|
2018
1996
|
const save = useCallback((edit: (all: SavedBoard[]) => SavedBoard[]): Promise<void> => {
|
|
2019
1997
|
if (!editable) return Promise.resolve();
|
|
@@ -2049,13 +2027,13 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
2049
2027
|
return run;
|
|
2050
2028
|
}, [token, name, readBoard, editable]);
|
|
2051
2029
|
const change = (id: string, edit: (b: SavedBoard) => SavedBoard): void => { void save((list) => list.map((b) => (b.id === id ? edit(b) : b))); };
|
|
2052
|
-
const parentKey = (f: BoardFrame): string | null =>
|
|
2030
|
+
const parentKey = (f: BoardFrame): string | null => f.parent ? frameKey({ ...f, id: f.parent }) : null;
|
|
2053
2031
|
const keys = new Set(all.map(frameKey));
|
|
2054
2032
|
// a page under one that is not on the list stands at the top
|
|
2055
2033
|
const top = (f: BoardFrame): boolean => { const p = parentKey(f); return !p || !keys.has(p); };
|
|
2056
2034
|
const shown = lens === 'changes' ? all.filter((f) => f.changes)
|
|
2057
|
-
: board.include ? shownOn(board, all)
|
|
2058
|
-
: shownOn(board, all).filter((f) => top(f) || opened.has(parentKey(f)!));
|
|
2035
|
+
: board.include ? shownOn(board, all, aliases)
|
|
2036
|
+
: shownOn(board, all, aliases).filter((f) => top(f) || opened.has(parentKey(f)!));
|
|
2059
2037
|
const elsewhere = all.filter((f) => f.changes && !shown.includes(f)).reduce((n, f) => n + f.changes, 0);
|
|
2060
2038
|
// 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
|
|
2061
2039
|
const [active, setActive] = useState<string | null>(null);
|
|
@@ -2065,7 +2043,15 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
2065
2043
|
const flow = useReactFlow();
|
|
2066
2044
|
const others = boards.filter((b) => b.include);
|
|
2067
2045
|
const addTo = (id: string, key: string): void => change(id, (b) => ({ ...b, include: [...(b.include ?? []), key] }));
|
|
2068
|
-
const
|
|
2046
|
+
const savedFrameKey = (key: string, held: string[]): string => {
|
|
2047
|
+
const f = all.find(frame => frameKey(frame) === key);
|
|
2048
|
+
return f ? frameKeys(f, aliases).find(candidate => held.includes(candidate)) ?? key : key;
|
|
2049
|
+
};
|
|
2050
|
+
const removeFrame = { label: board.include ? 'Take it off this board' : 'Hide it', act: (key: string): void => {
|
|
2051
|
+
setPicked(null);
|
|
2052
|
+
change(board.id, b => b.include ? { ...b, include: b.include.filter(k => k !== savedFrameKey(key, b.include!)) }
|
|
2053
|
+
: { ...b, hidden: [...(b.hidden ?? []), key] });
|
|
2054
|
+
} };
|
|
2069
2055
|
const rect = (n: Node): { x: number; y: number; w: number; h: number } => ({ x: n.position.x, y: n.position.y, w: n.width ?? n.measured?.width ?? FRAME_W, h: n.height ?? n.measured?.height ?? FRAME_H });
|
|
2070
2056
|
/** The section a frame sits in, as on a canvas: the smallest whose region holds the frame's middle, the newest of equals
|
|
2071
2057
|
* (a section drawn around a frame takes it). */
|
|
@@ -2079,7 +2065,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
2079
2065
|
* sections people drew where they drew them. */
|
|
2080
2066
|
const layout = useCallback((): Node[] => {
|
|
2081
2067
|
const at = new Map<string, { x: number; y: number }>();
|
|
2082
|
-
for (const f of shown) { const p = board.placed?.[
|
|
2068
|
+
for (const f of shown) { const p = frameKeys(f, aliases).map(k => board.placed?.[k]).find(Boolean); if (p) at.set(frameKey(f), p); }
|
|
2083
2069
|
// a new group goes under the frames placed so far; a section someone draws never moves what is already laid out
|
|
2084
2070
|
let bottom = Math.max(-FRAME_GAP, ...[...at.values()].map((p) => p.y + FRAME_H));
|
|
2085
2071
|
const groups = [...new Set(shown.map(sectionKey))];
|
|
@@ -2097,7 +2083,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
2097
2083
|
for (const s of groups) {
|
|
2098
2084
|
const f = shown.find((x) => sectionKey(x) === s)!;
|
|
2099
2085
|
// a pack's group as people left it, under whichever id it was known by when they did (an account adopted since)
|
|
2100
|
-
const keyed = sectionKeys(f).find((k) => kept[k]) ?? s;
|
|
2086
|
+
const keyed = sectionKeys(f, aliases).find((k) => kept[k]) ?? s;
|
|
2101
2087
|
const own = kept[keyed];
|
|
2102
2088
|
if (own?.gone) continue;
|
|
2103
2089
|
const ps = shown.filter((x) => sectionKey(x) === s).map((x) => at.get(frameKey(x))!);
|
|
@@ -2105,16 +2091,16 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
2105
2091
|
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 };
|
|
2106
2092
|
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;
|
|
2107
2093
|
// a group people arranged still takes its new frames: its region reaches over the ones placed after it
|
|
2108
|
-
const unplacedOf = shown.filter((x) => sectionKey(x) === s && !board.placed?.[
|
|
2094
|
+
const unplacedOf = shown.filter((x) => sectionKey(x) === s && !frameKeys(x, aliases).some(key => board.placed?.[key])).map((x) => at.get(frameKey(x))!);
|
|
2109
2095
|
const r = own ? { ...own } : around;
|
|
2110
2096
|
if (own) for (const p of unplacedOf) { r.w = Math.max(r.w, p.x + FRAME_W + SECTION_PAD - r.x); r.h = Math.max(r.h, p.y + FRAME_H + SECTION_PAD - r.y); }
|
|
2111
|
-
sections.push(section(
|
|
2097
|
+
sections.push(section(s, r, own?.name ?? `${f.browser ? `${f.browser} · ` : ''}${vendorName(f.vendor)} · ${f.section.title}`, f.vendor, Boolean(own?.name)));
|
|
2112
2098
|
}
|
|
2113
2099
|
for (const [k, r] of Object.entries(kept)) if (k.startsWith('user:') && !r.gone) sections.push(section(k, r, r.name ?? 'Section', null, true));
|
|
2114
2100
|
// frames above the sections they sit in; a section's name tag above the board, where it can be clicked
|
|
2115
2101
|
// a frame's size given, never left to be measured again: a board laid out anew keeps showing what did not change size.
|
|
2116
2102
|
// A frame moves from anywhere on it, as a canvas app's does, until it is in use (its page then takes the pointer)
|
|
2117
|
-
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, branch: Boolean(viewing), frame: f, url: viewing ? branchFrameUrl(name, viewing, f) : frameUrl(name, f) } }));
|
|
2103
|
+
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: { aliases, world: name, branch: Boolean(viewing), frame: f, url: viewing ? branchFrameUrl(name, viewing, f) : frameUrl(name, f) } }));
|
|
2118
2104
|
return [...sections, ...frames];
|
|
2119
2105
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the frames, the board and its places are what change it
|
|
2120
2106
|
}, [JSON.stringify(shown), JSON.stringify(board), name, editable, viewing]);
|
|
@@ -2169,9 +2155,9 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
2169
2155
|
const frames = new Map(moved.filter((m) => !m.id.startsWith('sec:')).map((m) => [m.id, { x: m.x, y: m.y }]));
|
|
2170
2156
|
const sections = new Set([...(node.type === 'section' ? [(node.data as { key: string }).key] : []), ...moved.filter((m) => m.id.startsWith('sec:')).map((m) => m.id.slice(4))]);
|
|
2171
2157
|
// a group by every id its section was known by (its kept settings may be under an adopted account's earlier one)
|
|
2172
|
-
const groups = new Set(ns.filter((n) => frames.has(n.id)).
|
|
2158
|
+
const groups = new Set(ns.filter((n) => frames.has(n.id)).map((n) => sectionKey((n.data as Screen).frame)));
|
|
2173
2159
|
for (const n of ns) {
|
|
2174
|
-
if (n.type === 'screen' && sectionKeys((n.data as Screen).frame).some((k) => groups.has(k)) && !frames.has(n.id)) frames.set(n.id, { x: n.position.x, y: n.position.y });
|
|
2160
|
+
if (n.type === 'screen' && sectionKeys((n.data as Screen).frame, aliases).some((k) => groups.has(k)) && !frames.has(n.id)) frames.set(n.id, { x: n.position.x, y: n.position.y });
|
|
2175
2161
|
if (n.type === 'section' && groups.has((n.data as SectionData).key)) sections.add((n.data as SectionData).key);
|
|
2176
2162
|
}
|
|
2177
2163
|
keep(Object.fromEntries(frames), [...sections]);
|
|
@@ -2179,7 +2165,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
2179
2165
|
};
|
|
2180
2166
|
/** A pack's group's frames where they now stand: kept with its section when the section is arranged, so they stay in it. */
|
|
2181
2167
|
const groupFrames = (key: string): Record<string, { x: number; y: number }> =>
|
|
2182
|
-
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 }]));
|
|
2168
|
+
Object.fromEntries(flow.getNodes().filter((n) => n.type === 'screen' && sectionKeys((n.data as Screen).frame, aliases).includes(key)).map((n) => [n.id, { x: n.position.x, y: n.position.y }]));
|
|
2183
2169
|
// the name being edited where it is shown: a section's (its key) or this board's (`board:<id>`)
|
|
2184
2170
|
const [renaming, setRenaming] = useState<string | null>(null);
|
|
2185
2171
|
const renameSection = (key: string): void => { setPicked(`sec:${key}`); setRenaming(key); };
|
|
@@ -2231,52 +2217,17 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
2231
2217
|
};
|
|
2232
2218
|
el.addEventListener('pointermove', move); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up);
|
|
2233
2219
|
};
|
|
2234
|
-
const choosePerson = async (vendor: string, person: string | null): Promise<void> => {
|
|
2235
|
-
if (!editable || busyAs.current.has(vendor)) return;
|
|
2236
|
-
const target = nodes.find((n) => n.type === 'screen' && (n.data as Screen).frame.vendor === vendor && (n.data as Screen).frame.at && 'screen' in (n.data as Screen).frame.at!);
|
|
2237
|
-
if (!target) { toast('Open a page of this vendor on the board first', 'error'); return; }
|
|
2238
|
-
const frame = (target.data as Screen).frame;
|
|
2239
|
-
busyAs.current.add(vendor); setAskingAs([...busyAs.current]);
|
|
2240
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
2241
|
-
let settled = false;
|
|
2242
|
-
const done = (viewed?: ViewedPerson, error?: string): void => {
|
|
2243
|
-
if (settled || !viewAsMounted.current) return;
|
|
2244
|
-
settled = true;
|
|
2245
|
-
if (timer) { clearTimeout(timer); viewAsTimers.current.delete(timer); }
|
|
2246
|
-
busyAs.current.delete(vendor);
|
|
2247
|
-
setAskingAs([...busyAs.current]);
|
|
2248
|
-
setSigningAs((all) => { const next = { ...all }; delete next[vendor]; return next; });
|
|
2249
|
-
if (error) { toast(error, 'error'); return; }
|
|
2250
|
-
// Reload every frame, including one that had navigated to the sign-in page. No board arrangement is saved.
|
|
2251
|
-
setViewedAs((all) => ({ ...all, [vendor]: { label: viewed?.label ?? all[vendor]?.label ?? '', revision: (all[vendor]?.revision ?? 0) + 1 } }));
|
|
2252
|
-
};
|
|
2253
|
-
try {
|
|
2254
|
-
const land = `/${name}/${vendor}${(frame.at as { screen: string }).screen}`;
|
|
2255
|
-
const answer = await door(SESSION, `/-/${name}/twins/${vendor}/session`, { method: 'POST', body: JSON.stringify({ person, land }) });
|
|
2256
|
-
if (!answer.ok) { done(undefined, await failure(answer)); return; }
|
|
2257
|
-
const opened = await answer.json() as { url: string };
|
|
2258
|
-
if (!viewAsMounted.current) return;
|
|
2259
|
-
const pass = new URLSearchParams(new URL(opened.url).hash.slice(1)).get('pass');
|
|
2260
|
-
if (!pass) throw new Error('The World did not return a sign-in pass.');
|
|
2261
|
-
setSigningAs((all) => ({ ...all, [vendor]: { frame: target.id, url: opened.url, pass, done } }));
|
|
2262
|
-
// The pass lasts a minute; also recover if its frame was removed before it answered.
|
|
2263
|
-
timer = setTimeout(() => done(undefined, 'The sign-in did not finish. Choose View as again.'), 60_000);
|
|
2264
|
-
viewAsTimers.current.add(timer);
|
|
2265
|
-
} catch (error) { done(undefined, error instanceof Error ? error.message : 'The World did not answer.'); }
|
|
2266
|
-
};
|
|
2267
|
-
const viewAsMenu = (vendor: string | null): ViewAsMenuData | undefined => editable && token === SESSION && vendor && nodes.some((n) => n.type === 'screen' && (n.data as Screen).frame.vendor === vendor && (n.data as Screen).frame.at && 'screen' in (n.data as Screen).frame.at!)
|
|
2268
|
-
? { world: name, token, vendor, label: viewedAs[vendor]?.label, busy: askingAs.includes(vendor), choose: (person) => { void choosePerson(vendor, person); } } : undefined;
|
|
2269
2220
|
// what each node shows: in use, selected, and what can be done to it
|
|
2270
2221
|
const shownNodes = nodes.map((n) => (n.type === 'section'
|
|
2271
|
-
? { ...n, data: { ...n.data,
|
|
2272
|
-
: { ...n, data: { ...n.data,
|
|
2222
|
+
? { ...n, data: { ...n.data, grab: grab(n.id), 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]); }) } }
|
|
2223
|
+
: { ...n, data: { ...n.data, grab: grab(n.id), active: active === n.id, picked: picked === n.id, boards: editable ? others : [], addTo, remove: !editable || (board.include && !frameKeys((n.data as Screen).frame, aliases).some(k => board.include!.includes(k))) ? null : removeFrame, compare: (k: string) => { setPicked(k); setComparing(k); } } }));
|
|
2273
2224
|
// the keys a canvas app answers: Esc leaves a frame, then the selection; Delete removes what is selected; ⇧S draws a section
|
|
2274
2225
|
useEffect(() => {
|
|
2275
2226
|
const on = (e: KeyboardEvent): void => {
|
|
2276
2227
|
if ((e.target as HTMLElement | null)?.closest('input, textarea, select, [contenteditable]')) return;
|
|
2277
2228
|
if (e.key === 'Escape') { if (active) setActive(null); else setPicked(null); return; }
|
|
2278
2229
|
if (!editable || active) return;
|
|
2279
|
-
if ((e.key === 'Delete' || e.key === 'Backspace') && picked) { e.preventDefault(); if (picked.startsWith('sec:')) removeSection(picked.slice(4)); else if (!board.include || board.include
|
|
2230
|
+
if ((e.key === 'Delete' || e.key === 'Backspace') && picked) { e.preventDefault(); if (picked.startsWith('sec:')) removeSection(picked.slice(4)); else if (!board.include || all.some(f => frameKey(f) === picked && frameKeys(f, aliases).some(k => board.include!.includes(k)))) removeFrame.act(picked); else toast('It is on this board with its whole group: take the group off in the list to remove it', 'error'); return; }
|
|
2280
2231
|
if (e.key === 'S' && e.shiftKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); addSection(); }
|
|
2281
2232
|
};
|
|
2282
2233
|
document.addEventListener('keydown', on); return () => document.removeEventListener('keydown', on);
|
|
@@ -2296,8 +2247,8 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
2296
2247
|
const rename = (): void => setRenaming(`board:${board.id}`);
|
|
2297
2248
|
const drop = (): void => { const gone = board.id; setBoardId(null); void save((list) => list.filter((b) => b.id !== gone)); };
|
|
2298
2249
|
const groups = [...new Map(all.map((f) => [sectionKey(f), f])).values()];
|
|
2299
|
-
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 }))] : [];
|
|
2300
|
-
const hidden = board.include ? [] : all.filter((f) => (board.hidden ?? []).includes(
|
|
2250
|
+
const missing = board.include ? [...groups.filter((f) => !sectionKeys(f, aliases).some((k) => board.include!.includes(k))).map((f) => ({ key: sectionKey(f), label: `${f.browser ? `${f.browser} · ` : ''}${vendorName(f.vendor)} · ${f.section.title} (all)` })), ...all.filter((f) => !shown.includes(f)).map((f) => ({ key: frameKey(f), label: `${f.browser ? `${f.browser} · ` : ''}${f.title}` }))] : [];
|
|
2251
|
+
const hidden = board.include ? [] : all.filter((f) => frameKeys(f, aliases).some(k => (board.hidden ?? []).includes(k)));
|
|
2301
2252
|
// the layers as the canvas has them: each section with the frames in it, then the frames in none
|
|
2302
2253
|
const sectionNodes = nodes.filter((n) => n.type === 'section');
|
|
2303
2254
|
const frameNodes = nodes.filter((n) => n.type === 'screen');
|
|
@@ -2351,7 +2302,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
|
|
|
2351
2302
|
</ul>
|
|
2352
2303
|
{board.include && !shown.length ? <p className="mode pad-s">An empty board: add frames from Everything, or below.</p> : null}
|
|
2353
2304
|
{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}
|
|
2354
|
-
{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(
|
|
2305
|
+
{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 !== savedFrameKey(frameKey(f), b.hidden ?? [])) }))}><span>{f.browser ? `${f.browser} · ` : ''}{f.title}</span><span className="mode">Show</span></button> : <span className="pad-s">{f.browser ? `${f.browser} · ` : ''}{f.title}</span>}</li>)}</ul></details> : null}
|
|
2355
2306
|
{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}
|
|
2356
2307
|
<Pending load={loaded} what="the board" />
|
|
2357
2308
|
{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}
|