diff --git a/kicad b/kicad index 0bf6c9c..c2e0545 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit 0bf6c9c34e08fc2e36dfe97acad55574cbfc8cd1 +Subproject commit c2e0545e4745d38eddc98da122dcf7c147fbf610 diff --git a/scripts/common/shims/jspi-scheduler.js b/scripts/common/shims/jspi-scheduler.js index 97e660b..9c8a5c8 100644 --- a/scripts/common/shims/jspi-scheduler.js +++ b/scripts/common/shims/jspi-scheduler.js @@ -100,6 +100,7 @@ "kicadCollabSetViewport", "kicadCollabFitViewport", "kicadCollabReleaseSelection", "kicadSetColorTheme", "kicadSaveBoard", "kicadSaveSchematic", "kicadSaveDrawingSheet", + "kicadLayersSetVisible", "kicadLayersSetActive", ], mutatorQueue: [], mutatorsWrapped: 0, diff --git a/tests/web/read-only-editor.spec.ts b/tests/web/read-only-editor.spec.ts index b245deb..a06f6ec 100644 --- a/tests/web/read-only-editor.spec.ts +++ b/tests/web/read-only-editor.spec.ts @@ -10,9 +10,10 @@ test.describe.configure({ mode: 'serial' }); /** * Read-only viewer e2e (read-only-viewer): `?readonly=1` boots the pcbnew * editor as a locked viewer — chrome force-hidden with no toggle, the - * Cmd/Ctrl+\ chord inert, nothing selectable or deletable through the REAL - * input paths (the kicad PCBJAM_READ_ONLY gates), zoom/pan alive — while a - * writer tab on the same board (broadcastchannel room) stays fully editable. + * Cmd/Ctrl+\ chord inert, selection alive for INSPECTION (viewer-panels) but + * nothing editable through the REAL input paths (the kicad PCBJAM_READ_ONLY + * gates), zoom/pan alive — while a writer tab on the same board + * (broadcastchannel room) stays fully editable. * * The viewer boots FIRST on the fresh room (fresh browser context ⇒ empty * broadcastchannel room): a read-only binding must not seed it; the writer @@ -33,6 +34,10 @@ type Mod = { kicadCollabTestClearSelection(): boolean; kicadCollabGetPos(id: string): string; kicadCollabTestMoveFirst(dx: number, dy: number): string; + // Layer bridge (viewer-panels). + kicadLayersGetState(): string; + kicadLayersSetVisible(id: number, visible: boolean): boolean | Promise; + kicadLayersSetActive(id: number): boolean | Promise; }; type W = { Module: Mod }; @@ -118,7 +123,7 @@ test.afterAll(async () => { await viewer?.close(); }); -test('viewer boots locked: chrome-less, nothing selectable, hotkey edits inert, zoom alive', async () => { +test('viewer boots locked: chrome-less, selection inspect-only, hotkey edits inert, zoom alive', async () => { test.setTimeout(240_000); // Chrome force-hidden: no menubar, no console footer, no toggle — the @@ -180,7 +185,7 @@ test('viewer boots locked: chrome-less, nothing selectable, hotkey edits inert, .poll( async () => (await selection(writer)).length > 0 || - (await writer.getByText(/Show More Choices/).count()) > 0, + (await writer.locator('.wx-menu-popup').count()) > 0, { timeout: 20000, message: "writer's click should select the item or pop the clarify menu", @@ -190,10 +195,79 @@ test('viewer boots locked: chrome-less, nothing selectable, hotkey edits inert, await writer.keyboard.press('Escape'); // dismiss a clarify popup, drop any selection await writer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection()); + // Selection is LIVE for viewers (viewer-panels): the same real click that + // selects for the writer selects for the viewer too (or pops the clarify + // list when several items overlap) — the inspector panel consumes it. + // Everything downstream of the selection stays locked (probed below). const viewerClick = await screenPosOf(viewer, itemWorld); await viewer.mouse.click(viewerClick.x, viewerClick.y); - expect(await selection(viewer), 'viewer click on the item must select nothing').toEqual([]); - await expect(viewer.getByText(/Show More Choices/)).toHaveCount(0); + await expect + .poll( + async () => + (await selection(viewer)).length > 0 || + (await viewer.locator('.wx-menu-popup').count()) > 0, + { + timeout: 20000, + message: "viewer's click should select the item or pop the clarify menu", + }, + ) + .toBe(true); + await viewer.keyboard.press('Escape'); // dismiss a clarify popup, drop the selection + await viewer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection()); + + // Right-click: the clarify (disambiguation) list stays ALLOWED for viewers + // — it is pure selection — but the CONTEXT menu that follows a resolved + // right-click must NOT open (it offers edit entries the action gate + // silently swallows). Positive control first: the writer's right-click + // (clarify entry 1 if ambiguous) opens the context menu. + // All popup probes scope to `.wx-menu-popup` — the hidden wx chrome keeps + // e.g. a "Properties" pane caption in the DOM, so a bare getByText count + // would false-positive on both pages. + const popupWithProperties = (pg: Page) => + pg.locator('.wx-menu-popup').getByText(/Properties/).count(); + // Any open wx popup — the clarify list has numbered rows but only shows + // "Show More Choices" when the collector held extra candidates, so the + // text is NOT a reliable marker. + const clarifyOpen = (pg: Page) => + pg.locator('.wx-menu-popup').count(); + + await writer.mouse.click(writerClick.x, writerClick.y, { button: 'right' }); + await expect + .poll( + async () => (await clarifyOpen(writer)) > 0 || (await popupWithProperties(writer)) > 0, + { timeout: 20000, message: "writer's right-click should open a menu" }, + ) + .toBe(true); + if ((await clarifyOpen(writer)) > 0 && (await popupWithProperties(writer)) === 0) { + await writer.locator('.wx-menu-popup > div').filter({ hasText: /^\s*1\s/ }).first().click(); // choose clarify entry 1 + } + await expect + .poll(() => popupWithProperties(writer), { + timeout: 20000, + message: "writer's resolved right-click should open the context menu", + }) + .toBeGreaterThan(0); + await writer.keyboard.press('Escape'); + await writer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection()); + + // Viewer, same gesture: the clarify list may resolve the selection, but + // no context menu follows — no popup remains (or reopens) after the choice. + await viewer.mouse.click(viewerClick.x, viewerClick.y, { button: 'right' }); + await expect + .poll( + async () => (await clarifyOpen(viewer)) > 0 || (await selection(viewer)).length > 0, + { timeout: 20000, message: "viewer's right-click should reach selection" }, + ) + .toBe(true); + if ((await clarifyOpen(viewer)) > 0) { + await viewer.locator('.wx-menu-popup > div').filter({ hasText: /^\s*1\s/ }).first().click(); // choose clarify entry 1 + } + // Documented interaction dwell: the context menu would open within a frame + // or two of the resolved selection — give it time, then assert it didn't. + await viewer.waitForTimeout(800); // dwell + await expect(viewer.locator('.wx-menu-popup')).toHaveCount(0); + await viewer.keyboard.press('Escape'); + await viewer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection()); // Gate 1 (action allowlist): even with an item force-selected through the // test hook (AddItemToSel bypasses Selectable by design), the Delete hotkey @@ -253,3 +327,129 @@ test("a writer's edits stream into the viewer live (and never the reverse)", asy // The viewer's board still matches the writer's (nothing flowed back). expect(await posOf(writer, itemId)).toBe(await posOf(viewer, itemId)); }); + +test('viewer panels: layer selector + selection inspector (viewer-panels)', async () => { + test.setTimeout(240_000); + + const layersState = () => + viewer.evaluate( + () => + JSON.parse((window as unknown as W).Module.kicadLayersGetState()) as { + active: number; + layers: Array<{ id: number; name: string; copper: boolean; visible: boolean }>; + }, + ); + + // ── layer panel ──────────────────────────────────────────────────────────── + await openOverlayMenu(viewer); + await viewer.getByTestId('layers-panel-toggle').click(); + await expect(viewer.getByTestId('layers-panel')).toBeVisible(); + // Close the overlay menu (z-50) — it overlaps the panel's default anchor + // and would swallow the row clicks below. + await viewer.keyboard.press('Escape'); + await expect(viewer.getByTestId('overlay-menu-panel')).toHaveCount(0); + await expect(viewer.locator('[data-testid="layer-row"]').first()).toBeVisible(); + + const st0 = await layersState(); + expect(st0.layers.length, 'bridge lists the enabled layers').toBeGreaterThan(0); + const copper = st0.layers.filter((l) => l.copper); + expect(copper.length, 'demo board has F.Cu + B.Cu').toBeGreaterThanOrEqual(2); + const target = copper.find((l) => l.id !== st0.active) ?? copper[0]!; + const row = viewer.locator(`[data-testid="layer-row"][data-layer-id="${target.id}"]`); + + // Eye toggle hides/shows the layer — confirmed through the bridge (the + // apply runs on the wasm coroutine; the panel updates from the C++ push). + await row.getByTestId('layer-visibility').click(); + await expect + .poll(async () => (await layersState()).layers.find((l) => l.id === target.id)?.visible, { + timeout: 15000, + message: 'eye toggle should hide the layer', + }) + .toBe(false); + await row.getByTestId('layer-visibility').click(); + await expect + .poll(async () => (await layersState()).layers.find((l) => l.id === target.id)?.visible, { + timeout: 15000, + message: 'second toggle should show the layer again', + }) + .toBe(true); + + // Row click sets the ACTIVE layer. + await row.getByTestId('layer-activate').click(); + await expect + .poll(async () => (await layersState()).active, { timeout: 15000 }) + .toBe(target.id); + + // Drag by the header — the shared draggable-panel behavior (position also + // persists via localStorage, covered by useDraggablePanel's unit tests and + // the comments panel spec). + const before = (await viewer.getByTestId('layers-panel').boundingBox())!; + const hb = (await viewer.getByTestId('layers-panel-header').boundingBox())!; + await viewer.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2); + await viewer.mouse.down(); + await viewer.mouse.move(hb.x + hb.width / 2 - 120, hb.y + hb.height / 2 + 90, { steps: 5 }); + await viewer.mouse.up(); + const after = (await viewer.getByTestId('layers-panel').boundingBox())!; + expect(Math.round(after.x - before.x)).toBe(-120); + expect(Math.round(after.y - before.y)).toBe(90); + + // Close it before the canvas click below — a floating panel over the item + // would swallow the click. + await viewer.getByTestId('layers-panel-close').click(); + await expect(viewer.getByTestId('layers-panel')).toHaveCount(0); + + // ── selection inspector ──────────────────────────────────────────────────── + // A REAL canvas click selects for the viewer (viewer-panels); the store + // keeps the selection, so the inspector may open after the click. + const itemId = await writer.evaluate(() => + (window as unknown as W).Module.kicadCollabTestSelectFirst(), + ); + const itemWorld = await posOf(writer, itemId); + await writer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection()); + + const pt = await screenPosOf(viewer, itemWorld); + await viewer.mouse.click(pt.x, pt.y); + await expect + .poll( + async () => + (await selection(viewer)).length > 0 || + (await viewer.locator('.wx-menu-popup').count()) > 0, + { timeout: 20000, message: 'viewer click should select or pop the clarify list' }, + ) + .toBe(true); + // Overlapping items popped the clarify list — pick the first entry. + if ((await viewer.locator('.wx-menu-popup').count()) > 0) { + await viewer.locator('.wx-menu-popup > div').filter({ hasText: /^\s*1\s/ }).first().click(); + await expect + .poll(async () => (await selection(viewer)).length, { timeout: 15000 }) + .toBeGreaterThan(0); + } + + await openOverlayMenu(viewer); + await viewer.getByTestId('inspector-panel-toggle').click(); + await expect(viewer.getByTestId('inspector-panel')).toBeVisible(); + // Close the covering overlay menu WITHOUT Escape — the allowlisted + // cancelInteractive would clear the selection the inspector is about to + // show. The FAB toggle never touches the canvas. + await viewer.getByTestId('overlay-menu-fab').click(); + await expect(viewer.getByTestId('overlay-menu-panel')).toHaveCount(0); + // The already-made selection renders with real property rows (every item + // type yields at least one of these labels). + await expect + .poll(() => viewer.getByTestId('inspector-item').count(), { + timeout: 20000, + message: 'inspector should list the selected item', + }) + .toBeGreaterThan(0); + await expect(viewer.getByTestId('inspector-item').first()).toContainText( + /Position|Start|Net|Layer/, + ); + + await viewer.screenshot({ path: shotPath(viewer, 'web-viewer-panels.png'), scale: 'css' }); + + // Close + clear: Esc drops the selection, the inspector empties live. + await viewer.keyboard.press('Escape'); + await viewer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection()); + await viewer.getByTestId('inspector-panel-close').click(); + await expect(viewer.getByTestId('inspector-panel')).toHaveCount(0); +}); diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index 493b1cf..3039f19 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -31,6 +31,8 @@ #include #include #include +#include +#include #include #include #include @@ -1681,6 +1683,119 @@ void pcbSetDarkChrome( bool aDark ) pcbjam_theme::setDarkChromeFlag( aDark ); } +// ── Layer bridge (viewer-panels) ───────────────────────────────────────────── +// The React layer panel's read/toggle surface for canvas-only sessions (the wx +// Appearance pane is chrome-hidden there). Per-layer visibility has no +// TOOL_ACTION, so the setter bodies mirror KiCad's IPC handlers +// (pcbnew/api/api_handler_pcb.cpp handleSetVisibleLayers/handleSetActiveLayer — +// compiled into this image but unreachable, nng transport stubbed). Both +// setters are view-only state: nothing touches the document or the save path +// (visibility persists to the .kicad_prl on native, nowhere here). + +// Full layer state as one JSON payload: +// { "active": int, "layers": [{ id, name, canonical, copper, visible, color }] } +// in UI order (the wx Appearance panel's), colors from the live COLOR_SETTINGS +// so the panel's swatches match the canvas theme. +static json layersStateJson( PCB_EDIT_FRAME* aFrame ) +{ + BOARD* board = aFrame->GetBoard(); + COLOR_SETTINGS* colors = aFrame->GetColorSettings(); + + json layers = json::array(); + + for( PCB_LAYER_ID layer : board->GetEnabledLayers().UIOrder() ) + { + layers.push_back( json{ + { "id", static_cast( layer ) }, + { "name", pcbjam_collab::toUtf8( board->GetLayerName( layer ) ) }, + { "canonical", pcbjam_collab::toUtf8( BOARD::GetStandardLayerName( layer ) ) }, + { "copper", IsCopperLayer( layer ) }, + { "visible", board->IsLayerVisible( layer ) }, + { "color", colors ? pcbjam_collab::toUtf8( colors->GetColor( layer ).ToCSSString() ) + : std::string() } } ); + } + + return json{ { "active", static_cast( aFrame->GetActiveLayer() ) }, + { "layers", layers } }; +} + +// Post-apply push: window.kicadCollab.onLayersState — the panel updates +// event-driven instead of polling (both setters apply on the coroutine, so a +// synchronous re-read right after the embind call would still see old state). +static void emitLayersState( PCB_EDIT_FRAME* aFrame ) +{ + std::string s = layersStateJson( aFrame ).dump(); + EM_ASM( { + if( window.kicadCollab && window.kicadCollab.onLayersState ) + window.kicadCollab.onLayersState( UTF8ToString( $0 ) ); + }, s.c_str() ); +} + +std::string pcbLayersGetState() +{ + PCB_EDIT_FRAME* fr = pcbFrame(); + + if( !fr ) + return ""; + + return layersStateJson( fr ).dump(); +} + +// Show/hide ONE layer. Validated synchronously (frame up, layer enabled); +// the apply itself runs on the coroutine like every other view mutation +// from JS, then pushes the fresh state to onLayersState. +bool pcbLayersSetVisible( int aLayer, bool aVisible ) +{ + PCB_EDIT_FRAME* fr = pcbFrame(); + + if( !fr ) + return false; + + PCB_LAYER_ID layer = static_cast( aLayer ); + + if( !fr->GetBoard()->GetEnabledLayers().Contains( layer ) ) + return false; + + pcbjam_collab::runOnCoroutine( fr, [fr, layer, aVisible]() { + BOARD* board = fr->GetBoard(); + LSET visible = board->GetVisibleLayers(); + + visible.set( layer, aVisible ); + board->SetVisibleLayers( visible ); + + // Keep the (chrome-hidden but alive) wx Appearance pane in sync — + // same follow-ups as the IPC handler. + if( APPEARANCE_CONTROLS* panel = fr->GetAppearancePanel() ) + panel->OnBoardChanged(); + + fr->GetCanvas()->SyncLayersVisibility( board ); + fr->Refresh(); + emitLayersState( fr ); + } ); + + return true; +} + +bool pcbLayersSetActive( int aLayer ) +{ + PCB_EDIT_FRAME* fr = pcbFrame(); + + if( !fr ) + return false; + + PCB_LAYER_ID layer = static_cast( aLayer ); + + if( !fr->GetBoard()->GetEnabledLayers().Contains( layer ) ) + return false; + + pcbjam_collab::runOnCoroutine( fr, [fr, layer]() { + fr->SetActiveLayer( layer, /* aForceRedraw */ true ); + emitLayersState( fr ); + } ); + + return true; +} + // Tuner helper: a VARIED demo-selection set — labeled uuid groups (smallest + // largest footprint, the two busiest nets' track segments) so the style // preview shows the real range of shapes instead of two overlapping items. @@ -2382,6 +2497,11 @@ EMSCRIPTEN_BINDINGS(pcbnew) { // Programmatic save of the in-memory board (round-trip tests, README §A). function("kicadSaveBoard", &kicadSaveBoard); + // Layer bridge (viewer-panels) — pcbnew-only names, merged-image safe + // (null-frame no-op when eeschema is the live frame). + function("kicadLayersGetState", &pcbLayersGetState); + function("kicadLayersSetVisible", &pcbLayersSetVisible); + function("kicadLayersSetActive", &pcbLayersSetActive); // pcbnew-only test helper (no eeschema counterpart — name is not shared). function("kicadCollabTestItemBlob", &kicadCollabTestItemBlob); // pcbnew-only ysync-review repro hooks (names not shared with eeschema). diff --git a/web/standalone/src/components/LayerPanel.tsx b/web/standalone/src/components/LayerPanel.tsx new file mode 100644 index 0000000..c6e41bf --- /dev/null +++ b/web/standalone/src/components/LayerPanel.tsx @@ -0,0 +1,239 @@ +import * as React from "react"; +import { ChevronDown, ChevronRight, Eye, EyeOff, X } from "lucide-react"; +import { useDraggablePanel } from "@/components/useDraggablePanel"; + +/** + * Floating layer panel (viewer-panels): the canvas-only replacement for the + * wx Appearance pane that kicadSetChrome(false) hides. Reads/toggles per-layer + * visibility and the active layer through the layer bridge (pcbnew builds + * only — kicadLayersGetState/SetVisible/SetActive), updating event-driven from + * the C++ `window.kicadCollab.onLayersState` push that follows every apply. + * + * Same draggable-panel conventions as the comments panel (comments-ux 0001 B): + * header = drag handle, collapse-to-header, position/collapse persisted, + * always-onscreen restore via useDraggablePanel. + */ + +const PANEL_POS_KEY = "pcbjam:layers-panel-pos"; +const PANEL_COLLAPSED_KEY = "pcbjam:layers-panel-collapsed"; +const PANEL_W = 256; // w-64 +const PANEL_HEADER_H = 36; + +export interface LayerRow { + id: number; + name: string; + canonical: string; + copper: boolean; + visible: boolean; + color: string; +} + +export interface LayersState { + active: number; + layers: LayerRow[]; +} + +export interface LayersModule { + kicadLayersGetState(): string; + kicadLayersSetVisible(id: number, visible: boolean): boolean | Promise; + kicadLayersSetActive(id: number): boolean | Promise; +} + +interface LayersWindow { + kicadCollab?: { onLayersState?: (json: string) => void }; +} + +/** True when the loaded wasm exposes the layer bridge (pcbnew builds). */ +export function hasLayersBridge(mod: unknown): mod is LayersModule { + const m = mod as Partial | undefined; + return ( + typeof m?.kicadLayersGetState === "function" && + typeof m?.kicadLayersSetVisible === "function" && + typeof m?.kicadLayersSetActive === "function" + ); +} + +export function parseLayersState(json: string): LayersState | null { + try { + const v: unknown = JSON.parse(json); + if (!v || typeof v !== "object") return null; + const o = v as { active?: unknown; layers?: unknown }; + if (typeof o.active !== "number" || !Array.isArray(o.layers)) return null; + const layers: LayerRow[] = []; + for (const l of o.layers) { + const r = l as Partial | null; + if (!r || typeof r.id !== "number" || typeof r.name !== "string") continue; + layers.push({ + id: r.id, + name: r.name, + canonical: typeof r.canonical === "string" ? r.canonical : r.name, + copper: r.copper === true, + visible: r.visible !== false, + color: typeof r.color === "string" ? r.color : "", + }); + } + return { active: o.active, layers }; + } catch { + return null; + } +} + +export function LayerPanel({ mod, onClose }: { mod: LayersModule; onClose: () => void }) { + const rootRef = React.useRef(null); + const drag = useDraggablePanel({ + storageKey: PANEL_POS_KEY, + handleWidth: PANEL_W, + handleHeight: PANEL_HEADER_H, + }); + const [collapsed, setCollapsedState] = React.useState(() => { + try { + return localStorage.getItem(PANEL_COLLAPSED_KEY) === "1"; + } catch { + return false; + } + }); + const setCollapsed = (v: boolean) => { + setCollapsedState(v); + try { + localStorage.setItem(PANEL_COLLAPSED_KEY, v ? "1" : "0"); + } catch { + /* private mode */ + } + }; + + const [state, setState] = React.useState(() => + parseLayersState(mod.kicadLayersGetState() || "null"), + ); + + // Event-driven refresh: the C++ side pushes the fresh state after every + // apply (both setters run on the coroutine — a synchronous re-read right + // after the call would still see old state). Spread-preserving install, + // same etiquette as the presence bridge's handlers. + React.useEffect(() => { + const win = window as LayersWindow; + win.kicadCollab = { + ...win.kicadCollab, + onLayersState: (json) => { + const s = parseLayersState(json); + if (s) setState(s); + }, + }; + return () => { + if (win.kicadCollab) delete win.kicadCollab.onLayersState; + }; + }, []); + + const setVisible = (id: number, visible: boolean) => { + // Optimistic flip for a snappy checkbox; the onLayersState push corrects. + setState((s) => + s + ? { ...s, layers: s.layers.map((l) => (l.id === id ? { ...l, visible } : l)) } + : s, + ); + void mod.kicadLayersSetVisible(id, visible); + }; + + const setActive = (id: number) => { + setState((s) => (s ? { ...s, active: id } : s)); + void mod.kicadLayersSetActive(id); + }; + + // Default anchor: below the overlay-menu FAB (right-anchored, top 12 + 36 + gap). + const style: React.CSSProperties = drag.pos + ? { left: drag.pos.x, top: drag.pos.y } + : { right: 12, top: 56 }; + + return ( +
+ {/* Header = drag handle. Interactive children stop pointerdown so they + don't start a drag. */} +
drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())} + onPointerMove={(e) => void drag.onPointerMove(e)} + onPointerUp={() => void drag.onPointerUp()} + > + + Layers + e.stopPropagation()}> + + +
+ + {!collapsed && ( +
+ {!state && ( +

+ Layer state isn't available yet. +

+ )} + {state?.layers.map((l) => ( +
+ {/* Row body sets the ACTIVE layer (the wx Appearance pane's + click semantics); the eye toggles visibility. */} + + +
+ ))} +
+ )} +
+ ); +} diff --git a/web/standalone/src/components/SelectionInspector.tsx b/web/standalone/src/components/SelectionInspector.tsx new file mode 100644 index 0000000..64d552e --- /dev/null +++ b/web/standalone/src/components/SelectionInspector.tsx @@ -0,0 +1,203 @@ +import * as React from "react"; +import type * as Y from "yjs"; +import { + kicadItemsMap, + yToItemUnchecked, + Y_KDOC_LAYOUT, + type KicadItem, + type Slot, +} from "@pcbjam/shared"; +import { ChevronDown, ChevronRight, X } from "lucide-react"; +import { useDraggablePanel } from "@/components/useDraggablePanel"; +import { + getLocalSelection, + subscribeLocalSelection, +} from "@/wasm/collab/local-selection"; +import { netNameResolver, summarizeItem, type ItemSummary } from "@/lib/item-summary"; + +/** + * Floating selection inspector (viewer-panels): read-only properties of the + * items currently selected on the canvas — reference/value, position, + * footprint/symbol link, layer, nets… Selection arrives through the local + * selection store (the C++ onSelection emit); item data comes from the collab + * Y.Doc's kdoc_items map, so the panel needs no wasm reads and stays live + * under remote edits. + * + * Same draggable-panel conventions as the comments panel (comments-ux 0001 B). + */ + +const PANEL_POS_KEY = "pcbjam:inspector-panel-pos"; +const PANEL_COLLAPSED_KEY = "pcbjam:inspector-panel-collapsed"; +const PANEL_W = 288; // w-72 +const PANEL_HEADER_H = 36; + +/** Cap the rendered selection — a select-all on a big board must not build + * thousands of row lists. */ +const MAX_ITEMS = 20; + +export function SelectionInspector({ + doc, + onClose, +}: { + /** The bound collab doc (pcbnew: the board room; eeschema: the ACTIVE + * sheet's room). Null when no doc room is bound (?collab=0) — the panel + * then shows selection counts only. */ + doc: Y.Doc | null; + onClose: () => void; +}) { + const rootRef = React.useRef(null); + const drag = useDraggablePanel({ + storageKey: PANEL_POS_KEY, + handleWidth: PANEL_W, + handleHeight: PANEL_HEADER_H, + }); + const [collapsed, setCollapsedState] = React.useState(() => { + try { + return localStorage.getItem(PANEL_COLLAPSED_KEY) === "1"; + } catch { + return false; + } + }); + const setCollapsed = (v: boolean) => { + setCollapsedState(v); + try { + localStorage.setItem(PANEL_COLLAPSED_KEY, v ? "1" : "0"); + } catch { + /* private mode */ + } + }; + + const selection = React.useSyncExternalStore(subscribeLocalSelection, getLocalSelection); + + // Re-summarize when a remote edit touches the item map (the panel shows + // live values, not select-time snapshots). A plain version counter — the + // memo below re-reads the Y state. + const [itemsVersion, setItemsVersion] = React.useState(0); + React.useEffect(() => { + if (!doc) return; + const items = kicadItemsMap(doc); + const bump = () => setItemsVersion((v) => v + 1); + items.observeDeep(bump); + return () => items.unobserveDeep(bump); + }, [doc]); + + const summaries: ItemSummary[] = React.useMemo(() => { + if (!doc) return []; + const items = kicadItemsMap(doc); + const itemOf = (uuid: string): KicadItem | undefined => { + const ym = items.get(uuid); + if (!ym) return undefined; + try { + return yToItemUnchecked(ym); + } catch { + return undefined; + } + }; + const netName = netNameResolver(doc.getArray(Y_KDOC_LAYOUT).toArray()); + return selection.uuids.slice(0, MAX_ITEMS).flatMap((uuid) => { + const item = itemOf(uuid); + return item ? [summarizeItem({ uuid, item, itemOf, netName })] : []; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [doc, selection, itemsVersion]); + + const count = selection.uuids.length; + + // Default anchor: below the overlay-menu FAB, clear of the layer panel's + // default (right 12 / top 56, w-64) — stack under it. + const style: React.CSSProperties = drag.pos + ? { left: drag.pos.x, top: drag.pos.y } + : { right: 12, top: 96 }; + + return ( +
+ {/* Header = drag handle. Interactive children stop pointerdown so they + don't start a drag. */} +
drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())} + onPointerMove={(e) => void drag.onPointerMove(e)} + onPointerUp={() => void drag.onPointerUp()} + > + + + Inspector + {count > 0 && ( + + ({count}) + + )} + + e.stopPropagation()}> + + +
+ + {!collapsed && ( +
+ {count === 0 && ( +

+ Click an item on the canvas to inspect it. +

+ )} + {count > 0 && summaries.length === 0 && ( +

+ {count} item{count === 1 ? "" : "s"} selected. +

+ )} + {summaries.map((s) => ( +
+
+ {s.title} +
+ {s.rows.map((r, i) => ( +
+ + {r.label} + + + {r.value} + +
+ ))} +
+ ))} + {count > MAX_ITEMS && ( +

+ +{count - MAX_ITEMS} more selected +

+ )} +
+ )} +
+ ); +} diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index e54d410..a1c30ed 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -15,7 +15,7 @@ import { type KicadDoc, type Tool, } from "@pcbjam/shared"; -import { ChevronDown, ChevronUp, Download, Eye, EyeOff, Loader2, Moon, PanelsTopLeft, Sun } from "lucide-react"; +import { ChevronDown, ChevronUp, Crosshair, Download, Eye, EyeOff, Layers, Loader2, Moon, PanelsTopLeft, Sun } from "lucide-react"; import { API_BASE_URL, APP_URL, @@ -121,6 +121,9 @@ import { 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"; +import { bindLocalSelectionFeed } from "@/wasm/collab/local-selection"; import { createSheetCollabManager, registerSheetChangedHook, @@ -156,6 +159,11 @@ function chromeSetter(win: Window): ((show: boolean) => boolean) | null { return typeof fn === "function" ? (fn as (show: boolean) => boolean) : null; } +// Viewer panels (viewer-panels): floating layer selector + selection +// inspector open-state persistence, mirroring the comments panel's keys. +const LAYERS_OPEN_KEY = "pcbjam:layers-panel-open"; +const INSPECTOR_OPEN_KEY = "pcbjam:inspector-panel-open"; + // Tooltip only — the matcher accepts both chords on any platform. const CHROME_HOTKEY_LABEL = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform) @@ -1167,6 +1175,43 @@ export function WasmTool({ const [commentsSlot, setCommentsSlot] = React.useState(null); const [viewportState, setViewportState] = React.useState(null); const commentsRef = React.useRef(null); + // Viewer panels (viewer-panels): the SelectionInspector's data doc — the + // bound collab doc (pcbnew: the board room; eeschema: the ACTIVE sheet's + // room, re-pointed on navigation). Null without a doc room (?collab=0). + const [panelDoc, setPanelDoc] = React.useState(null); + // Read-only sessions never bind presence, so the inspector's selection + // store is fed by this minimal local handler (+ the C++ input hooks). + const localSelectionRef = React.useRef<{ destroy(): void } | null>(null); + const [layersOpen, setLayersOpenState] = React.useState(() => { + try { + return localStorage.getItem(LAYERS_OPEN_KEY) === "1"; + } catch { + return false; + } + }); + const setLayersOpen = React.useCallback((v: boolean) => { + setLayersOpenState(v); + try { + localStorage.setItem(LAYERS_OPEN_KEY, v ? "1" : "0"); + } catch { + /* private mode */ + } + }, []); + const [inspectorOpen, setInspectorOpenState] = React.useState(() => { + try { + return localStorage.getItem(INSPECTOR_OPEN_KEY) === "1"; + } catch { + return false; + } + }); + const setInspectorOpen = React.useCallback((v: boolean) => { + setInspectorOpenState(v); + try { + localStorage.setItem(INSPECTOR_OPEN_KEY, v ? "1" : "0"); + } catch { + /* private mode */ + } + }, []); // A doc session that connected but has not been ADOPTED by an owner yet // (collab handle / sheet manager). Owned here so a boot failure, an // open-never-settled degrade, or unmount can destroy it instead of leaking @@ -1178,6 +1223,9 @@ export function WasmTool({ // delivering awareness/doc updates and each one re-entered the dead // instance — an unbounded ticket storm underneath the fatal overlay. const teardownCollab = React.useCallback(() => { + localSelectionRef.current?.destroy(); + localSelectionRef.current = null; + setPanelDoc(null); commentsRef.current?.destroy(); commentsRef.current = null; followRef.current?.destroy(); @@ -2096,6 +2144,7 @@ export function WasmTool({ crossAppRef.current?.setDocPath(activeRoom?.sheetPath); startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc); startComments(activeRoom?.doc); + setPanelDoc(activeRoom?.doc ?? null); if (activeRoom && !readOnly) { driftRef.current = startDriftDetection({ doc: activeRoom.doc, @@ -2149,6 +2198,7 @@ export function WasmTool({ collabDocRef.current = collabHandle?.doc ?? null; startPresence(collabHandle?.provider, undefined, collabHandle?.doc); startComments(collabHandle?.doc); + setPanelDoc(collabHandle?.doc ?? null); // Live sibling mirror (project-sync 0001 bug 3): keep the schematic // files a PCB session syncs from fresh in MEMFS, instead of the // one-shot boot snapshot. Same opt-out as the room collab; read-only @@ -2211,6 +2261,17 @@ export function WasmTool({ }); } } + // Viewer selection feed (viewer-panels): read-only sessions never + // bind presence (no room, no awareness), so the SelectionInspector's + // store is fed by a minimal onSelection handler + the C++ canvas + // input hooks. Edit sessions get the same store fed from + // bindKicadPresence's handler instead. + if (readOnly && (tool === "pcbnew" || tool === "eeschema")) { + localSelectionRef.current = bindLocalSelectionFeed({ + mod: win.Module, + win: win as unknown as PresenceKicadWindow, + }); + } }; // Degrading without an adoption must not strand the pre-connected doc // session (findings C-1: the open-never-settled path was the most @@ -2338,6 +2399,14 @@ export function WasmTool({ [ready], ); + // Layer bridge (viewer-panels), pcbnew sessions only — the merged bundle + // exports the names for every frame, but they no-op on a non-PCB frame. + const layersMod = React.useMemo(() => { + if (!ready || tool !== "pcbnew") return null; + const mod = (window as { Module?: unknown }).Module; + return hasLayersBridge(mod) ? mod : null; + }, [ready, tool]); + // Apply the chrome-visibility state to the wasm frame. A LAYOUT effect with // a synchronous first attempt: `ready` unmounts the opaque boot overlay in // this same commit, and a passive effect would let one frame of full chrome @@ -2610,6 +2679,33 @@ export function WasmTool({ )} + {/* 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 && ( + + )} + {effectiveChromeHidden && (tool === "pcbnew" || tool === "eeschema") && ( + + )} {setChromeFn !== null && !readOnly && (