@volter/world-console 3.0.20 → 3.0.22
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 +2 -0
- package/client/console.tsx +81 -38
- package/dist/client/console.bundle.js +12 -12
- package/dist/client/console.css +2 -0
- package/dist/client/console.tsx +81 -38
- package/dist/client/main.bundle.js +98 -98
- package/package.json +3 -3
package/client/console.css
CHANGED
|
@@ -662,6 +662,8 @@ main.app.home { max-width: none; }
|
|
|
662
662
|
.compare-head { display: flex; align-items: center; gap: 8px; }
|
|
663
663
|
.compare-base { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
|
664
664
|
.compare-base select { font: inherit; max-width: 22ch; }
|
|
665
|
+
.board-viewing { margin-left: 0; }
|
|
666
|
+
.board-viewing select { min-width: 0; flex: 1; }
|
|
665
667
|
.compare-acts-head { margin: 12px 0 0; font-size: 12px; }
|
|
666
668
|
.compare-head .mode { flex: 1; }
|
|
667
669
|
.compare-sides { display: flex; gap: 16px; flex-wrap: wrap; }
|
package/client/console.tsx
CHANGED
|
@@ -1691,10 +1691,12 @@ const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160; const SECTION_
|
|
|
1691
1691
|
const FRAME_NAME_ROOM = 72;
|
|
1692
1692
|
/** A change behind a frame's count: the writes one request made, as words read them, and when. */
|
|
1693
1693
|
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'; 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' | 'branch-sites'; changes: number; /** when its first change not yet cut was made */ since?: string | null };
|
|
1695
1695
|
/** A section of a board: its region and name, or a pack's group removed from the board (`gone`). */
|
|
1696
1696
|
type SavedSection = { x: number; y: number; w: number; h: number; name?: string; gone?: true };
|
|
1697
1697
|
type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }>; sections?: Record<string, SavedSection> };
|
|
1698
|
+
type BoardBranch = { name: string; origin?: string; sites?: string; at: { instant?: string; label?: string; live?: boolean } | null; expiresAt: string | null };
|
|
1699
|
+
const BRANCH_SITES_UNSEEN = "This World shows its branches' sites only on this machine.";
|
|
1698
1700
|
type BoardDoor = { frames: BoardFrame[]; boards: SavedBoard[]; version: string };
|
|
1699
1701
|
const frameKey = (f: BoardFrame): string => `${f.vendor}:${f.id}`;
|
|
1700
1702
|
const sectionKey = (f: BoardFrame): string => `${f.vendor}:section:${f.section.id}`;
|
|
@@ -1702,13 +1704,17 @@ const sectionKey = (f: BoardFrame): string => `${f.vendor}:section:${f.section.i
|
|
|
1702
1704
|
const sectionKeys = (f: BoardFrame): string[] => [f.section.id, ...(f.section.aka ?? [])].map((id) => `${f.vendor}:section:${id}`);
|
|
1703
1705
|
/** Where a frame opens in this World: a site at its own origin, a vendor page at the twin's place, or nowhere yet. */
|
|
1704
1706
|
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
|
+
/** A vendor page on a branch always opens through its parent's read-pass door. */
|
|
1708
|
+
const branchFrameUrl = (name: string, branch: string, f: BoardFrame): string | null => f.at && 'screen' in f.at
|
|
1709
|
+
? `${WORLDS}/-/${name}/branches/${branch}/pages?${new URLSearchParams({ land: `/${branch}/${f.vendor}${f.at.screen}` }).toString()}`
|
|
1710
|
+
: frameUrl(branch, f);
|
|
1705
1711
|
/** The frames a board shows: everything but what it hides, or what it includes (a frame, or a section's every frame). */
|
|
1706
1712
|
const shownOn = (b: SavedBoard, all: BoardFrame[]): BoardFrame[] => b.include
|
|
1707
1713
|
? all.filter((f) => b.include!.includes(frameKey(f)) || sectionKeys(f).some((k) => b.include!.includes(k)))
|
|
1708
1714
|
: all.filter((f) => !(b.hidden ?? []).includes(frameKey(f)));
|
|
1709
|
-
type Screen = { world: string; 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 };
|
|
1715
|
+
type Screen = { 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 };
|
|
1710
1716
|
function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
1711
|
-
const { frame, url, active, picked, boards, addTo, remove, compare, grab } = data;
|
|
1717
|
+
const { frame, url, active, picked, boards, addTo, remove, compare, grab, branch } = data;
|
|
1712
1718
|
const key = frameKey(frame);
|
|
1713
1719
|
const others = boards.filter((b) => b.include && !b.include.includes(key) && !sectionKeys(frame).some((k) => b.include!.includes(k)));
|
|
1714
1720
|
// its name is drawn the same size at every zoom while the head above it shrinks: zoomed out past the point the name fits
|
|
@@ -1719,7 +1725,7 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
|
1719
1725
|
{/* its name, as a canvas app labels its frames; what can be done to it once it is selected */}
|
|
1720
1726
|
<NodeToolbar isVisible={roomy || picked || active} position={Position.Top} align="start" className="frame-label">
|
|
1721
1727
|
<span className="frame-name" onPointerDown={grab}><Mark vendor={frame.vendor} /><span>{frame.title}</span></span>
|
|
1722
|
-
{frame.changes ? <button type="button" className="frame-changed nodrag" onClick={() => compare(key)} title={`${counted(frame.changes, 'change')} here, not in a changeset: compare
|
|
1728
|
+
{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>
|
|
1723
1729
|
: 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}
|
|
1724
1730
|
{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}
|
|
1725
1731
|
{picked && remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
|
|
@@ -1733,7 +1739,7 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
|
1733
1739
|
<div className={active ? 'frame-body nodrag nopan nowheel' : 'frame-body'}>
|
|
1734
1740
|
{url
|
|
1735
1741
|
? <iframe title={frame.title} src={url} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
|
|
1736
|
-
: <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>}
|
|
1742
|
+
: <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>}
|
|
1737
1743
|
</div>
|
|
1738
1744
|
</div>
|
|
1739
1745
|
</>
|
|
@@ -1774,19 +1780,21 @@ function Thumb({ url, title }: { url: string; title: string }): React.JSX.Elemen
|
|
|
1774
1780
|
/** THE COMPARISON, as a canvas app's "compare changes": a frame's changes not yet cut, each in words, and the frame as it
|
|
1775
1781
|
* was the moment before the first of them beside how it is now. "Before" is a read-only copy of the World as of then (a
|
|
1776
1782
|
* branch the World makes for an hour, removed when this closes), its site read at the copy's own address. */
|
|
1777
|
-
function Compare({ name, token, frame, onClose, reads }: { name: string; token: string; frame: BoardFrame; onClose: () => void; /** the board's reads so far: its changes are read again with each */ reads: number }): React.JSX.Element {
|
|
1783
|
+
function Compare({ name, token, frame, onClose, reads, viewing = '' }: { viewing?: string; name: string; token: string; frame: BoardFrame; onClose: () => void; /** the board's reads so far: its changes are read again with each */ reads: number }): React.JSX.Element {
|
|
1778
1784
|
// what "before" is: the World as it was the moment before this frame's first change not yet cut (a copy made for the
|
|
1779
1785
|
// comparison), or any branch of the World chosen here (a pull request's preview, a named branch), as a compare view
|
|
1780
1786
|
// sets one branch beside another
|
|
1781
|
-
const [base, setBase] = useState<string>(frame.since ? 'before' : '');
|
|
1787
|
+
const [base, setBase] = useState<string>(viewing ? 'world' : frame.since ? 'before' : '');
|
|
1782
1788
|
const [before, setBefore] = useState<{ state: 'opening' } | { state: 'ok'; url: string; at?: string } | { state: 'none'; why: string }>({ state: 'opening' });
|
|
1783
1789
|
const [made, setMade] = useState<string | null>(null);
|
|
1784
1790
|
// the copies this panel made, kept out of the choices after they are gone (the list is read again only every so often)
|
|
1785
1791
|
const mine = useRef(new Set<string>());
|
|
1786
|
-
const branches = useDoor<{ branches:
|
|
1787
|
-
const choices = branches.state === 'ok' ? branches.data.branches.filter((b) => b.name !== made && !mine.current.has(b.name)) : [];
|
|
1792
|
+
const branches = useDoor<{ branches: BoardBranch[] }>(token, `/-/${name}/branches`, reads);
|
|
1793
|
+
const choices = branches.state === 'ok' ? branches.data.branches.filter((b) => b.name !== viewing && b.name !== made && !mine.current.has(b.name)) : [];
|
|
1794
|
+
const parent = useDoor<BoardDoor>(token, viewing ? `/-/${name}/board` : null, reads);
|
|
1795
|
+
const parentFrame = parent.state === 'ok' ? parent.data.frames.find((f) => frameKey(f) === frameKey(frame)) : undefined;
|
|
1788
1796
|
// its changes, read for this comparison alone
|
|
1789
|
-
const listed = useDoor<{ total: number; acts: BoardAct[] }>(token, `/-/${name}
|
|
1797
|
+
const listed = useDoor<{ total: number; acts: BoardAct[] }>(token, `/-/${name}/${viewing ? `branches/${viewing}/` : ''}board/changes?frame=${encodeURIComponent(frameKey(frame))}`, reads);
|
|
1790
1798
|
// a branch's page beside this World's: a site at the branch's own site address, a vendor's page on the branch's own
|
|
1791
1799
|
// content plane through this World's branch door (a one-time read pass), never running on this World's origin
|
|
1792
1800
|
const pageOf = async (branch: string, sites: string | null, isClosed: () => boolean): Promise<{ url: string } | { why: string }> => {
|
|
@@ -1796,7 +1804,7 @@ function Compare({ name, token, frame, onClose, reads }: { name: string; token:
|
|
|
1796
1804
|
// private to its people and open through the World's own address
|
|
1797
1805
|
const u = new URL(frame.url);
|
|
1798
1806
|
const nested = sites && /^https?:\/\/\{host\}\./.test(sites) ? sites.replace('{host}', u.hostname.toLowerCase()) : null;
|
|
1799
|
-
return nested ? { url: `${nested}${u.pathname}${u.search}` } : { why:
|
|
1807
|
+
return nested ? { url: `${nested}${u.pathname}${u.search}` } : { why: BRANCH_SITES_UNSEEN };
|
|
1800
1808
|
}
|
|
1801
1809
|
const beside = `${WORLDS}/-/${name}/branches/${branch}/pages?${new URLSearchParams({ land: `/${branch}/${frame.vendor}${shown.screen}` }).toString()}`;
|
|
1802
1810
|
// the door answers a navigation with the branch's address; a host that shows no content plane says so instead
|
|
@@ -1805,7 +1813,12 @@ function Compare({ name, token, frame, onClose, reads }: { name: string; token:
|
|
|
1805
1813
|
return asked?.type === 'opaqueredirect' ? { url: beside } : { why: `This World does not show its branches' pages${asked ? `: ${await failure(asked)}` : ''}.` };
|
|
1806
1814
|
};
|
|
1807
1815
|
useEffect(() => {
|
|
1808
|
-
if (
|
|
1816
|
+
if (base === 'world') {
|
|
1817
|
+
if (parent.state === 'loading') { setBefore({ state: 'opening' }); return; }
|
|
1818
|
+
const url = parentFrame ? frameUrl(name, parentFrame) : null;
|
|
1819
|
+
setBefore(url ? { state: 'ok', url } : { state: 'none', why: parent.state === 'error' ? parent.error : 'The World has no page here to show.' }); return;
|
|
1820
|
+
}
|
|
1821
|
+
if (!frame.at) { setBefore({ state: 'none', why: frame.unseen === 'branch-sites' ? BRANCH_SITES_UNSEEN : `${vendorName(frame.vendor)}'s twin draws no page for this ${frame.kind} yet; its changes are listed below.` }); return; }
|
|
1809
1822
|
if (base === '') { setBefore({ state: 'none', why: 'Choose a branch of this World to set beside it.' }); return; }
|
|
1810
1823
|
let closed = false;
|
|
1811
1824
|
if (base !== 'before') {
|
|
@@ -1835,20 +1848,21 @@ function Compare({ name, token, frame, onClose, reads }: { name: string; token:
|
|
|
1835
1848
|
const left = (e: PageTransitionEvent): void => { if (!e.persisted) drop(); };
|
|
1836
1849
|
window.addEventListener('pagehide', left);
|
|
1837
1850
|
return () => { closed = true; clearTimeout(wait); window.removeEventListener('pagehide', left); drop(); setMade(null); };
|
|
1838
|
-
}, [name, token, frame.vendor, frame.id, frame.since, base]);
|
|
1839
|
-
const now = frameUrl(name, frame);
|
|
1851
|
+
}, [name, token, frame.vendor, frame.id, frame.since, base, parent.state, JSON.stringify(parentFrame?.at)]);
|
|
1852
|
+
const now = viewing ? branchFrameUrl(name, viewing, frame) : frameUrl(name, frame);
|
|
1840
1853
|
// nothing to choose: said, rather than an empty list
|
|
1841
1854
|
const noBranches = !frame.at || base !== '' ? null : branches.state === 'ok' && choices.length === 0 ? 'This World has no branches to set beside it: make one under Branches.' : branches.state === 'error' ? `The World's branches could not be read: ${branches.error}` : null;
|
|
1842
1855
|
return (
|
|
1843
1856
|
<div className="compare" role="dialog" aria-label={`Changes to ${frame.title}`}>
|
|
1844
1857
|
<div className="compare-head">
|
|
1845
1858
|
<Mark vendor={frame.vendor} /><strong>{frame.title}</strong>
|
|
1846
|
-
<span className="mode">{frame.changes ? `${counted(frame.changes, 'change')} not yet cut${frame.since ? `, since ${when(frame.since)}` : ''}` : 'No changes here not yet cut'}</span>
|
|
1859
|
+
<span className="mode">{frame.changes ? `${counted(frame.changes, 'change')} ${viewing ? 'on this branch' : 'not yet cut'}${frame.since ? `, since ${when(frame.since)}` : ''}` : viewing ? 'No changes here on this branch' : 'No changes here not yet cut'}</span>
|
|
1847
1860
|
{frame.at ? <label className="compare-base">Compare with{' '}
|
|
1848
1861
|
<select value={base} onChange={(e) => setBase(e.target.value)}>
|
|
1849
1862
|
{base === '' ? <option value="">Choose a branch…</option> : null}
|
|
1850
|
-
{
|
|
1851
|
-
{
|
|
1863
|
+
{viewing ? <option value="world">The World</option> : null}
|
|
1864
|
+
{!viewing && (frame.since || base === 'before') ? <option value="before">Before these changes</option> : null}
|
|
1865
|
+
{base && base !== 'before' && base !== 'world' && !choices.some((b) => b.name === base) ? <option value={base}>{base.split('/')[1]}</option> : null}
|
|
1852
1866
|
{choices.map((b) => <option key={b.name} value={b.name}>{b.name.split('/')[1]}{b.at?.label ? ` (${b.at.label})` : b.at?.instant ? ` (as of ${when(b.at.instant)})` : b.at?.live ? ' (live)' : ''}</option>)}
|
|
1853
1867
|
</select>
|
|
1854
1868
|
</label> : null}
|
|
@@ -1856,16 +1870,16 @@ function Compare({ name, token, frame, onClose, reads }: { name: string; token:
|
|
|
1856
1870
|
</div>
|
|
1857
1871
|
<div className="compare-sides">
|
|
1858
1872
|
<figure>
|
|
1859
|
-
<figcaption>{base === 'before' ? `Before${before.state === 'ok' && before.at ? ` · as of ${when(before.at)}` : ''}` : base ? base.split('/')[1] : 'A branch'}</figcaption>
|
|
1873
|
+
<figcaption>{base === 'before' ? `Before${before.state === 'ok' && before.at ? ` · as of ${when(before.at)}` : ''}` : base === 'world' ? 'The World' : base ? base.split('/')[1] : 'A branch'}</figcaption>
|
|
1860
1874
|
{noBranches ? <p className="mode compare-note">{noBranches}</p> : before.state === 'ok' ? <Thumb url={before.url} title={`${frame.title} before`} /> : before.state === 'opening' ? <p className="mode compare-note" role="status">Opening the World as it was…</p> : <p className="mode compare-note">{before.why}</p>}
|
|
1861
1875
|
</figure>
|
|
1862
1876
|
<figure>
|
|
1863
|
-
<figcaption>Now</figcaption>
|
|
1864
|
-
{now ? <Thumb url={now} title={`${frame.title} now`} /> : <p className="mode compare-note">{vendorName(frame.vendor)}'s twin draws no page for this {frame.kind} yet
|
|
1877
|
+
<figcaption>{viewing ? `${viewing.split('/')[1]} · branch` : 'Now'}</figcaption>
|
|
1878
|
+
{now ? <Thumb url={now} title={`${frame.title} now`} /> : <p className="mode compare-note">{frame.unseen === 'branch-sites' ? BRANCH_SITES_UNSEEN : `${vendorName(frame.vendor)}'s twin draws no page for this ${frame.kind} yet.`}</p>}
|
|
1865
1879
|
</figure>
|
|
1866
1880
|
</div>
|
|
1867
1881
|
{listed.state === 'error' ? <p role="alert" className="mode">Its changes could not be read: {listed.error}</p> : null}
|
|
1868
|
-
{base !== 'before' && base && listed.state === 'ok' && listed.data.acts.length ? <p className="mode compare-acts-head">This World's own changes here, not yet cut
|
|
1882
|
+
{base !== 'before' && base && listed.state === 'ok' && listed.data.acts.length ? <p className="mode compare-acts-head">{viewing ? 'This branch’s changes here:' : "This World's own changes here, not yet cut:"}</p> : null}
|
|
1869
1883
|
<ol className="compare-acts">
|
|
1870
1884
|
{(listed.state === 'ok' ? listed.data.acts : []).map((a) => {
|
|
1871
1885
|
const first = a.entries[0]!; const words = said({ ...first, service: frame.vendor, id: '', occurredAt: a.at } as unknown as Entry);
|
|
@@ -1880,17 +1894,28 @@ function Compare({ name, token, frame, onClose, reads }: { name: string; token:
|
|
|
1880
1894
|
})}
|
|
1881
1895
|
</ol>
|
|
1882
1896
|
{listed.state === 'ok' && listed.data.total > listed.data.acts.length ? <p className="mode">and {counted(listed.data.total - listed.data.acts.length, 'earlier change')}, in Changes</p> : null}
|
|
1883
|
-
<a className="compare-all" href={`${BASE}/${name}/~/changes`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changes']); }}>Every change in the World →</a>
|
|
1897
|
+
{!viewing ? <a className="compare-all" href={`${BASE}/${name}/~/changes`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changes']); }}>Every change in the World →</a> : null}
|
|
1884
1898
|
</div>
|
|
1885
1899
|
);
|
|
1886
1900
|
}
|
|
1887
1901
|
const FRAME_TYPES = { screen: ScreenFrame, section: SectionBox };
|
|
1888
1902
|
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 {
|
|
1889
|
-
|
|
1903
|
+
const [viewing, setViewing] = useState(() => new URLSearchParams(window.location.search).get('viewing') ?? '');
|
|
1904
|
+
useEffect(() => {
|
|
1905
|
+
const on = (): void => setViewing(new URLSearchParams(window.location.search).get('viewing') ?? '');
|
|
1906
|
+
window.addEventListener('popstate', on); return () => window.removeEventListener('popstate', on);
|
|
1907
|
+
}, []);
|
|
1908
|
+
const view = (branch: string): void => {
|
|
1909
|
+
const url = new URL(window.location.href);
|
|
1910
|
+
if (branch) url.searchParams.set('viewing', branch); else url.searchParams.delete('viewing');
|
|
1911
|
+
window.history.pushState(null, '', url);
|
|
1912
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
1913
|
+
};
|
|
1914
|
+
return <ReactFlowProvider key={`${props.name}:${viewing}`}><CanvasSurface {...props} viewing={viewing} view={view} /></ReactFlowProvider>;
|
|
1890
1915
|
}
|
|
1891
1916
|
/** 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
|
|
1892
1917
|
* the top left (a canvas app's pages and layers), zoom at the bottom left, the minimap at the bottom right. */
|
|
1893
|
-
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 {
|
|
1918
|
+
function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite, viewing, view }: { viewing: string; view: (branch: string) => void; name: string; token: string; twins: Load<{ twins: Twin[] }>; session: ViewSession; retry: () => void; snapshot: Snapshot | null; back: () => void; canWrite: boolean }): React.JSX.Element {
|
|
1894
1919
|
// 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.
|
|
1895
1920
|
// Edits are saved one after another, each made from the version the last save left; one the World refuses (another
|
|
1896
1921
|
// person saved first) is dropped and the World's boards are shown, said in a toast
|
|
@@ -1905,28 +1930,37 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1905
1930
|
// the last read that did not answer, said beside the board it leaves on screen
|
|
1906
1931
|
const [stale, setStale] = useState<string | null>(null);
|
|
1907
1932
|
const [reads, setReads] = useState(0);
|
|
1933
|
+
const branches = useDoor<{ branches: BoardBranch[] }>(token, `/-/${name}/branches`, reads);
|
|
1934
|
+
const branch = branches.state === 'ok' ? branches.data.branches.find((b) => b.name === viewing) : undefined;
|
|
1908
1935
|
// reads in the order they went out: an earlier one answering after a later one is not taken
|
|
1909
1936
|
const sent = useRef(0); const taken = useRef(0);
|
|
1910
1937
|
/** The World's board read again (its frames always, its boards when no save moved since the read went out). */
|
|
1911
1938
|
const readBoard = useCallback(async (): Promise<void> => {
|
|
1912
1939
|
const at = generation.current; const mine = ++sent.current;
|
|
1913
|
-
const
|
|
1940
|
+
const path = viewing ? `branches/${viewing.split('/').map(encodeURIComponent).join('/')}/board` : 'board';
|
|
1941
|
+
const [r, parent] = await Promise.all([
|
|
1942
|
+
door(token, `/-/${name}/${path}`, { signal: AbortSignal.timeout(15000) }).catch(() => null),
|
|
1943
|
+
viewing ? door(token, `/-/${name}/board`, { signal: AbortSignal.timeout(15000) }).catch(() => null) : Promise.resolve(null),
|
|
1944
|
+
]);
|
|
1914
1945
|
const data = r?.ok ? ((await r.json().catch(() => null)) as BoardDoor | null) : null;
|
|
1946
|
+
const arrangement = viewing ? parent?.ok ? ((await parent.json().catch(() => null)) as BoardDoor | null) : null : data;
|
|
1947
|
+
if (data && arrangement) { data.boards = arrangement.boards; data.version = arrangement.version; }
|
|
1915
1948
|
if (mine < taken.current) return;
|
|
1916
1949
|
taken.current = mine;
|
|
1917
|
-
if (!data?.frames || !
|
|
1918
|
-
const
|
|
1919
|
-
|
|
1950
|
+
if (!data?.frames || !arrangement?.boards || !arrangement.version) {
|
|
1951
|
+
const failed = !r?.ok ? r : parent;
|
|
1952
|
+
const error = failed ? `the World answered ${failed.status}` : 'the World did not answer';
|
|
1953
|
+
setLoaded((l) => (l.state === 'ok' ? l : { state: 'error', status: failed?.status ?? 0, error }));
|
|
1920
1954
|
setStale(error);
|
|
1921
1955
|
return;
|
|
1922
1956
|
}
|
|
1923
1957
|
setStale(null); setReads((n) => n + 1);
|
|
1924
1958
|
if (at === generation.current && pending.current === 0) held.current = { boards: data.boards, version: data.version };
|
|
1925
1959
|
setLoaded({ state: 'ok', data });
|
|
1926
|
-
}, [token, name]);
|
|
1960
|
+
}, [token, name, viewing]);
|
|
1927
1961
|
// read now and every ten seconds while the page is shown: another person's arrangement arrives as the World's changes do
|
|
1928
1962
|
useEffect(() => { void readBoard(); const id = setInterval(() => { if (!document.hidden) void readBoard(); }, 10000); return () => clearInterval(id); }, [readBoard]);
|
|
1929
|
-
const all = loaded.state === 'ok' ? loaded.data.frames : [];
|
|
1963
|
+
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) : [];
|
|
1930
1964
|
const boards = held.current?.boards ?? [{ id: 'everything', name: 'Everything' }];
|
|
1931
1965
|
// what is shown (architecture, "The board"): the pages whose records changed, everything, or a board people keep.
|
|
1932
1966
|
// Changes is no board of its own: it is the first board's places and sections, holding only the pages that changed
|
|
@@ -1937,9 +1971,10 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1937
1971
|
const board = (lens === 'changes' ? boards[0] : boards.find((b) => b.id === lens)) ?? boards[0]!;
|
|
1938
1972
|
// the pages opened from the ones they sit under (Everything shows a vendor's top pages; a page's own open from it)
|
|
1939
1973
|
const [opened, setOpened] = useState<Set<string>>(new Set());
|
|
1940
|
-
const editable = canWrite && !snapshot;
|
|
1974
|
+
const editable = canWrite && !snapshot && !viewing;
|
|
1941
1975
|
/** An edit of the boards: shown at once, saved after the edits before it, made from the boards as they then stand. */
|
|
1942
1976
|
const save = useCallback((edit: (all: SavedBoard[]) => SavedBoard[]): Promise<void> => {
|
|
1977
|
+
if (!editable) return Promise.resolve();
|
|
1943
1978
|
if (!held.current) { toast('The board is being read again: make that change once it shows', 'error'); return Promise.resolve(); }
|
|
1944
1979
|
held.current = { ...held.current, boards: edit(held.current.boards) };
|
|
1945
1980
|
pending.current += 1; generation.current += 1; rerender((n) => n + 1);
|
|
@@ -1970,7 +2005,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1970
2005
|
});
|
|
1971
2006
|
queue.current = run;
|
|
1972
2007
|
return run;
|
|
1973
|
-
}, [token, name, readBoard]);
|
|
2008
|
+
}, [token, name, readBoard, editable]);
|
|
1974
2009
|
const change = (id: string, edit: (b: SavedBoard) => SavedBoard): void => { void save((list) => list.map((b) => (b.id === id ? edit(b) : b))); };
|
|
1975
2010
|
const parentKey = (f: BoardFrame): string | null => (f.parent ? `${f.vendor}:${f.parent}` : null);
|
|
1976
2011
|
const keys = new Set(all.map(frameKey));
|
|
@@ -2037,10 +2072,10 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
2037
2072
|
// frames above the sections they sit in; a section's name tag above the board, where it can be clicked
|
|
2038
2073
|
// a frame's size given, never left to be measured again: a board laid out anew keeps showing what did not change size.
|
|
2039
2074
|
// 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) } }));
|
|
2075
|
+
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) } }));
|
|
2041
2076
|
return [...sections, ...frames];
|
|
2042
2077
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the frames, the board and its places are what change it
|
|
2043
|
-
}, [JSON.stringify(shown), JSON.stringify(board), name, editable]);
|
|
2078
|
+
}, [JSON.stringify(shown), JSON.stringify(board), name, editable, viewing]);
|
|
2044
2079
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
|
2045
2080
|
// 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
2081
|
const zoom = useStore((st) => st.transform[2]);
|
|
@@ -2192,7 +2227,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
2192
2227
|
const inSection = new Map(frameNodes.map((n) => [n.id, sectionOf(n, nodes)?.id ?? null]));
|
|
2193
2228
|
// how many pages sit under each page, opened from it in Everything
|
|
2194
2229
|
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); }
|
|
2195
|
-
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')}
|
|
2230
|
+
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')} ${viewing ? 'on this branch' : 'not in a changeset'}: its mark on the board opens a comparison`}><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>; };
|
|
2196
2231
|
const dark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
2197
2232
|
const ready = session.state === 'ok' && loaded.state === 'ok';
|
|
2198
2233
|
return (
|
|
@@ -2209,10 +2244,18 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
2209
2244
|
<Panel position="top-left" className="float layers">
|
|
2210
2245
|
<div className="float-head">
|
|
2211
2246
|
<button type="button" className="back" onClick={back} aria-label={`Back to ${name}`}>←</button>
|
|
2212
|
-
<span className="float-title"><strong>{name.split('/')[1]}</strong><span className="mode">{name.split('/')[0]}{snapshot ? ' · snapshot' : ''}</span></span>
|
|
2247
|
+
<span className="float-title"><strong>{(viewing || name).split('/')[1]}</strong><span className="mode">{name.split('/')[0]}{viewing ? ' · branch' : snapshot ? ' · snapshot' : ''}</span></span>
|
|
2213
2248
|
</div>
|
|
2249
|
+
<label className="compare-base board-viewing">Viewing
|
|
2250
|
+
<select value={viewing} disabled={pending.current > 0} onChange={(e) => view(e.target.value)}>
|
|
2251
|
+
<option value="">The World</option>
|
|
2252
|
+
{viewing && !branch ? <option value={viewing}>{viewing} · branch</option> : null}
|
|
2253
|
+
{(branches.state === 'ok' ? branches.data.branches : []).map((b) => <option key={b.name} value={b.name}>{b.name.split('/')[1]} · branch</option>)}
|
|
2254
|
+
</select>
|
|
2255
|
+
</label>
|
|
2256
|
+
{branches.state === 'error' && branches.status !== 404 ? <p className="mode" role="alert">Branches could not be read: {branches.error}</p> : null}
|
|
2214
2257
|
<div className="board-tabs" role="tablist" aria-label="Boards">
|
|
2215
|
-
<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>
|
|
2258
|
+
<button type="button" role="tab" aria-selected={lens === 'changes'} className={lens === 'changes' ? 'on' : undefined} onClick={() => { setPicked(null); setActive(null); setBoardId('changes'); }} title={viewing ? "The pages whose records changed on this branch" : "The pages whose records changed and are not yet in a changeset"}>Changes{anyChanged ? ` (${all.filter((f) => f.changes).length})` : ''}</button>
|
|
2216
2259
|
{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>))}
|
|
2217
2260
|
{editable ? <button type="button" className="ghost" onClick={addBoard} aria-label="A new board">+</button> : null}
|
|
2218
2261
|
</div>
|
|
@@ -2243,7 +2286,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
2243
2286
|
: <Panel position="top-center" className="float hint mode">Scroll to pan · pinch or ⌘ scroll to zoom · double-click a frame to use it</Panel>}
|
|
2244
2287
|
{/* the board's own tools, along the bottom as a canvas app keeps them */}
|
|
2245
2288
|
{editable ? <Panel position="bottom-center" className="float board-tools"><button type="button" className="ghost" onClick={addSection} title="Section (⇧S): around the selected frame, else in the middle"><Icon name="layout-grid" size={16} /><span>Section</span><kbd>⇧S</kbd></button></Panel> : null}
|
|
2246
|
-
{comparing && shown.some((f) => frameKey(f) === comparing) ? <Panel position="top-right" className="float compare-panel"><Compare key={comparing} name={name} token={token} frame={shown.find((f) => frameKey(f) === comparing)!} onClose={() => setComparing(null)} reads={reads} /></Panel> : null}
|
|
2289
|
+
{comparing && shown.some((f) => frameKey(f) === comparing) ? <Panel position="top-right" className="float compare-panel"><Compare key={comparing} viewing={viewing} name={name} token={token} frame={shown.find((f) => frameKey(f) === comparing)!} onClose={() => setComparing(null)} reads={reads} /></Panel> : null}
|
|
2247
2290
|
<Controls showInteractive={false} position="bottom-left" />
|
|
2248
2291
|
<MiniMap pannable zoomable nodeBorderRadius={8} position="bottom-right" />
|
|
2249
2292
|
</ReactFlow>
|