standalone: split the WasmTool component body
useLibNotices hook (toast/stale-lib/load-badge state + window listeners + auto-dismiss timers) and presentational children — NoticeStack, SessionMenu (+ StaleLibsRow, FollowBanner), BootOverlay, LibLoadingOverlay, FatalOverlay, ConsolePanel. Behavior and data-testids unchanged; WasmTool.tsx 2378 → 1680 lines. The boot effect + collab wiring stay in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wd1r3ewftpV1DBSEArpRa
This commit is contained in:
parent
866db5888c
commit
079522d1e7
8 changed files with 1111 additions and 773 deletions
|
|
@ -7,7 +7,7 @@ import {
|
|||
syncLayoutToY,
|
||||
type Tool,
|
||||
} from "@pcbjam/shared";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Crosshair, Download, EyeOff, Layers, Loader2, Moon, PanelsTopLeft, RefreshCw, Sun } from "lucide-react";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
APP_URL,
|
||||
|
|
@ -22,34 +22,18 @@ import {
|
|||
} from "@/lib/config";
|
||||
import { redirectTargetFor } from "@/lib/redirect";
|
||||
import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity";
|
||||
import { setTheme, useThemeValue } from "@/lib/theme";
|
||||
import { useThemeValue } from "@/lib/theme";
|
||||
import { bootKicadTool } from "@/wasm/boot";
|
||||
import {
|
||||
autoDownloadEnabled,
|
||||
isWasmDownloaded,
|
||||
markWasmDownloaded,
|
||||
resolveWasmMeta,
|
||||
setAutoDownloadEnabled,
|
||||
} from "@/wasm/wasm-assets";
|
||||
import {
|
||||
LIB_BUSY_EVENT,
|
||||
LIB_ERROR_EVENT,
|
||||
LIB_ITEM_UPDATED_EVENT,
|
||||
LIB_LOADING_EVENT,
|
||||
LIB_SET_CHANGED_EVENT,
|
||||
type LibBusyDetail,
|
||||
type LibErrorDetail,
|
||||
type LibItemUpdatedDetail,
|
||||
type LibLoadingDetail,
|
||||
type LibSetChangedDetail,
|
||||
type LibsSource,
|
||||
} from "@/wasm/libs/source";
|
||||
import { addAnnouncedLib } from "@/wasm/libs/runtime-add";
|
||||
|
||||
import {
|
||||
MODELS_LOADING_EVENT,
|
||||
type ModelsLoadingDetail,
|
||||
} from "@/wasm/libs/models-bridge";
|
||||
import { TOOL_FRAME } from "@/wasm/constants";
|
||||
import {
|
||||
driveProjectIntoTool,
|
||||
|
|
@ -88,7 +72,6 @@ import {
|
|||
startSiblingRestage,
|
||||
type SiblingRestageHandle,
|
||||
} from "@/wasm/collab/sibling-restage";
|
||||
import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding";
|
||||
import {
|
||||
createComments,
|
||||
hasCommentsBridge,
|
||||
|
|
@ -97,11 +80,6 @@ import {
|
|||
} from "@/wasm/collab/comments";
|
||||
import { PresenceRoster } from "@/components/PresenceRoster";
|
||||
import { CommentLayer } from "@/components/CommentLayer";
|
||||
import {
|
||||
OverlayMenu,
|
||||
OverlayMenuSection,
|
||||
overlayRowClass,
|
||||
} from "@/components/OverlayMenu";
|
||||
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
|
||||
import { hasLayersBridge, LayerPanel, type LayersModule } from "@/components/LayerPanel";
|
||||
import { SelectionInspector } from "@/components/SelectionInspector";
|
||||
|
|
@ -122,11 +100,15 @@ import {
|
|||
} from "@/lib/chrome-visibility";
|
||||
import { recordFatalLog, showFatalScreen } from "@/wasm/fatal-screen";
|
||||
import { WasmErrorBoundary } from "@/components/wasm-tool/WasmErrorBoundary";
|
||||
import { BootOverlay } from "@/components/wasm-tool/BootOverlay";
|
||||
import { ConsolePanel } from "@/components/wasm-tool/ConsolePanel";
|
||||
import { FatalOverlay } from "@/components/wasm-tool/FatalOverlay";
|
||||
import { LibLoadingOverlay } from "@/components/wasm-tool/LibLoadingOverlay";
|
||||
import { NoticeStack } from "@/components/wasm-tool/NoticeStack";
|
||||
import { FollowBanner, SessionMenu } from "@/components/wasm-tool/SessionMenu";
|
||||
import { useLibNotices } from "@/components/wasm-tool/useLibNotices";
|
||||
import {
|
||||
DownloadConsent,
|
||||
DownloadProgress,
|
||||
gatherConsentInfo,
|
||||
libSyncLabel,
|
||||
type ConsentInfo,
|
||||
} from "@/components/wasm-tool/DownloadConsent";
|
||||
import {
|
||||
|
|
@ -138,13 +120,11 @@ import {
|
|||
import { installQuitHook } from "@/components/wasm-tool/quit-hook";
|
||||
import { installToolNavigationHook } from "@/components/wasm-tool/tool-navigation";
|
||||
import {
|
||||
CHROME_HOTKEY_LABEL,
|
||||
chromeSetter,
|
||||
COLLAB_TOOLS,
|
||||
INSPECTOR_OPEN_KEY,
|
||||
LAYERS_OPEN_KEY,
|
||||
LIB_KIND_FOR_TOOL,
|
||||
reloadFallbackMsg,
|
||||
} from "@/components/wasm-tool/ui-helpers";
|
||||
|
||||
/**
|
||||
|
|
@ -310,8 +290,6 @@ export function WasmTool({
|
|||
// This bundle+version finished downloading before (completion marker) — the
|
||||
// load overlay says "from cache" instead of the first-download excuse.
|
||||
const [warmBoot, setWarmBoot] = React.useState(false);
|
||||
// A library item currently being fetched (open/save), for a transient spinner.
|
||||
const [libBusy, setLibBusy] = React.useState<string | null>(null);
|
||||
// Load-screen pre-sync progress: warming the project's lib bundles into IDB in
|
||||
// parallel with the wasm download. Null when idle/done. Counts only (no
|
||||
// "current lib") — the fetches run several-at-a-time, so there is no single
|
||||
|
|
@ -327,124 +305,19 @@ export function WasmTool({
|
|||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
// Last lib error (e.g. a backend 404 on open), shown as a dismissible toast.
|
||||
const [libError, setLibError] = React.useState<string | null>(null);
|
||||
// A collaborator updated library items that are PLACED in the open document
|
||||
// (LIB_ITEM_UPDATED_EVENT) — placed copies keep the previous version, so warn.
|
||||
const [libUpdate, setLibUpdate] = React.useState<string | null>(null);
|
||||
// Persistent "behind the library" state (libs 0017 §2b): every PLACED item a
|
||||
// peer's lib edit touched, keyed `<kind>\u0000<lib>` → names. The toast above
|
||||
// is disposable; this survives until the user updates from the library
|
||||
// (2c) or dismisses it, and drives the FAB's amber triangle + the Document
|
||||
// section row. Symbols/footprints only — the kinds with a placed-usage
|
||||
// bridge (kicadLibsSymbolUsage / kicadLibsFootprintUsage).
|
||||
const [staleLibItems, setStaleLibItems] = React.useState<
|
||||
Map<string, { kind: string; lib: string; names: Set<string> }>
|
||||
>(() => new Map());
|
||||
const [staleUpdating, setStaleUpdating] = React.useState(false);
|
||||
const staleKey = (kind: string, lib: string) => `${kind}\u0000${lib}`;
|
||||
const noteStale = React.useCallback((kind: string, lib: string, names: string[]) => {
|
||||
if (names.length === 0) return;
|
||||
setStaleLibItems((prev) => {
|
||||
const next = new Map(prev);
|
||||
const k = staleKey(kind, lib);
|
||||
const cur = next.get(k) ?? { kind, lib, names: new Set<string>() };
|
||||
const merged = new Set(cur.names);
|
||||
for (const n of names) merged.add(n);
|
||||
next.set(k, { kind, lib, names: merged });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const clearStale = React.useCallback((key?: string) => {
|
||||
setStaleLibItems((prev) => {
|
||||
if (key === undefined) return new Map();
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
/** Update every placed instance of the stale items from the library (2c). */
|
||||
const updateStaleFromLibrary = React.useCallback(async () => {
|
||||
const mod = (window as { Module?: { kicadUpdateFromLibrary?: unknown } }).Module;
|
||||
const fn = mod?.kicadUpdateFromLibrary;
|
||||
if (typeof fn !== "function") {
|
||||
setLibError("This editor build can't update placed items from the library — reload to refresh them.");
|
||||
return;
|
||||
}
|
||||
setStaleUpdating(true);
|
||||
try {
|
||||
for (const [key, entry] of staleLibItems) {
|
||||
// The bridge queues the edit on the frame's coroutine and answers
|
||||
// {queued:true}; the outcome arrives as a `pcbjam:lib-update-done`
|
||||
// window event (or {ok:false,error} synchronously).
|
||||
const done = new Promise<{ ok: boolean; updated?: number; error?: string }>((resolve) => {
|
||||
const onDone = (e: Event) => {
|
||||
window.removeEventListener("pcbjam:lib-update-done", onDone);
|
||||
resolve((e as CustomEvent<{ ok: boolean; updated?: number }>).detail);
|
||||
};
|
||||
window.addEventListener("pcbjam:lib-update-done", onDone);
|
||||
setTimeout(() => {
|
||||
window.removeEventListener("pcbjam:lib-update-done", onDone);
|
||||
resolve({ ok: false, error: "timed out" });
|
||||
}, 30_000);
|
||||
});
|
||||
let res: { ok?: boolean; queued?: boolean; error?: string } = {};
|
||||
try {
|
||||
res = JSON.parse(
|
||||
(fn as (kind: string, lib: string, namesJson: string) => string)(
|
||||
entry.kind,
|
||||
entry.lib,
|
||||
JSON.stringify([...entry.names]),
|
||||
),
|
||||
) as typeof res;
|
||||
} catch {
|
||||
res = { ok: false, error: "bridge call failed" };
|
||||
}
|
||||
const outcome = res.ok === false ? { ok: false, error: res.error } : await done;
|
||||
if (!outcome.ok) {
|
||||
setLibError(`Couldn't update from the library: ${outcome.error ?? "unknown error"}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`[libs] updated ${outcome.updated ?? "?"} placed ${entry.kind}(s) from "${entry.lib}"`);
|
||||
clearStale(key);
|
||||
}
|
||||
} finally {
|
||||
setStaleUpdating(false);
|
||||
}
|
||||
}, [staleLibItems, clearStale]);
|
||||
// The backend rolled this document back to its last valid state
|
||||
// (kicad-validity 0001 — DOC_REVERTED_EVENT from the collab binding).
|
||||
const [docReverted, setDocReverted] = React.useState<string | null>(null);
|
||||
// A peer changed the team's lib SET mid-session (LIB_SET_CHANGED_EVENT —
|
||||
// the scope room's `libset` broadcast). The lib table is frozen at boot, so
|
||||
// the toast's click action loads the new lib live (addAnnouncedLib), with a
|
||||
// reload fallback when the runtime bridge is missing.
|
||||
const [libSetNotice, setLibSetNotice] = React.useState<{
|
||||
message: string;
|
||||
detail: LibSetChangedDetail;
|
||||
mode: "load" | "reload";
|
||||
} | null>(null);
|
||||
// The one libs source instance the running editor uses (set by the boot
|
||||
// effect) — the libset toast's action needs it to re-list and load.
|
||||
const activeLibsSourceRef = React.useRef<LibsSource | null>(null);
|
||||
// Every library / document notice (toasts, stale-lib state, load badges) —
|
||||
// state + listeners in useLibNotices, rendered by NoticeStack / SessionMenu
|
||||
// / LibLoadingOverlay below.
|
||||
const notices = useLibNotices({ getLibsSource: () => activeLibsSourceRef.current });
|
||||
// A save path entered the DURABLE blocked state (409 conflict / unknown
|
||||
// commit state — save-flow's absorbing blockedPaths). Rendered as a
|
||||
// persistent banner, never auto-dismissed: further Ctrl+S on the path is
|
||||
// silently absorbed, so without this surface the user would keep "saving"
|
||||
// into the void.
|
||||
const [saveBlocked, setSaveBlocked] = React.useState<SaveBlock | null>(null);
|
||||
// Eager whole-library idb→wasm load in flight (the ~tens-of-seconds fat-load on
|
||||
// first chooser/editor open). Drives a full-cover overlay so the freeze reads as
|
||||
// "loading, just slow" rather than a hang. Null when idle; `done/total` count the
|
||||
// per-lib fat-load crossings so the overlay can show a progress bar.
|
||||
const [libLoading, setLibLoading] = React.useState<{
|
||||
kind: string;
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
// Board 3D-model prefetch in flight (background; the viewer works without it —
|
||||
// anything still missing lazy-loads per model). Small badge, not an overlay.
|
||||
const [modelsSync, setModelsSync] = React.useState<string | null>(null);
|
||||
// The OTHER users in this document's collab room (awareness roster) — drives
|
||||
// the PresenceRoster chip next to SourceChip. Empty when collab is off, the
|
||||
// provider has no awareness (kind "none"), or nobody else is here.
|
||||
|
|
@ -614,142 +487,6 @@ export function WasmTool({
|
|||
setLogs((prev) => [...prev.slice(-800), msg]);
|
||||
}, []);
|
||||
|
||||
// Loading/error chrome for library item fetches (open/save), driven by events
|
||||
// the libs bridge dispatches (wasm/libs/source). The fetch is otherwise
|
||||
// invisible; a 404 would silently do nothing without this.
|
||||
React.useEffect(() => {
|
||||
let busyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const onBusy = (e: Event) => {
|
||||
const d = (e as CustomEvent<LibBusyDetail>).detail;
|
||||
clearTimeout(busyTimer);
|
||||
if (d.busy) {
|
||||
// Debounce — only flag slow fetches, so fast ones don't flicker.
|
||||
busyTimer = setTimeout(() => setLibBusy(d.name || "library item"), 180);
|
||||
} else {
|
||||
setLibBusy(null);
|
||||
}
|
||||
};
|
||||
const onError = (e: Event) => {
|
||||
setLibError((e as CustomEvent<LibErrorDetail>).detail.message);
|
||||
};
|
||||
const onItemUpdated = (e: Event) => {
|
||||
const d = (e as CustomEvent<LibItemUpdatedDetail>).detail;
|
||||
// Footprints have no placed-usage bridge (kicadLibsSymbolUsage is
|
||||
// symbol-only), so every applied peer edit is announced — silently
|
||||
// refreshing the lib under the user was the worse failure mode.
|
||||
// Only warn when the update touches something PLACED here — the library
|
||||
// tree already reflects updates to everything else. Both kinds have a
|
||||
// placed-usage bridge now (libs 0017 §2d added the footprint one).
|
||||
if (d.usedNames.length === 0) return;
|
||||
const label = d.kind === "footprint" ? "Footprint" : "Symbol";
|
||||
const names = d.usedNames.map((n) => `"${n}"`).join(", ");
|
||||
noteStale(d.kind, d.lib, d.usedNames);
|
||||
setLibUpdate(
|
||||
`${d.usedNames.length === 1 ? label : `${label}s`} ${names} in "${d.lib}" ` +
|
||||
`${d.usedNames.length === 1 ? "was" : "were"} updated by a collaborator — ` +
|
||||
`placed copies keep the previous version. Update them from the session menu.`,
|
||||
);
|
||||
};
|
||||
const onDocReverted = (e: Event) => {
|
||||
const d = (e as CustomEvent<{ reason?: string; at?: string }>).detail;
|
||||
setDocReverted(
|
||||
`This document was rolled back to its last valid state — invalid content ` +
|
||||
`was detected${d?.reason ? ` (${d.reason})` : ""}. Recent edits may have been undone.`,
|
||||
);
|
||||
};
|
||||
const onLibSet = (e: Event) => {
|
||||
const d = (e as CustomEvent<LibSetChangedDetail>).detail;
|
||||
// Only additions get a call to action — a removed lib's table row is
|
||||
// inert until the next boot and needs no interruption.
|
||||
if (d.op !== "add") return;
|
||||
setLibSetNotice({
|
||||
message: d.name
|
||||
? `A collaborator added library "${d.name}" — click to load it into this session.`
|
||||
: `A collaborator added a new library — click to load it into this session.`,
|
||||
detail: d,
|
||||
mode: "load",
|
||||
});
|
||||
};
|
||||
window.addEventListener(LIB_BUSY_EVENT, onBusy);
|
||||
window.addEventListener(LIB_ERROR_EVENT, onError);
|
||||
window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
|
||||
window.addEventListener(LIB_SET_CHANGED_EVENT, onLibSet);
|
||||
window.addEventListener(DOC_REVERTED_EVENT, onDocReverted);
|
||||
return () => {
|
||||
clearTimeout(busyTimer);
|
||||
window.removeEventListener(LIB_BUSY_EVENT, onBusy);
|
||||
window.removeEventListener(LIB_ERROR_EVENT, onError);
|
||||
window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
|
||||
window.removeEventListener(LIB_SET_CHANGED_EVENT, onLibSet);
|
||||
window.removeEventListener(DOC_REVERTED_EVENT, onDocReverted);
|
||||
};
|
||||
}, [noteStale]);
|
||||
|
||||
// Auto-dismiss the lib error toast.
|
||||
React.useEffect(() => {
|
||||
if (!libError) return;
|
||||
const t = setTimeout(() => setLibError(null), 6000);
|
||||
return () => clearTimeout(t);
|
||||
}, [libError]);
|
||||
|
||||
// Auto-dismiss the lib update toast (a touch longer — it carries a caveat).
|
||||
React.useEffect(() => {
|
||||
if (!libUpdate) return;
|
||||
const t = setTimeout(() => setLibUpdate(null), 10_000);
|
||||
return () => clearTimeout(t);
|
||||
}, [libUpdate]);
|
||||
|
||||
// Auto-dismiss the lib-set toast (long — it carries a click action).
|
||||
React.useEffect(() => {
|
||||
if (!libSetNotice) return;
|
||||
const t = setTimeout(() => setLibSetNotice(null), 30_000);
|
||||
return () => clearTimeout(t);
|
||||
}, [libSetNotice]);
|
||||
|
||||
// Auto-dismiss the doc-reverted toast (longest — the user should see it).
|
||||
React.useEffect(() => {
|
||||
if (!docReverted) return;
|
||||
const t = setTimeout(() => setDocReverted(null), 15_000);
|
||||
return () => clearTimeout(t);
|
||||
}, [docReverted]);
|
||||
|
||||
// Full-library eager load overlay. The fat-load fires one loading:true/false
|
||||
// pair PER library (222 on the full set), and between them the C++ side parses
|
||||
// with the main thread blocked. Show immediately on `true`, and only hide after
|
||||
// a short quiet gap on `false` (reset by the next lib's `true`) — so the overlay
|
||||
// stays continuous across the whole run and drops shortly after the last lib,
|
||||
// instead of flickering 222 times.
|
||||
React.useEffect(() => {
|
||||
let hideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const onLoading = (e: Event) => {
|
||||
const d = (e as CustomEvent<LibLoadingDetail>).detail;
|
||||
clearTimeout(hideTimer);
|
||||
// Update the bar on every event (true and false) so the count reflects the
|
||||
// latest lib; arm the hide only when the run reports it's winding down.
|
||||
setLibLoading({ kind: d.kind || "library", done: d.done, total: d.total });
|
||||
if (!d.loading) {
|
||||
hideTimer = setTimeout(() => setLibLoading(null), 700);
|
||||
}
|
||||
};
|
||||
window.addEventListener(LIB_LOADING_EVENT, onLoading);
|
||||
return () => {
|
||||
clearTimeout(hideTimer);
|
||||
window.removeEventListener(LIB_LOADING_EVENT, onLoading);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Board 3D-model prefetch progress (models-bridge prescan) — background badge.
|
||||
React.useEffect(() => {
|
||||
const onModels = (e: Event) => {
|
||||
const d = (e as CustomEvent<ModelsLoadingDetail>).detail;
|
||||
setModelsSync(
|
||||
d.loading ? `Fetching 3D models — ${d.done}/${d.total}` : null,
|
||||
);
|
||||
};
|
||||
window.addEventListener(MODELS_LOADING_EVENT, onModels);
|
||||
return () => window.removeEventListener(MODELS_LOADING_EVENT, onModels);
|
||||
}, []);
|
||||
|
||||
// "Taking too long": once the tool has been loading for a while without
|
||||
// becoming ready, surface a hint (slow link / something may be wrong) + a
|
||||
// reload, so a stalled boot doesn't look like a frozen blank screen. Paused
|
||||
|
|
@ -1811,116 +1548,20 @@ export function WasmTool({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Boot overlay — covers the big WASM download/compile freeze until the
|
||||
tool has booted + opened. */}
|
||||
{!ready && (
|
||||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-[#1a1a2e] text-white">
|
||||
{status.startsWith("Error") ? (
|
||||
<>
|
||||
<p className="max-w-md px-6 text-center font-mono text-sm text-red-300">
|
||||
{status}
|
||||
</p>
|
||||
<button
|
||||
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</>
|
||||
) : consent ? (
|
||||
<DownloadConsent
|
||||
info={consent}
|
||||
onAccept={(always) => {
|
||||
if (always) setAutoDownloadEnabled(true);
|
||||
consentResolveRef.current?.(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={32} />
|
||||
<p className="font-mono text-sm text-white/80">
|
||||
{status || "Loading…"}
|
||||
</p>
|
||||
<DownloadProgress progress={progress} />
|
||||
{/* The parallel fan-out's other progress: project files staging
|
||||
into MEMFS, and the lib warm-up. The lib line says "checking"
|
||||
on purpose — the walk visits every lib but downloads only new
|
||||
or changed ones, so a bare 15/155 next to the consent dialog's
|
||||
MB figures would read as 155 big downloads. */}
|
||||
{fileSync && fileSync.total > 0 && (
|
||||
<p className="whitespace-pre font-mono text-xs text-white/50">
|
||||
Project files — {String(fileSync.done).padStart(String(fileSync.total).length, " ")}/{fileSync.total}
|
||||
</p>
|
||||
)}
|
||||
{libSync && (
|
||||
<p className="whitespace-pre font-mono text-xs text-white/50">
|
||||
{libSyncLabel(libSync)}
|
||||
</p>
|
||||
)}
|
||||
<p className="font-mono text-xs text-white/40">
|
||||
{warmBoot
|
||||
? "Loading from your browser's cache — no download needed."
|
||||
: "Downloading the editor — it's cached for future visits."}
|
||||
</p>
|
||||
{slow && (
|
||||
<>
|
||||
<p className="max-w-sm px-6 text-center font-mono text-xs text-amber-300/90">
|
||||
This is taking longer than usual — a slow connection, or
|
||||
something may be wrong. You can keep waiting, or reload.
|
||||
</p>
|
||||
<button
|
||||
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<BootOverlay
|
||||
status={status}
|
||||
consent={consent}
|
||||
onConsentAccept={() => consentResolveRef.current?.(true)}
|
||||
progress={progress}
|
||||
fileSync={fileSync}
|
||||
libSync={libSync}
|
||||
warmBoot={warmBoot}
|
||||
slow={slow}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Eager library load overlay — the first chooser/editor open hydrates the
|
||||
whole library set from IDB into wasm (tens of seconds on the full CDN
|
||||
set) with the main thread blocked. Cover the (frozen) editor so it reads
|
||||
as "loading, just slow" rather than a hang. Shown post-boot; before
|
||||
`ready` the boot overlay already covers it. */}
|
||||
{ready && libLoading && (
|
||||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-[#1a1a2e]/95 text-white">
|
||||
<Loader2 className="animate-spin" size={32} />
|
||||
<p className="font-mono text-sm text-white/80">
|
||||
{libLoading.kind === "library"
|
||||
? "Loading libraries…"
|
||||
: `Loading ${libLoading.kind} libraries…`}
|
||||
</p>
|
||||
{libLoading.total > 0 && (
|
||||
<div className="w-64 max-w-[70vw]">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-white/15">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-400 transition-[width] duration-200 ease-out"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.round((libLoading.done / libLoading.total) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Space-pad `done` to `total`'s width so the centered line
|
||||
doesn't shift as the count gains a digit. */}
|
||||
<p className="mt-1 whitespace-pre text-center font-mono text-[11px] text-white/50">
|
||||
{String(Math.min(libLoading.done, libLoading.total)).padStart(
|
||||
String(libLoading.total).length,
|
||||
" ",
|
||||
)}{" "}
|
||||
/ {libLoading.total} libraries
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="max-w-sm px-6 text-center font-mono text-xs text-white/40">
|
||||
Moving the library set into the editor. The first open can take a
|
||||
moment — it's cached after this.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{ready && notices.libLoading && <LibLoadingOverlay libLoading={notices.libLoading} />}
|
||||
|
||||
{/* Transient post-boot status (e.g. file open). */}
|
||||
{ready && status && (
|
||||
|
|
@ -1929,210 +1570,41 @@ export function WasmTool({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Follow-user (0008): who we're following + how to stop. Esc also works
|
||||
because any canvas key input breaks the follow via noteLocalViewport
|
||||
only when the viewport moves — this banner is the explicit out. */}
|
||||
{ready && followingTarget && (
|
||||
<div
|
||||
data-testid="follow-banner"
|
||||
className="absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded-full bg-black/70 px-3 py-1 text-xs text-white shadow-sm ring-1 ring-inset ring-white/20"
|
||||
>
|
||||
<span>
|
||||
Following <span className="font-semibold">{followingTarget.name}</span> — move to stop
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-white/15 px-2 py-0.5 font-medium hover:bg-white/25"
|
||||
onClick={() => followRef.current?.unfollow()}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
<FollowBanner target={followingTarget} onStop={() => followRef.current?.unfollow()} />
|
||||
)}
|
||||
|
||||
{/* Overlay menu (0010): the single draggable circular icon replacing the
|
||||
old top-right row. Its badge is the peer count; the panel stacks the
|
||||
session sections — roster, source chip, view-only pill, follow row,
|
||||
comments (portal slot filled by CommentLayer), chrome toggle. It is
|
||||
the one control that stays up in canvas-only (chrome-hidden) mode. */}
|
||||
{/* Session menu (0010): the draggable FAB + its sections. */}
|
||||
{ready && (
|
||||
<OverlayMenu
|
||||
badge={peers.length}
|
||||
unread={commentsUnread.threads}
|
||||
unreadMention={commentsUnread.mentioned}
|
||||
alert={staleLibItems.size > 0}
|
||||
>
|
||||
{/* PEOPLE — who else is here, and whose view you're locked to. The
|
||||
follow state lives on each person's own row (PresenceRoster), so
|
||||
there is no separate "Following…" banner to keep in sync. */}
|
||||
{peers.length > 0 && (
|
||||
<OverlayMenuSection label="People">
|
||||
<PresenceRoster
|
||||
peers={peers}
|
||||
activeSheetPath={activeSheetPath}
|
||||
following={followingTarget}
|
||||
onFollow={(t) => {
|
||||
if (t) followRef.current?.follow(t);
|
||||
else followRef.current?.unfollow();
|
||||
}}
|
||||
/>
|
||||
</OverlayMenuSection>
|
||||
)}
|
||||
|
||||
{/* DOCUMENT — where this file came from and whether you may edit it.
|
||||
SourceChip is shared with the light project pages, so instead of
|
||||
restyling it we ask for its `muted` tone: colour drops to a dot,
|
||||
and the chip sits in a normal row like everything else. */}
|
||||
{(sourceDescriptor || readOnly || staleLibItems.size > 0) && (
|
||||
<OverlayMenuSection label="Document">
|
||||
{/* Behind-the-library state (libs 0017 §2b/2c): placed items a
|
||||
peer updated in the library. Persistent — unlike the toast —
|
||||
and actionable without a page reload: "Update from library"
|
||||
re-reads just those items into the placed instances. */}
|
||||
{staleLibItems.size > 0 && (
|
||||
<div
|
||||
data-testid="stale-libs-row"
|
||||
className="flex w-full flex-col gap-1 rounded-md bg-amber-500/10 px-2 py-1.5 text-xs text-neutral-800 dark:text-white/90"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="shrink-0 text-amber-500" />
|
||||
<span>
|
||||
{[...staleLibItems.values()].reduce((n, e) => n + e.names.size, 0)} placed{" "}
|
||||
{[...staleLibItems.values()].every((e) => e.kind === "footprint")
|
||||
? "footprint(s)"
|
||||
: [...staleLibItems.values()].every((e) => e.kind === "symbol")
|
||||
? "symbol(s)"
|
||||
: "item(s)"}{" "}
|
||||
behind the library
|
||||
</span>
|
||||
</div>
|
||||
<ul className="ml-6 list-disc font-mono text-[11px] text-neutral-600 dark:text-white/70">
|
||||
{[...staleLibItems.values()].flatMap((e) =>
|
||||
[...e.names].map((n) => (
|
||||
<li key={`${e.kind}:${e.lib}:${n}`} data-testid="stale-lib-item">
|
||||
{e.lib}:{n}
|
||||
</li>
|
||||
)),
|
||||
)}
|
||||
</ul>
|
||||
<div className="ml-6 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="update-from-library"
|
||||
disabled={staleUpdating || readOnly}
|
||||
onClick={() => void updateStaleFromLibrary()}
|
||||
className="inline-flex items-center gap-1 rounded bg-amber-500 px-2 py-0.5 text-[11px] font-medium text-neutral-900 hover:bg-amber-400 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={11} className={staleUpdating ? "animate-spin" : ""} />
|
||||
Update from library
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="stale-libs-dismiss"
|
||||
onClick={() => clearStale()}
|
||||
className="rounded px-2 py-0.5 text-[11px] text-neutral-500 hover:bg-black/5 dark:text-white/50 dark:hover:bg-white/10"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sourceDescriptor && (
|
||||
<div className={`${overlayRowClass} cursor-default`}>
|
||||
<SourceChip descriptor={sourceDescriptor} tone="muted" />
|
||||
</div>
|
||||
)}
|
||||
{readOnly && (
|
||||
<div
|
||||
data-testid="view-only-pill"
|
||||
className={`${overlayRowClass} cursor-default`}
|
||||
>
|
||||
<EyeOff size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
<span>View only</span>
|
||||
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/40">
|
||||
read-only
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMenuSection>
|
||||
)}
|
||||
|
||||
{commentsCtl && (
|
||||
<OverlayMenuSection label="Comments">
|
||||
<div
|
||||
data-testid="overlay-menu-comments"
|
||||
ref={setCommentsSlot}
|
||||
className="flex w-full flex-col items-start gap-2"
|
||||
/>
|
||||
</OverlayMenuSection>
|
||||
)}
|
||||
|
||||
<OverlayMenuSection label="View">
|
||||
{/* Viewer panels (viewer-panels): canvas-only stand-ins for the
|
||||
chrome-hidden wx panes — available to viewers and to editors
|
||||
in hide-UI mode alike. */}
|
||||
{effectiveChromeHidden && layersMod && (
|
||||
<button
|
||||
data-testid="layers-panel-toggle"
|
||||
aria-pressed={layersOpen}
|
||||
className={overlayRowClass}
|
||||
title="Board layers — visibility and active layer"
|
||||
onClick={() => setLayersOpen(!layersOpen)}
|
||||
>
|
||||
<Layers size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
<span>{layersOpen ? "Hide layers" : "Layers"}</span>
|
||||
</button>
|
||||
)}
|
||||
{effectiveChromeHidden && (tool === "pcbnew" || tool === "eeschema") && (
|
||||
<button
|
||||
data-testid="inspector-panel-toggle"
|
||||
aria-pressed={inspectorOpen}
|
||||
className={overlayRowClass}
|
||||
title="Properties of the selected items"
|
||||
onClick={() => setInspectorOpen(!inspectorOpen)}
|
||||
>
|
||||
<Crosshair size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
<span>{inspectorOpen ? "Hide inspector" : "Inspector"}</span>
|
||||
</button>
|
||||
)}
|
||||
{setChromeFn !== null && !readOnly && (
|
||||
<button
|
||||
data-testid="chrome-toggle"
|
||||
aria-pressed={chromeHidden}
|
||||
className={overlayRowClass}
|
||||
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
|
||||
onClick={() => toggleChromeHidden()}
|
||||
>
|
||||
{chromeHidden ? (
|
||||
<PanelsTopLeft size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
) : (
|
||||
<EyeOff size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
)}
|
||||
<span>{chromeHidden ? "Show UI" : "Hide UI"}</span>
|
||||
<kbd className="ml-auto rounded bg-black/10 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/10 dark:text-white/50">
|
||||
{CHROME_HOTKEY_LABEL}
|
||||
</kbd>
|
||||
</button>
|
||||
)}
|
||||
{/* Light/dark toggle (comments-ux 0002): flips the shell theme;
|
||||
the F4 effect above re-themes the GAL canvas through the
|
||||
bridge. Available to viewers too — theming isn't editing. */}
|
||||
<button
|
||||
data-testid="overlay-theme-toggle"
|
||||
aria-pressed={theme === "dark"}
|
||||
className={overlayRowClass}
|
||||
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
) : (
|
||||
<Moon size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
)}
|
||||
<span>{theme === "dark" ? "Light mode" : "Dark mode"}</span>
|
||||
</button>
|
||||
</OverlayMenuSection>
|
||||
</OverlayMenu>
|
||||
<SessionMenu
|
||||
tool={tool}
|
||||
readOnly={readOnly}
|
||||
theme={theme}
|
||||
peers={peers}
|
||||
activeSheetPath={activeSheetPath}
|
||||
followingTarget={followingTarget}
|
||||
onFollow={(t) => {
|
||||
if (t) followRef.current?.follow(t);
|
||||
else followRef.current?.unfollow();
|
||||
}}
|
||||
sourceDescriptor={sourceDescriptor}
|
||||
staleLibItems={notices.staleLibItems}
|
||||
staleUpdating={notices.staleUpdating}
|
||||
onUpdateStale={() => void notices.updateStaleFromLibrary()}
|
||||
onDismissStale={() => notices.clearStale()}
|
||||
commentsUnread={commentsUnread}
|
||||
hasComments={commentsCtl !== null}
|
||||
setCommentsSlot={setCommentsSlot}
|
||||
effectiveChromeHidden={effectiveChromeHidden}
|
||||
hasLayers={layersMod !== null}
|
||||
layersOpen={layersOpen}
|
||||
setLayersOpen={setLayersOpen}
|
||||
inspectorOpen={inspectorOpen}
|
||||
setInspectorOpen={setInspectorOpen}
|
||||
canToggleChrome={setChromeFn !== null}
|
||||
chromeHidden={chromeHidden}
|
||||
onToggleChrome={() => toggleChromeHidden()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Figma-like comments (0005): GAL pin dots + this DOM layer (hit targets,
|
||||
|
|
@ -2176,201 +1648,31 @@ export function WasmTool({
|
|||
{/* DEV: presence style tuner (VITE_PRESENCE_TUNER=1). */}
|
||||
{ready && tunerMod && <PresenceTuner mod={tunerMod} tool={tool} />}
|
||||
|
||||
{/* Lib pre-sync warming IDB after the editor opened (big set) — the ONLY
|
||||
surface for it now that the warm-up starts post-open: a small unobtrusive
|
||||
indicator so the user knows browsing is still filling in behind them,
|
||||
never something they are waiting on. */}
|
||||
{ready && libSync && (
|
||||
<div className="pointer-events-none absolute bottom-9 left-3 z-20 flex items-center gap-2 rounded bg-black/80 px-3 py-1.5 font-mono text-xs text-emerald-200">
|
||||
<Loader2 className="animate-spin" size={14} />{" "}
|
||||
<span className="whitespace-pre">{libSyncLabel(libSync)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Board 3D models still prefetching into the cache (background). */}
|
||||
{ready && modelsSync && (
|
||||
<div className="pointer-events-none absolute bottom-[4.25rem] left-3 z-20 flex items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-sky-200">
|
||||
<Loader2 className="animate-spin" size={14} /> {modelsSync}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A library item is being fetched (open/save). */}
|
||||
{ready && libBusy && (
|
||||
<div className="pointer-events-none absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-white">
|
||||
<Loader2 className="animate-spin" size={14} /> Loading {libBusy}…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A save path is durably BLOCKED (CAS conflict / unknown commit state) —
|
||||
persistent full-width banner, no auto-dismiss: subsequent Ctrl+S on
|
||||
the path is absorbed by the save lane, so this must stay visible. */}
|
||||
{saveBlocked && (
|
||||
<div
|
||||
data-testid="save-blocked-banner"
|
||||
className="absolute inset-x-0 top-0 z-40 bg-red-900/95 px-4 py-2 text-center text-xs font-medium text-red-100 shadow-lg"
|
||||
>
|
||||
{saveBlocked.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-center toast column: simultaneous notices stack instead of
|
||||
overlapping (they all used to render at the same absolute spot). */}
|
||||
<div className="absolute left-1/2 top-3 z-40 flex -translate-x-1/2 flex-col items-center gap-2">
|
||||
|
||||
{/* Library error (e.g. a backend 404 on open) — auto-dismisses. */}
|
||||
{libError && (
|
||||
<button
|
||||
className="max-w-md rounded bg-red-950/95 px-3 py-2 text-center text-xs text-red-100 shadow-lg ring-1 ring-red-500/40"
|
||||
onClick={() => setLibError(null)}
|
||||
title="Dismiss"
|
||||
>
|
||||
{libError}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* A collaborator updated a symbol PLACED in this document — auto-dismisses. */}
|
||||
{libUpdate && (
|
||||
<button
|
||||
data-testid="lib-update-toast"
|
||||
className="max-w-md rounded bg-amber-950/95 px-3 py-2 text-center text-xs text-amber-100 shadow-lg ring-1 ring-amber-500/40"
|
||||
onClick={() => setLibUpdate(null)}
|
||||
title="Dismiss"
|
||||
>
|
||||
{libUpdate}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* A peer changed the team's lib set — click loads the new lib live
|
||||
(kicadLibsAddEntry bridge), falling back to a reload offer. */}
|
||||
{libSetNotice && (
|
||||
<button
|
||||
data-testid="lib-set-toast"
|
||||
className="max-w-md rounded bg-sky-950/95 px-3 py-2 text-center text-xs text-sky-100 shadow-lg ring-1 ring-sky-500/40"
|
||||
onClick={() => {
|
||||
const notice = libSetNotice;
|
||||
if (notice.mode === "reload") {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
const source = activeLibsSourceRef.current;
|
||||
if (!source) {
|
||||
setLibSetNotice({ ...notice, mode: "reload", message: reloadFallbackMsg(notice) });
|
||||
return;
|
||||
}
|
||||
setLibSetNotice(null);
|
||||
void addAnnouncedLib(source, notice.detail, (m) => console.log(m)).then(
|
||||
(ok) => {
|
||||
if (!ok) {
|
||||
setLibSetNotice({
|
||||
...notice,
|
||||
mode: "reload",
|
||||
message: reloadFallbackMsg(notice),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
title={libSetNotice.mode === "reload" ? "Reload" : "Load the new library"}
|
||||
>
|
||||
{libSetNotice.message}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Backend rolled this doc back to the last valid state (kicad-validity). */}
|
||||
{docReverted && (
|
||||
<button
|
||||
data-testid="doc-reverted-toast"
|
||||
className="max-w-md rounded bg-orange-950/95 px-3 py-2 text-center text-xs text-orange-100 shadow-lg ring-1 ring-orange-500/40"
|
||||
onClick={() => setDocReverted(null)}
|
||||
title="Dismiss"
|
||||
>
|
||||
{docReverted}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<NoticeStack
|
||||
ready={ready}
|
||||
libSync={libSync}
|
||||
modelsSync={notices.modelsSync}
|
||||
libBusy={notices.libBusy}
|
||||
saveBlocked={saveBlocked}
|
||||
libError={notices.libError}
|
||||
onDismissLibError={notices.dismissLibError}
|
||||
libUpdate={notices.libUpdate}
|
||||
onDismissLibUpdate={notices.dismissLibUpdate}
|
||||
libSetNotice={notices.libSetNotice}
|
||||
onLibSetClick={notices.onLibSetClick}
|
||||
docReverted={notices.docReverted}
|
||||
onDismissDocReverted={notices.dismissDocReverted}
|
||||
/>
|
||||
|
||||
</WasmErrorBoundary>
|
||||
|
||||
{/* Terminal failure — z-35, ABOVE the boot overlay but below the console
|
||||
panel, OUTSIDE the error boundary, and independent of `ready`: a
|
||||
post-boot runtime death gets a proper blue screen instead of a blank
|
||||
page, with the console panel forced open beneath it. */}
|
||||
{fatal && (
|
||||
<div
|
||||
data-testid="fatal-overlay"
|
||||
className="absolute inset-0 z-[35] flex flex-col items-center justify-center gap-3 bg-[#1a1a2e] text-white"
|
||||
>
|
||||
<p className="font-mono text-4xl text-white/90">:(</p>
|
||||
<p className="font-mono text-sm text-white">
|
||||
The editor hit an unrecoverable error and stopped.
|
||||
</p>
|
||||
<p className="max-w-lg px-6 text-center font-mono text-xs text-blue-100/90">
|
||||
{fatal}
|
||||
</p>
|
||||
<p className="max-w-md px-6 text-center font-mono text-xs text-blue-200/60">
|
||||
The console below records what was loading when this happened —
|
||||
please copy it into a bug report.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="rounded border border-white/40 px-3 py-1 text-xs hover:bg-white/10"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{fatal && <FatalOverlay message={fatal} />}
|
||||
|
||||
{/* z-40 (above the z-30 boot overlay and the z-35 fatal overlay): when a
|
||||
load fails, the log this panel holds is the only account of WHY, so it
|
||||
must never end up underneath the thing reporting the failure. Forced
|
||||
visible on a fatal even with chrome hidden, for the same reason. */}
|
||||
{/* The log console (z-40, above boot + fatal overlays) — forced visible
|
||||
on a fatal even with chrome hidden: the log is the only account of
|
||||
WHY a load failed. */}
|
||||
{(!effectiveChromeHidden || fatal) && (
|
||||
/* Closed: a content-width tab pinned bottom-left (no right-0), so the
|
||||
version badge and the app's bottom edge stay visible/clickable.
|
||||
Open: the full-width footer panel. */
|
||||
<div
|
||||
ref={consolePanelRef}
|
||||
className={
|
||||
showLog ? "absolute bottom-0 left-0 right-0 z-40" : "absolute bottom-0 left-0 z-40"
|
||||
}
|
||||
>
|
||||
{showLog ? (
|
||||
<>
|
||||
<div className="flex items-center bg-black/70">
|
||||
<button
|
||||
className="flex items-center gap-1 px-3 py-1 font-mono text-xs text-white"
|
||||
onClick={() => setShowLog(false)}
|
||||
>
|
||||
<ChevronDown size={14} /> console ({logs.length})
|
||||
</button>
|
||||
<button
|
||||
className="ml-auto px-3 py-1 font-mono text-xs text-white/70 hover:text-white"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(logs.join("\n")).then(
|
||||
() => append("[console] copied to clipboard"),
|
||||
() => append("[console] clipboard copy failed"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
copy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="max-h-64 select-text cursor-text overflow-auto bg-black/85 p-3 font-mono text-[11px] leading-tight text-green-300">
|
||||
{logs.join("\n")}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"
|
||||
onClick={() => setShowLog(true)}
|
||||
>
|
||||
<ChevronUp size={14} /> console ({logs.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ConsolePanel ref={consolePanelRef} logs={logs} open={showLog} setOpen={setShowLog} append={append} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
99
web/standalone/src/components/wasm-tool/BootOverlay.tsx
Normal file
99
web/standalone/src/components/wasm-tool/BootOverlay.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { setAutoDownloadEnabled } from "@/wasm/wasm-assets";
|
||||
import { DownloadConsent, DownloadProgress, libSyncLabel, type ConsentInfo } from "./DownloadConsent";
|
||||
|
||||
/**
|
||||
* Boot overlay — covers the big WASM download/compile freeze until the tool
|
||||
* has booted + opened: the error state, the download-consent card, or the
|
||||
* spinner with download / staging / lib warm-up progress and the "taking
|
||||
* too long" hint.
|
||||
*/
|
||||
export function BootOverlay({
|
||||
status,
|
||||
consent,
|
||||
onConsentAccept,
|
||||
progress,
|
||||
fileSync,
|
||||
libSync,
|
||||
warmBoot,
|
||||
slow,
|
||||
}: {
|
||||
status: string;
|
||||
consent: ConsentInfo | null;
|
||||
/** The consent card's OK — resolves the boot's gate. */
|
||||
onConsentAccept: () => void;
|
||||
progress: { loaded: number; total: number } | null;
|
||||
fileSync: { done: number; total: number } | null;
|
||||
libSync: { kind: string; done: number; total: number } | null;
|
||||
warmBoot: boolean;
|
||||
slow: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-[#1a1a2e] text-white">
|
||||
{status.startsWith("Error") ? (
|
||||
<>
|
||||
<p className="max-w-md px-6 text-center font-mono text-sm text-red-300">
|
||||
{status}
|
||||
</p>
|
||||
<button
|
||||
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</>
|
||||
) : consent ? (
|
||||
<DownloadConsent
|
||||
info={consent}
|
||||
onAccept={(always) => {
|
||||
if (always) setAutoDownloadEnabled(true);
|
||||
onConsentAccept();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={32} />
|
||||
<p className="font-mono text-sm text-white/80">
|
||||
{status || "Loading…"}
|
||||
</p>
|
||||
<DownloadProgress progress={progress} />
|
||||
{/* The parallel fan-out's other progress: project files staging
|
||||
into MEMFS, and the lib warm-up. The lib line says "checking"
|
||||
on purpose — the walk visits every lib but downloads only new
|
||||
or changed ones, so a bare 15/155 next to the consent dialog's
|
||||
MB figures would read as 155 big downloads. */}
|
||||
{fileSync && fileSync.total > 0 && (
|
||||
<p className="whitespace-pre font-mono text-xs text-white/50">
|
||||
Project files — {String(fileSync.done).padStart(String(fileSync.total).length, " ")}/{fileSync.total}
|
||||
</p>
|
||||
)}
|
||||
{libSync && (
|
||||
<p className="whitespace-pre font-mono text-xs text-white/50">
|
||||
{libSyncLabel(libSync)}
|
||||
</p>
|
||||
)}
|
||||
<p className="font-mono text-xs text-white/40">
|
||||
{warmBoot
|
||||
? "Loading from your browser's cache — no download needed."
|
||||
: "Downloading the editor — it's cached for future visits."}
|
||||
</p>
|
||||
{slow && (
|
||||
<>
|
||||
<p className="max-w-sm px-6 text-center font-mono text-xs text-amber-300/90">
|
||||
This is taking longer than usual — a slow connection, or
|
||||
something may be wrong. You can keep waiting, or reload.
|
||||
</p>
|
||||
<button
|
||||
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
web/standalone/src/components/wasm-tool/ConsolePanel.tsx
Normal file
64
web/standalone/src/components/wasm-tool/ConsolePanel.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
|
||||
import * as React from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
/**
|
||||
* The editor's log console. z-40 (above the z-30 boot overlay and the z-35
|
||||
* fatal overlay): when a load fails, the log this panel holds is the only
|
||||
* account of WHY, so it must never end up underneath the thing reporting the
|
||||
* failure. Closed: a content-width tab pinned bottom-left (no right-0), so the
|
||||
* version badge and the app's bottom edge stay visible/clickable. Open: the
|
||||
* full-width footer panel. `panelRef` lets WasmTool detect log-text selections
|
||||
* for its Ctrl/Cmd+C interception.
|
||||
*/
|
||||
export const ConsolePanel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
logs: string[];
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
/** Append a line to the log (used for the copy outcome). */
|
||||
append: (msg: string) => void;
|
||||
}
|
||||
>(function ConsolePanel({ logs, open, setOpen, append }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={open ? "absolute bottom-0 left-0 right-0 z-40" : "absolute bottom-0 left-0 z-40"}
|
||||
>
|
||||
{open ? (
|
||||
<>
|
||||
<div className="flex items-center bg-black/70">
|
||||
<button
|
||||
className="flex items-center gap-1 px-3 py-1 font-mono text-xs text-white"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<ChevronDown size={14} /> console ({logs.length})
|
||||
</button>
|
||||
<button
|
||||
className="ml-auto px-3 py-1 font-mono text-xs text-white/70 hover:text-white"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(logs.join("\n")).then(
|
||||
() => append("[console] copied to clipboard"),
|
||||
() => append("[console] clipboard copy failed"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
copy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="max-h-64 select-text cursor-text overflow-auto bg-black/85 p-3 font-mono text-[11px] leading-tight text-green-300">
|
||||
{logs.join("\n")}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<ChevronUp size={14} /> console ({logs.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
36
web/standalone/src/components/wasm-tool/FatalOverlay.tsx
Normal file
36
web/standalone/src/components/wasm-tool/FatalOverlay.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
|
||||
|
||||
/**
|
||||
* Terminal failure — z-35, ABOVE the boot overlay but below the console panel,
|
||||
* OUTSIDE the error boundary, and independent of `ready`: a post-boot runtime
|
||||
* death gets a proper blue screen instead of a blank page, with the console
|
||||
* panel forced open beneath it.
|
||||
*/
|
||||
export function FatalOverlay({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="fatal-overlay"
|
||||
className="absolute inset-0 z-[35] flex flex-col items-center justify-center gap-3 bg-[#1a1a2e] text-white"
|
||||
>
|
||||
<p className="font-mono text-4xl text-white/90">:(</p>
|
||||
<p className="font-mono text-sm text-white">
|
||||
The editor hit an unrecoverable error and stopped.
|
||||
</p>
|
||||
<p className="max-w-lg px-6 text-center font-mono text-xs text-blue-100/90">
|
||||
{message}
|
||||
</p>
|
||||
<p className="max-w-md px-6 text-center font-mono text-xs text-blue-200/60">
|
||||
The console below records what was loading when this happened —
|
||||
please copy it into a bug report.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="rounded border border-white/40 px-3 py-1 text-xs hover:bg-white/10"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { LibLoadingState } from "./useLibNotices";
|
||||
|
||||
/**
|
||||
* Eager library load overlay — the first chooser/editor open hydrates the
|
||||
* whole library set from IDB into wasm (tens of seconds on the full CDN set)
|
||||
* with the main thread blocked. Cover the (frozen) editor so it reads as
|
||||
* "loading, just slow" rather than a hang. Shown post-boot; before `ready`
|
||||
* the boot overlay already covers it.
|
||||
*/
|
||||
export function LibLoadingOverlay({ libLoading }: { libLoading: LibLoadingState }) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-[#1a1a2e]/95 text-white">
|
||||
<Loader2 className="animate-spin" size={32} />
|
||||
<p className="font-mono text-sm text-white/80">
|
||||
{libLoading.kind === "library"
|
||||
? "Loading libraries…"
|
||||
: `Loading ${libLoading.kind} libraries…`}
|
||||
</p>
|
||||
{libLoading.total > 0 && (
|
||||
<div className="w-64 max-w-[70vw]">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-white/15">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-400 transition-[width] duration-200 ease-out"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.round((libLoading.done / libLoading.total) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Space-pad `done` to `total`'s width so the centered line
|
||||
doesn't shift as the count gains a digit. */}
|
||||
<p className="mt-1 whitespace-pre text-center font-mono text-[11px] text-white/50">
|
||||
{String(Math.min(libLoading.done, libLoading.total)).padStart(
|
||||
String(libLoading.total).length,
|
||||
" ",
|
||||
)}{" "}
|
||||
/ {libLoading.total} libraries
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="max-w-sm px-6 text-center font-mono text-xs text-white/40">
|
||||
Moving the library set into the editor. The first open can take a
|
||||
moment — it's cached after this.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
web/standalone/src/components/wasm-tool/NoticeStack.tsx
Normal file
135
web/standalone/src/components/wasm-tool/NoticeStack.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { SaveBlock } from "@/wasm/save-flow";
|
||||
import { libSyncLabel } from "./DownloadConsent";
|
||||
import type { LibSetNotice } from "./useLibNotices";
|
||||
|
||||
/**
|
||||
* Every transient notice the running editor shows over the canvas: the
|
||||
* bottom-left progress badges (lib pre-sync, 3D models), the busy pill, the
|
||||
* durable save-blocked banner and the top-center toast column (lib error,
|
||||
* placed-item update, lib-set change, doc revert). State + timers live in
|
||||
* useLibNotices; this is the render.
|
||||
*/
|
||||
export function NoticeStack({
|
||||
ready,
|
||||
libSync,
|
||||
modelsSync,
|
||||
libBusy,
|
||||
saveBlocked,
|
||||
libError,
|
||||
onDismissLibError,
|
||||
libUpdate,
|
||||
onDismissLibUpdate,
|
||||
libSetNotice,
|
||||
onLibSetClick,
|
||||
docReverted,
|
||||
onDismissDocReverted,
|
||||
}: {
|
||||
ready: boolean;
|
||||
libSync: { kind: string; done: number; total: number } | null;
|
||||
modelsSync: string | null;
|
||||
libBusy: string | null;
|
||||
saveBlocked: SaveBlock | null;
|
||||
libError: string | null;
|
||||
onDismissLibError: () => void;
|
||||
libUpdate: string | null;
|
||||
onDismissLibUpdate: () => void;
|
||||
libSetNotice: LibSetNotice | null;
|
||||
onLibSetClick: () => void;
|
||||
docReverted: string | null;
|
||||
onDismissDocReverted: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Lib pre-sync warming IDB after the editor opened (big set) — the ONLY
|
||||
surface for it now that the warm-up starts post-open: a small unobtrusive
|
||||
indicator so the user knows browsing is still filling in behind them,
|
||||
never something they are waiting on. */}
|
||||
{ready && libSync && (
|
||||
<div className="pointer-events-none absolute bottom-9 left-3 z-20 flex items-center gap-2 rounded bg-black/80 px-3 py-1.5 font-mono text-xs text-emerald-200">
|
||||
<Loader2 className="animate-spin" size={14} />{" "}
|
||||
<span className="whitespace-pre">{libSyncLabel(libSync)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Board 3D models still prefetching into the cache (background). */}
|
||||
{ready && modelsSync && (
|
||||
<div className="pointer-events-none absolute bottom-[4.25rem] left-3 z-20 flex items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-sky-200">
|
||||
<Loader2 className="animate-spin" size={14} /> {modelsSync}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A library item is being fetched (open/save). */}
|
||||
{ready && libBusy && (
|
||||
<div className="pointer-events-none absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-white">
|
||||
<Loader2 className="animate-spin" size={14} /> Loading {libBusy}…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A save path is durably BLOCKED (CAS conflict / unknown commit state) —
|
||||
persistent full-width banner, no auto-dismiss: subsequent Ctrl+S on
|
||||
the path is absorbed by the save lane, so this must stay visible. */}
|
||||
{saveBlocked && (
|
||||
<div
|
||||
data-testid="save-blocked-banner"
|
||||
className="absolute inset-x-0 top-0 z-40 bg-red-900/95 px-4 py-2 text-center text-xs font-medium text-red-100 shadow-lg"
|
||||
>
|
||||
{saveBlocked.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-center toast column: simultaneous notices stack instead of
|
||||
overlapping (they all used to render at the same absolute spot). */}
|
||||
<div className="absolute left-1/2 top-3 z-40 flex -translate-x-1/2 flex-col items-center gap-2">
|
||||
{/* Library error (e.g. a backend 404 on open) — auto-dismisses. */}
|
||||
{libError && (
|
||||
<button
|
||||
className="max-w-md rounded bg-red-950/95 px-3 py-2 text-center text-xs text-red-100 shadow-lg ring-1 ring-red-500/40"
|
||||
onClick={onDismissLibError}
|
||||
title="Dismiss"
|
||||
>
|
||||
{libError}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* A collaborator updated a symbol PLACED in this document — auto-dismisses. */}
|
||||
{libUpdate && (
|
||||
<button
|
||||
data-testid="lib-update-toast"
|
||||
className="max-w-md rounded bg-amber-950/95 px-3 py-2 text-center text-xs text-amber-100 shadow-lg ring-1 ring-amber-500/40"
|
||||
onClick={onDismissLibUpdate}
|
||||
title="Dismiss"
|
||||
>
|
||||
{libUpdate}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* A peer changed the team's lib set — click loads the new lib live
|
||||
(kicadLibsAddEntry bridge), falling back to a reload offer. */}
|
||||
{libSetNotice && (
|
||||
<button
|
||||
data-testid="lib-set-toast"
|
||||
className="max-w-md rounded bg-sky-950/95 px-3 py-2 text-center text-xs text-sky-100 shadow-lg ring-1 ring-sky-500/40"
|
||||
onClick={onLibSetClick}
|
||||
title={libSetNotice.mode === "reload" ? "Reload" : "Load the new library"}
|
||||
>
|
||||
{libSetNotice.message}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Backend rolled this doc back to the last valid state (kicad-validity). */}
|
||||
{docReverted && (
|
||||
<button
|
||||
data-testid="doc-reverted-toast"
|
||||
className="max-w-md rounded bg-orange-950/95 px-3 py-2 text-center text-xs text-orange-100 shadow-lg ring-1 ring-orange-500/40"
|
||||
onClick={onDismissDocReverted}
|
||||
title="Dismiss"
|
||||
>
|
||||
{docReverted}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
321
web/standalone/src/components/wasm-tool/SessionMenu.tsx
Normal file
321
web/standalone/src/components/wasm-tool/SessionMenu.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
|
||||
import * as React from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Crosshair,
|
||||
EyeOff,
|
||||
Layers,
|
||||
Moon,
|
||||
PanelsTopLeft,
|
||||
RefreshCw,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import type { Tool } from "@pcbjam/shared";
|
||||
import { setTheme } from "@/lib/theme";
|
||||
import type { SourceDescriptor } from "@/lib/project-source-shared";
|
||||
import type { PresencePeer } from "@/wasm/collab/presence";
|
||||
import type { FollowTarget } from "@/wasm/collab/follow-user";
|
||||
import { PresenceRoster } from "@/components/PresenceRoster";
|
||||
import { SourceChip } from "@/components/SourceChip";
|
||||
import { OverlayMenu, OverlayMenuSection, overlayRowClass } from "@/components/OverlayMenu";
|
||||
import { CHROME_HOTKEY_LABEL } from "./ui-helpers";
|
||||
import type { StaleLibEntry } from "./useLibNotices";
|
||||
|
||||
/**
|
||||
* Behind-the-library state (libs 0017 §2b/2c): placed items a peer updated in
|
||||
* the library. Persistent — unlike the toast — and actionable without a page
|
||||
* reload: "Update from library" re-reads just those items into the placed
|
||||
* instances.
|
||||
*/
|
||||
export function StaleLibsRow({
|
||||
items,
|
||||
updating,
|
||||
readOnly,
|
||||
onUpdate,
|
||||
onDismiss,
|
||||
}: {
|
||||
items: Map<string, StaleLibEntry>;
|
||||
updating: boolean;
|
||||
readOnly: boolean;
|
||||
onUpdate: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const entries = [...items.values()];
|
||||
return (
|
||||
<div
|
||||
data-testid="stale-libs-row"
|
||||
className="flex w-full flex-col gap-1 rounded-md bg-amber-500/10 px-2 py-1.5 text-xs text-neutral-800 dark:text-white/90"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="shrink-0 text-amber-500" />
|
||||
<span>
|
||||
{entries.reduce((n, e) => n + e.names.size, 0)} placed{" "}
|
||||
{entries.every((e) => e.kind === "footprint")
|
||||
? "footprint(s)"
|
||||
: entries.every((e) => e.kind === "symbol")
|
||||
? "symbol(s)"
|
||||
: "item(s)"}{" "}
|
||||
behind the library
|
||||
</span>
|
||||
</div>
|
||||
<ul className="ml-6 list-disc font-mono text-[11px] text-neutral-600 dark:text-white/70">
|
||||
{entries.flatMap((e) =>
|
||||
[...e.names].map((n) => (
|
||||
<li key={`${e.kind}:${e.lib}:${n}`} data-testid="stale-lib-item">
|
||||
{e.lib}:{n}
|
||||
</li>
|
||||
)),
|
||||
)}
|
||||
</ul>
|
||||
<div className="ml-6 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="update-from-library"
|
||||
disabled={updating || readOnly}
|
||||
onClick={onUpdate}
|
||||
className="inline-flex items-center gap-1 rounded bg-amber-500 px-2 py-0.5 text-[11px] font-medium text-neutral-900 hover:bg-amber-400 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={11} className={updating ? "animate-spin" : ""} />
|
||||
Update from library
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="stale-libs-dismiss"
|
||||
onClick={onDismiss}
|
||||
className="rounded px-2 py-0.5 text-[11px] text-neutral-500 hover:bg-black/5 dark:text-white/50 dark:hover:bg-white/10"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow-user (0008): who we're following + how to stop. Esc also works
|
||||
* because any canvas key input breaks the follow via noteLocalViewport only
|
||||
* when the viewport moves — this banner is the explicit out.
|
||||
*/
|
||||
export function FollowBanner({
|
||||
target,
|
||||
onStop,
|
||||
}: {
|
||||
target: FollowTarget;
|
||||
onStop: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="follow-banner"
|
||||
className="absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded-full bg-black/70 px-3 py-1 text-xs text-white shadow-sm ring-1 ring-inset ring-white/20"
|
||||
>
|
||||
<span>
|
||||
Following <span className="font-semibold">{target.name}</span> — move to stop
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-white/15 px-2 py-0.5 font-medium hover:bg-white/25"
|
||||
onClick={onStop}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay menu (0010): the single draggable circular icon replacing the old
|
||||
* top-right row. Its badge is the peer count; the panel stacks the session
|
||||
* sections — roster, source chip, view-only pill, stale-libs row, comments
|
||||
* (portal slot filled by CommentLayer), viewer-panel toggles, chrome toggle,
|
||||
* theme. It is the one control that stays up in canvas-only (chrome-hidden)
|
||||
* mode.
|
||||
*/
|
||||
export function SessionMenu({
|
||||
tool,
|
||||
readOnly,
|
||||
theme,
|
||||
peers,
|
||||
activeSheetPath,
|
||||
followingTarget,
|
||||
onFollow,
|
||||
sourceDescriptor,
|
||||
staleLibItems,
|
||||
staleUpdating,
|
||||
onUpdateStale,
|
||||
onDismissStale,
|
||||
commentsUnread,
|
||||
hasComments,
|
||||
setCommentsSlot,
|
||||
effectiveChromeHidden,
|
||||
hasLayers,
|
||||
layersOpen,
|
||||
setLayersOpen,
|
||||
inspectorOpen,
|
||||
setInspectorOpen,
|
||||
canToggleChrome,
|
||||
chromeHidden,
|
||||
onToggleChrome,
|
||||
}: {
|
||||
tool: Tool;
|
||||
readOnly: boolean;
|
||||
theme: string;
|
||||
peers: PresencePeer[];
|
||||
activeSheetPath: string | undefined;
|
||||
followingTarget: FollowTarget | null;
|
||||
onFollow: (target: FollowTarget | null) => void;
|
||||
sourceDescriptor: SourceDescriptor | undefined;
|
||||
staleLibItems: Map<string, StaleLibEntry>;
|
||||
staleUpdating: boolean;
|
||||
onUpdateStale: () => void;
|
||||
onDismissStale: () => void;
|
||||
commentsUnread: { threads: number; mentioned: boolean };
|
||||
/** A comments controller is bound — the Comments section renders its slot. */
|
||||
hasComments: boolean;
|
||||
/** Ref-callback slot the CommentLayer portals its bar/panel into. */
|
||||
setCommentsSlot: (el: HTMLDivElement | null) => void;
|
||||
effectiveChromeHidden: boolean;
|
||||
/** The layers bridge is available (pcbnew sessions). */
|
||||
hasLayers: boolean;
|
||||
layersOpen: boolean;
|
||||
setLayersOpen: (v: boolean) => void;
|
||||
inspectorOpen: boolean;
|
||||
setInspectorOpen: (v: boolean) => void;
|
||||
/** The loaded bundle exports kicadSetChrome. */
|
||||
canToggleChrome: boolean;
|
||||
chromeHidden: boolean;
|
||||
onToggleChrome: () => void;
|
||||
}) {
|
||||
return (
|
||||
<OverlayMenu
|
||||
badge={peers.length}
|
||||
unread={commentsUnread.threads}
|
||||
unreadMention={commentsUnread.mentioned}
|
||||
alert={staleLibItems.size > 0}
|
||||
>
|
||||
{/* PEOPLE — who else is here, and whose view you're locked to. The
|
||||
follow state lives on each person's own row (PresenceRoster), so
|
||||
there is no separate "Following…" banner to keep in sync. */}
|
||||
{peers.length > 0 && (
|
||||
<OverlayMenuSection label="People">
|
||||
<PresenceRoster
|
||||
peers={peers}
|
||||
activeSheetPath={activeSheetPath}
|
||||
following={followingTarget}
|
||||
onFollow={onFollow}
|
||||
/>
|
||||
</OverlayMenuSection>
|
||||
)}
|
||||
|
||||
{/* DOCUMENT — where this file came from and whether you may edit it.
|
||||
SourceChip is shared with the light project pages, so instead of
|
||||
restyling it we ask for its `muted` tone: colour drops to a dot,
|
||||
and the chip sits in a normal row like everything else. */}
|
||||
{(sourceDescriptor || readOnly || staleLibItems.size > 0) && (
|
||||
<OverlayMenuSection label="Document">
|
||||
{staleLibItems.size > 0 && (
|
||||
<StaleLibsRow
|
||||
items={staleLibItems}
|
||||
updating={staleUpdating}
|
||||
readOnly={readOnly}
|
||||
onUpdate={onUpdateStale}
|
||||
onDismiss={onDismissStale}
|
||||
/>
|
||||
)}
|
||||
{sourceDescriptor && (
|
||||
<div className={`${overlayRowClass} cursor-default`}>
|
||||
<SourceChip descriptor={sourceDescriptor} tone="muted" />
|
||||
</div>
|
||||
)}
|
||||
{readOnly && (
|
||||
<div
|
||||
data-testid="view-only-pill"
|
||||
className={`${overlayRowClass} cursor-default`}
|
||||
>
|
||||
<EyeOff size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
<span>View only</span>
|
||||
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/40">
|
||||
read-only
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMenuSection>
|
||||
)}
|
||||
|
||||
{hasComments && (
|
||||
<OverlayMenuSection label="Comments">
|
||||
<div
|
||||
data-testid="overlay-menu-comments"
|
||||
ref={setCommentsSlot}
|
||||
className="flex w-full flex-col items-start gap-2"
|
||||
/>
|
||||
</OverlayMenuSection>
|
||||
)}
|
||||
|
||||
<OverlayMenuSection label="View">
|
||||
{/* Viewer panels (viewer-panels): canvas-only stand-ins for the
|
||||
chrome-hidden wx panes — available to viewers and to editors
|
||||
in hide-UI mode alike. */}
|
||||
{effectiveChromeHidden && hasLayers && (
|
||||
<button
|
||||
data-testid="layers-panel-toggle"
|
||||
aria-pressed={layersOpen}
|
||||
className={overlayRowClass}
|
||||
title="Board layers — visibility and active layer"
|
||||
onClick={() => setLayersOpen(!layersOpen)}
|
||||
>
|
||||
<Layers size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
<span>{layersOpen ? "Hide layers" : "Layers"}</span>
|
||||
</button>
|
||||
)}
|
||||
{effectiveChromeHidden && (tool === "pcbnew" || tool === "eeschema") && (
|
||||
<button
|
||||
data-testid="inspector-panel-toggle"
|
||||
aria-pressed={inspectorOpen}
|
||||
className={overlayRowClass}
|
||||
title="Properties of the selected items"
|
||||
onClick={() => setInspectorOpen(!inspectorOpen)}
|
||||
>
|
||||
<Crosshair size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
<span>{inspectorOpen ? "Hide inspector" : "Inspector"}</span>
|
||||
</button>
|
||||
)}
|
||||
{canToggleChrome && !readOnly && (
|
||||
<button
|
||||
data-testid="chrome-toggle"
|
||||
aria-pressed={chromeHidden}
|
||||
className={overlayRowClass}
|
||||
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
|
||||
onClick={onToggleChrome}
|
||||
>
|
||||
{chromeHidden ? (
|
||||
<PanelsTopLeft size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
) : (
|
||||
<EyeOff size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
)}
|
||||
<span>{chromeHidden ? "Show UI" : "Hide UI"}</span>
|
||||
<kbd className="ml-auto rounded bg-black/10 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/10 dark:text-white/50">
|
||||
{CHROME_HOTKEY_LABEL}
|
||||
</kbd>
|
||||
</button>
|
||||
)}
|
||||
{/* Light/dark toggle (comments-ux 0002): flips the shell theme;
|
||||
the F4 effect in WasmTool re-themes the GAL canvas through the
|
||||
bridge. Available to viewers too — theming isn't editing. */}
|
||||
<button
|
||||
data-testid="overlay-theme-toggle"
|
||||
aria-pressed={theme === "dark"}
|
||||
className={overlayRowClass}
|
||||
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
) : (
|
||||
<Moon size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
)}
|
||||
<span>{theme === "dark" ? "Light mode" : "Dark mode"}</span>
|
||||
</button>
|
||||
</OverlayMenuSection>
|
||||
</OverlayMenu>
|
||||
);
|
||||
}
|
||||
333
web/standalone/src/components/wasm-tool/useLibNotices.ts
Normal file
333
web/standalone/src/components/wasm-tool/useLibNotices.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
|
||||
import * as React from "react";
|
||||
import {
|
||||
LIB_BUSY_EVENT,
|
||||
LIB_ERROR_EVENT,
|
||||
LIB_ITEM_UPDATED_EVENT,
|
||||
LIB_LOADING_EVENT,
|
||||
LIB_SET_CHANGED_EVENT,
|
||||
type LibBusyDetail,
|
||||
type LibErrorDetail,
|
||||
type LibItemUpdatedDetail,
|
||||
type LibLoadingDetail,
|
||||
type LibSetChangedDetail,
|
||||
type LibsSource,
|
||||
} from "@/wasm/libs/source";
|
||||
import { MODELS_LOADING_EVENT, type ModelsLoadingDetail } from "@/wasm/libs/models-bridge";
|
||||
import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding";
|
||||
import { addAnnouncedLib } from "@/wasm/libs/runtime-add";
|
||||
import { reloadFallbackMsg } from "./ui-helpers";
|
||||
|
||||
/** One library's placed items a peer updated (libs 0017 §2b). */
|
||||
export interface StaleLibEntry {
|
||||
kind: string;
|
||||
lib: string;
|
||||
names: Set<string>;
|
||||
}
|
||||
|
||||
export interface LibSetNotice {
|
||||
message: string;
|
||||
detail: LibSetChangedDetail;
|
||||
mode: "load" | "reload";
|
||||
}
|
||||
|
||||
export interface LibLoadingState {
|
||||
kind: string;
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every notice the running editor raises about its libraries and document —
|
||||
* the window events the libs bridge / collab binding dispatch, the transient
|
||||
* toasts they become (with their auto-dismiss timers), the persistent
|
||||
* "behind the library" state with its update-from-library action, and the
|
||||
* eager-load / 3D-model progress badges. Pure state + listeners; WasmTool
|
||||
* renders it through NoticeStack / SessionMenu / LibLoadingOverlay.
|
||||
*/
|
||||
export function useLibNotices(opts: {
|
||||
/** The one libs source instance the running editor uses (set by the boot
|
||||
* effect) — the libset toast's action needs it to re-list and load. */
|
||||
getLibsSource: () => LibsSource | null;
|
||||
}) {
|
||||
// A library item currently being fetched (open/save), for a transient spinner.
|
||||
const [libBusy, setLibBusy] = React.useState<string | null>(null);
|
||||
// Last lib error (e.g. a backend 404 on open), shown as a dismissible toast.
|
||||
const [libError, setLibError] = React.useState<string | null>(null);
|
||||
// A collaborator updated library items that are PLACED in the open document
|
||||
// (LIB_ITEM_UPDATED_EVENT) — placed copies keep the previous version, so warn.
|
||||
const [libUpdate, setLibUpdate] = React.useState<string | null>(null);
|
||||
// Persistent "behind the library" state (libs 0017 §2b): every PLACED item a
|
||||
// peer's lib edit touched, keyed `<kind>\u0000<lib>` → names. The toast above
|
||||
// is disposable; this survives until the user updates from the library
|
||||
// (2c) or dismisses it, and drives the FAB's amber triangle + the Document
|
||||
// section row. Symbols/footprints only — the kinds with a placed-usage
|
||||
// bridge (kicadLibsSymbolUsage / kicadLibsFootprintUsage).
|
||||
const [staleLibItems, setStaleLibItems] = React.useState<Map<string, StaleLibEntry>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [staleUpdating, setStaleUpdating] = React.useState(false);
|
||||
const staleKey = (kind: string, lib: string) => `${kind}\u0000${lib}`;
|
||||
const noteStale = React.useCallback((kind: string, lib: string, names: string[]) => {
|
||||
if (names.length === 0) return;
|
||||
setStaleLibItems((prev) => {
|
||||
const next = new Map(prev);
|
||||
const k = staleKey(kind, lib);
|
||||
const cur = next.get(k) ?? { kind, lib, names: new Set<string>() };
|
||||
const merged = new Set(cur.names);
|
||||
for (const n of names) merged.add(n);
|
||||
next.set(k, { kind, lib, names: merged });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const clearStale = React.useCallback((key?: string) => {
|
||||
setStaleLibItems((prev) => {
|
||||
if (key === undefined) return new Map();
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
/** Update every placed instance of the stale items from the library (2c). */
|
||||
const updateStaleFromLibrary = React.useCallback(async () => {
|
||||
const mod = (window as { Module?: { kicadUpdateFromLibrary?: unknown } }).Module;
|
||||
const fn = mod?.kicadUpdateFromLibrary;
|
||||
if (typeof fn !== "function") {
|
||||
setLibError("This editor build can't update placed items from the library — reload to refresh them.");
|
||||
return;
|
||||
}
|
||||
setStaleUpdating(true);
|
||||
try {
|
||||
for (const [key, entry] of staleLibItems) {
|
||||
// The bridge queues the edit on the frame's coroutine and answers
|
||||
// {queued:true}; the outcome arrives as a `pcbjam:lib-update-done`
|
||||
// window event (or {ok:false,error} synchronously).
|
||||
const done = new Promise<{ ok: boolean; updated?: number; error?: string }>((resolve) => {
|
||||
const onDone = (e: Event) => {
|
||||
window.removeEventListener("pcbjam:lib-update-done", onDone);
|
||||
resolve((e as CustomEvent<{ ok: boolean; updated?: number }>).detail);
|
||||
};
|
||||
window.addEventListener("pcbjam:lib-update-done", onDone);
|
||||
setTimeout(() => {
|
||||
window.removeEventListener("pcbjam:lib-update-done", onDone);
|
||||
resolve({ ok: false, error: "timed out" });
|
||||
}, 30_000);
|
||||
});
|
||||
let res: { ok?: boolean; queued?: boolean; error?: string } = {};
|
||||
try {
|
||||
res = JSON.parse(
|
||||
(fn as (kind: string, lib: string, namesJson: string) => string)(
|
||||
entry.kind,
|
||||
entry.lib,
|
||||
JSON.stringify([...entry.names]),
|
||||
),
|
||||
) as typeof res;
|
||||
} catch {
|
||||
res = { ok: false, error: "bridge call failed" };
|
||||
}
|
||||
const outcome = res.ok === false ? { ok: false, error: res.error } : await done;
|
||||
if (!outcome.ok) {
|
||||
setLibError(`Couldn't update from the library: ${outcome.error ?? "unknown error"}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`[libs] updated ${outcome.updated ?? "?"} placed ${entry.kind}(s) from "${entry.lib}"`);
|
||||
clearStale(key);
|
||||
}
|
||||
} finally {
|
||||
setStaleUpdating(false);
|
||||
}
|
||||
}, [staleLibItems, clearStale]);
|
||||
// The backend rolled this document back to its last valid state
|
||||
// (kicad-validity 0001 — DOC_REVERTED_EVENT from the collab binding).
|
||||
const [docReverted, setDocReverted] = React.useState<string | null>(null);
|
||||
// A peer changed the team's lib SET mid-session (LIB_SET_CHANGED_EVENT —
|
||||
// the scope room's `libset` broadcast). The lib table is frozen at boot, so
|
||||
// the toast's click action loads the new lib live (addAnnouncedLib), with a
|
||||
// reload fallback when the runtime bridge is missing.
|
||||
const [libSetNotice, setLibSetNotice] = React.useState<LibSetNotice | null>(null);
|
||||
// Eager whole-library idb→wasm load in flight (the ~tens-of-seconds fat-load on
|
||||
// first chooser/editor open). Drives a full-cover overlay so the freeze reads as
|
||||
// "loading, just slow" rather than a hang. Null when idle; `done/total` count the
|
||||
// per-lib fat-load crossings so the overlay can show a progress bar.
|
||||
const [libLoading, setLibLoading] = React.useState<LibLoadingState | null>(null);
|
||||
// Board 3D-model prefetch in flight (background; the viewer works without it —
|
||||
// anything still missing lazy-loads per model). Small badge, not an overlay.
|
||||
const [modelsSync, setModelsSync] = React.useState<string | null>(null);
|
||||
|
||||
// Loading/error chrome for library item fetches (open/save), driven by events
|
||||
// the libs bridge dispatches (wasm/libs/source). The fetch is otherwise
|
||||
// invisible; a 404 would silently do nothing without this.
|
||||
React.useEffect(() => {
|
||||
let busyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const onBusy = (e: Event) => {
|
||||
const d = (e as CustomEvent<LibBusyDetail>).detail;
|
||||
clearTimeout(busyTimer);
|
||||
if (d.busy) {
|
||||
// Debounce — only flag slow fetches, so fast ones don't flicker.
|
||||
busyTimer = setTimeout(() => setLibBusy(d.name || "library item"), 180);
|
||||
} else {
|
||||
setLibBusy(null);
|
||||
}
|
||||
};
|
||||
const onError = (e: Event) => {
|
||||
setLibError((e as CustomEvent<LibErrorDetail>).detail.message);
|
||||
};
|
||||
const onItemUpdated = (e: Event) => {
|
||||
const d = (e as CustomEvent<LibItemUpdatedDetail>).detail;
|
||||
// Only warn when the update touches something PLACED here — the library
|
||||
// tree already reflects updates to everything else. Both kinds have a
|
||||
// placed-usage bridge now (libs 0017 §2d added the footprint one).
|
||||
if (d.usedNames.length === 0) return;
|
||||
const label = d.kind === "footprint" ? "Footprint" : "Symbol";
|
||||
const names = d.usedNames.map((n) => `"${n}"`).join(", ");
|
||||
noteStale(d.kind, d.lib, d.usedNames);
|
||||
setLibUpdate(
|
||||
`${d.usedNames.length === 1 ? label : `${label}s`} ${names} in "${d.lib}" ` +
|
||||
`${d.usedNames.length === 1 ? "was" : "were"} updated by a collaborator — ` +
|
||||
`placed copies keep the previous version. Update them from the session menu.`,
|
||||
);
|
||||
};
|
||||
const onDocReverted = (e: Event) => {
|
||||
const d = (e as CustomEvent<{ reason?: string; at?: string }>).detail;
|
||||
setDocReverted(
|
||||
`This document was rolled back to its last valid state — invalid content ` +
|
||||
`was detected${d?.reason ? ` (${d.reason})` : ""}. Recent edits may have been undone.`,
|
||||
);
|
||||
};
|
||||
const onLibSet = (e: Event) => {
|
||||
const d = (e as CustomEvent<LibSetChangedDetail>).detail;
|
||||
// Only additions get a call to action — a removed lib's table row is
|
||||
// inert until the next boot and needs no interruption.
|
||||
if (d.op !== "add") return;
|
||||
setLibSetNotice({
|
||||
message: d.name
|
||||
? `A collaborator added library "${d.name}" — click to load it into this session.`
|
||||
: `A collaborator added a new library — click to load it into this session.`,
|
||||
detail: d,
|
||||
mode: "load",
|
||||
});
|
||||
};
|
||||
window.addEventListener(LIB_BUSY_EVENT, onBusy);
|
||||
window.addEventListener(LIB_ERROR_EVENT, onError);
|
||||
window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
|
||||
window.addEventListener(LIB_SET_CHANGED_EVENT, onLibSet);
|
||||
window.addEventListener(DOC_REVERTED_EVENT, onDocReverted);
|
||||
return () => {
|
||||
clearTimeout(busyTimer);
|
||||
window.removeEventListener(LIB_BUSY_EVENT, onBusy);
|
||||
window.removeEventListener(LIB_ERROR_EVENT, onError);
|
||||
window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
|
||||
window.removeEventListener(LIB_SET_CHANGED_EVENT, onLibSet);
|
||||
window.removeEventListener(DOC_REVERTED_EVENT, onDocReverted);
|
||||
};
|
||||
}, [noteStale]);
|
||||
|
||||
// Auto-dismiss the lib error toast.
|
||||
React.useEffect(() => {
|
||||
if (!libError) return;
|
||||
const t = setTimeout(() => setLibError(null), 6000);
|
||||
return () => clearTimeout(t);
|
||||
}, [libError]);
|
||||
|
||||
// Auto-dismiss the lib update toast (a touch longer — it carries a caveat).
|
||||
React.useEffect(() => {
|
||||
if (!libUpdate) return;
|
||||
const t = setTimeout(() => setLibUpdate(null), 10_000);
|
||||
return () => clearTimeout(t);
|
||||
}, [libUpdate]);
|
||||
|
||||
// Auto-dismiss the lib-set toast (long — it carries a click action).
|
||||
React.useEffect(() => {
|
||||
if (!libSetNotice) return;
|
||||
const t = setTimeout(() => setLibSetNotice(null), 30_000);
|
||||
return () => clearTimeout(t);
|
||||
}, [libSetNotice]);
|
||||
|
||||
// Auto-dismiss the doc-reverted toast (longest — the user should see it).
|
||||
React.useEffect(() => {
|
||||
if (!docReverted) return;
|
||||
const t = setTimeout(() => setDocReverted(null), 15_000);
|
||||
return () => clearTimeout(t);
|
||||
}, [docReverted]);
|
||||
|
||||
// Full-library eager load overlay. The fat-load fires one loading:true/false
|
||||
// pair PER library (222 on the full set), and between them the C++ side parses
|
||||
// with the main thread blocked. Show immediately on `true`, and only hide after
|
||||
// a short quiet gap on `false` (reset by the next lib's `true`) — so the overlay
|
||||
// stays continuous across the whole run and drops shortly after the last lib,
|
||||
// instead of flickering 222 times.
|
||||
React.useEffect(() => {
|
||||
let hideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const onLoading = (e: Event) => {
|
||||
const d = (e as CustomEvent<LibLoadingDetail>).detail;
|
||||
clearTimeout(hideTimer);
|
||||
// Update the bar on every event (true and false) so the count reflects the
|
||||
// latest lib; arm the hide only when the run reports it's winding down.
|
||||
setLibLoading({ kind: d.kind || "library", done: d.done, total: d.total });
|
||||
if (!d.loading) {
|
||||
hideTimer = setTimeout(() => setLibLoading(null), 700);
|
||||
}
|
||||
};
|
||||
window.addEventListener(LIB_LOADING_EVENT, onLoading);
|
||||
return () => {
|
||||
clearTimeout(hideTimer);
|
||||
window.removeEventListener(LIB_LOADING_EVENT, onLoading);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Board 3D-model prefetch progress (models-bridge prescan) — background badge.
|
||||
React.useEffect(() => {
|
||||
const onModels = (e: Event) => {
|
||||
const d = (e as CustomEvent<ModelsLoadingDetail>).detail;
|
||||
setModelsSync(
|
||||
d.loading ? `Fetching 3D models — ${d.done}/${d.total}` : null,
|
||||
);
|
||||
};
|
||||
window.addEventListener(MODELS_LOADING_EVENT, onModels);
|
||||
return () => window.removeEventListener(MODELS_LOADING_EVENT, onModels);
|
||||
}, []);
|
||||
|
||||
const { getLibsSource } = opts;
|
||||
/** The libset toast's click: load the announced lib live, else offer a reload. */
|
||||
const onLibSetClick = React.useCallback(() => {
|
||||
const notice = libSetNotice;
|
||||
if (!notice) return;
|
||||
if (notice.mode === "reload") {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
const source = getLibsSource();
|
||||
if (!source) {
|
||||
setLibSetNotice({ ...notice, mode: "reload", message: reloadFallbackMsg(notice) });
|
||||
return;
|
||||
}
|
||||
setLibSetNotice(null);
|
||||
void addAnnouncedLib(source, notice.detail, (m) => console.log(m)).then((ok) => {
|
||||
if (!ok) {
|
||||
setLibSetNotice({ ...notice, mode: "reload", message: reloadFallbackMsg(notice) });
|
||||
}
|
||||
});
|
||||
}, [libSetNotice, getLibsSource]);
|
||||
|
||||
const dismissLibError = React.useCallback(() => setLibError(null), []);
|
||||
const dismissLibUpdate = React.useCallback(() => setLibUpdate(null), []);
|
||||
const dismissDocReverted = React.useCallback(() => setDocReverted(null), []);
|
||||
|
||||
return {
|
||||
libBusy,
|
||||
libError,
|
||||
dismissLibError,
|
||||
libUpdate,
|
||||
dismissLibUpdate,
|
||||
libSetNotice,
|
||||
onLibSetClick,
|
||||
docReverted,
|
||||
dismissDocReverted,
|
||||
libLoading,
|
||||
modelsSync,
|
||||
staleLibItems,
|
||||
staleUpdating,
|
||||
clearStale,
|
||||
updateStaleFromLibrary,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue