feat: viewer panels — layer selector + selection inspector (viewer-panels)

Canvas-only sessions (read-only viewers, hide-UI editors) get two
floating, draggable, closable panels standing in for the chrome-hidden
wx panes, opened from the overlay menu's View section:

- wasm/bindings: layer bridge — kicadLayersGetState/SetVisible/SetActive
  (bodies mirror the compiled-in-but-unreachable IPC handlers, applies
  on the coroutine lane, fresh state pushed to
  window.kicadCollab.onLayersState); setters join the jspi-scheduler
  mutator lane.
- standalone: LayerPanel + SelectionInspector on the comments-panel
  shell conventions (useDraggablePanel, collapse, persisted state);
  local-selection store fed from presence's onSelection in edit
  sessions and bindLocalSelectionFeed for read-only viewers (with a
  bounded post-gesture pull burst — clarify-menu selections produce no
  canvas event); pure item-summary extraction + unit tests.
- kicad submodule: read-only selection unlock (selection live for
  inspection; point editors + RMB context menus stay locked).
- tests/web: read-only spec updated — viewer click selects (or pops the
  clarify list), RMB context menu suppressed with writer positive
  control, Delete still swallowed; new viewer-panels test (eye toggle
  round-trip, active layer, panel drag, inspector rows from a real
  canvas click).

Record: docs/features/read-only-viewer/0002-viewer-panels.md (root).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gJ3M1RpeZLeNUUj8jKC4h
This commit is contained in:
Gergő Törcsvári 2026-08-20 11:29:46 +02:00
commit acc76f65ec
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
11 changed files with 1419 additions and 9 deletions

2
kicad

@ -1 +1 @@
Subproject commit 0bf6c9c34e08fc2e36dfe97acad55574cbfc8cd1 Subproject commit c2e0545e4745d38eddc98da122dcf7c147fbf610

View file

@ -100,6 +100,7 @@
"kicadCollabSetViewport", "kicadCollabFitViewport", "kicadCollabSetViewport", "kicadCollabFitViewport",
"kicadCollabReleaseSelection", "kicadSetColorTheme", "kicadCollabReleaseSelection", "kicadSetColorTheme",
"kicadSaveBoard", "kicadSaveSchematic", "kicadSaveDrawingSheet", "kicadSaveBoard", "kicadSaveSchematic", "kicadSaveDrawingSheet",
"kicadLayersSetVisible", "kicadLayersSetActive",
], ],
mutatorQueue: [], mutatorQueue: [],
mutatorsWrapped: 0, mutatorsWrapped: 0,

View file

