From 04dd0b9c7de12159f24f8871e780f082560bf22c Mon Sep 17 00:00:00 2001 From: Istvan Matejcsok <119620946+matejcsok-ee@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:57:03 +0200 Subject: [PATCH] =?UTF-8?q?standalone:=20POC=20"Import=20from=20file"=20pa?= =?UTF-8?q?nel=20=E2=80=94=20place=20a=20local=20.kicad=5Fsym/.kicad=5Fmod?= =?UTF-8?q?=20on=20the=20canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proof of concept for plugin-style item ingress: a floating panel (session menu → "Import from file") takes a symbol library or footprint file from the user's machine (file picker or drag-and-drop onto the panel), lets them pick a symbol when the library holds several, and places it where they next click on the canvas (Esc cancels; viewport centre is the fallback). No C++: the blob goes through the editors' existing collab items-apply bridge (Module.kicadCollabApplyItems), i.e. the clipboard-paste parsers — pcbnew takes a bare (footprint …) with an (at …) added, eeschema takes the clipboard dialect (lib_symbols …) + (symbol …) so the definition travels with the instance and a never-seen library works. The click maps CSS px → canvas px (HiDPI) → world IU through kicadCollabGetViewport, same as the comment pins. Known POC limits, documented in wasm/import-item.ts: the apply commits with SKIP_UNDO and is folded into the collab baseline (not undoable, not broadcast to peers); the symbol lands unannotated; placement is a commit, not an interactive drag. The follow-up route is one embind export per editor feeding placeSymbol / placeFootprint a pre-built item. Co-Authored-By: Claude Fable 5.1 --- .../src/components/ImportItemPanel.tsx | 311 ++++++++++++++++++ web/standalone/src/components/WasmTool.tsx | 19 ++ .../src/components/wasm-tool/SessionMenu.tsx | 20 ++ web/standalone/src/wasm/import-item.test.ts | 141 ++++++++ web/standalone/src/wasm/import-item.ts | 311 ++++++++++++++++++ 5 files changed, 802 insertions(+) create mode 100644 web/standalone/src/components/ImportItemPanel.tsx create mode 100644 web/standalone/src/wasm/import-item.test.ts create mode 100644 web/standalone/src/wasm/import-item.ts diff --git a/web/standalone/src/components/ImportItemPanel.tsx b/web/standalone/src/components/ImportItemPanel.tsx new file mode 100644 index 000000000..4482b94f0 --- /dev/null +++ b/web/standalone/src/components/ImportItemPanel.tsx @@ -0,0 +1,311 @@ +import * as React from "react"; +import { ChevronDown, ChevronRight, FilePlus, X } from "lucide-react"; +import type { Tool } from "@pcbjam/shared"; +import { useDraggablePanel } from "@/components/useDraggablePanel"; +import { + applyEnvelope, + buildFootprintImport, + buildSymbolImport, + glCanvasRect, + kindForFile, + placementAtCssPx, + placementMm, + readViewport, + symbolNames, + type CssRect, + type ImportKind, + type ImportModule, +} from "@/wasm/import-item"; + +/** + * POC "plugin" sidebar (import-item): pick a `.kicad_sym` / `.kicad_mod` from + * the local filesystem (file picker or drag-and-drop onto the panel) and add + * it to the open canvas: "Add to canvas" arms a click catcher over the GAL + * canvas and the next click is the drop point (Esc cancels; the viewport + * centre is the fallback when no canvas rect is found). Placement goes + * through the editor's collab apply bridge — see wasm/import-item.ts for + * the contract and caveats. + * + * Same draggable-panel conventions as LayerPanel (header = drag handle, + * collapse-to-header, position persisted). + */ + +const PANEL_POS_KEY = "pcbjam:import-panel-pos"; +const PANEL_W = 288; // w-72 +const PANEL_HEADER_H = 36; + +interface Picked { + fileName: string; + text: string; + kind: ImportKind; + /** Symbol libs can hold many symbols; the user picks one. */ + names: string[]; +} + +const kindForTool = (tool: Tool): ImportKind | null => + tool === "eeschema" ? "symbol" : tool === "pcbnew" ? "footprint" : null; + +export function ImportItemPanel({ + mod, + tool, + onClose, +}: { + mod: ImportModule; + tool: Tool; + onClose: () => void; +}) { + const rootRef = React.useRef(null); + const inputRef = React.useRef(null); + const drag = useDraggablePanel({ + storageKey: PANEL_POS_KEY, + handleWidth: PANEL_W, + handleHeight: PANEL_HEADER_H, + }); + const [collapsed, setCollapsed] = React.useState(false); + const [picked, setPicked] = React.useState(null); + const [symbol, setSymbol] = React.useState(""); + const [dragOver, setDragOver] = React.useState(false); + const [status, setStatus] = React.useState<{ kind: "ok" | "err" | "info"; text: string } | null>(null); + /** Armed click catcher: the GAL canvas rect it covers. */ + const [placing, setPlacing] = React.useState(null); + + const wanted = kindForTool(tool); + + const take = async (file: File) => { + const kind = kindForFile(file.name); + if (!kind) { + setStatus({ kind: "err", text: `${file.name}: pick a .kicad_sym or .kicad_mod file.` }); + return; + } + const text = await file.text(); + const names = kind === "symbol" ? symbolNames(text) : []; + if (kind === "symbol" && names.length === 0) { + setStatus({ kind: "err", text: `${file.name}: no symbols found in this library.` }); + return; + } + setPicked({ fileName: file.name, text, kind, names }); + setSymbol(names[0] ?? ""); + setStatus( + kind === wanted + ? { kind: "info", text: `${file.name} ready — ${kind === "symbol" ? `${names.length} symbol(s)` : "footprint"}.` } + : { kind: "err", text: `${file.name} is a ${kind}; this is ${tool}. Open the ${kind === "symbol" ? "schematic" : "board"} to place it.` }, + ); + }; + + /** Build + apply the blob at (x, y) mm. */ + const placeAt = (at: { x: number; y: number }, how: string) => { + if (!picked || picked.kind !== wanted) return; + try { + let sexpr: string; + let label: string; + if (picked.kind === "symbol") { + const nick = picked.fileName.replace(/\.kicad_sym$/i, ""); + const r = buildSymbolImport(picked.text, symbol || undefined, nick, at.x, at.y); + sexpr = r.sexpr; + label = `${r.libId} as ${r.reference}`; + } else { + const r = buildFootprintImport(picked.text, at.x, at.y); + sexpr = r.sexpr; + label = r.name; + } + // The apply runs deferred on the editor's coroutine; a parse failure is + // logged by the C++ side (`[collab] … parse`), not thrown here. + void mod.kicadCollabApplyItems(applyEnvelope(sexpr)); + setStatus({ kind: "ok", text: `Added ${label} at ${at.x} mm, ${at.y} mm (${how}).` }); + } catch (e) { + setStatus({ kind: "err", text: e instanceof Error ? e.message : String(e) }); + } + }; + + /** "Add to canvas": arm the click catcher (or drop at the centre if the + * canvas can't be located). */ + const add = () => { + if (!picked || picked.kind !== wanted) return; + if (mod.kicadOpenFileBusy?.()) { + setStatus({ kind: "err", text: "The editor is still loading — try again in a moment." }); + return; + } + const rect = glCanvasRect(); + if (!rect || !readViewport(mod)) { + placeAt(placementMm(mod, picked.kind), "viewport centre"); + return; + } + setPlacing(rect); + setStatus({ kind: "info", text: "Click on the canvas where you want it (Esc to cancel)." }); + }; + + const onCatcherClick = (e: React.MouseEvent) => { + if (!picked) return; + // Re-read both at click time: the user may have panned/zoomed (wheel goes + // through the catcher to the canvas) or resized since arming. + const rect = glCanvasRect() ?? placing; + const vp = readViewport(mod); + setPlacing(null); + if (!rect || !vp) { + placeAt(placementMm(mod, picked.kind), "viewport centre"); + return; + } + placeAt(placementAtCssPx(vp, rect, { x: e.clientX, y: e.clientY }, picked.kind), "clicked point"); + }; + + React.useEffect(() => { + if (!placing) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setPlacing(null); + setStatus({ kind: "info", text: "Placement cancelled." }); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [placing]); + + const style: React.CSSProperties = drag.pos + ? { left: drag.pos.x, top: drag.pos.y } + : { right: 12, top: 100 }; + + const iconBtn = + "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"; + const canAdd = picked !== null && picked.kind === wanted; + + return ( + <> + {/* Click catcher over the drawing area only (fixed: the panel's own + offset parent is irrelevant). Wheel/pan still reach the canvas. */} + {placing && ( +
+ )} +
+
drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())} + onPointerMove={(e) => void drag.onPointerMove(e)} + onPointerUp={() => void drag.onPointerUp()} + > + + + Import from file + e.stopPropagation()}> + + +
+ + {!collapsed && ( +
+
inputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files[0]; + if (f) void take(f); + }} + > + + Drop a {wanted === "symbol" ? ".kicad_sym" : wanted === "footprint" ? ".kicad_mod" : ".kicad_sym / .kicad_mod"} here + + or click to choose a file + { + const f = e.target.files?.[0]; + if (f) void take(f); + e.target.value = ""; + }} + /> +
+ + {picked && ( +
+
+ File: + {picked.fileName} +
+ {picked.kind === "symbol" && picked.names.length > 1 && ( + + )} +
+ )} + + + + {status && ( +

+ {status.text} +

+ )} +
+ )} +
+ + ); +} diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 78b57b802..e16f545ac 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -82,6 +82,8 @@ import { PresenceRoster } from "@/components/PresenceRoster"; import { CommentLayer } from "@/components/CommentLayer"; import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner"; import { hasLayersBridge, LayerPanel, type LayersModule } from "@/components/LayerPanel"; +import { ImportItemPanel } from "@/components/ImportItemPanel"; +import { hasImportBridge, type ImportModule } from "@/wasm/import-item"; import { SelectionInspector } from "@/components/SelectionInspector"; import { hasSheetsBridge, SheetPanel, type SheetsModule } from "@/components/SheetPanel"; import { bindLocalSelectionFeed } from "@/wasm/collab/local-selection"; @@ -367,6 +369,8 @@ export function WasmTool({ /* private mode */ } }, []); + // POC import-from-file panel (import-item): session-only, not persisted. + const [importOpen, setImportOpen] = React.useState(false); const [inspectorOpen, setInspectorOpenState] = React.useState(() => { try { const stored = localStorage.getItem(INSPECTOR_OPEN_KEY); @@ -1533,6 +1537,13 @@ export function WasmTool({ return hasLayersBridge(mod) ? mod : null; }, [ready, tool]); + // Items-apply bridge for the POC import panel (import-item): pcbnew + eeschema. + const importMod = React.useMemo(() => { + if (!ready || (tool !== "pcbnew" && tool !== "eeschema")) return null; + const mod = (window as { Module?: unknown }).Module; + return hasImportBridge(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. @@ -1673,6 +1684,9 @@ export function WasmTool({ setSheetsOpen={setSheetsOpen} inspectorOpen={inspectorOpen} setInspectorOpen={setInspectorOpen} + hasImport={importMod !== null} + importOpen={importOpen} + setImportOpen={setImportOpen} canToggleChrome={setChromeFn !== null} chromeHidden={chromeHidden} onToggleChrome={() => toggleChromeHidden()} @@ -1725,6 +1739,11 @@ export function WasmTool({ /> )} + {/* POC (import-item): add a local .kicad_sym / .kicad_mod to the canvas. */} + {ready && importOpen && importMod && ( + setImportOpen(false)} /> + )} + {/* DEV: presence style tuner (VITE_PRESENCE_TUNER=1). */} {ready && tunerMod && } diff --git a/web/standalone/src/components/wasm-tool/SessionMenu.tsx b/web/standalone/src/components/wasm-tool/SessionMenu.tsx index 0b15b1593..acdcba2e9 100644 --- a/web/standalone/src/components/wasm-tool/SessionMenu.tsx +++ b/web/standalone/src/components/wasm-tool/SessionMenu.tsx @@ -5,6 +5,7 @@ import { Box, Crosshair, EyeOff, + FilePlus, Layers, ListTree, Moon, @@ -157,6 +158,9 @@ export function SessionMenu({ setSheetsOpen, inspectorOpen, setInspectorOpen, + hasImport, + importOpen, + setImportOpen, canToggleChrome, chromeHidden, onToggleChrome, @@ -190,6 +194,10 @@ export function SessionMenu({ setSheetsOpen: (v: boolean) => void; inspectorOpen: boolean; setInspectorOpen: (v: boolean) => void; + /** The items-apply bridge is available (POC import-from-file panel). */ + hasImport: boolean; + importOpen: boolean; + setImportOpen: (v: boolean) => void; /** The loaded bundle exports kicadSetChrome. */ canToggleChrome: boolean; chromeHidden: boolean; @@ -304,6 +312,18 @@ export function SessionMenu({ {inspectorOpen ? "Hide inspector" : "Inspector"} )} + {hasImport && !readOnly && ( + + )} {tool === "pcbnew" && onShow3D && (