feat: open library files + lib-scoped editors; fix wx-dom.js boot

Open a single library scoped to itself in its editor: a backend lib (HomePage
chip → scopedLibsSource) or a local .kicad_sym/.kicad_mod file (LocalProjectView
→ localFileLibsSource + a browser-side multi-symbol .kicad_sym parser). WasmTool
gains an optional libsSource override (transparent default). All via the existing
lib bridge — no MEMFS doc open, no fork changes.

Also fix a systemic editor boot failure: the build split the wx glue into
wx.js + wx-dom.js, but boot.ts only injected wx.js + <tool>.js. wx-dom.js defines
window.wxDomCreateControl (used by every tool to build its UI), so without it the
canvas rendered but no wx controls did (ReferenceError: wxDomCreateControl is not
defined). Inject wx-dom.js between wx.js and the tool, best-effort (older non-DOM
builds tolerated). Bump pcbjam-shared for LIB_EXTENSION_TOOL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-15 18:10:26 +02:00
commit 4b404f3771
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
7 changed files with 326 additions and 9 deletions

@ -1 +1 @@
Subproject commit f21b6ea006bcfb8bd92c2d6d2afa42504db6bda4
Subproject commit 43b28a47e7df8be7e659f9512eb8afa860719df5

View file

@ -1,10 +1,11 @@
import {
EXTENSION_TOOL,
FILELESS_TOOLS,
LIB_EXTENSION_TOOL,
TOOL_LABELS,
type Tool,
} from "@pcbjam/shared";
import { ArrowLeft, ExternalLink, FolderOpen } from "lucide-react";
import { ArrowLeft, ExternalLink, FolderOpen, Library } from "lucide-react";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@ -13,10 +14,18 @@ export interface LocalFile {
size?: number;
}
function toolForPath(path: string): Tool | null {
function ext(path: string): string {
const dot = path.lastIndexOf(".");
if (dot < 0) return null;
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
return dot < 0 ? "" : path.slice(dot).toLowerCase();
}
function toolForPath(path: string): Tool | null {
return EXTENSION_TOOL[ext(path)] ?? null;
}
/** A library file (.kicad_sym / .kicad_mod) → the editor that opens it scoped. */
function libToolForPath(path: string): Tool | null {
return LIB_EXTENSION_TOOL[ext(path)] ?? null;
}
/**
@ -31,12 +40,15 @@ export function LocalProjectView({
name,
files,
onOpen,
onOpenLib,
onBack,
}: {
name: string;
files: LocalFile[];
/** Launch a tool; `path` is undefined for file-less tools. */
onOpen: (tool: Tool, path?: string) => void;
/** Open a local library FILE (.kicad_sym/.kicad_mod) scoped in its editor. */
onOpenLib: (tool: Tool, path: string) => void;
onBack: () => void;
}) {
return (
@ -70,6 +82,7 @@ export function LocalProjectView({
<div className="divide-y rounded-lg border">
{files.map((f) => {
const tool = toolForPath(f.path);
const libTool = tool ? null : libToolForPath(f.path);
return (
<div
key={f.path}
@ -89,6 +102,14 @@ export function LocalProjectView({
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
</button>
)}
{libTool && (
<button
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
onClick={() => onOpenLib(libTool, f.path)}
>
<Library size={14} /> Open in {TOOL_LABELS[libTool]}
</button>
)}
</div>
);
})}

View file

@ -19,6 +19,7 @@ import {
type DocSource,
} from "@/lib/config";
import { bootKicadTool } from "@/wasm/boot";
import type { LibsSource } from "@/wasm/libs/source";
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
@ -348,6 +349,7 @@ export function WasmTool({
saveBytes,
docSource,
assetBaseUrl,
libsSource,
}: {
tool: Tool;
slug: string;
@ -355,6 +357,12 @@ export function WasmTool({
projectId: string;
files: ToolFile[];
targetPath?: string;
/**
* Override the library source the editor browses. Omitted the configured
* default (`libsSourceConfig`). Used to open a single library scoped to itself
* a specific backend lib, or a local `.kicad_sym`/`.kicad_mod` file.
*/
libsSource?: LibsSource | null;
/** Fetch one project-relative file's bytes (contract loader or local folder). */
fetchBytes: (relPath: string) => Promise<Uint8Array>;
/**
@ -442,7 +450,8 @@ export function WasmTool({
log: append,
onStatus: setStatus,
onAbort: oom.onAbort,
libsSource: libsSourceConfig(projectId),
libsSource:
libsSource !== undefined ? libsSource : libsSourceConfig(projectId),
});
// Register the save sink before the file opens: from here on, every
// editor File→Save (MEMFS write) is routed onward through saveBytes.

View file

@ -3,6 +3,10 @@ import { Link } from "react-router-dom";
import type { Lib, Tool } from "@pcbjam/shared";
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
import { useLibs, useProjects } from "@/lib/api";
import { libsSourceConfig } from "@/lib/config";
import { scopedLibsSource } from "@/wasm/libs/scoped-source";
import { localFileLibsSource } from "@/wasm/libs/local-file-source";
import type { LibsSource } from "@/wasm/libs/source";
import { downloadBytes } from "@/lib/download";
import { Button } from "@/components/ui/button";
import { ToolGrid } from "@/components/ToolGrid";
@ -110,7 +114,21 @@ export function HomePage() {
// A tool launched straight from the home page — no local folder, no backend
// project. File-less editors browse backend libraries; document editors
// (schematic/PCB/drawing sheet) boot to a blank document (KiCad-launcher style).
const [launchedTool, setLaunchedTool] = React.useState<{ tool: Tool } | null>(null);
// `libsSource`, when set, scopes the editor to ONE library (a backend lib, or a
// local lib file); omitted ⇒ the editor's configured default source.
const [launchedTool, setLaunchedTool] = React.useState<{
tool: Tool;
libsSource?: LibsSource | null;
} | null>(null);
/** Open a backend library scoped to itself in its matching editor. */
const openScopedLib = (tool: Tool, lib: Lib) => {
const base = libsSourceConfig("local");
setLaunchedTool({
tool,
libsSource: base ? scopedLibsSource(base, lib.id) : undefined,
});
};
// <input webkitdirectory> is non-standard; set it imperatively.
React.useEffect(() => {
@ -128,6 +146,7 @@ export function HomePage() {
slug="local"
projectId="local"
files={[]}
libsSource={launchedTool.libsSource}
fetchBytes={async (p) => {
throw new Error(`no project file to fetch: ${p}`);
}}
@ -158,6 +177,21 @@ export function HomePage() {
name={local.name}
files={local.files}
onOpen={(tool, path) => setLaunched({ tool, target: path })}
onOpenLib={(tool, path) => {
void (async () => {
const bytes = await local.fetchBytes(path);
const text = new TextDecoder().decode(bytes);
const id = (path.split("/").pop() ?? path).replace(
/\.(kicad_sym|kicad_mod)$/i,
"",
);
const kind = tool === "footprint_editor" ? "footprint" : "symbol";
setLaunchedTool({
tool,
libsSource: localFileLibsSource(id, text, kind),
});
})();
}}
onBack={() => setLocal(null)}
/>
);
@ -265,13 +299,13 @@ export function HomePage() {
icon={<Library size={16} />}
label="Symbols"
query={symbolLibs}
onOpen={() => setLaunchedTool({ tool: "symbol_editor" })}
onOpen={(lib) => openScopedLib("symbol_editor", lib)}
/>
<LibGroup
icon={<Package size={16} />}
label="Footprints"
query={footprintLibs}
onOpen={() => setLaunchedTool({ tool: "footprint_editor" })}
onOpen={(lib) => openScopedLib("footprint_editor", lib)}
/>
</div>
</section>

View file

@ -0,0 +1,162 @@
/**
* Browser-side parser for a single-file multi-symbol `.kicad_sym` library, so a
* local file can be served to the symbol editor as a one-lib `LibsSource`.
*
* Ported from the GPL backend's `extract/kicad-symdir.ts` (string-scanning only,
* no deps). The backend reads the unpacked one-symbol-per-file layout; here a
* single `.kicad_sym` holds the whole lib, so we enumerate ALL top-level
* `(symbol …)` blocks and resolve `extends` against siblings in the same file.
* Bodies are emitted self-contained + version-capped, exactly like the bridge
* expects (the editor's plugin parses one `kicad_symbol_lib` per item).
*/
// Max symbol-lib format version the WASM fork parses (mirror the backend cap).
const FORK_MAX_LIB_VERSION = 20250925;
// Symbol-level leaf tokens the fork's parser can't handle (lossless to drop).
const FORK_UNSUPPORTED_SYMBOL_TOKENS = ["in_pos_files", "embedded_fonts"];
const UNSUPPORTED_RE = new RegExp(
`^[\\t ]*\\(\\s*(?:${FORK_UNSUPPORTED_SYMBOL_TOKENS.join("|")})\\s+[^()]*\\)\\s*\\n?`,
"gm",
);
const unesc = (s: string): string => s.replace(/\\(.)/g, "$1");
/** Skip from `i` (just after an opening quote) to the index past the close. */
function endOfString(src: string, i: number): number {
while (i < src.length) {
const c = src[i];
if (c === "\\") {
i += 2;
continue;
}
if (c === '"') return i + 1;
i += 1;
}
return i;
}
/** [start, end) of the balanced block beginning at `open` (a `(`), quote-aware. */
function matchParen(src: string, open: number): [number, number] {
let depth = 0;
let i = open;
while (i < src.length) {
const c = src[i];
if (c === '"') {
i = endOfString(src, i + 1);
continue;
}
if (c === "(") depth += 1;
else if (c === ")") {
depth -= 1;
if (depth === 0) return [open, i + 1];
}
i += 1;
}
throw new Error("unbalanced parentheses in s-expr");
}
/** Direct children (depth-1 forms) of the outermost `(kicad_symbol_lib …)`. */
function* topChildren(src: string): Generator<string> {
const open = src.indexOf("(");
if (open < 0) return;
let i = src.indexOf("(", open + 1);
while (i >= 0) {
const [s, e] = matchParen(src, i);
yield src.slice(s, e);
i = src.indexOf("(", e);
}
}
function symbolName(block: string): string | null {
const m = block.match(/^\(\s*symbol\s+"((?:[^"\\]|\\.)*)"/);
return m ? unesc(m[1]!) : null;
}
/** Direct child `(extends "Parent")` of a symbol block, or null. */
function symbolExtends(block: string): string | null {
let i = block.indexOf("(", 1);
while (i >= 0 && i < block.length) {
const [s, e] = matchParen(block, i);
const form = block.slice(s, e);
const em = form.match(/^\(\s*extends\s+"((?:[^"\\]|\\.)*)"/);
if (em) return unesc(em[1]!);
i = block.indexOf("(", e);
}
return null;
}
/** The `kicad_symbol_lib` header forms (version/generator), before any symbol. */
function libHeader(src: string): string {
const parts: string[] = [];
for (const form of topChildren(src)) {
if (/^\(\s*symbol\b/.test(form)) break;
parts.push(form);
}
return parts.join("\n\t");
}
function capVersion(header: string): string {
return header.replace(/\(\s*version\s+(\d+)\s*\)/, (m, v) =>
Number(v) > FORK_MAX_LIB_VERSION ? `(version ${FORK_MAX_LIB_VERSION})` : m,
);
}
const sanitize = (block: string): string => block.replace(UNSUPPORTED_RE, "");
function buildSelfContainedLib(
header: string,
parentBlocks: string[],
primaryBlock: string,
): string {
const body = [...parentBlocks, primaryBlock]
.map((b) => sanitize(b).replace(/^/gm, "\t").trimStart())
.join("\n\t");
return `(kicad_symbol_lib\n\t${capVersion(header)}\n\t${body}\n)\n`;
}
export interface ParsedSymLib {
/** All top-level symbol names, file order. */
names: string[];
/** Self-contained single-symbol body (parents bundled), or null if absent. */
bodyFor(name: string): string | null;
}
/** Parse a single-file `.kicad_sym` lib into per-symbol self-contained bodies. */
export function parseKicadSymLib(src: string): ParsedSymLib {
const header = libHeader(src);
const blocks = new Map<string, string>();
const exts = new Map<string, string | null>();
const names: string[] = [];
for (const form of topChildren(src)) {
if (!/^\(\s*symbol\b/.test(form)) continue;
const name = symbolName(form);
if (!name) continue;
names.push(name);
blocks.set(name, form);
exts.set(name, symbolExtends(form));
}
const resolveChain = (name: string): string[] => {
const chain: string[] = [];
const seen = new Set<string>([name]);
let parent = exts.get(name) ?? null;
while (parent) {
if (seen.has(parent)) break; // cycle guard
seen.add(parent);
const pb = blocks.get(parent);
if (!pb) break; // parent not in this file — emit what we have
chain.unshift(pb);
parent = exts.get(parent) ?? null;
}
return chain;
};
return {
names,
bodyFor(name: string): string | null {
const block = blocks.get(name);
if (!block) return null;
return buildSelfContainedLib(header, resolveChain(name), block);
},
};
}

View file

@ -0,0 +1,66 @@
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
import { parseKicadSymLib } from "./kicad-sym-parse";
/**
* A one-lib `LibsSource` over a single local library FILE (a `.kicad_sym` symbol
* lib, or a `.kicad_mod` footprint) the user picked from a folder. The editor
* browses it scoped to itself same bridge path as a backend lib, no MEMFS doc
* open. Read-only (local files aren't written back through the lib bridge).
*/
// Max board/footprint file-format version the WASM fork parses (mirror backend).
const FORK_MAX_BOARD_VERSION = 20251028;
function capFootprintVersion(body: string): string {
return body.replace(/\(\s*version\s+(\d+)\s*\)/, (m, v) =>
Number(v) > FORK_MAX_BOARD_VERSION ? `(version ${FORK_MAX_BOARD_VERSION})` : m,
);
}
function footprintName(text: string, fallback: string): string {
const m = text.match(/\(\s*footprint\s+"((?:[^"\\]|\\.)*)"/);
return m ? m[1]!.replace(/\\(.)/g, "$1") : fallback;
}
export function localFileLibsSource(
libId: string,
text: string,
kind: "symbol" | "footprint",
): LibsSource {
const lib: LibInfo = {
id: libId,
name: libId,
description: `Local ${kind} library (${libId})`,
};
if (kind === "footprint") {
const name = footprintName(text, libId);
const body = capFootprintVersion(text);
return {
async listLibs(k?: string): Promise<LibInfo[]> {
return !k || k === "footprint" ? [lib] : [];
},
async listItems(id: string): Promise<LibItemInfo[]> {
return id === libId ? [{ kind: "footprint", name }] : [];
},
async getItemBody(id, k, n): Promise<string | null> {
return id === libId && k === "footprint" && n === name ? body : null;
},
};
}
const parsed = parseKicadSymLib(text);
return {
async listLibs(k?: string): Promise<LibInfo[]> {
return !k || k === "symbol" ? [lib] : [];
},
async listItems(id: string): Promise<LibItemInfo[]> {
return id === libId
? parsed.names.map((name) => ({ kind: "symbol", name }))
: [];
},
async getItemBody(id, k, n): Promise<string | null> {
return id === libId && k === "symbol" ? parsed.bodyFor(n) : null;
},
};
}

View file

@ -0,0 +1,25 @@
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
/**
* Wrap a `LibsSource` so the editor sees exactly ONE library the lib-table
* gets a single row, instead of "browse all backend libs". Used to open a
* specific backend library scoped to itself (HomePage a lib chip).
*
* Read-only scope: `listItems`/`getItemBody` forward to the base for the target
* lib; no `createLib`/`saveItemBody` is exposed (the editor opens this origin to
* browse, not to write matching today's read-only-origin behavior).
*/
export function scopedLibsSource(base: LibsSource, libId: string): LibsSource {
return {
async listLibs(kind?: string): Promise<LibInfo[]> {
const all = await base.listLibs(kind);
return all.filter((l) => l.id === libId);
},
listItems(id: string): Promise<LibItemInfo[]> {
return base.listItems(id);
},
getItemBody(id: string, kind: string, name: string): Promise<string | null> {
return base.getItemBody(id, kind, name);
},
};
}