@volter/world-console 3.0.5 → 3.0.6
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 +34 -7
- package/client/console.tsx +277 -66
- package/dist/client/console.bundle.js +13 -13
- package/dist/client/console.css +34 -7
- package/dist/client/console.tsx +277 -66
- package/dist/client/main.bundle.js +94 -94
- package/package.json +3 -3
package/dist/client/console.css
CHANGED
|
@@ -610,11 +610,19 @@ main.app.home { max-width: none; }
|
|
|
610
610
|
.ws-rule-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
|
|
611
611
|
.ws-rule-row select { width: auto; }
|
|
612
612
|
.ws-rules fieldset.choice { border: 0; padding: 0; margin: 4px 0; display: grid; gap: 6px; }
|
|
613
|
-
/* THE BOARD: sections
|
|
614
|
-
|
|
615
|
-
.section
|
|
616
|
-
.section-
|
|
617
|
-
.section-
|
|
613
|
+
/* THE BOARD: sections are regions with a name, as a canvas app's are: a quiet fill behind the frames, the name a small tag
|
|
614
|
+
above the top left that keeps its size at every zoom; a selected frame or section is outlined in the accent */
|
|
615
|
+
.canvas .react-flow__node-section { padding: 0; border: 0; background: none; }
|
|
616
|
+
.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
|
+
.section-box.on { outline: 2px solid var(--accent); outline-offset: 0; }
|
|
618
|
+
.section-tag { display: flex; align-items: center; gap: 2px; }
|
|
619
|
+
.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
|
+
.section-tag button.on { background: var(--accent); color: var(--surface); }
|
|
621
|
+
.section-tag .mark { width: 14px; height: 14px; }
|
|
622
|
+
.frame.picked { outline: 4px solid var(--accent-soft); outline-offset: 3px; }
|
|
623
|
+
.float.board-tools { display: flex; gap: 4px; padding: 4px; }
|
|
624
|
+
.float.board-tools button { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 13px; }
|
|
625
|
+
.float.board-tools kbd { font: 11px var(--mono); color: var(--muted); }
|
|
618
626
|
.frame-none { height: 100%; display: grid; place-content: center; text-align: center; gap: 8px; padding: 40px; font-size: 24px; color: var(--muted); background: repeating-linear-gradient(135deg, var(--surface-2) 0 18px, var(--surface) 18px 36px); }
|
|
619
627
|
.frame-none p { margin: 0; }
|
|
620
628
|
.frame-label select, .frame-label button { font-size: 12px; padding: 1px 6px; }
|
|
@@ -623,10 +631,29 @@ main.app.home { max-width: none; }
|
|
|
623
631
|
.board-tabs button.on { background: var(--accent-soft); border-color: var(--accent); }
|
|
624
632
|
.board-acts { display: flex; gap: 4px; margin-top: 4px; }
|
|
625
633
|
.board-acts button { font-size: 12px; padding: 2px 6px; }
|
|
626
|
-
.layer-section {
|
|
627
|
-
.layer-
|
|
634
|
+
.layer-list button.layer-section { font-size: 12px; font-weight: 600; }
|
|
635
|
+
.layer-list .mark { width: 16px; height: 16px; }
|
|
628
636
|
.layer-list.inner { border-top: 0; margin: 0; padding: 0 0 0 14px; }
|
|
629
637
|
.board-add { width: 100%; margin-top: 8px; font-size: 12.5px; }
|
|
630
638
|
.board-hidden { margin-top: 8px; font-size: 12.5px; }
|
|
631
639
|
.board-elsewhere { width: 100%; margin-top: 8px; display: flex; align-items: center; gap: 6px; font-size: 12.5px; border: 1px solid var(--line); background: transparent; border-radius: 8px; padding: 4px 8px; }
|
|
632
640
|
.pad-s { padding: 6px 8px; margin: 0; font-size: 12.5px; }
|
|
641
|
+
.float.hint.using { display: flex; align-items: center; gap: 10px; pointer-events: auto; padding: 4px 6px 4px 12px; }
|
|
642
|
+
.float.hint.using button { padding: 2px 10px; font-size: 12px; }
|
|
643
|
+
.float.hint.using kbd { font: 11px var(--mono); color: var(--muted); }
|
|
644
|
+
/* THE COMPARISON: before and now side by side over the board's top right, the changes in words under them */
|
|
645
|
+
.float.compare-panel { max-height: calc(100vh - 32px); overflow-y: auto; padding: 0; }
|
|
646
|
+
.compare { display: grid; gap: 12px; padding: 12px 14px; width: 900px; max-width: calc(100vw - 320px); }
|
|
647
|
+
.compare-head { display: flex; align-items: center; gap: 8px; }
|
|
648
|
+
.compare-head .mode { flex: 1; }
|
|
649
|
+
.compare-sides { display: flex; gap: 16px; flex-wrap: wrap; }
|
|
650
|
+
.compare-sides figure { margin: 0; display: grid; gap: 6px; }
|
|
651
|
+
.compare-sides figcaption { font-size: 12px; font-weight: 600; color: var(--muted); }
|
|
652
|
+
.compare-thumb { position: relative; overflow: hidden; border: 1px solid var(--line); border-radius: 6px; background: var(--ground); }
|
|
653
|
+
.compare-thumb iframe { position: absolute; top: 0; left: 0; border: 0; transform-origin: 0 0; pointer-events: none; }
|
|
654
|
+
.compare-note { width: 420px; height: 269px; margin: 0; display: grid; place-content: center; text-align: center; padding: 16px; border: 1px dashed var(--line-strong); border-radius: 6px; font-size: 13px; }
|
|
655
|
+
.compare-acts { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 13px; }
|
|
656
|
+
.compare-acts > li { display: grid; grid-template-columns: 120px 1fr; gap: 8px; align-items: baseline; }
|
|
657
|
+
.compare-acts ul { margin: 4px 0 0; padding-left: 16px; }
|
|
658
|
+
.compare-all { font-size: 13px; }
|
|
659
|
+
.frame-label button.frame-changed { display: inline-flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 0 4px; color: inherit; }
|
package/dist/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, 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, 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';
|
|
@@ -1680,8 +1680,12 @@ type ViewSession = { state: 'pending' } | { state: 'ok'; scope: string } | { sta
|
|
|
1680
1680
|
* come. Moving, hiding and including are kept by the World (`PUT /-/<world>/board`), for everyone who opens it. Pan by
|
|
1681
1681
|
* dragging the background, move a frame by its title bar, click a frame to use it and the background or Esc to leave. */
|
|
1682
1682
|
const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160; const SECTION_PAD = 72; const SECTION_HEAD = 72;
|
|
1683
|
-
|
|
1684
|
-
type
|
|
1683
|
+
/** A change behind a frame's count: the writes one request made, as words read them, and when. */
|
|
1684
|
+
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 };
|
|
1686
|
+
/** A section of a board: its region and name, or a pack's group removed from the board (`gone`). */
|
|
1687
|
+
type SavedSection = { x: number; y: number; w: number; h: number; name?: string; gone?: true };
|
|
1688
|
+
type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }>; sections?: Record<string, SavedSection> };
|
|
1685
1689
|
type BoardDoor = { frames: BoardFrame[]; boards: SavedBoard[]; version: string };
|
|
1686
1690
|
const frameKey = (f: BoardFrame): string => `${f.vendor}:${f.id}`;
|
|
1687
1691
|
const sectionKey = (f: BoardFrame): string => `${f.vendor}:section:${f.section.id}`;
|
|
@@ -1693,22 +1697,23 @@ const frameUrl = (name: string, f: BoardFrame): string | null => (f.at && 'site'
|
|
|
1693
1697
|
const shownOn = (b: SavedBoard, all: BoardFrame[]): BoardFrame[] => b.include
|
|
1694
1698
|
? all.filter((f) => b.include!.includes(frameKey(f)) || sectionKeys(f).some((k) => b.include!.includes(k)))
|
|
1695
1699
|
: all.filter((f) => !(b.hidden ?? []).includes(frameKey(f)));
|
|
1696
|
-
type Screen = { world: string; frame: BoardFrame; url: string | null; active: boolean; boards: SavedBoard[]; addTo: (board: string, key: string) => void; remove: { label: string; act: (key: string) => void } | null };
|
|
1700
|
+
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 };
|
|
1697
1701
|
function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
1698
|
-
const {
|
|
1702
|
+
const { frame, url, active, picked, boards, addTo, remove, compare } = data;
|
|
1699
1703
|
const key = frameKey(frame);
|
|
1700
1704
|
const others = boards.filter((b) => b.include && !b.include.includes(key) && !sectionKeys(frame).some((k) => b.include!.includes(k)));
|
|
1701
1705
|
return (
|
|
1702
1706
|
<>
|
|
1707
|
+
{/* its name always, as a canvas app labels its frames; what can be done to it once it is selected */}
|
|
1703
1708
|
<NodeToolbar isVisible position={Position.Top} align="start" className="frame-label">
|
|
1704
|
-
<Mark vendor={frame.vendor} /><span>{
|
|
1705
|
-
{frame.changes ? <
|
|
1706
|
-
{others.length ? <select className="nodrag frame-add" value="" aria-label={`Add ${frame.title} to a board`} onChange={(e) => { if (e.target.value) addTo(e.target.value, key); }}><option value="">Add to board…</option>{others.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}</select> : null}
|
|
1707
|
-
{remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
|
|
1709
|
+
<Mark vendor={frame.vendor} /><span>{frame.title}</span>
|
|
1710
|
+
{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> : null}
|
|
1711
|
+
{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}
|
|
1712
|
+
{picked && remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
|
|
1708
1713
|
</NodeToolbar>
|
|
1709
|
-
<div className={active ? 'frame on' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
|
|
1710
|
-
<div className="frame-bar" title="Drag to move">
|
|
1711
|
-
<Mark vendor={frame.vendor} /><strong>{frame.
|
|
1714
|
+
<div className={active ? 'frame on' : picked ? 'frame picked' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
|
|
1715
|
+
<div className="frame-bar" title="Drag to move · double-click to use">
|
|
1716
|
+
<Mark vendor={frame.vendor} /><strong>{vendorName(frame.vendor)} · {frame.kind}</strong>
|
|
1712
1717
|
<span className="frame-url">{frame.url.replace(/^https?:\/\//, '')}</span>
|
|
1713
1718
|
{url ? <a className="nodrag" href={url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${frame.title} in a new tab`}>↗</a> : null}
|
|
1714
1719
|
</div>
|
|
@@ -1721,9 +1726,90 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
|
1721
1726
|
</>
|
|
1722
1727
|
);
|
|
1723
1728
|
}
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1729
|
+
/** 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
|
|
1730
|
+
* same size at every zoom; selected, it can be resized from its edges, renamed (double-click the name) or removed. */
|
|
1731
|
+
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 };
|
|
1732
|
+
function SectionBox({ data }: NodeProps<Node<SectionData>>): React.JSX.Element {
|
|
1733
|
+
return (
|
|
1734
|
+
<>
|
|
1735
|
+
<NodeResizer isVisible={data.picked && data.editable} minWidth={480} minHeight={360} color="var(--accent)" onResizeEnd={data.resized} />
|
|
1736
|
+
<NodeToolbar isVisible position={Position.Top} align="start" offset={6} className="section-tag">
|
|
1737
|
+
<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}>
|
|
1738
|
+
{data.vendor ? <Mark vendor={data.vendor} /> : null}<span>{data.name}</span>
|
|
1739
|
+
</button>
|
|
1740
|
+
{data.picked && data.editable ? <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}
|
|
1741
|
+
</NodeToolbar>
|
|
1742
|
+
<div className={data.picked ? 'section-box on' : 'section-box'} />
|
|
1743
|
+
</>
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
/** A frame's page at a desktop's size, shown smaller: what it looks like, not a place to use it. */
|
|
1747
|
+
function Thumb({ url, title }: { url: string; title: string }): React.JSX.Element {
|
|
1748
|
+
const width = 420;
|
|
1749
|
+
return <div className="compare-thumb" style={{ width, height: (width * FRAME_H) / FRAME_W }}><iframe title={title} src={url} loading="lazy" style={{ width: FRAME_W, height: FRAME_H, transform: `scale(${width / FRAME_W})` }} /></div>;
|
|
1750
|
+
}
|
|
1751
|
+
/** THE COMPARISON, as a canvas app's "compare changes": a frame's changes not yet cut, each in words, and the frame as it
|
|
1752
|
+
* 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
|
|
1753
|
+
* branch the World makes for an hour, removed when this closes), its site read at the copy's own address. */
|
|
1754
|
+
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 {
|
|
1755
|
+
const [before, setBefore] = useState<{ state: 'opening' } | { state: 'ok'; url: string; at: string } | { state: 'none'; why: string }>({ state: 'opening' });
|
|
1756
|
+
// its changes, read for this comparison alone
|
|
1757
|
+
const listed = useDoor<{ total: number; acts: BoardAct[] }>(token, `/-/${name}/board/changes?frame=${encodeURIComponent(frameKey(frame))}`, reads);
|
|
1758
|
+
useEffect(() => {
|
|
1759
|
+
if (!frame.since) { setBefore({ state: 'none', why: 'Nothing here has changed since the World was last cut.' }); return; }
|
|
1760
|
+
if (!frame.at || !('site' in frame.at)) { setBefore({ state: 'none', why: `A ${vendorName(frame.vendor)} page as it was needs a signed-in copy of the World; its changes are listed below.` }); return; }
|
|
1761
|
+
const at = new Date(Date.parse(frame.since) - 1).toISOString();
|
|
1762
|
+
let made: string | null = null; let closed = false;
|
|
1763
|
+
// a copy is made once the comparison has stayed open a moment: one passed through on the way to another makes none
|
|
1764
|
+
const wait = setTimeout(() => void (async () => {
|
|
1765
|
+
const r = await door(token, `/-/${name}/branches`, { method: 'POST', body: JSON.stringify({ at: { instant: at }, ttl: 3600 }), signal: AbortSignal.timeout(60000) }).catch(() => null);
|
|
1766
|
+
if (!r?.ok) { setBefore({ state: 'none', why: `The World made no copy of itself as it was then${r ? `: ${await failure(r)}` : ''}.` }); return; }
|
|
1767
|
+
const b = (await r.json()) as { name: string; origin?: string };
|
|
1768
|
+
made = b.name;
|
|
1769
|
+
if (closed) { void door(token, `/-/${name}/branches/${b.name}`, { method: 'DELETE' }).catch(() => null); return; }
|
|
1770
|
+
const o = httpOrigin(b.origin);
|
|
1771
|
+
if (!o) { setBefore({ state: 'none', why: 'This World shows sites only at its own local address.' }); return; }
|
|
1772
|
+
const u = new URL(frame.url);
|
|
1773
|
+
setBefore({ state: 'ok', url: `${o.protocol}//${u.hostname}.${o.host}${u.pathname}${u.search}`, at });
|
|
1774
|
+
})(), 500);
|
|
1775
|
+
return () => { closed = true; clearTimeout(wait); if (made) void door(token, `/-/${name}/branches/${made}`, { method: 'DELETE' }).catch(() => null); };
|
|
1776
|
+
}, [name, token, frame.vendor, frame.id, frame.since]);
|
|
1777
|
+
const now = frameUrl(name, frame);
|
|
1778
|
+
return (
|
|
1779
|
+
<div className="compare" role="dialog" aria-label={`Changes to ${frame.title}`}>
|
|
1780
|
+
<div className="compare-head">
|
|
1781
|
+
<Mark vendor={frame.vendor} /><strong>{frame.title}</strong>
|
|
1782
|
+
<span className="mode">{counted(frame.changes, 'change')} not yet cut{frame.since ? `, since ${when(frame.since)}` : ''}</span>
|
|
1783
|
+
<button type="button" className="ghost" onClick={onClose} aria-label="Close the comparison">✕</button>
|
|
1784
|
+
</div>
|
|
1785
|
+
<div className="compare-sides">
|
|
1786
|
+
<figure>
|
|
1787
|
+
<figcaption>Before{before.state === 'ok' ? ` · as of ${when(before.at)}` : ''}</figcaption>
|
|
1788
|
+
{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>}
|
|
1789
|
+
</figure>
|
|
1790
|
+
<figure>
|
|
1791
|
+
<figcaption>Now</figcaption>
|
|
1792
|
+
{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.</p>}
|
|
1793
|
+
</figure>
|
|
1794
|
+
</div>
|
|
1795
|
+
{listed.state === 'error' ? <p role="alert" className="mode">Its changes could not be read: {listed.error}</p> : null}
|
|
1796
|
+
<ol className="compare-acts">
|
|
1797
|
+
{(listed.state === 'ok' ? listed.data.acts : []).map((a) => {
|
|
1798
|
+
const first = a.entries[0]!; const words = said({ ...first, service: frame.vendor, id: '', occurredAt: a.at } as unknown as Entry);
|
|
1799
|
+
return (
|
|
1800
|
+
<li key={`${a.at}:${first.subject.type}:${first.subject.id}`}>
|
|
1801
|
+
<span className="mode">{when(a.at)}</span>
|
|
1802
|
+
<details><summary><strong>{words.what}</strong>{words.label ? ` · ${words.label}` : ''}{a.writes > 1 ? <span className="mode"> and {counted(a.writes - 1, 'more write')}</span> : null}</summary>
|
|
1803
|
+
<ul>{a.entries.map((e, i) => <li key={i}><code>{e.operation ?? e.op}</code> <span className="mode">{e.subject.type} {shown(e as unknown as Entry)}</span></li>)}{a.writes > a.entries.length ? <li className="mode">and {counted(a.writes - a.entries.length, 'more write')}</li> : null}</ul>
|
|
1804
|
+
</details>
|
|
1805
|
+
</li>
|
|
1806
|
+
);
|
|
1807
|
+
})}
|
|
1808
|
+
</ol>
|
|
1809
|
+
{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}
|
|
1810
|
+
<a className="compare-all" href={`${BASE}/${name}/~/changes`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changes']); }}>Every change in the World →</a>
|
|
1811
|
+
</div>
|
|
1812
|
+
);
|
|
1727
1813
|
}
|
|
1728
1814
|
const FRAME_TYPES = { screen: ScreenFrame, section: SectionBox };
|
|
1729
1815
|
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 {
|
|
@@ -1745,6 +1831,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1745
1831
|
const [loaded, setLoaded] = useState<Load<BoardDoor>>({ state: 'loading' });
|
|
1746
1832
|
// the last read that did not answer, said beside the board it leaves on screen
|
|
1747
1833
|
const [stale, setStale] = useState<string | null>(null);
|
|
1834
|
+
const [reads, setReads] = useState(0);
|
|
1748
1835
|
// reads in the order they went out: an earlier one answering after a later one is not taken
|
|
1749
1836
|
const sent = useRef(0); const taken = useRef(0);
|
|
1750
1837
|
/** The World's board read again (its frames always, its boards when no save moved since the read went out). */
|
|
@@ -1760,7 +1847,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1760
1847
|
setStale(error);
|
|
1761
1848
|
return;
|
|
1762
1849
|
}
|
|
1763
|
-
setStale(null);
|
|
1850
|
+
setStale(null); setReads((n) => n + 1);
|
|
1764
1851
|
if (at === generation.current && pending.current === 0) held.current = { boards: data.boards, version: data.version };
|
|
1765
1852
|
setLoaded({ state: 'ok', data });
|
|
1766
1853
|
}, [token, name]);
|
|
@@ -1807,15 +1894,33 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1807
1894
|
const change = (id: string, edit: (b: SavedBoard) => SavedBoard): void => { void save((list) => list.map((b) => (b.id === id ? edit(b) : b))); };
|
|
1808
1895
|
const shown = shownOn(board, all);
|
|
1809
1896
|
const elsewhere = all.filter((f) => f.changes && !shown.includes(f)).reduce((n, f) => n + f.changes, 0);
|
|
1897
|
+
// 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
|
|
1810
1898
|
const [active, setActive] = useState<string | null>(null);
|
|
1899
|
+
const [picked, setPicked] = useState<string | null>(null);
|
|
1900
|
+
// the frame whose changes are being compared
|
|
1901
|
+
const [comparing, setComparing] = useState<string | null>(null);
|
|
1811
1902
|
const flow = useReactFlow();
|
|
1812
|
-
|
|
1813
|
-
const
|
|
1903
|
+
const others = boards.filter((b) => b.include);
|
|
1904
|
+
const addTo = (id: string, key: string): void => change(id, (b) => ({ ...b, include: [...(b.include ?? []), key] }));
|
|
1905
|
+
const removeFrame = { label: board.include ? 'Take it off this board' : 'Hide it', act: (key: string): void => { setPicked(null); change(board.id, (b) => (b.include ? { ...b, include: b.include.filter((k) => k !== key) } : { ...b, hidden: [...(b.hidden ?? []), key] })); } };
|
|
1906
|
+
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 });
|
|
1907
|
+
/** The section a frame sits in, as on a canvas: the smallest whose region holds the frame's middle, the newest of equals
|
|
1908
|
+
* (a section drawn around a frame takes it). */
|
|
1909
|
+
const sectionOf = (frame: Node, all: Node[]): Node | undefined => {
|
|
1910
|
+
const r = rect(frame); const cx = r.x + r.w / 2; const cy = r.y + r.h / 2;
|
|
1911
|
+
return all.filter((n) => n.type === 'section').map((s, i) => ({ s, i })).filter(({ s }) => { const q = rect(s); return cx >= q.x && cx <= q.x + q.w && cy >= q.y && cy <= q.y + q.h; })
|
|
1912
|
+
.sort((a, b) => rect(a.s).w * rect(a.s).h - rect(b.s).w * rect(b.s).h || b.i - a.i)[0]?.s;
|
|
1913
|
+
};
|
|
1914
|
+
/** The board laid out: each frame where it was put on this board, else after its pack's group, a new group under all;
|
|
1915
|
+
* each of the packs' groups a section around its frames until it is moved, resized, renamed or removed, and the
|
|
1916
|
+
* sections people drew where they drew them. */
|
|
1917
|
+
const layout = useCallback((): Node[] => {
|
|
1814
1918
|
const at = new Map<string, { x: number; y: number }>();
|
|
1815
1919
|
for (const f of shown) { const p = board.placed?.[frameKey(f)]; if (p) at.set(frameKey(f), p); }
|
|
1920
|
+
// a new group goes under the frames placed so far; a section someone draws never moves what is already laid out
|
|
1816
1921
|
let bottom = Math.max(-FRAME_GAP, ...[...at.values()].map((p) => p.y + FRAME_H));
|
|
1817
|
-
const
|
|
1818
|
-
for (const s of
|
|
1922
|
+
const groups = [...new Set(shown.map(sectionKey))];
|
|
1923
|
+
for (const s of groups) {
|
|
1819
1924
|
const members = shown.filter((f) => sectionKey(f) === s);
|
|
1820
1925
|
const placedOnes = members.filter((f) => at.has(frameKey(f))).map((f) => at.get(frameKey(f))!);
|
|
1821
1926
|
let x = placedOnes.length ? Math.max(...placedOnes.map((p) => p.x)) + FRAME_W + FRAME_GAP : 0;
|
|
@@ -1823,42 +1928,137 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1823
1928
|
for (const f of members) if (!at.has(frameKey(f))) { at.set(frameKey(f), { x, y }); x += FRAME_W + FRAME_GAP; }
|
|
1824
1929
|
bottom = Math.max(bottom, y + FRAME_H);
|
|
1825
1930
|
}
|
|
1826
|
-
const
|
|
1827
|
-
const
|
|
1828
|
-
const
|
|
1829
|
-
|
|
1931
|
+
const kept = board.sections ?? {};
|
|
1932
|
+
const section = (key: string, r: { x: number; y: number; w: number; h: number }, name: string, vendor: string | null, named: boolean): Node => ({ id: `sec:${key}`, type: 'section', position: { x: r.x, y: r.y }, width: r.w, height: r.h, zIndex: 0, draggable: editable, data: { key, name, vendor, named } });
|
|
1933
|
+
const sections: Node[] = [];
|
|
1934
|
+
for (const s of groups) {
|
|
1935
|
+
const f = shown.find((x) => sectionKey(x) === s)!;
|
|
1936
|
+
// a pack's group as people left it, under whichever id it was known by when they did (an account adopted since)
|
|
1937
|
+
const keyed = sectionKeys(f).find((k) => kept[k]) ?? s;
|
|
1938
|
+
const own = kept[keyed];
|
|
1939
|
+
if (own?.gone) continue;
|
|
1940
|
+
const ps = shown.filter((x) => sectionKey(x) === s).map((x) => at.get(frameKey(x))!);
|
|
1941
|
+
const around = { x: Math.min(...ps.map((p) => p.x)) - SECTION_PAD, y: Math.min(...ps.map((p) => p.y)) - SECTION_PAD, w: 0, h: 0 };
|
|
1942
|
+
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;
|
|
1943
|
+
// a group people arranged still takes its new frames: its region reaches over the ones placed after it
|
|
1944
|
+
const unplacedOf = shown.filter((x) => sectionKey(x) === s && !board.placed?.[frameKey(x)]).map((x) => at.get(frameKey(x))!);
|
|
1945
|
+
const r = own ? { ...own } : around;
|
|
1946
|
+
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); }
|
|
1947
|
+
sections.push(section(keyed, r, own?.name ?? `${vendorName(f.vendor)} · ${f.section.title}`, f.vendor, Boolean(own?.name)));
|
|
1948
|
+
}
|
|
1949
|
+
for (const [k, r] of Object.entries(kept)) if (k.startsWith('user:') && !r.gone) sections.push(section(k, r, r.name ?? 'Section', null, true));
|
|
1950
|
+
// frames above the sections they sit in; a section's name tag above the board, where it can be clicked
|
|
1951
|
+
// a frame's size given, never left to be measured again: a board laid out anew keeps showing what did not change size
|
|
1952
|
+
const frames: Node[] = shown.map((f) => ({ id: frameKey(f), type: 'screen', zIndex: 1, width: FRAME_W, height: FRAME_H, dragHandle: '.frame-bar', draggable: editable, position: at.get(frameKey(f))!, data: { world: name, frame: f, url: frameUrl(name, f) } }));
|
|
1953
|
+
return [...sections, ...frames];
|
|
1830
1954
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the frames, the board and its places are what change it
|
|
1831
|
-
}, [JSON.stringify(shown), JSON.stringify(board),
|
|
1832
|
-
const [nodes, setNodes, onNodesChange] = useNodesState<Node
|
|
1955
|
+
}, [JSON.stringify(shown), JSON.stringify(board), name, editable]);
|
|
1956
|
+
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
|
1833
1957
|
// a board opened is fitted to its own frames, once they are set: each board keeps its own layout
|
|
1834
1958
|
const fitted = useRef('');
|
|
1835
1959
|
useEffect(() => {
|
|
1836
|
-
const next =
|
|
1837
|
-
setNodes(next
|
|
1838
|
-
if (next.length && fitted.current !== board.id) { fitted.current = board.id; requestAnimationFrame(() => { void flow.fitView({ padding:
|
|
1839
|
-
}, [
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1960
|
+
const next = layout();
|
|
1961
|
+
setNodes(next);
|
|
1962
|
+
if (next.length && fitted.current !== board.id) { fitted.current = board.id; requestAnimationFrame(() => { void flow.fitView({ padding: { top: '72px', left: '272px', right: '40px', bottom: '72px' }, duration: 0 }); }); }
|
|
1963
|
+
}, [layout, setNodes]);
|
|
1964
|
+
/** What was just arranged, kept on this board beside what was kept before: the frames moved, and the sections moved,
|
|
1965
|
+
* resized or renamed (a pack's group is kept only once someone arranges it; its name only once someone renames it). */
|
|
1966
|
+
const keep = (frames: Record<string, { x: number; y: number }>, sections: string[] = [], names: Record<string, string> = {}): void => {
|
|
1967
|
+
const regions = Object.fromEntries(sections.map((key) => {
|
|
1968
|
+
const n = flow.getNode(`sec:${key}`); if (!n) return [key, undefined];
|
|
1969
|
+
const r = rect(n); const d = n.data as { name: string; named?: boolean };
|
|
1970
|
+
const name = names[key] ?? (d.named ? d.name : undefined);
|
|
1971
|
+
return [key, { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.w), h: Math.round(r.h), ...(name ? { name: name.slice(0, 80) } : {}) }];
|
|
1972
|
+
}).filter(([, v]) => v !== undefined));
|
|
1973
|
+
const placed = Object.fromEntries(Object.entries(frames).map(([k, p]) => [k, { x: Math.round(p.x), y: Math.round(p.y) }]));
|
|
1974
|
+
change(board.id, (b) => ({ ...b, placed: { ...(b.placed ?? {}), ...placed }, sections: { ...(b.sections ?? {}), ...regions } }));
|
|
1975
|
+
};
|
|
1976
|
+
// a section carries what is in it as it is moved: the frames whose middle it holds, and the sections inside it
|
|
1977
|
+
const carrying = useRef<{ id: string; from: { x: number; y: number }; members: Array<{ id: string; x: number; y: number }> } | null>(null);
|
|
1978
|
+
const onDragStart = (_e: unknown, node: Node): void => {
|
|
1979
|
+
if (node.type !== 'section') { carrying.current = null; return; }
|
|
1980
|
+
const ns = flow.getNodes(); const q = rect(node);
|
|
1981
|
+
const holds = (n: Node): boolean => {
|
|
1982
|
+
const r = rect(n);
|
|
1983
|
+
if (n.type === 'screen') { const cx = r.x + r.w / 2; const cy = r.y + r.h / 2; return cx >= q.x && cx <= q.x + q.w && cy >= q.y && cy <= q.y + q.h; }
|
|
1984
|
+
return n.id !== node.id && r.x >= q.x && r.y >= q.y && r.x + r.w <= q.x + q.w && r.y + r.h <= q.y + q.h;
|
|
1985
|
+
};
|
|
1986
|
+
carrying.current = { id: node.id, from: { ...node.position }, members: ns.filter(holds).map((n) => ({ id: n.id, x: n.position.x, y: n.position.y })) };
|
|
1987
|
+
};
|
|
1988
|
+
const onDrag = (_e: unknown, node: Node): void => {
|
|
1989
|
+
const c = carrying.current; if (!c || c.id !== node.id) return;
|
|
1990
|
+
const dx = node.position.x - c.from.x; const dy = node.position.y - c.from.y;
|
|
1991
|
+
const by = new Map(c.members.map((m) => [m.id, m]));
|
|
1992
|
+
setNodes((ns) => ns.map((n) => { const m = by.get(n.id); return m ? { ...n, position: { x: m.x + dx, y: m.y + dy } } : n; }));
|
|
1993
|
+
};
|
|
1994
|
+
/** A drag ended: where it ended, from the drag itself, for the node and everything it carried; and each group a moved
|
|
1995
|
+
* frame belongs to kept as it now stands (its other frames, and its section), so nothing moves unless someone moves it. */
|
|
1996
|
+
const onDragStop = (_e: unknown, node: Node): void => {
|
|
1997
|
+
const c = carrying.current; carrying.current = null;
|
|
1998
|
+
const dx = node.position.x - (c?.from.x ?? node.position.x); const dy = node.position.y - (c?.from.y ?? node.position.y);
|
|
1999
|
+
const moved = node.type === 'screen' ? [{ id: node.id, x: node.position.x, y: node.position.y }] : (c?.members ?? []).map((m) => ({ ...m, x: m.x + dx, y: m.y + dy }));
|
|
2000
|
+
requestAnimationFrame(() => {
|
|
2001
|
+
const ns = flow.getNodes();
|
|
2002
|
+
const frames = new Map(moved.filter((m) => !m.id.startsWith('sec:')).map((m) => [m.id, { x: m.x, y: m.y }]));
|
|
2003
|
+
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))]);
|
|
2004
|
+
// a group by every id its section was known by (its kept settings may be under an adopted account's earlier one)
|
|
2005
|
+
const groups = new Set(ns.filter((n) => frames.has(n.id)).flatMap((n) => sectionKeys((n.data as Screen).frame)));
|
|
2006
|
+
for (const n of ns) {
|
|
2007
|
+
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 });
|
|
2008
|
+
if (n.type === 'section' && groups.has((n.data as SectionData).key)) sections.add((n.data as SectionData).key);
|
|
2009
|
+
}
|
|
2010
|
+
keep(Object.fromEntries(frames), [...sections]);
|
|
2011
|
+
});
|
|
2012
|
+
};
|
|
2013
|
+
/** A pack's group's frames where they now stand: kept with its section when the section is arranged, so they stay in it. */
|
|
2014
|
+
const groupFrames = (key: string): Record<string, { x: number; y: number }> =>
|
|
2015
|
+
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 }]));
|
|
2016
|
+
const renameSection = (key: string): void => {
|
|
2017
|
+
const n = flow.getNode(`sec:${key}`); const now = (n?.data as { name?: string } | undefined)?.name ?? 'Section';
|
|
2018
|
+
const next = window.prompt('Name this section', now)?.trim(); if (!next) return;
|
|
2019
|
+
setNodes((ns) => ns.map((x) => (x.id === `sec:${key}` ? { ...x, data: { ...x.data, name: next.slice(0, 80), named: true } } : x)));
|
|
2020
|
+
requestAnimationFrame(() => keep(groupFrames(key), [key], { [key]: next }));
|
|
2021
|
+
};
|
|
2022
|
+
/** A section removed: its frames stay where they are; a pack's group is kept as removed, so it does not come back. */
|
|
2023
|
+
const removeSection = (key: string): void => {
|
|
2024
|
+
setPicked(null);
|
|
2025
|
+
const r = flow.getNode(`sec:${key}`);
|
|
2026
|
+
change(board.id, (b) => {
|
|
2027
|
+
const rest = Object.fromEntries(Object.entries(b.sections ?? {}).filter(([k]) => k !== key));
|
|
2028
|
+
return { ...b, sections: key.startsWith('user:') ? rest : { ...rest, [key]: { ...(r ? { x: Math.round(r.position.x), y: Math.round(r.position.y), w: Math.round(rect(r).w), h: Math.round(rect(r).h) } : { x: 0, y: 0, w: 0, h: 0 }), gone: true } } };
|
|
1852
2029
|
});
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
const
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
const
|
|
1860
|
-
const
|
|
1861
|
-
|
|
2030
|
+
};
|
|
2031
|
+
/** A new section: around the selected frame, else in the middle of the window. */
|
|
2032
|
+
const addSection = (): void => {
|
|
2033
|
+
const sel = picked ? flow.getNode(picked) : undefined;
|
|
2034
|
+
const middle = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
|
2035
|
+
const r = sel?.type === 'screen' ? { x: sel.position.x - SECTION_PAD, y: sel.position.y - SECTION_PAD, w: FRAME_W + SECTION_PAD * 2, h: FRAME_H + SECTION_PAD * 2 } : { x: middle.x - 900, y: middle.y - 600, w: 1800, h: 1200 };
|
|
2036
|
+
const key = `user:${Date.now().toString(36)}`;
|
|
2037
|
+
const count = Object.keys(board.sections ?? {}).filter((k) => k.startsWith('user:')).length + 1;
|
|
2038
|
+
setPicked(`sec:${key}`);
|
|
2039
|
+
change(board.id, (b) => ({ ...b, sections: { ...(b.sections ?? {}), [key]: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.w), h: Math.round(r.h), name: `Section ${count}` } } }));
|
|
2040
|
+
};
|
|
2041
|
+
// what each node shows: in use, selected, and what can be done to it
|
|
2042
|
+
const shownNodes = nodes.map((n) => (n.type === 'section'
|
|
2043
|
+
? { ...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]); }) } }
|
|
2044
|
+
: { ...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); } } }));
|
|
2045
|
+
// the keys a canvas app answers: Esc leaves a frame, then the selection; Delete removes what is selected; ⇧S draws a section
|
|
2046
|
+
useEffect(() => {
|
|
2047
|
+
const on = (e: KeyboardEvent): void => {
|
|
2048
|
+
if ((e.target as HTMLElement | null)?.closest('input, textarea, select, [contenteditable]')) return;
|
|
2049
|
+
if (e.key === 'Escape') { if (active) setActive(null); else setPicked(null); return; }
|
|
2050
|
+
if (!editable || active) return;
|
|
2051
|
+
if ((e.key === 'Delete' || e.key === 'Backspace') && picked) { e.preventDefault(); if (picked.startsWith('sec:')) removeSection(picked.slice(4)); else if (!board.include || board.include.includes(picked)) 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; }
|
|
2052
|
+
if (e.key === 'S' && e.shiftKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); addSection(); }
|
|
2053
|
+
};
|
|
2054
|
+
document.addEventListener('keydown', on); return () => document.removeEventListener('keydown', on);
|
|
2055
|
+
});
|
|
2056
|
+
/** A layer's row: bring that frame or section to the middle of the window, selected. */
|
|
2057
|
+
const focus = (id: string): void => {
|
|
2058
|
+
setPicked(id);
|
|
2059
|
+
const n = flow.getNode(id); if (!n) return;
|
|
2060
|
+
const r = rect(n);
|
|
2061
|
+
void flow.fitBounds({ x: r.x, y: r.y, width: r.w, height: r.h }, { padding: 0.15, duration: 400 });
|
|
1862
2062
|
};
|
|
1863
2063
|
const addBoard = (): void => {
|
|
1864
2064
|
const id = `board-${Date.now().toString(36)}`;
|
|
@@ -1867,17 +2067,24 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1867
2067
|
};
|
|
1868
2068
|
const rename = (): void => { const n = window.prompt('Name this board', board.name)?.trim(); if (n) change(board.id, (b) => ({ ...b, name: n.slice(0, 80) })); };
|
|
1869
2069
|
const drop = (): void => { const gone = board.id; setBoardId('everything'); void save((list) => list.filter((b) => b.id !== gone)); };
|
|
1870
|
-
const
|
|
1871
|
-
const missing = board.include ? [...
|
|
2070
|
+
const groups = [...new Map(all.map((f) => [sectionKey(f), f])).values()];
|
|
2071
|
+
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 }))] : [];
|
|
1872
2072
|
const hidden = board.include ? [] : all.filter((f) => !shown.includes(f));
|
|
2073
|
+
// the layers as the canvas has them: each section with the frames in it, then the frames in none
|
|
2074
|
+
const sectionNodes = nodes.filter((n) => n.type === 'section');
|
|
2075
|
+
const frameNodes = nodes.filter((n) => n.type === 'screen');
|
|
2076
|
+
const inSection = new Map(frameNodes.map((n) => [n.id, sectionOf(n, nodes)?.id ?? null]));
|
|
2077
|
+
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></li>; };
|
|
1873
2078
|
const dark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
1874
2079
|
const ready = session.state === 'ok' && loaded.state === 'ok';
|
|
1875
2080
|
return (
|
|
1876
2081
|
<div className="canvas full" role="region" aria-label="The World's board">
|
|
1877
|
-
<ReactFlow nodes={ready ?
|
|
1878
|
-
|
|
2082
|
+
<ReactFlow nodes={ready ? shownNodes : []} onNodesChange={onNodesChange} nodeTypes={FRAME_TYPES}
|
|
2083
|
+
onNodeDragStart={onDragStart} onNodeDrag={onDrag} onNodeDragStop={onDragStop}
|
|
2084
|
+
onNodeClick={(_e, n) => { if (n.id !== active) setPicked(n.id); }} onNodeDoubleClick={(_e, n) => { if (n.type === 'screen') { setPicked(n.id); setActive(n.id); } }}
|
|
2085
|
+
onPaneClick={() => { setActive(null); setPicked(null); }}
|
|
1879
2086
|
minZoom={0.03} maxZoom={1.5} nodesConnectable={false} elementsSelectable={false}
|
|
1880
|
-
panOnScroll={false} zoomOnDoubleClick={false} colorMode={dark ? 'dark' : 'light'} proOptions={{ hideAttribution: true }}>
|
|
2087
|
+
panOnScroll zoomOnPinch zoomOnScroll={false} zoomOnDoubleClick={false} colorMode={dark ? 'dark' : 'light'} proOptions={{ hideAttribution: true }}>
|
|
1881
2088
|
<Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
|
|
1882
2089
|
<Panel position="top-left" className="float layers">
|
|
1883
2090
|
<div className="float-head">
|
|
@@ -1885,22 +2092,21 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1885
2092
|
<span className="float-title"><strong>{name.split('/')[1]}</strong><span className="mode">{name.split('/')[0]}{snapshot ? ' · snapshot' : ''}</span></span>
|
|
1886
2093
|
</div>
|
|
1887
2094
|
<div className="board-tabs" role="tablist" aria-label="Boards">
|
|
1888
|
-
{boards.map((b) => <button key={b.id} type="button" role="tab" aria-selected={b.id === board.id} className={b.id === board.id ? 'on' : undefined} onClick={() => setBoardId(b.id)} onDoubleClick={editable && b.id === board.id && b.include ? rename : undefined}>{b.name}</button>)}
|
|
2095
|
+
{boards.map((b) => <button key={b.id} type="button" role="tab" aria-selected={b.id === board.id} className={b.id === board.id ? 'on' : undefined} onClick={() => { setPicked(null); setActive(null); setBoardId(b.id); }} onDoubleClick={editable && b.id === board.id && b.include ? rename : undefined}>{b.name}</button>)}
|
|
1889
2096
|
{editable ? <button type="button" className="ghost" onClick={addBoard} aria-label="A new board">+</button> : null}
|
|
1890
2097
|
</div>
|
|
1891
2098
|
{board.include && editable ? <div className="board-acts"><button type="button" className="ghost" onClick={rename}>Rename</button><button type="button" className="ghost" onClick={drop}>Delete board</button></div> : null}
|
|
1892
|
-
<ul className="layer-list" aria-label="
|
|
1893
|
-
{
|
|
1894
|
-
const
|
|
2099
|
+
<ul className="layer-list" aria-label="Layers">
|
|
2100
|
+
{sectionNodes.map((s) => {
|
|
2101
|
+
const d = s.data as SectionData;
|
|
1895
2102
|
return (
|
|
1896
|
-
<li key={s}>
|
|
1897
|
-
<
|
|
1898
|
-
<ul className="layer-list inner">
|
|
1899
|
-
{members.map((f) => <li key={frameKey(f)}><button type="button" className={active === frameKey(f) ? 'on' : undefined} onClick={() => focus(frameKey(f))}><span>{f.title}</span>{f.changes ? <span className="layer-changed" title={`${counted(f.changes, 'change')}, not in a changeset`}><Icon name="square-dot" size={14} />{f.changes}</span> : null}</button></li>)}
|
|
1900
|
-
</ul>
|
|
2103
|
+
<li key={s.id}>
|
|
2104
|
+
<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>
|
|
2105
|
+
<ul className="layer-list inner">{frameNodes.filter((n) => inSection.get(n.id) === s.id).map(frameRow)}</ul>
|
|
1901
2106
|
</li>
|
|
1902
2107
|
);
|
|
1903
2108
|
})}
|
|
2109
|
+
{frameNodes.filter((n) => !inSection.get(n.id)).map(frameRow)}
|
|
1904
2110
|
</ul>
|
|
1905
2111
|
{board.include && !shown.length ? <p className="mode pad-s">An empty board: add frames from Everything, or below.</p> : null}
|
|
1906
2112
|
{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}
|
|
@@ -1911,7 +2117,12 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
|
|
|
1911
2117
|
{session.state === 'pending' ? <p className="mode" role="status">Opening…</p> : null}
|
|
1912
2118
|
{session.state === 'error' ? <p role="alert">The World did not open its screens: {session.error} <button type="button" onClick={retry}>Try again</button></p> : null}
|
|
1913
2119
|
</Panel>
|
|
1914
|
-
|
|
2120
|
+
{/* the way out of a frame's live page, which keeps the keys it has (Esc reaches the board only from outside it) */}
|
|
2121
|
+
{active ? <Panel position="top-center" className="float hint using"><span className="mode">Using {shown.find((f) => frameKey(f) === active)?.title ?? active}</span><button type="button" onClick={() => setActive(null)}>Done <kbd>Esc</kbd></button></Panel>
|
|
2122
|
+
: <Panel position="top-center" className="float hint mode">Scroll to pan · pinch or ⌘ scroll to zoom · double-click a frame to use it</Panel>}
|
|
2123
|
+
{/* the board's own tools, along the bottom as a canvas app keeps them */}
|
|
2124
|
+
{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}
|
|
2125
|
+
{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}
|
|
1915
2126
|
<Controls showInteractive={false} position="bottom-left" />
|
|
1916
2127
|
<MiniMap pannable zoomable nodeBorderRadius={8} position="bottom-right" />
|
|
1917
2128
|
</ReactFlow>
|