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:
parent
b0b7f69846
commit
4b404f3771
7 changed files with 326 additions and 9 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit f21b6ea006bcfb8bd92c2d6d2afa42504db6bda4
|
||||
Subproject commit 43b28a47e7df8be7e659f9512eb8afa860719df5
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
162
web/standalone/src/wasm/libs/kicad-sym-parse.ts
Normal file
162
web/standalone/src/wasm/libs/kicad-sym-parse.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
66
web/standalone/src/wasm/libs/local-file-source.ts
Normal file
66
web/standalone/src/wasm/libs/local-file-source.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
25
web/standalone/src/wasm/libs/scoped-source.ts
Normal file
25
web/standalone/src/wasm/libs/scoped-source.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue