feat(standalone): floating sheet navigator panel backed by a C++ hierarchy export (sheet-panel)
Canvas-only eeschema sessions (read-only viewers, hide-UI editors) could not reach the wx hierarchy pane, so a viewer of a hierarchical design was stuck on the entry sheet. Adds the React stand-in (SheetPanel) + the bridge: - eeschema_embind: kicadSheetsGetTree() (SCHEMATIC::Hierarchy(), page-number ordered: instance KIID path / parent / Sheetname / file / page / depth) and kicadSheetsEnter(path) (validated synchronously, applied on the coroutine via SCH_ACTIONS::changeSheet — same route as the wx pane, history intact). OnSchSheetChanged pushes window.kicadCollab.onSheetsState after EVERY navigation, wx-driven included, so the panel is event-driven. - kicad → 9ab93b838e: navigate actions allowlisted for read-only viewers. - SheetPanel.tsx: LayerPanel shell conventions; rendered + menu row only when the hierarchy has >1 sheet instance; viewers boot it collapsed. - tests: fixtures/demo/hier (root → Power, IO → Sub) + web/sheet-panel.spec (list/order/depth, navigate + collab rebind, menu toggle/close, flat schematic has no panel, bridge-driven navigation updates the panel); SheetPanel.test.ts for the wire parser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CtN6ASBvMGNbjPqY5boycg
This commit is contained in:
parent
fab6108cbb
commit
f3119e690f
12 changed files with 762 additions and 1 deletions
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit d0afbc4bd35e19bb98685ceecd53f848914cff9d
|
||||
Subproject commit 9ab93b838e86bedab0ca649a36a3104f30d01e34
|
||||
49
tests/fixtures/demo/hier/hier.kicad_sch
vendored
Normal file
49
tests/fixtures/demo/hier/hier.kicad_sch
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
(kicad_sch
|
||||
(version 20250114)
|
||||
(generator "eeschema")
|
||||
(generator_version "9.0")
|
||||
(uuid "a0000000-0000-4000-8000-000000000001")
|
||||
(paper "A4")
|
||||
(title_block
|
||||
(title "Hierarchy demo — root")
|
||||
)
|
||||
(lib_symbols)
|
||||
(wire
|
||||
(pts (xy 50.8 101.6) (xy 101.6 101.6))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid "c0000000-0000-4000-8000-000000000011")
|
||||
)
|
||||
(sheet
|
||||
(at 50.8 50.8)
|
||||
(size 25.4 20.32)
|
||||
(exclude_from_sim no)
|
||||
(in_bom yes)
|
||||
(on_board yes)
|
||||
(dnp no)
|
||||
(fields_autoplaced yes)
|
||||
(stroke (width 0.1524) (type solid))
|
||||
(fill (color 0 0 0 0.0000))
|
||||
(uuid "a0000000-0000-4000-8000-0000000000a1")
|
||||
(property "Sheetname" "Power" (at 50.8 50.0876 0) (effects (font (size 1.27 1.27)) (justify left bottom)))
|
||||
(property "Sheetfile" "power.kicad_sch" (at 50.8 71.7046 0) (effects (font (size 1.27 1.27)) (justify left top)))
|
||||
(instances (project "hier" (path "/a0000000-0000-4000-8000-000000000001" (page "2"))))
|
||||
)
|
||||
(sheet
|
||||
(at 101.6 50.8)
|
||||
(size 25.4 20.32)
|
||||
(exclude_from_sim no)
|
||||
(in_bom yes)
|
||||
(on_board yes)
|
||||
(dnp no)
|
||||
(fields_autoplaced yes)
|
||||
(stroke (width 0.1524) (type solid))
|
||||
(fill (color 0 0 0 0.0000))
|
||||
(uuid "a0000000-0000-4000-8000-0000000000a2")
|
||||
(property "Sheetname" "IO" (at 101.6 50.0876 0) (effects (font (size 1.27 1.27)) (justify left bottom)))
|
||||
(property "Sheetfile" "io.kicad_sch" (at 101.6 71.7046 0) (effects (font (size 1.27 1.27)) (justify left top)))
|
||||
(instances (project "hier" (path "/a0000000-0000-4000-8000-000000000001" (page "3"))))
|
||||
)
|
||||
(sheet_instances
|
||||
(path "/" (page "1"))
|
||||
)
|
||||
)
|
||||
31
tests/fixtures/demo/hier/io.kicad_sch
vendored
Normal file
31
tests/fixtures/demo/hier/io.kicad_sch
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
(kicad_sch
|
||||
(version 20250114)
|
||||
(generator "eeschema")
|
||||
(generator_version "9.0")
|
||||
(uuid "b0000000-0000-4000-8000-000000000003")
|
||||
(paper "A4")
|
||||
(title_block
|
||||
(title "Hierarchy demo — io")
|
||||
)
|
||||
(lib_symbols)
|
||||
(wire
|
||||
(pts (xy 50.8 101.6) (xy 101.6 101.6))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid "c0000000-0000-4000-8000-000000000013")
|
||||
)
|
||||
(sheet
|
||||
(at 50.8 50.8)
|
||||
(size 25.4 20.32)
|
||||
(exclude_from_sim no)
|
||||
(in_bom yes)
|
||||
(on_board yes)
|
||||
(dnp no)
|
||||
(fields_autoplaced yes)
|
||||
(stroke (width 0.1524) (type solid))
|
||||
(fill (color 0 0 0 0.0000))
|
||||
(uuid "a0000000-0000-4000-8000-0000000000a3")
|
||||
(property "Sheetname" "Sub" (at 50.8 50.0876 0) (effects (font (size 1.27 1.27)) (justify left bottom)))
|
||||
(property "Sheetfile" "io_sub.kicad_sch" (at 50.8 71.7046 0) (effects (font (size 1.27 1.27)) (justify left top)))
|
||||
(instances (project "hier" (path "/a0000000-0000-4000-8000-000000000001/a0000000-0000-4000-8000-0000000000a2" (page "4"))))
|
||||
)
|
||||
)
|
||||
16
tests/fixtures/demo/hier/io_sub.kicad_sch
vendored
Normal file
16
tests/fixtures/demo/hier/io_sub.kicad_sch
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
(kicad_sch
|
||||
(version 20250114)
|
||||
(generator "eeschema")
|
||||
(generator_version "9.0")
|
||||
(uuid "b0000000-0000-4000-8000-000000000004")
|
||||
(paper "A4")
|
||||
(title_block
|
||||
(title "Hierarchy demo — io sub")
|
||||
)
|
||||
(lib_symbols)
|
||||
(wire
|
||||
(pts (xy 50.8 101.6) (xy 101.6 101.6))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid "c0000000-0000-4000-8000-000000000014")
|
||||
)
|
||||
)
|
||||
16
tests/fixtures/demo/hier/power.kicad_sch
vendored
Normal file
16
tests/fixtures/demo/hier/power.kicad_sch
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
(kicad_sch
|
||||
(version 20250114)
|
||||
(generator "eeschema")
|
||||
(generator_version "9.0")
|
||||
(uuid "b0000000-0000-4000-8000-000000000002")
|
||||
(paper "A4")
|
||||
(title_block
|
||||
(title "Hierarchy demo — power")
|
||||
)
|
||||
(lib_symbols)
|
||||
(wire
|
||||
(pts (xy 50.8 101.6) (xy 101.6 101.6))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid "c0000000-0000-4000-8000-000000000012")
|
||||
)
|
||||
)
|
||||
149
tests/web/sheet-panel.spec.ts
Normal file
149
tests/web/sheet-panel.spec.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { openOverlayMenu } from './overlay-menu';
|
||||
import { shotPath } from '../e2e/utils/element-tracker';
|
||||
|
||||
/**
|
||||
* Sheet navigator panel e2e (sheet-panel): the floating React stand-in for
|
||||
* the wx hierarchy pane that canvas-only sessions can't reach. Opens the
|
||||
* hierarchical fixture (tests/fixtures/demo/hier: root → Power, IO → Sub) and
|
||||
* asserts
|
||||
* - the panel lists every sheet instance, page-ordered and depth-indented,
|
||||
* from the C++ tree export (kicadSheetsGetTree — names come from the
|
||||
* Sheetname properties, not filenames);
|
||||
* - clicking a row navigates (kicadSheetsEnter → SCH_ACTIONS::changeSheet)
|
||||
* and the panel follows through the onSheetsState push; the collab sheet
|
||||
* manager rebinds to the now-active .kicad_sch (the `[sheet]` log);
|
||||
* - this works for the READ-ONLY viewer (`?readonly=1`): the navigate
|
||||
* actions are on the PCBJAM_READ_ONLY allowlist;
|
||||
* - a flat schematic (demo.kicad_sch) gets no panel and no menu row.
|
||||
*
|
||||
* Needs a wasm build exporting kicadSheetsGetTree/kicadSheetsEnter — an
|
||||
* older bundle hides the panel (feature-detected), which fails the first
|
||||
* assertion; rebuild + `npm run setup:kicad`.
|
||||
*/
|
||||
|
||||
const SCOPE = 'default';
|
||||
|
||||
type Mod = {
|
||||
kicadSheetsGetTree(): string;
|
||||
kicadSheetsEnter(path: string): boolean | Promise<boolean>;
|
||||
};
|
||||
type SheetsState = { current: string; sheets: { path: string; name: string; depth: number; page: string }[] };
|
||||
|
||||
async function bootSchematic(page: Page, file: string, params = ''): Promise<string[]> {
|
||||
const logs: string[] = [];
|
||||
page.on('console', (m) => logs.push(m.text()));
|
||||
await page.goto(`/${SCOPE}/projects/demo/${file}${params}`);
|
||||
await expect(page.locator('#canvas')).toBeVisible({ timeout: 180000 });
|
||||
await expect
|
||||
.poll(() => page.title(), { timeout: 120000, intervals: [1000] })
|
||||
.toMatch(/Schematic Editor/i);
|
||||
await expect(page.locator('div.inset-0.z-30')).toHaveCount(0, { timeout: 180000 });
|
||||
return logs;
|
||||
}
|
||||
|
||||
async function tree(page: Page): Promise<SheetsState> {
|
||||
return page.evaluate(() => {
|
||||
const mod = (window as unknown as { Module: Mod }).Module;
|
||||
return JSON.parse(mod.kicadSheetsGetTree()) as SheetsState;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('sheet panel', () => {
|
||||
test('read-only viewer: lists the hierarchy and navigates between sheets', async ({ page }) => {
|
||||
test.setTimeout(300000); // Firefox cold boot alone is ~2 min; three navigations follow.
|
||||
const logs = await bootSchematic(page, 'hier/hier.kicad_sch', '?readonly=1');
|
||||
|
||||
// Viewer boot: the panel is up as a collapsed header (viewer-panels default).
|
||||
const panel = page.getByTestId('sheet-panel');
|
||||
await expect(panel).toBeVisible({ timeout: 30000 });
|
||||
await expect(page.getByTestId('sheet-panel-list')).toHaveCount(0);
|
||||
await page.getByTestId('sheet-panel-collapse').click();
|
||||
|
||||
// Every instance, page-ordered, names from Sheetname, depth-indented.
|
||||
const rows = page.getByTestId('sheet-row');
|
||||
await expect(rows).toHaveCount(4);
|
||||
await expect(rows).toHaveText([/hier/, /Power/, /IO/, /Sub/]);
|
||||
const state = await tree(page);
|
||||
expect(state.sheets.map((s) => [s.name, s.depth, s.page])).toEqual([
|
||||
['hier', 0, '1'],
|
||||
['Power', 1, '2'],
|
||||
['IO', 1, '3'],
|
||||
['Sub', 2, '4'],
|
||||
]);
|
||||
expect(state.current).toBe(state.sheets[0]!.path);
|
||||
await expect(rows.nth(0)).toHaveAttribute('data-active', 'true');
|
||||
|
||||
await page.screenshot({ path: shotPath(page, 'web-sheet-panel-root.png'), scale: 'css' });
|
||||
|
||||
// Navigate to the nested sheet: the row activates via the C++ push, the
|
||||
// frame's current sheet moved, and collab rebound its room to io_sub.
|
||||
await rows.nth(3).click();
|
||||
await expect(rows.nth(3)).toHaveAttribute('data-active', 'true', { timeout: 30000 });
|
||||
await expect(rows.nth(0)).not.toHaveAttribute('data-active', 'true');
|
||||
await expect.poll(async () => (await tree(page)).current).toBe(state.sheets[3]!.path);
|
||||
await expect
|
||||
.poll(() => logs.some((l) => /\[sheet\].*io_sub\.kicad_sch/.test(l)), { timeout: 30000 })
|
||||
.toBe(true);
|
||||
|
||||
await page.screenshot({ path: shotPath(page, 'web-sheet-panel-sub.png'), scale: 'css' });
|
||||
|
||||
// And back to the root.
|
||||
await rows.nth(0).click();
|
||||
await expect(rows.nth(0)).toHaveAttribute('data-active', 'true', { timeout: 30000 });
|
||||
await expect.poll(async () => (await tree(page)).current).toBe(state.sheets[0]!.path);
|
||||
|
||||
// Menu row toggles it off/on; close button removes it.
|
||||
await openOverlayMenu(page);
|
||||
await page.getByTestId('sheet-panel-toggle').click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await openOverlayMenu(page);
|
||||
await page.getByTestId('sheet-panel-toggle').click();
|
||||
await expect(panel).toBeVisible();
|
||||
// The open menu (z-50) sits over the panel's right-anchored header —
|
||||
// close it (FAB toggles) before the close button is clickable.
|
||||
await page.getByTestId('overlay-menu-fab').click();
|
||||
await expect(page.getByTestId('overlay-menu-panel')).toHaveCount(0);
|
||||
await page.getByTestId('sheet-panel-close').click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a flat schematic shows no sheet panel and no menu row', async ({ page }) => {
|
||||
await bootSchematic(page, 'demo.kicad_sch', '?readonly=1');
|
||||
// The other viewer panel proves the chrome-hidden panel layer is up.
|
||||
await expect(page.getByTestId('inspector-panel')).toBeVisible({ timeout: 30000 });
|
||||
await expect(page.getByTestId('sheet-panel')).toHaveCount(0);
|
||||
await openOverlayMenu(page);
|
||||
await expect(page.getByTestId('inspector-panel-toggle')).toBeVisible();
|
||||
await expect(page.getByTestId('sheet-panel-toggle')).toHaveCount(0);
|
||||
const state = await tree(page);
|
||||
expect(state.sheets).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('wx-driven navigation updates the panel (kicadSheetsEnter without the panel open)', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(300000);
|
||||
await bootSchematic(page, 'hier/hier.kicad_sch', '?readonly=1');
|
||||
const panel = page.getByTestId('sheet-panel');
|
||||
await expect(panel).toBeVisible({ timeout: 30000 });
|
||||
await page.getByTestId('sheet-panel-collapse').click();
|
||||
const rows = page.getByTestId('sheet-row');
|
||||
await expect(rows).toHaveCount(4);
|
||||
const state = await tree(page);
|
||||
// Drive the bridge directly (stands in for a double-clicked sheet symbol /
|
||||
// toolbar arrow: both end in DisplayCurrentSheet → OnSchSheetChanged push).
|
||||
const ok = await page.evaluate(
|
||||
(p) => (window as unknown as { Module: Mod }).Module.kicadSheetsEnter(p),
|
||||
state.sheets[1]!.path,
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
await expect(rows.nth(1)).toHaveAttribute('data-active', 'true', { timeout: 30000 });
|
||||
// An unknown path is rejected synchronously and changes nothing.
|
||||
const bad = await page.evaluate(
|
||||
() => (window as unknown as { Module: Mod }).Module.kicadSheetsEnter('/not-a-sheet'),
|
||||
);
|
||||
expect(bad).toBe(false);
|
||||
await expect(rows.nth(1)).toHaveAttribute('data-active', 'true');
|
||||
});
|
||||
});
|
||||
|
|
@ -57,6 +57,7 @@
|
|||
#include <sch_sheet_path.h>
|
||||
#include <schematic_settings.h>
|
||||
#include <tool/actions.h>
|
||||
#include <tools/sch_actions.h>
|
||||
#include <tool/coroutine.h>
|
||||
#include <pcbjam_remote_lock.h>
|
||||
#include "collab_common.h"
|
||||
|
|
@ -390,6 +391,68 @@ void emitSheetChanged()
|
|||
}, s.c_str() );
|
||||
}
|
||||
|
||||
// ───────────────────────── sheet navigator bridge (sheet-panel) ─────────────────────────
|
||||
//
|
||||
// The React stand-in for the docked wx HIERARCHY_PANE that kicadSetChrome(false)
|
||||
// hides: kicadSheetsGetTree() returns the loaded hierarchy (every SCH_SHEET_PATH
|
||||
// instance — a sheet file placed twice is two rows, like the wx pane) plus the
|
||||
// current path; kicadSheetsEnter(path) navigates. Same shape as pcbnew's layer
|
||||
// bridge: validate synchronously, apply on the frame's coroutine, then push the
|
||||
// fresh state to window.kicadCollab.onSheetsState so the panel is event-driven.
|
||||
// Rows are page-number ordered (the wx pane's order); `parent` is the KIID path
|
||||
// of the enclosing instance ("" for the root) so JS can rebuild the tree.
|
||||
json sheetsStateJson( SCH_EDIT_FRAME* aFrame )
|
||||
{
|
||||
SCH_SHEET_LIST list = aFrame->Schematic().Hierarchy();
|
||||
list.SortByPageNumbers( /* aUpdateVirtualPageNums */ false );
|
||||
|
||||
json sheets = json::array();
|
||||
|
||||
for( const SCH_SHEET_PATH& path : list )
|
||||
{
|
||||
SCH_SHEET_PATH parent = path;
|
||||
|
||||
if( parent.size() > 1 )
|
||||
parent.pop_back();
|
||||
|
||||
SCH_SCREEN* screen = path.LastScreen();
|
||||
SCH_SHEET* sheet = path.Last();
|
||||
wxString name = sheet ? sheet->GetName() : wxString();
|
||||
|
||||
// The root sheet carries no Sheetname — show its file like the wx pane does.
|
||||
if( name.IsEmpty() && screen )
|
||||
name = wxFileName( screen->GetFileName() ).GetName();
|
||||
|
||||
sheets.push_back( {
|
||||
{ "path", toUtf8( path.PathAsString() ) },
|
||||
{ "parent", path.size() > 1 ? toUtf8( parent.PathAsString() ) : std::string() },
|
||||
{ "name", toUtf8( name ) },
|
||||
{ "file", screen ? toUtf8( screen->GetFileName() ) : std::string() },
|
||||
{ "page", toUtf8( path.GetPageNumber() ) },
|
||||
{ "depth", static_cast<int>( path.size() ) - 1 },
|
||||
} );
|
||||
}
|
||||
|
||||
return { { "current", toUtf8( aFrame->GetCurrentSheet().PathAsString() ) },
|
||||
{ "sheets", sheets } };
|
||||
}
|
||||
|
||||
void emitSheetsState( SCH_EDIT_FRAME* aFrame )
|
||||
{
|
||||
if( !aFrame )
|
||||
return;
|
||||
|
||||
std::string s = sheetsStateJson( aFrame ).dump();
|
||||
EM_ASM( {
|
||||
if( window.kicadCollab && window.kicadCollab.onSheetsState )
|
||||
{
|
||||
// Never let a throwing listener unwind the wasm frame (findings P-1).
|
||||
try { window.kicadCollab.onSheetsState( UTF8ToString( $0 ) ); }
|
||||
catch( e ) { console.error( '[pcbjam sheets] onSheetsState listener threw', e ); }
|
||||
}
|
||||
}, s.c_str() );
|
||||
}
|
||||
|
||||
// Serialize one live schematic item to its native s-expr via a one-item
|
||||
// SCH_SELECTION through SCH_IO_KICAD_SEXPR::Format. For a symbol the output also
|
||||
// carries its (lib_symbols …) definition (that prelude is emitted for any symbol
|
||||
|
|
@ -693,6 +756,9 @@ public:
|
|||
{
|
||||
rebaseline();
|
||||
emitSheetChanged();
|
||||
// The floating sheet panel follows wx-driven navigation too (double-
|
||||
// clicking a sheet symbol, Alt+Backspace, the toolbar arrows).
|
||||
emitSheetsState( schFrame() );
|
||||
}
|
||||
|
||||
private:
|
||||
|
|
@ -2270,6 +2336,64 @@ void kicadSaveSchematic( std::string path )
|
|||
}
|
||||
|
||||
|
||||
// Sheet navigator bridge (sheet-panel) — see sheetsStateJson. ensureBridge()
|
||||
// so the OnSchSheetChanged push is wired even for sessions that never bind
|
||||
// presence (read-only viewers).
|
||||
std::string schSheetsGetTree()
|
||||
{
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return "";
|
||||
|
||||
ensureBridge();
|
||||
return sheetsStateJson( fr ).dump();
|
||||
}
|
||||
|
||||
// Navigate to the sheet instance whose KIID path is aPath (as reported by
|
||||
// kicadSheetsGetTree). Validated synchronously against the live hierarchy; the
|
||||
// switch itself runs on the coroutine through the navigate tool's changeSheet
|
||||
// action — the same route the wx hierarchy pane takes, so back/forward history
|
||||
// stays consistent. DisplayCurrentSheet fires OnSchSheetChanged → the collab
|
||||
// sheet-manager rebinds its room and the panel gets its onSheetsState push.
|
||||
// The action is on the read-only allowlist (view-only), so viewers can navigate.
|
||||
bool schSheetsEnter( const std::string& aPath )
|
||||
{
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return false;
|
||||
|
||||
wxString wanted = wxString::FromUTF8( aPath.c_str() );
|
||||
SCH_SHEET_LIST list = fr->Schematic().Hierarchy();
|
||||
auto it = std::find_if( list.begin(), list.end(),
|
||||
[&]( const SCH_SHEET_PATH& p )
|
||||
{
|
||||
return p.PathAsString() == wanted;
|
||||
} );
|
||||
|
||||
if( it == list.end() )
|
||||
return false;
|
||||
|
||||
if( *it == fr->GetCurrentSheet() )
|
||||
{
|
||||
emitSheetsState( fr );
|
||||
return true;
|
||||
}
|
||||
|
||||
SCH_SHEET_PATH target = *it;
|
||||
|
||||
pcbjam_collab::runOnCoroutine( fr, [fr, target]() {
|
||||
SCH_SHEET_PATH path = target;
|
||||
fr->GetToolManager()->RunAction<SCH_SHEET_PATH*>( SCH_ACTIONS::changeSheet, &path );
|
||||
// changeSheet already pushed via OnSchSheetChanged; a no-op path
|
||||
// (already there / rejected) still leaves the panel consistent.
|
||||
emitSheetsState( fr );
|
||||
} );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool kicadCollabBusyProbe()
|
||||
{
|
||||
return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().empty();
|
||||
|
|
@ -2289,6 +2413,9 @@ EMSCRIPTEN_BINDINGS(eeschema) {
|
|||
function("kicadCollabTestMoveSchItem", &schCollabTestMoveSchItem);
|
||||
function("kicadCollabTestMirrorSchItem", &schCollabTestMirrorSchItem);
|
||||
function("kicadCollabTestDuplicateSchItem", &schCollabTestDuplicateSchItem);
|
||||
// Sheet navigator bridge (sheet-panel): hierarchy tree + navigate.
|
||||
function("kicadSheetsGetTree", &schSheetsGetTree);
|
||||
function("kicadSheetsEnter", &schSheetsEnter);
|
||||
|
||||
#ifndef KICAD_MERGED_EMBIND
|
||||
// JS names ALSO registered by pcbnew_embind.cpp — in the merged image these are
|
||||
|
|
|
|||
44
web/standalone/src/components/SheetPanel.test.ts
Normal file
44
web/standalone/src/components/SheetPanel.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { hasSheetsBridge, parseSheetsState } from "./SheetPanel";
|
||||
|
||||
/** Sheet navigator bridge parsing (sheet-panel) — the wire shape emitted by
|
||||
* eeschema_embind.cpp's sheetsStateJson / kicadSheetsGetTree. */
|
||||
describe("parseSheetsState", () => {
|
||||
it("parses the C++ wire shape, keeping row order (page-number sorted)", () => {
|
||||
const s = parseSheetsState(
|
||||
JSON.stringify({
|
||||
current: "/root",
|
||||
sheets: [
|
||||
{ path: "/root", parent: "", name: "Arduino Leonardo", file: "/p/root.kicad_sch", page: "1", depth: 0 },
|
||||
{ path: "/root/aaa", parent: "/root", name: "Headers", file: "/p/Headers.kicad_sch", page: "2", depth: 1 },
|
||||
{ path: "/root/bbb", parent: "/root", name: "Power", file: "/p/Power.kicad_sch", page: "3", depth: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(s?.current).toBe("/root");
|
||||
expect(s?.sheets.map((r) => r.name)).toEqual(["Arduino Leonardo", "Headers", "Power"]);
|
||||
expect(s?.sheets[1]).toMatchObject({ parent: "/root", depth: 1, page: "2" });
|
||||
});
|
||||
|
||||
it("tolerates missing optional fields and drops rows without a path", () => {
|
||||
const s = parseSheetsState(
|
||||
JSON.stringify({ current: "/r", sheets: [{ path: "/r" }, { name: "no path" }, null] }),
|
||||
);
|
||||
expect(s?.sheets).toEqual([{ path: "/r", parent: "", name: "", file: "", page: "", depth: 0 }]);
|
||||
});
|
||||
|
||||
it("returns null for garbage / an empty bridge answer", () => {
|
||||
expect(parseSheetsState("")).toBeNull();
|
||||
expect(parseSheetsState("null")).toBeNull();
|
||||
expect(parseSheetsState('{"sheets":[]}')).toBeNull();
|
||||
expect(parseSheetsState("{not json")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSheetsBridge", () => {
|
||||
it("needs both exports (older bundles / non-eeschema frames have neither)", () => {
|
||||
expect(hasSheetsBridge({ kicadSheetsGetTree: () => "", kicadSheetsEnter: () => true })).toBe(true);
|
||||
expect(hasSheetsBridge({ kicadSheetsGetTree: () => "" })).toBe(false);
|
||||
expect(hasSheetsBridge(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
259
web/standalone/src/components/SheetPanel.tsx
Normal file
259
web/standalone/src/components/SheetPanel.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import * as React from "react";
|
||||
import { ChevronDown, ChevronRight, FileText, X } from "lucide-react";
|
||||
import { useDraggablePanel } from "@/components/useDraggablePanel";
|
||||
|
||||
/**
|
||||
* Floating sheet navigator (sheet-panel): the canvas-only stand-in for the
|
||||
* docked wx HIERARCHY_PANE that kicadSetChrome(false) hides. Lists the loaded
|
||||
* schematic hierarchy — every sheet INSTANCE, page-number ordered, indented by
|
||||
* depth, like the wx pane — and navigates through the sheet bridge
|
||||
* (eeschema builds only — kicadSheetsGetTree / kicadSheetsEnter), updating
|
||||
* event-driven from the C++ `window.kicadCollab.onSheetsState` push that
|
||||
* follows every navigation (ours, or a wx-driven one: double-clicking a sheet
|
||||
* symbol, the toolbar arrows).
|
||||
*
|
||||
* Same draggable-panel conventions as the layer panel (viewer-panels):
|
||||
* header = drag handle, collapse-to-header, position/collapse persisted,
|
||||
* always-onscreen restore via useDraggablePanel.
|
||||
*/
|
||||
|
||||
const PANEL_POS_KEY = "pcbjam:sheet-panel-pos";
|
||||
const PANEL_COLLAPSED_KEY = "pcbjam:sheet-panel-collapsed";
|
||||
const PANEL_W = 256; // w-64
|
||||
const PANEL_HEADER_H = 36;
|
||||
|
||||
export interface SheetRow {
|
||||
/** KIID path of this sheet INSTANCE — the navigation key. */
|
||||
path: string;
|
||||
/** KIID path of the enclosing instance; "" for the root. */
|
||||
parent: string;
|
||||
name: string;
|
||||
/** Absolute MEMFS path of the sheet's .kicad_sch. */
|
||||
file: string;
|
||||
page: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface SheetsState {
|
||||
current: string;
|
||||
sheets: SheetRow[];
|
||||
}
|
||||
|
||||
export interface SheetsModule {
|
||||
kicadSheetsGetTree(): string;
|
||||
kicadSheetsEnter(path: string): boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
interface SheetsWindow {
|
||||
kicadCollab?: { onSheetsState?: (json: string) => void };
|
||||
}
|
||||
|
||||
/** True when the loaded wasm exposes the sheet bridge (eeschema builds). */
|
||||
export function hasSheetsBridge(mod: unknown): mod is SheetsModule {
|
||||
const m = mod as Partial<SheetsModule> | undefined;
|
||||
return (
|
||||
typeof m?.kicadSheetsGetTree === "function" &&
|
||||
typeof m?.kicadSheetsEnter === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export function parseSheetsState(json: string): SheetsState | null {
|
||||
try {
|
||||
const v: unknown = JSON.parse(json);
|
||||
if (!v || typeof v !== "object") return null;
|
||||
const o = v as { current?: unknown; sheets?: unknown };
|
||||
if (typeof o.current !== "string" || !Array.isArray(o.sheets)) return null;
|
||||
const sheets: SheetRow[] = [];
|
||||
for (const s of o.sheets) {
|
||||
const r = s as Partial<SheetRow> | null;
|
||||
if (!r || typeof r.path !== "string") continue;
|
||||
sheets.push({
|
||||
path: r.path,
|
||||
parent: typeof r.parent === "string" ? r.parent : "",
|
||||
name: typeof r.name === "string" ? r.name : "",
|
||||
file: typeof r.file === "string" ? r.file : "",
|
||||
page: typeof r.page === "string" ? r.page : "",
|
||||
depth: typeof r.depth === "number" && r.depth >= 0 ? r.depth : 0,
|
||||
});
|
||||
}
|
||||
return { current: o.current, sheets };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Basename of a MEMFS/relative path, for the row tooltip. */
|
||||
function fileLabel(file: string): string {
|
||||
const i = file.lastIndexOf("/");
|
||||
return i >= 0 ? file.slice(i + 1) : file;
|
||||
}
|
||||
|
||||
export function SheetPanel({
|
||||
mod,
|
||||
defaultCollapsed,
|
||||
onClose,
|
||||
}: {
|
||||
mod: SheetsModule;
|
||||
/** Boot state when no per-browser choice is stored (read-only sessions
|
||||
* start as a collapsed header — viewer-panels). */
|
||||
defaultCollapsed?: boolean;
|
||||
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 {
|
||||
const stored = localStorage.getItem(PANEL_COLLAPSED_KEY);
|
||||
if (stored !== null) return stored === "1";
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
return defaultCollapsed === true;
|
||||
});
|
||||
const setCollapsed = (v: boolean) => {
|
||||
setCollapsedState(v);
|
||||
try {
|
||||
localStorage.setItem(PANEL_COLLAPSED_KEY, v ? "1" : "0");
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
};
|
||||
|
||||
const [state, setState] = React.useState<SheetsState | null>(() =>
|
||||
parseSheetsState(mod.kicadSheetsGetTree() || "null"),
|
||||
);
|
||||
|
||||
// Event-driven refresh: the C++ side pushes the fresh state after every
|
||||
// navigation (the switch runs on the coroutine — a synchronous re-read
|
||||
// right after the call would still see the old sheet). Spread-preserving
|
||||
// install, same etiquette as the layer/presence bridges.
|
||||
React.useEffect(() => {
|
||||
const win = window as SheetsWindow;
|
||||
win.kicadCollab = {
|
||||
...win.kicadCollab,
|
||||
onSheetsState: (json) => {
|
||||
const s = parseSheetsState(json);
|
||||
if (s) setState(s);
|
||||
},
|
||||
};
|
||||
// Re-read on mount: the panel can open after a wx-driven navigation
|
||||
// that happened while no listener was installed.
|
||||
const s = parseSheetsState(mod.kicadSheetsGetTree() || "null");
|
||||
if (s) setState(s);
|
||||
return () => {
|
||||
if (win.kicadCollab) delete win.kicadCollab.onSheetsState;
|
||||
};
|
||||
}, [mod]);
|
||||
|
||||
const enter = (path: string) => {
|
||||
// Optimistic highlight for a snappy click; the onSheetsState push corrects.
|
||||
setState((s) => (s ? { ...s, current: path } : s));
|
||||
void mod.kicadSheetsEnter(path);
|
||||
};
|
||||
|
||||
// Default anchor: below the layer panel's slot (right column) — a schematic
|
||||
// session has no layer panel, so this sits where that would.
|
||||
const style: React.CSSProperties = drag.pos
|
||||
? { left: drag.pos.x, top: drag.pos.y }
|
||||
: { right: 12, top: 56 };
|
||||
|
||||
const single = (state?.sheets.length ?? 0) <= 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid="sheet-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="sheet-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="Sheets — drag to move"
|
||||
onPointerDown={(e) => drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())}
|
||||
onPointerMove={(e) => void drag.onPointerMove(e)}
|
||||
onPointerUp={() => void drag.onPointerUp()}
|
||||
>
|
||||
<button
|
||||
data-testid="sheet-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>Sheets</span>
|
||||
{state && state.sheets.length > 1 && (
|
||||
<span className="text-[10px] font-normal text-neutral-400 dark:text-white/40">
|
||||
{state.sheets.length}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-0.5" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
data-testid="sheet-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="sheet-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">
|
||||
Sheet hierarchy isn't available yet.
|
||||
</p>
|
||||
)}
|
||||
{state && single && (
|
||||
<p className="px-3 pb-3 text-xs text-neutral-500 dark:text-white/50">
|
||||
This schematic has no sub-sheets.
|
||||
</p>
|
||||
)}
|
||||
{state?.sheets.map((s) => {
|
||||
const active = state.current === s.path;
|
||||
return (
|
||||
<button
|
||||
key={s.path}
|
||||
data-testid="sheet-row"
|
||||
data-sheet-path={s.path}
|
||||
data-active={active || undefined}
|
||||
aria-current={active ? "page" : undefined}
|
||||
title={`${fileLabel(s.file)} — page ${s.page}`}
|
||||
onClick={() => enter(s.path)}
|
||||
className={`flex w-full items-center gap-2 border-t border-black/5 px-3 py-1 text-left text-xs hover:text-sky-600 dark:border-white/5 dark:hover:text-sky-300 ${
|
||||
active ? "bg-sky-500/10 dark:bg-sky-400/10" : ""
|
||||
}`}
|
||||
style={{ paddingLeft: 12 + Math.min(s.depth, 8) * 12 }}
|
||||
>
|
||||
<FileText
|
||||
size={13}
|
||||
className={`shrink-0 ${
|
||||
active ? "text-sky-600 dark:text-sky-300" : "text-neutral-400 dark:text-white/40"
|
||||
}`}
|
||||
/>
|
||||
<span className={`min-w-0 flex-1 truncate ${active ? "font-semibold" : ""}`}>
|
||||
{s.name || fileLabel(s.file)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-[10px] text-neutral-400 dark:text-white/40">
|
||||
{s.page}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ import { CommentLayer } from "@/components/CommentLayer";
|
|||
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
|
||||
import { hasLayersBridge, LayerPanel, type LayersModule } from "@/components/LayerPanel";
|
||||
import { SelectionInspector } from "@/components/SelectionInspector";
|
||||
import { hasSheetsBridge, SheetPanel, type SheetsModule } from "@/components/SheetPanel";
|
||||
import { bindLocalSelectionFeed } from "@/wasm/collab/local-selection";
|
||||
import {
|
||||
type SheetCollabManager,
|
||||
|
|
@ -127,6 +128,7 @@ import {
|
|||
INSPECTOR_OPEN_KEY,
|
||||
LAYERS_OPEN_KEY,
|
||||
LIB_KIND_FOR_TOOL,
|
||||
SHEETS_OPEN_KEY,
|
||||
} from "@/components/wasm-tool/ui-helpers";
|
||||
|
||||
/**
|
||||
|
|
@ -382,6 +384,27 @@ export function WasmTool({
|
|||
/* private mode */
|
||||
}
|
||||
}, []);
|
||||
// Sheet navigator (sheet-panel): eeschema's stand-in for the wx hierarchy
|
||||
// pane. Open by default like the other viewer panels, but only ever RENDERS
|
||||
// when the hierarchy has sub-sheets (see sheetsMod) — a flat schematic
|
||||
// shouldn't pay for an empty panel.
|
||||
const [sheetsOpen, setSheetsOpenState] = React.useState<boolean>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SHEETS_OPEN_KEY);
|
||||
if (stored !== null) return stored === "1";
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const setSheetsOpen = React.useCallback((v: boolean) => {
|
||||
setSheetsOpenState(v);
|
||||
try {
|
||||
localStorage.setItem(SHEETS_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
|
||||
|
|
@ -1510,6 +1533,22 @@ export function WasmTool({
|
|||
return hasLayersBridge(mod) ? mod : null;
|
||||
}, [ready, tool]);
|
||||
|
||||
// Sheet bridge (sheet-panel), eeschema sessions only. Re-evaluated on every
|
||||
// sheet switch (activeSheetPath) so a hierarchy that only gains sub-sheets
|
||||
// later ("Add Sheet") surfaces the panel; hidden for a flat schematic.
|
||||
const sheetsMod = React.useMemo<SheetsModule | null>(() => {
|
||||
if (!ready || tool !== "eeschema") return null;
|
||||
const mod = (window as { Module?: unknown }).Module;
|
||||
if (!hasSheetsBridge(mod)) return null;
|
||||
try {
|
||||
const raw = mod.kicadSheetsGetTree() || "null";
|
||||
const parsed = JSON.parse(raw) as { sheets?: unknown[] } | null;
|
||||
return (parsed?.sheets?.length ?? 0) > 1 ? mod : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [ready, tool, activeSheetPath]);
|
||||
|
||||
// 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
|
||||
|
|
@ -1629,6 +1668,9 @@ export function WasmTool({
|
|||
hasLayers={layersMod !== null}
|
||||
layersOpen={layersOpen}
|
||||
setLayersOpen={setLayersOpen}
|
||||
hasSheets={sheetsMod !== null}
|
||||
sheetsOpen={sheetsOpen}
|
||||
setSheetsOpen={setSheetsOpen}
|
||||
inspectorOpen={inspectorOpen}
|
||||
setInspectorOpen={setInspectorOpen}
|
||||
canToggleChrome={setChromeFn !== null}
|
||||
|
|
@ -1665,6 +1707,13 @@ export function WasmTool({
|
|||
onClose={() => setLayersOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{ready && effectiveChromeHidden && sheetsOpen && sheetsMod && (
|
||||
<SheetPanel
|
||||
mod={sheetsMod}
|
||||
defaultCollapsed={readOnly}
|
||||
onClose={() => setSheetsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{ready &&
|
||||
effectiveChromeHidden &&
|
||||
inspectorOpen &&
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Crosshair,
|
||||
EyeOff,
|
||||
Layers,
|
||||
ListTree,
|
||||
Moon,
|
||||
PanelsTopLeft,
|
||||
RefreshCw,
|
||||
|
|
@ -151,6 +152,9 @@ export function SessionMenu({
|
|||
hasLayers,
|
||||
layersOpen,
|
||||
setLayersOpen,
|
||||
hasSheets,
|
||||
sheetsOpen,
|
||||
setSheetsOpen,
|
||||
inspectorOpen,
|
||||
setInspectorOpen,
|
||||
canToggleChrome,
|
||||
|
|
@ -180,6 +184,10 @@ export function SessionMenu({
|
|||
hasLayers: boolean;
|
||||
layersOpen: boolean;
|
||||
setLayersOpen: (v: boolean) => void;
|
||||
/** The sheet bridge is available AND the schematic has sub-sheets. */
|
||||
hasSheets: boolean;
|
||||
sheetsOpen: boolean;
|
||||
setSheetsOpen: (v: boolean) => void;
|
||||
inspectorOpen: boolean;
|
||||
setInspectorOpen: (v: boolean) => void;
|
||||
/** The loaded bundle exports kicadSetChrome. */
|
||||
|
|
@ -272,6 +280,18 @@ export function SessionMenu({
|
|||
<span>{layersOpen ? "Hide layers" : "Layers"}</span>
|
||||
</button>
|
||||
)}
|
||||
{effectiveChromeHidden && hasSheets && (
|
||||
<button
|
||||
data-testid="sheet-panel-toggle"
|
||||
aria-pressed={sheetsOpen}
|
||||
className={overlayRowClass}
|
||||
title="Schematic sheets — navigate the hierarchy"
|
||||
onClick={() => setSheetsOpen(!sheetsOpen)}
|
||||
>
|
||||
<ListTree size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
<span>{sheetsOpen ? "Hide sheets" : "Sheets"}</span>
|
||||
</button>
|
||||
)}
|
||||
{effectiveChromeHidden && (tool === "pcbnew" || tool === "eeschema") && (
|
||||
<button
|
||||
data-testid="inspector-panel-toggle"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export function show3DOpener(win: Window): (() => boolean) | null {
|
|||
// Viewer panels (viewer-panels): floating layer selector + selection
|
||||
// inspector open-state persistence, mirroring the comments panel's keys.
|
||||
export const LAYERS_OPEN_KEY = "pcbjam:layers-panel-open";
|
||||
export const SHEETS_OPEN_KEY = "pcbjam:sheet-panel-open";
|
||||
export const INSPECTOR_OPEN_KEY = "pcbjam:inspector-panel-open";
|
||||
|
||||
// Tooltip only — the matcher accepts both chords on any platform.
|
||||
|
|
|
|||
Loading…
Reference in a new issue