Serve the standalone editor under a CMMS sub-path with no change to
@pcbjam/shared or the wasm/C++ side. No-op for a root deploy (BASE_URL='/').
- main.tsx: <BrowserRouter basename={import.meta.env.BASE_URL}>
- lib/base-path.ts (new): stripBase() for raw location.pathname reads,
hardNav() for the cross-document window.location.assign() navs (the
process-global wasm runtime means a tool switch / File->Quit is a full
page load, and @pcbjam/shared's projectPath() is origin-absolute)
- config.ts currentScope(): stripBase() before splitting for the scope segment
- WasmTool.tsx quit-path: stripBase() for the projects/libs segment test
- tool-navigation.ts (x2), NewFileDialog.tsx, ProjectView.tsx, quit-hook.ts:
window.location.assign(projectPath(...)) -> hardNav(...)
- .env.production: VITE_API_BASE_URL=/pcb-project-api.php, PROJECT_SOURCE=remote,
DOC_SOURCE=api, LIBS_SOURCE=off, WASM_ROOT=/vendor/pcbjam/wasm,
YJS_PROVIDER=broadcastchannel; APP_URL/USER/SCOPE unset
Build: vite build --base=/vendor/pcbjam/
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
214 lines
6.9 KiB
TypeScript
214 lines
6.9 KiB
TypeScript
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
|
|
import {
|
|
EXTENSION_TOOL,
|
|
FILELESS_TOOLS,
|
|
projectPath,
|
|
projectToolPath,
|
|
toolSchema,
|
|
type Tool,
|
|
} from "@pcbjam/shared";
|
|
import { hardNav } from "@/lib/base-path";
|
|
import { currentScope } from "@/lib/config";
|
|
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
|
|
import { memfsProjectDir } from "@/wasm/constants";
|
|
import type { ToolFile } from "@/wasm/kicad-runner";
|
|
import { markDeliberateNavigation } from "./quit-hook";
|
|
|
|
const LEGACY_EXTENSION_TOOL: Record<string, Tool> = {
|
|
".sch": "eeschema",
|
|
".brd": "pcbnew",
|
|
};
|
|
|
|
let activeToolNavigationHook:
|
|
| ((toolName: string, fileName: string) => boolean)
|
|
| undefined;
|
|
|
|
const toolNavigationDispatcher = (toolName: string, fileName: string) =>
|
|
activeToolNavigationHook?.(toolName, fileName) ?? false;
|
|
|
|
function ensureToolNavigationDispatcher(win: ToolWindow): boolean {
|
|
if (win.kicadWebOpenTool === toolNavigationDispatcher) return true;
|
|
|
|
try {
|
|
Object.defineProperty(win, "kicadWebOpenTool", {
|
|
configurable: true,
|
|
value: toolNavigationDispatcher,
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
ensureToolNavigationDispatcher(window as ToolWindow);
|
|
}
|
|
|
|
export function normalizeToolName(rawName: string): Tool | null {
|
|
const basename = rawName.replace(/\\/g, "/").split("/").pop() ?? rawName;
|
|
const withoutExe = basename.replace(/\.exe$/i, "");
|
|
const toolName = withoutExe === "pcb_calculator" ? "calculator" : withoutExe;
|
|
const parsed = toolSchema.safeParse(toolName);
|
|
return parsed.success ? parsed.data : null;
|
|
}
|
|
|
|
export function relativeProjectPath(slug: string, path: string): string | undefined {
|
|
if (!path) return undefined;
|
|
|
|
const normalized = path.replace(/\\/g, "/");
|
|
const prefix = `${memfsProjectDir(slug)}/`;
|
|
|
|
if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
|
|
|
|
const marker = `/projects/${slug}/`;
|
|
const markerIndex = normalized.indexOf(marker);
|
|
|
|
if (markerIndex >= 0) return normalized.slice(markerIndex + marker.length);
|
|
|
|
return normalized.startsWith("/") ? undefined : normalized;
|
|
}
|
|
|
|
export function fileStem(path: string): string {
|
|
const name = path.replace(/\\/g, "/").split("/").pop() ?? path;
|
|
return name.replace(/\.[^.]+$/, "");
|
|
}
|
|
|
|
export function fileTool(path: string): Tool | undefined {
|
|
const lower = path.toLowerCase();
|
|
|
|
for (const [extension, mappedTool] of Object.entries({
|
|
...EXTENSION_TOOL,
|
|
...LEGACY_EXTENSION_TOOL,
|
|
})) {
|
|
if (lower.endsWith(extension)) return mappedTool;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export function chooseToolFile(
|
|
files: ToolFile[],
|
|
nextTool: Tool,
|
|
requestedPath?: string,
|
|
currentPath?: string,
|
|
): string | undefined {
|
|
if (requestedPath && files.some((file) => file.path === requestedPath)) {
|
|
return requestedPath;
|
|
}
|
|
|
|
const candidates = files.filter((file) => fileTool(file.path) === nextTool);
|
|
const preferredStem = requestedPath
|
|
? fileStem(requestedPath)
|
|
: currentPath
|
|
? fileStem(currentPath)
|
|
: undefined;
|
|
|
|
if (preferredStem) {
|
|
const matchingStem = candidates.find(
|
|
(file) => fileStem(file.path) === preferredStem,
|
|
);
|
|
if (matchingStem) return matchingStem.path;
|
|
}
|
|
|
|
return candidates[0]?.path;
|
|
}
|
|
|
|
export function installToolNavigationHook(
|
|
win: ToolWindow,
|
|
opts: {
|
|
slug: string;
|
|
files: ToolFile[];
|
|
targetPath?: string;
|
|
/** Persist a new file into the project (see the WasmTool prop). Absent ⇒
|
|
* this session can't create one, and a missing target stays a no-op. */
|
|
createFile?: (relPath: string, bytes: Uint8Array) => Promise<void>;
|
|
log: (m: string) => void;
|
|
},
|
|
): () => void {
|
|
// One create at a time: a double-fired menu item must not upload twice.
|
|
// Cleared only on failure — success navigates the page away.
|
|
let pendingCreate: string | null = null;
|
|
|
|
const hook = (rawToolName: string, rawFileName: string): boolean => {
|
|
const nextTool = normalizeToolName(rawToolName);
|
|
|
|
if (!nextTool) {
|
|
opts.log(`[nav] unsupported KiCad tool: ${rawToolName}`);
|
|
return false;
|
|
}
|
|
|
|
const requestedPath = relativeProjectPath(opts.slug, rawFileName);
|
|
const nextPath = FILELESS_TOOLS.has(nextTool)
|
|
? undefined
|
|
: chooseToolFile(opts.files, nextTool, requestedPath, opts.targetPath);
|
|
|
|
if (!FILELESS_TOOLS.has(nextTool) && !nextPath) {
|
|
// Native KiCad's "Switch to PCB Editor" with no board opens pcbnew on a
|
|
// NEW empty board at the derived path — mirror it by creating the
|
|
// templated counterpart in the project (the shape NewFileDialog writes)
|
|
// and navigating to it. Only sessions that can persist pass `createFile`
|
|
// (ToolPage); viewers and scratch/local-folder sessions keep the quiet
|
|
// no-op. C++ calls this hook synchronously (EM_ASM_INT) and ignores the
|
|
// result beyond a log line, so the create+navigate runs async and we
|
|
// answer true optimistically once it's kicked off.
|
|
const createFile = opts.createFile;
|
|
if (!createFile) {
|
|
opts.log(`[nav] no project file found for ${nextTool}: ${rawFileName}`);
|
|
return false;
|
|
}
|
|
if (pendingCreate) {
|
|
opts.log(`[nav] create already pending: ${pendingCreate}`);
|
|
return true;
|
|
}
|
|
const relPath =
|
|
requestedPath ??
|
|
(opts.targetPath
|
|
? withExtension(nextTool, fileStem(opts.targetPath))
|
|
: defaultFileName(nextTool));
|
|
const url =
|
|
projectPath(currentScope(), opts.slug, relPath) + win.location.search;
|
|
pendingCreate = relPath;
|
|
void (async () => {
|
|
try {
|
|
const bytes = new TextEncoder().encode(
|
|
newFileTemplate(nextTool, crypto.randomUUID()),
|
|
);
|
|
await createFile(relPath, bytes);
|
|
opts.log(`[nav] created missing ${nextTool} file ${relPath} -> ${url}`);
|
|
markDeliberateNavigation();
|
|
hardNav(url, win);
|
|
} catch (e) {
|
|
pendingCreate = null;
|
|
opts.log(
|
|
`[nav] create failed for ${relPath}: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
}
|
|
})();
|
|
return true;
|
|
}
|
|
|
|
// Scope/kind/name grammar: a fileless tool boots at `…/-/:tool`; a file route
|
|
// carries the path (its tool is inferred). Scope = the current URL's scope.
|
|
const scope = currentScope();
|
|
const url =
|
|
(FILELESS_TOOLS.has(nextTool)
|
|
? projectToolPath(scope, opts.slug, nextTool)
|
|
: projectPath(scope, opts.slug, nextPath)) + win.location.search;
|
|
|
|
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
|
|
markDeliberateNavigation();
|
|
hardNav(url, win);
|
|
return true;
|
|
};
|
|
|
|
if (!ensureToolNavigationDispatcher(win)) {
|
|
opts.log("[nav] unable to install KiCad tool navigation hook");
|
|
}
|
|
|
|
activeToolNavigationHook = hook;
|
|
|
|
return () => {
|
|
if (activeToolNavigationHook === hook) activeToolNavigationHook = undefined;
|
|
};
|
|
}
|
|
|