@ -10,9 +10,10 @@ test.describe.configure({ mode: 'serial' });
/** /**
* Read-only viewer e2e (read-only-viewer): `?readonly=1` boots the pcbnew * Read-only viewer e2e (read-only-viewer): `?readonly=1` boots the pcbnew
* editor as a locked viewer chrome force-hidden with no toggle, the * editor as a locked viewer chrome force-hidden with no toggle, the
* Cmd/Ctrl+\ chord inert, nothing selectable or deletable through the REAL * Cmd/Ctrl+\ chord inert, selection alive for INSPECTION (viewer-panels) but
* input paths (the kicad PCBJAM_READ_ONLY gates), zoom/pan alive while a * nothing editable through the REAL input paths (the kicad PCBJAM_READ_ONLY
* writer tab on the same board (broadcastchannel room) stays fully editable. * 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 * The viewer boots FIRST on the fresh room (fresh browser context empty
* broadcastchannel room): a read-only binding must not seed it; the writer * broadcastchannel room): a read-only binding must not seed it; the writer
@ -33,6 +34,10 @@ type Mod = {
kicadCollabTestClearSelection(): boolean; kicadCollabTestClearSelection(): boolean;
kicadCollabGetPos(id: string): string; kicadCollabGetPos(id: string): string;
kicadCollabTestMoveFirst(dx: number, dy: number): string; kicadCollabTestMoveFirst(dx: number, dy: number): string;
// Layer bridge (viewer-panels).
kicadLayersGetState(): string;
kicadLayersSetVisible(id: number, visible: boolean): boolean | Promise<boolean>;
kicadLayersSetActive(id: number): boolean | Promise<boolean>;
}; };
type W = { Module: Mod }; type W = { Module: Mod };
@ -118,7 +123,7 @@ test.afterAll(async () => {
await viewer?.close(); 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); test.setTimeout(240_000);
// Chrome force-hidden: no menubar, no console footer, no toggle — the // 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( .poll(
async () => async () =>
(await selection(writer)).length > 0 || (await selection(writer)).length > 0 ||
(await writer.getByText(/Show More Choices/).count()) > 0, (await writer.locator('.wx-menu-popup').count()) > 0,
{ {
timeout: 20000, timeout: 20000,
message: "writer's click should select the item or pop the clarify menu", 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.keyboard.press('Escape'); // dismiss a clarify popup, drop any selection
await writer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection()); 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); const viewerClick = await screenPosOf(viewer, itemWorld);
await viewer.mouse.click(viewerClick.x, viewerClick.y); await viewer.mouse.click(viewerClick.x, viewerClick.y);
expect(await selection(viewer), 'viewer click on the item must select nothing').toEqual([]); await expect
await expect(viewer.getByText(/Show More Choices/)).toHaveCount(0); .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 // Gate 1 (action allowlist): even with an item force-selected through the
// test hook (AddItemToSel bypasses Selectable by design), the Delete hotkey // 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). // The viewer's board still matches the writer's (nothing flowed back).
expect(await posOf(writer, itemId)).toBe(await posOf(viewer, itemId)); 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);
});

View file

@ -31,6 +31,8 @@
#include <richio.h> #include <richio.h>
#include <tools/pcb_selection.h> #include <tools/pcb_selection.h>
#include <tools/pcb_selection_tool.h> #include <tools/pcb_selection_tool.h>
#include <settings/color_settings.h>
#include <widgets/appearance_controls.h>
#include <geometry/shape_poly_set.h> #include <geometry/shape_poly_set.h>
#include <geometry/shape_line_chain.h> #include <geometry/shape_line_chain.h>
#include <geometry/eda_angle.h> #include <geometry/eda_angle.h>
@ -1681,6 +1683,119 @@ void pcbSetDarkChrome( bool aDark )
pcbjam_theme::setDarkChromeFlag( 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<int>( 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<int>( 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<PCB_LAYER_ID>( 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<PCB_LAYER_ID>( 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 + // Tuner helper: a VARIED demo-selection set — labeled uuid groups (smallest +
// largest footprint, the two busiest nets' track segments) so the style // largest footprint, the two busiest nets' track segments) so the style
// preview shows the real range of shapes instead of two overlapping items. // 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). // Programmatic save of the in-memory board (round-trip tests, README §A).
function("kicadSaveBoard", &kicadSaveBoard); 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). // pcbnew-only test helper (no eeschema counterpart — name is not shared).
function("kicadCollabTestItemBlob", &kicadCollabTestItemBlob); function("kicadCollabTestItemBlob", &kicadCollabTestItemBlob);
// pcbnew-only ysync-review repro hooks (names not shared with eeschema). // pcbnew-only ysync-review repro hooks (names not shared with eeschema).

View file

@ -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<boolean>;
kicadLayersSetActive(id: number): boolean | Promise<boolean>;
}
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<LayersModule> | 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<LayerRow> | 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<HTMLDivElement | null>(null);
const drag = useDraggablePanel({
storageKey: PANEL_POS_KEY,
handleWidth: PANEL_W,
handleHeight: PANEL_HEADER_H,
});
const [collapsed, setCollapsedState] = React.useState<boolean>(() => {
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<LayersState | null>(() =>
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 (
<div
ref={rootRef}
data-testid="layers-panel"
className="absolute z-40 flex w-64 flex-col overflow-hidden rounded-xl bg-white/95 text-neutral-900 shadow-2xl ring-1 ring-inset ring-black/10 backdrop-blur-sm dark:bg-neutral-950/90 dark:text-white dark:ring-white/15"
style={style}
>
{/* Header = drag handle. Interactive children stop pointerdown so they
don't start a drag. */}
<div
data-testid="layers-panel-header"
className="flex cursor-grab select-none items-center gap-2 px-3 py-2 text-xs font-semibold active:cursor-grabbing"
style={{ touchAction: "none" }}
title="Layers — drag to move"
onPointerDown={(e) => drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())}
onPointerMove={(e) => void drag.onPointerMove(e)}
onPointerUp={() => void drag.onPointerUp()}
>
<button
data-testid="layers-panel-collapse"
aria-expanded={!collapsed}
title={collapsed ? "Expand" : "Collapse to header"}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => setCollapsed(!collapsed)}
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</button>
<span>Layers</span>
<span className="ml-auto flex items-center gap-0.5" onPointerDown={(e) => e.stopPropagation()}>
<button
data-testid="layers-panel-close"
title="Close"
onClick={onClose}
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
>
<X size={14} />
</button>
</span>
</div>
{!collapsed && (
<div data-testid="layers-panel-list" className="max-h-[60vh] overflow-y-auto pb-1">
{!state && (
<p className="px-3 pb-3 text-xs text-neutral-500 dark:text-white/50">
Layer state isn't available yet.
</p>
)}
{state?.layers.map((l) => (
<div
key={l.id}
data-testid="layer-row"
data-layer-id={l.id}
data-active={state.active === l.id || undefined}
className={`flex w-full items-center gap-2 border-t border-black/5 px-3 py-1 text-xs dark:border-white/5 ${
state.active === l.id ? "bg-sky-500/10 dark:bg-sky-400/10" : ""
}`}
>
{/* Row body sets the ACTIVE layer (the wx Appearance pane's
click semantics); the eye toggles visibility. */}
<button
data-testid="layer-activate"
className="flex min-w-0 flex-1 items-center gap-2 text-left hover:text-sky-600 dark:hover:text-sky-300"
title={`Make ${l.name} the active layer`}
onClick={() => setActive(l.id)}
>
<span
className="h-3 w-3 shrink-0 rounded-sm ring-1 ring-inset ring-black/20 dark:ring-white/25"
style={l.color ? { backgroundColor: l.color } : undefined}
/>
<span className={`truncate ${state.active === l.id ? "font-semibold" : ""}`}>
{l.name}
</span>
</button>
<button
data-testid="layer-visibility"
aria-pressed={l.visible}
title={l.visible ? `Hide ${l.name}` : `Show ${l.name}`}
onClick={() => setVisible(l.id, !l.visible)}
className={`rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10 ${
l.visible
? "text-neutral-600 dark:text-white/70"
: "text-neutral-300 dark:text-white/25"
}`}
>
{l.visible ? <Eye size={13} /> : <EyeOff size={13} />}
</button>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -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<HTMLDivElement | null>(null);
const drag = useDraggablePanel({
storageKey: PANEL_POS_KEY,
handleWidth: PANEL_W,
handleHeight: PANEL_HEADER_H,
});
const [collapsed, setCollapsedState] = React.useState<boolean>(() => {
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<Slot>(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 (
<div
ref={rootRef}
data-testid="inspector-panel"
className="absolute z-40 flex w-72 flex-col overflow-hidden rounded-xl bg-white/95 text-neutral-900 shadow-2xl ring-1 ring-inset ring-black/10 backdrop-blur-sm dark:bg-neutral-950/90 dark:text-white dark:ring-white/15"
style={style}
>
{/* Header = drag handle. Interactive children stop pointerdown so they
don't start a drag. */}
<div
data-testid="inspector-panel-header"
className="flex cursor-grab select-none items-center gap-2 px-3 py-2 text-xs font-semibold active:cursor-grabbing"
style={{ touchAction: "none" }}
title="Inspector — drag to move"
onPointerDown={(e) => drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())}
onPointerMove={(e) => void drag.onPointerMove(e)}
onPointerUp={() => void drag.onPointerUp()}
>
<button
data-testid="inspector-panel-collapse"
aria-expanded={!collapsed}
title={collapsed ? "Expand" : "Collapse to header"}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => setCollapsed(!collapsed)}
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</button>
<span>
Inspector
{count > 0 && (
<span className="ml-1 font-normal text-neutral-400 dark:text-white/40">
({count})
</span>
)}
</span>
<span className="ml-auto flex items-center gap-0.5" onPointerDown={(e) => e.stopPropagation()}>
<button
data-testid="inspector-panel-close"
title="Close"
onClick={onClose}
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
>
<X size={14} />
</button>
</span>
</div>
{!collapsed && (
<div data-testid="inspector-panel-list" className="max-h-[60vh] overflow-y-auto pb-1">
{count === 0 && (
<p data-testid="inspector-empty" className="px-3 pb-3 text-xs text-neutral-500 dark:text-white/50">
Click an item on the canvas to inspect it.
</p>
)}
{count > 0 && summaries.length === 0 && (
<p className="px-3 pb-3 text-xs text-neutral-500 dark:text-white/50">
{count} item{count === 1 ? "" : "s"} selected.
</p>
)}
{summaries.map((s) => (
<div
key={s.uuid}
data-testid="inspector-item"
data-item-type={s.type}
className="border-t border-black/5 px-3 py-2 dark:border-white/5"
>
<div className="truncate text-xs font-semibold" title={s.title}>
{s.title}
</div>
{s.rows.map((r, i) => (
<div key={i} className="mt-0.5 flex items-baseline gap-2 text-[11px]">
<span className="w-20 shrink-0 text-neutral-400 dark:text-white/40">
{r.label}
</span>
<span className="min-w-0 break-words text-neutral-800 dark:text-white/85">
{r.value}
</span>
</div>
))}
</div>
))}
{count > MAX_ITEMS && (
<p className="border-t border-black/5 px-3 py-2 text-[11px] text-neutral-400 dark:border-white/5 dark:text-white/40">
+{count - MAX_ITEMS} more selected
</p>
)}
</div>
)}
</div>
);
}

View file

@ -15,7 +15,7 @@ import {
type KicadDoc, type KicadDoc,
type Tool, type Tool,
} from "@pcbjam/shared"; } 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 { import {
API_BASE_URL, API_BASE_URL,
APP_URL, APP_URL,
@ -121,6 +121,9 @@ import {
overlayRowClass, overlayRowClass,
} from "@/components/OverlayMenu"; } from "@/components/OverlayMenu";
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner"; 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 { import {
createSheetCollabManager, createSheetCollabManager,
registerSheetChangedHook, registerSheetChangedHook,
@ -156,6 +159,11 @@ function chromeSetter(win: Window): ((show: boolean) => boolean) | null {
return typeof fn === "function" ? (fn as (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. // Tooltip only — the matcher accepts both chords on any platform.
const CHROME_HOTKEY_LABEL = const CHROME_HOTKEY_LABEL =
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform) typeof navigator !== "undefined" && /Mac/i.test(navigator.platform)
@ -1167,6 +1175,43 @@ export function WasmTool({
const [commentsSlot, setCommentsSlot] = React.useState<HTMLDivElement | null>(null); const [commentsSlot, setCommentsSlot] = React.useState<HTMLDivElement | null>(null);
const [viewportState, setViewportState] = React.useState<ViewportState | null>(null); const [viewportState, setViewportState] = React.useState<ViewportState | null>(null);
const commentsRef = React.useRef<CommentsController | null>(null); const commentsRef = React.useRef<CommentsController | null>(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<Y.Doc | null>(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<boolean>(() => {
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<boolean>(() => {
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 // 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 // (collab handle / sheet manager). Owned here so a boot failure, an
// open-never-settled degrade, or unmount can destroy it instead of leaking // 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 // delivering awareness/doc updates and each one re-entered the dead
// instance — an unbounded ticket storm underneath the fatal overlay. // instance — an unbounded ticket storm underneath the fatal overlay.
const teardownCollab = React.useCallback(() => { const teardownCollab = React.useCallback(() => {
localSelectionRef.current?.destroy();
localSelectionRef.current = null;
setPanelDoc(null);
commentsRef.current?.destroy(); commentsRef.current?.destroy();
commentsRef.current = null; commentsRef.current = null;
followRef.current?.destroy(); followRef.current?.destroy();
@ -2096,6 +2144,7 @@ export function WasmTool({
crossAppRef.current?.setDocPath(activeRoom?.sheetPath); crossAppRef.current?.setDocPath(activeRoom?.sheetPath);
startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc); startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc);
startComments(activeRoom?.doc); startComments(activeRoom?.doc);
setPanelDoc(activeRoom?.doc ?? null);
if (activeRoom && !readOnly) { if (activeRoom && !readOnly) {
driftRef.current = startDriftDetection({ driftRef.current = startDriftDetection({
doc: activeRoom.doc, doc: activeRoom.doc,
@ -2149,6 +2198,7 @@ export function WasmTool({
collabDocRef.current = collabHandle?.doc ?? null; collabDocRef.current = collabHandle?.doc ?? null;
startPresence(collabHandle?.provider, undefined, collabHandle?.doc); startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
startComments(collabHandle?.doc); startComments(collabHandle?.doc);
setPanelDoc(collabHandle?.doc ?? null);
// Live sibling mirror (project-sync 0001 bug 3): keep the schematic // Live sibling mirror (project-sync 0001 bug 3): keep the schematic
// files a PCB session syncs from fresh in MEMFS, instead of the // 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 // 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 // Degrading without an adoption must not strand the pre-connected doc
// session (findings C-1: the open-never-settled path was the most // session (findings C-1: the open-never-settled path was the most
@ -2338,6 +2399,14 @@ export function WasmTool({
[ready], [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<LayersModule | null>(() => {
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 // Apply the chrome-visibility state to the wasm frame. A LAYOUT effect with
// a synchronous first attempt: `ready` unmounts the opaque boot overlay in // 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 // this same commit, and a passive effect would let one frame of full chrome
@ -2610,6 +2679,33 @@ export function WasmTool({
)} )}
<OverlayMenuSection label="View"> <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 && ( {setChromeFn !== null && !readOnly && (
<button <button
data-testid="chrome-toggle" data-testid="chrome-toggle"
@ -2667,6 +2763,19 @@ export function WasmTool({
/> />
)} )}
{/* Viewer panels (viewer-panels): floating layer selector + selection
inspector for canvas-only sessions the React stand-ins for the wx
Appearance/Properties panes that kicadSetChrome(false) hides. */}
{ready && effectiveChromeHidden && layersOpen && layersMod && (
<LayerPanel mod={layersMod} onClose={() => setLayersOpen(false)} />
)}
{ready &&
effectiveChromeHidden &&
inspectorOpen &&
(tool === "pcbnew" || tool === "eeschema") && (
<SelectionInspector doc={panelDoc} onClose={() => setInspectorOpen(false)} />
)}
{/* DEV: presence style tuner (VITE_PRESENCE_TUNER=1). */} {/* DEV: presence style tuner (VITE_PRESENCE_TUNER=1). */}
{ready && tunerMod && <PresenceTuner mod={tunerMod} tool={tool} />} {ready && tunerMod && <PresenceTuner mod={tunerMod} tool={tool} />}

View file

@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { fileToDoc, type KicadItem } from "@pcbjam/shared";
import { netNameResolver, summarizeItem, unq } from "./item-summary";
/** Build a doc from board text and summarize the item of the given type. */
function summarizeFromBoard(text: string, type: string) {
const doc = fileToDoc(text);
const entry = Object.entries(doc.items).find(([, it]) => it.type === type);
if (!entry) throw new Error(`no ${type} item in fixture`);
const [uuid, item] = entry;
return summarizeItem({
uuid,
item,
itemOf: (u) => doc.items[u],
netName: netNameResolver(doc.layout),
});
}
const BOARD = `(kicad_pcb (version 20240108) (generator "pcbnew")
(general (thickness 1.6))
(net 0 "")
(net 1 "GND")
(net 2 "/CLK")
(footprint "Resistor_SMD:R_0402"
(layer "F.Cu")
(uuid "aaaaaaaa-0000-0000-0000-000000000001")
(at 149.5 105.25 90)
(property "Reference" "R5" (at 0 -1.17 90) (layer "F.SilkS")
(uuid "aaaaaaaa-0000-0000-0000-000000000002"))
(property "Value" "10k" (at 0 1.17 90) (layer "F.Fab")
(uuid "aaaaaaaa-0000-0000-0000-000000000003"))
(property "Datasheet" "https://example.com/r.pdf" (at 0 0 0) (layer "F.Fab") hide
(uuid "aaaaaaaa-0000-0000-0000-000000000004"))
(property "ki_description" "internal" (at 0 0 0) (layer "F.Fab") hide
(uuid "aaaaaaaa-0000-0000-0000-000000000007"))
(attr smd)
(pad "1" smd roundrect (at -0.51 0 90) (size 0.54 0.64) (layers "F.Cu" "F.Paste" "F.Mask")
(net 1 "GND") (uuid "aaaaaaaa-0000-0000-0000-000000000005"))
(pad "2" smd roundrect (at 0.51 0 90) (size 0.54 0.64) (layers "F.Cu" "F.Paste" "F.Mask")
(net 2 "/CLK") (uuid "aaaaaaaa-0000-0000-0000-000000000006")))
(segment (start 100 50) (end 103 54) (width 0.250000) (layer "F.Cu") (net 2)
(uuid "bbbbbbbb-0000-0000-0000-000000000001"))
(via (at 103 54) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net 1)
(uuid "bbbbbbbb-0000-0000-0000-000000000002"))
)`;
const SCHEMATIC = `(kicad_sch (version 20231120) (generator "eeschema")
(symbol (lib_id "Device:C") (at 120.65 73.66 0) (unit 1)
(uuid "cccccccc-0000-0000-0000-000000000001")
(property "Reference" "C3" (at 124 72 0))
(property "Value" "100n" (at 124 75 0))
(property "Footprint" "Capacitor_SMD:C_0402" (at 120 73 0))
(pin "1" (uuid "cccccccc-0000-0000-0000-000000000002"))
(pin "2" (uuid "cccccccc-0000-0000-0000-000000000003")))
(label "CLK" (at 100 50 0) (uuid "dddddddd-0000-0000-0000-000000000001"))
)`;
describe("unq", () => {
it("strips kept quotes and unescapes", () => {
expect(unq('"GND"')).toBe("GND");
expect(unq('"a \\"b\\" \\\\c"')).toBe('a "b" \\c');
expect(unq("bare")).toBe("bare");
expect(unq(undefined)).toBe("");
});
});
describe("summarizeItem (viewer-panels inspector rows)", () => {
it("footprint: title from Reference/Value, lib id, position, pads + nets", () => {
const s = summarizeFromBoard(BOARD, "footprint");
expect(s.title).toBe("R5 · 10k");
const byLabel = Object.fromEntries(s.rows.map((r) => [r.label, r.value]));
expect(byLabel["Footprint"]).toBe("Resistor_SMD:R_0402");
expect(byLabel["Position"]).toBe("149.5, 105.25 mm");
expect(byLabel["Rotation"]).toBe("90°");
expect(byLabel["Layer"]).toBe("F.Cu");
expect(byLabel["Datasheet"]).toBe("https://example.com/r.pdf");
expect(byLabel["Pads"]).toBe("2");
expect(byLabel["Nets"]).toBe("GND, /CLK");
// Internal ki_* properties never render.
expect(s.rows.some((r) => r.label === "ki_description")).toBe(false);
});
it("segment: endpoints, computed length, width, layer, net name via layout", () => {
const s = summarizeFromBoard(BOARD, "segment");
expect(s.title).toBe("Track");
const byLabel = Object.fromEntries(s.rows.map((r) => [r.label, r.value]));
expect(byLabel["Start"]).toBe("100, 50 mm");
expect(byLabel["End"]).toBe("103, 54 mm");
expect(byLabel["Length"]).toBe("5 mm"); // 3-4-5
expect(byLabel["Width"]).toBe("0.25 mm");
expect(byLabel["Net"]).toBe("/CLK"); // resolved from (net 2 "/CLK")
});
it("via: size/drill/layers and net resolved by number", () => {
const s = summarizeFromBoard(BOARD, "via");
const byLabel = Object.fromEntries(s.rows.map((r) => [r.label, r.value]));
expect(s.title).toBe("Via");
expect(byLabel["Size"]).toBe("0.8 mm");
expect(byLabel["Drill"]).toBe("0.4 mm");
expect(byLabel["Layers"]).toBe("F.Cu, B.Cu");
expect(byLabel["Net"]).toBe("GND");
});
it("symbol: title from Reference/Value, lib id and Footprint property", () => {
const s = summarizeFromBoard(SCHEMATIC, "symbol");
expect(s.title).toBe("C3 · 100n");
const byLabel = Object.fromEntries(s.rows.map((r) => [r.label, r.value]));
expect(byLabel["Symbol"]).toBe("Device:C");
expect(byLabel["Footprint"]).toBe("Capacitor_SMD:C_0402");
expect(byLabel["Position"]).toBe("120.65, 73.66 mm");
});
it("label: quoted text in the title", () => {
const s = summarizeFromBoard(SCHEMATIC, "label");
expect(s.title).toBe('Label "CLK"');
});
it("unknown types fall back to a generic summary without throwing", () => {
const item: KicadItem = {
type: "mystery_thing",
parent: null,
body: [{ k: "at", v: [{ atom: "1" }, { atom: "2" }] }],
};
const s = summarizeItem({
uuid: "u",
item,
itemOf: () => undefined,
netName: () => undefined,
});
expect(s.title).toBe("Mystery thing");
expect(s.rows[0]).toEqual({ label: "Position", value: "1, 2 mm" });
});
});

View file

@ -0,0 +1,282 @@
import { args, field, fields, scalar, type KicadItem, type Slot } from "@pcbjam/shared";
/**
* Pure display-model extraction for the SelectionInspector (viewer-panels):
* one selected item's kdoc slots a titled row list. No React, no Yjs the
* caller supplies the item, an item resolver (nested pads/pins) and a net-name
* resolver (pcbnew layout `(net N "name")` entries carry the names tracks and
* vias reference by number only).
*/
export interface ItemSummary {
uuid: string;
/** s-expr head, e.g. "footprint" / "symbol" / "segment". */
type: string;
/** Human title, e.g. "R5 · 10k" / "Track" / 'Label "CLK"'. */
title: string;
rows: Array<{ label: string; value: string }>;
}
/** Strip the kept surrounding quotes of a string atom and unescape it. */
export function unq(atom: string | undefined): string {
if (atom === undefined) return "";
if (atom.length >= 2 && atom.startsWith('"') && atom.endsWith('"')) {
return atom.slice(1, -1).replace(/\\(["\\])/g, "$1");
}
return atom;
}
/** File numbers verbatim are fine, but trim float noise ("1.270000" → "1.27"). */
function fmtNum(atom: string | undefined): string {
if (atom === undefined) return "";
const n = Number(atom);
if (!Number.isFinite(n)) return atom;
return String(Math.round(n * 10000) / 10000);
}
function pos(body: Slot[]): { x: string; y: string; rot?: string } | null {
const at = field(body, "at");
if (!at) return null;
const a = args(at);
if (a.length < 2) return null;
const out: { x: string; y: string; rot?: string } = { x: fmtNum(a[0]), y: fmtNum(a[1]) };
if (a[2] !== undefined && Number(a[2]) !== 0) out.rot = fmtNum(a[2]);
return out;
}
function pushPos(rows: ItemSummary["rows"], body: Slot[]): void {
const p = pos(body);
if (!p) return;
rows.push({ label: "Position", value: `${p.x}, ${p.y} mm` });
if (p.rot) rows.push({ label: "Rotation", value: `${p.rot}°` });
}
function pushLayer(rows: ItemSummary["rows"], body: Slot[]): void {
const layer = scalar(body, "layer");
if (layer) rows.push({ label: "Layer", value: unq(layer) });
const layersField = field(body, "layers");
if (layersField) {
rows.push({ label: "Layers", value: args(layersField).map(unq).join(", ") });
}
}
/**
* `(property "Name" "Value" …)` pairs, internal `ki_*` ones skipped. Board
* properties carry uuids, so the kdoc flattening hoists them into child ITEMS
* (`{item}` refs); schematic ones stay inline as `{k:"property"}` slots
* read both shapes.
*/
function properties(
body: Slot[],
itemOf: (uuid: string) => KicadItem | undefined,
): Array<{ name: string; value: string }> {
const out: Array<{ name: string; value: string }> = [];
const push = (v: Slot[]) => {
const a = args(v);
const name = unq(a[0]);
if (!name || name.startsWith("ki_")) return;
out.push({ name, value: unq(a[1]) });
};
for (const v of fields(body, "property")) push(v);
for (const s of body) {
if (!("item" in s)) continue;
const child = itemOf(s.item);
if (child?.type === "property") push(child.body);
}
return out;
}
function netRow(
body: Slot[],
netName: (num: string) => string | undefined,
): { label: string; value: string } | null {
const net = field(body, "net");
if (!net) return null;
const a = args(net);
const name = a[1] !== undefined ? unq(a[1]) : netName(a[0] ?? "");
return { label: "Net", value: name ? name : a[0] !== undefined ? `#${a[0]}` : "" };
}
export function summarizeItem(opts: {
uuid: string;
item: KicadItem;
itemOf: (uuid: string) => KicadItem | undefined;
netName: (num: string) => string | undefined;
}): ItemSummary {
const { uuid, item, itemOf, netName } = opts;
const body = item.body;
const rows: ItemSummary["rows"] = [];
let title = item.type.charAt(0).toUpperCase() + item.type.slice(1).replace(/_/g, " ");
const childrenOfType = (type: string): KicadItem[] =>
body.flatMap((s) => {
if (!("item" in s)) return [];
const child = itemOf(s.item);
return child && child.type === type ? [child] : [];
});
switch (item.type) {
case "footprint": {
const props = properties(body, itemOf);
const ref = props.find((p) => p.name === "Reference")?.value;
const value = props.find((p) => p.name === "Value")?.value;
title = ref ? (value ? `${ref} · ${value}` : ref) : "Footprint";
rows.push({ label: "Footprint", value: unq(args(body)[0]) });
pushPos(rows, body);
pushLayer(rows, body);
for (const p of props) {
if (p.name === "Reference" || p.name === "Value") continue;
if (p.value) rows.push({ label: p.name, value: p.value });
}
const pads = childrenOfType("pad");
if (pads.length) {
rows.push({ label: "Pads", value: String(pads.length) });
const nets = new Set<string>();
for (const pad of pads) {
const r = netRow(pad.body, netName);
if (r?.value) nets.add(r.value);
}
if (nets.size) rows.push({ label: "Nets", value: [...nets].join(", ") });
}
break;
}
case "symbol": {
const props = properties(body, itemOf);
const ref = props.find((p) => p.name === "Reference")?.value;
const value = props.find((p) => p.name === "Value")?.value;
title = ref ? (value ? `${ref} · ${value}` : ref) : "Symbol";
const libId = scalar(body, "lib_id");
if (libId) rows.push({ label: "Symbol", value: unq(libId) });
pushPos(rows, body);
const unit = scalar(body, "unit");
if (unit && unit !== "1") rows.push({ label: "Unit", value: unit });
for (const p of props) {
if (p.name === "Reference" || p.name === "Value") continue;
if (p.value) rows.push({ label: p.name, value: p.value });
}
break;
}
case "segment":
case "arc": {
title = item.type === "arc" ? "Track (arc)" : "Track";
const start = args(field(body, "start") ?? []);
const end = args(field(body, "end") ?? []);
if (start.length >= 2 && end.length >= 2) {
rows.push({ label: "Start", value: `${fmtNum(start[0])}, ${fmtNum(start[1])} mm` });
rows.push({ label: "End", value: `${fmtNum(end[0])}, ${fmtNum(end[1])} mm` });
const dx = Number(end[0]) - Number(start[0]);
const dy = Number(end[1]) - Number(start[1]);
if (item.type === "segment" && Number.isFinite(dx) && Number.isFinite(dy)) {
rows.push({ label: "Length", value: `${fmtNum(String(Math.hypot(dx, dy)))} mm` });
}
}
const width = scalar(body, "width");
if (width) rows.push({ label: "Width", value: `${fmtNum(width)} mm` });
pushLayer(rows, body);
const net = netRow(body, netName);
if (net) rows.push(net);
break;
}
case "via": {
title = "Via";
pushPos(rows, body);
const size = scalar(body, "size");
if (size) rows.push({ label: "Size", value: `${fmtNum(size)} mm` });
const drill = scalar(body, "drill");
if (drill) rows.push({ label: "Drill", value: `${fmtNum(drill)} mm` });
pushLayer(rows, body);
const net = netRow(body, netName);
if (net) rows.push(net);
break;
}
case "zone": {
const name = scalar(body, "net_name");
title = name ? `Zone ${unq(name)}` : "Zone";
const net = netRow(body, netName);
if (net) rows.push(net);
pushLayer(rows, body);
break;
}
case "gr_text":
case "text": {
const text = unq(args(body)[0]);
title = text ? `Text "${text.length > 24 ? text.slice(0, 24) + "…" : text}"` : "Text";
pushPos(rows, body);
pushLayer(rows, body);
break;
}
case "label":
case "global_label":
case "hierarchical_label": {
const text = unq(args(body)[0]);
const kind =
item.type === "global_label"
? "Global label"
: item.type === "hierarchical_label"
? "Hierarchical label"
: "Label";
title = text ? `${kind} "${text}"` : kind;
pushPos(rows, body);
break;
}
case "wire":
case "bus": {
title = item.type === "wire" ? "Wire" : "Bus";
const pts = field(body, "pts");
if (pts) {
const xys = fields(pts, "xy");
if (xys.length >= 2) {
const first = args(xys[0]!);
const last = args(xys[xys.length - 1]!);
rows.push({ label: "From", value: `${fmtNum(first[0])}, ${fmtNum(first[1])} mm` });
rows.push({ label: "To", value: `${fmtNum(last[0])}, ${fmtNum(last[1])} mm` });
}
}
break;
}
case "sheet": {
const props = properties(body, itemOf);
const name = props.find((p) => p.name === "Sheetname")?.value;
title = name ? `Sheet ${name}` : "Sheet";
for (const p of props) if (p.value) rows.push({ label: p.name, value: p.value });
pushPos(rows, body);
break;
}
default: {
pushPos(rows, body);
pushLayer(rows, body);
const net = netRow(body, netName);
if (net) rows.push(net);
}
}
return { uuid, type: item.type, title, rows };
}
/**
* Net-number name resolver over the document layout's `(net N "name")`
* entries (pcbnew; eeschema layouts have none resolves nothing there).
*/
export function netNameResolver(layout: Slot[]): (num: string) => string | undefined {
let map: Map<string, string> | null = null;
return (num) => {
if (!map) {
map = new Map();
for (const v of fields(layout, "net")) {
const a = args(v);
if (a[0] !== undefined && a[1] !== undefined) map.set(a[0], unq(a[1]));
}
}
const name = map.get(num);
return name === "" ? undefined : name;
};
}

View file

@ -0,0 +1,119 @@
import { clog } from "./debug";
import {
hasPresenceBridge,
parseSelectionEmit,
type PresenceKicadModule,
type PresenceKicadWindow,
} from "./presence-kicad";
/**
* THIS tab's live canvas selection, as a tiny module-global store (the
* pin-geometry pattern) the shell's SelectionInspector renders from it
* (viewer-panels).
*
* Fed from the C++ `window.kicadCollab.onSelection` emit through whichever
* path owns that handler in the session:
*
* - collab/edit sessions: bindKicadPresence's handler publishes here in
* addition to awareness (one extra call in presence-kicad.ts).
* - read-only viewers: presence never binds (no room, no awareness), so
* `bindLocalSelectionFeed` below installs a minimal handler and starts
* the C++ input hooks (kicadCollabPresenceStart) the emitter is
* presence-agnostic and dedupes on its own.
*/
export interface LocalSelection {
uuids: string[];
fpPaths?: string[];
}
let current: LocalSelection = { uuids: [] };
const subs = new Set<() => void>();
export function publishLocalSelection(sel: LocalSelection): void {
current = sel;
for (const cb of subs) cb();
}
/** `useSyncExternalStore` pair. */
export function subscribeLocalSelection(cb: () => void): () => void {
subs.add(cb);
return () => subs.delete(cb);
}
export function getLocalSelection(): LocalSelection {
return current;
}
/**
* Selection feed for sessions WITHOUT the presence bridge (read-only viewers):
* installs the onSelection handler (spread-preserving, same etiquette as
* bindKicadPresence) and starts the C++ canvas input hooks. The start call is
* idempotent on the wasm side; under the JSPI scheduler it may return a
* Promise (queued behind a live open) fire-and-forget is fine, the seed
* pull below covers a selection made before the hooks landed.
*/
export function bindLocalSelectionFeed(opts: {
mod: unknown;
win: PresenceKicadWindow;
}): { destroy(): void } | null {
const { win } = opts;
if (!hasPresenceBridge(opts.mod)) return null;
const mod = opts.mod as PresenceKicadModule;
win.kicadCollab = {
...win.kicadCollab,
onSelection: (uuidsJson) => {
const parsed = parseSelectionEmit(uuidsJson);
if (parsed) publishLocalSelection(parsed);
},
};
try {
void mod.kicadCollabPresenceStart();
} catch (err) {
clog("local-selection: presence start failed:", err);
}
const pull = () => {
try {
const parsed = parseSelectionEmit(
(mod.kicadCollabGetSelectionFull?.() ?? mod.kicadCollabGetSelection()) || "[]",
);
if (parsed && JSON.stringify(parsed) !== JSON.stringify(current)) {
publishLocalSelection(parsed);
}
} catch {
/* frame not up yet / busy — a later gesture re-pulls */
}
};
// Seed: a selection may already exist (rebind), and the C++ emitter only
// fires on change.
pull();
// The C++ emitter hangs off the CANVAS input hooks — a selection resolved
// through a DOM surface (the clarify popup's rows) produces no canvas
// event and would never reach the store. Cover it with a bounded pull
// burst after any pointer/key gesture (no timers while idle; the reads
// dedupe, so a no-change burst publishes nothing).
const pullSoon = () => {
setTimeout(pull, 0);
setTimeout(pull, 200);
setTimeout(pull, 600);
};
document.addEventListener("pointerup", pullSoon, true);
document.addEventListener("keyup", pullSoon, true);
clog("local-selection: feed bound (viewer mode)");
return {
destroy() {
document.removeEventListener("pointerup", pullSoon, true);
document.removeEventListener("keyup", pullSoon, true);
if (win.kicadCollab) delete win.kicadCollab.onSelection;
publishLocalSelection({ uuids: [] });
},
};
}

View file

@ -1,5 +1,6 @@
import { symbolUuidFromFootprintPath } from "@pcbjam/shared"; import { symbolUuidFromFootprintPath } from "@pcbjam/shared";
import { clog } from "./debug"; import { clog } from "./debug";
import { publishLocalSelection } from "./local-selection";
import type { PresenceHandle, PresencePeer } from "./presence"; import type { PresenceHandle, PresencePeer } from "./presence";
import type { CrossAppHandle } from "./cross-app"; import type { CrossAppHandle } from "./cross-app";
import { contestedReleases, remoteLocks, type LockClient } from "./lock-tiebreak"; import { contestedReleases, remoteLocks, type LockClient } from "./lock-tiebreak";
@ -156,6 +157,8 @@ export function bindKicadPresence(opts: {
ownSelection = parsed.uuids; ownSelection = parsed.uuids;
presence.setSelection(parsed.uuids); presence.setSelection(parsed.uuids);
crossApp?.setSelection(parsed.uuids, parsed.fpPaths); crossApp?.setSelection(parsed.uuids, parsed.fpPaths);
// Local mirror (viewer-panels): the SelectionInspector renders from it.
publishLocalSelection(parsed);
}, },
onCursor: (x, y, active) => { onCursor: (x, y, active) => {
presence.setCursor(active ? { x, y } : null); presence.setCursor(active ? { x, y } : null);
@ -202,6 +205,7 @@ export function bindKicadPresence(opts: {
ownSelection = seed.uuids; ownSelection = seed.uuids;
presence.setSelection(seed.uuids); presence.setSelection(seed.uuids);
crossApp?.setSelection(seed.uuids, seed.fpPaths); crossApp?.setSelection(seed.uuids, seed.fpPaths);
publishLocalSelection(seed);
} }
} catch { } catch {
/* bridge present but frame not up yet — the first emit will seed */ /* bridge present but frame not up yet — the first emit will seed */