import type { WebModuleRouter } from "./module-router.js"; import type { ViewOwner } from "./part-view-facade.js"; import { requestOwnerParameters, saved, persist } from "./view-navigation-preferences.js"; import { createFailure, createSuccess } from "./facade-envelope.js"; import type { StandardCommandResult } from "./web-standard-command-contract.js"; export const toolbarPath = "User parameter:Tux/PersistentToolbars"; export const toolbarVisibilityPath = "User parameter:BaseApp/MainWindow/Toolbars"; export const areas = ["Top", "Left", "Right", "Bottom"] as const; export type Area = typeof areas[number]; export type Placement = { name: string; area: Area; breakBefore: boolean }; const legacyFile = "Std_DlgParameter-PersistentToolbars.FCParam"; const file = "Std_DlgParameter-PersistentToolbars.json"; const snapshotSchema = "freecad-web/persistent-toolbars/v1"; type ToolbarSnapshot = { schema: typeof snapshotSchema; userParameters: string; enabled?: boolean; visibilityParameters?: string }; function decodeSnapshot(data: string): ToolbarSnapshot | undefined { const text = atob(data); if (!text.trimStart().startsWith("{")) return undefined; // Legacy User-only FCParam. const snapshot = JSON.parse(text) as ToolbarSnapshot; if (snapshot.schema !== snapshotSchema || typeof snapshot.userParameters !== "string" || snapshot.enabled !== undefined && typeof snapshot.enabled !== "boolean" || snapshot.visibilityParameters !== undefined && typeof snapshot.visibilityParameters !== "string") throw new Error("Invalid persistent toolbar preference snapshot"); return snapshot; } function enabledParameter(root: StandardCommandResult): boolean | undefined { const value = root.parameters?.find((entry: unknown[]) => entry[1] === "Enabled")?.[2]; if (value === undefined) return undefined; return value !== false && value !== 0 && !["false", "0"].includes(String(value).toLowerCase()); } const states = new WeakMap }>>(); const listeners = new Set<() => void>(); let latest: string | undefined; const channel = typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel("freecad-toolbar-parameters"); if (channel) channel.onmessage = event => { if (typeof event.data !== "string") return; latest = event.data; listeners.forEach(fn => fn()); }; export function onToolbarPreferencesChanged(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; } // Persist root Enabled and User together; System/add* remain native session presets. export function toolbarParameters(router: WebModuleRouter, owner: ViewOwner, documentId: string, action: (request: (args: Record) => Promise) => Promise, changed = false) { let owners = states.get(router); if (!owners) { owners = new Map(); states.set(router, owners); } let state = owners.get(owner); if (!state) { state = { ready: false, tail: Promise.resolve() }; owners.set(owner, state); } const current = state; const run = async () => { const request = async (args: Record) => { const reply = await requestOwnerParameters(router, owner, documentId, args); if (reply.kind !== "result") throw new Error(reply.error.message); return reply.result as StandardCommandResult; }; const userPath = `${toolbarPath}/User`; const capture = async () => { const root = await request({ path: toolbarPath, operation: "read" }); const exported = await request({ path: userPath, operation: "export" }); const visibility = await request({ path: toolbarVisibilityPath, operation: "export" }); if (typeof exported.fileData !== "string" || typeof visibility.fileData !== "string") throw new Error("Native toolbar parameter export is missing"); // Fields are ASCII (FCParam is already base64), so btoa preserves the full snapshot. return btoa(JSON.stringify({ schema: snapshotSchema, userParameters: exported.fileData, enabled: enabledParameter(root), visibilityParameters: visibility.fileData } satisfies ToolbarSnapshot)); }; const replace = async (data: string) => { const snapshot = decodeSnapshot(data); const root = await request({ path: userPath, operation: "read" }); for (const group of root.groups ?? []) for (const name of ["Saved", ...areas]) await request({ path: `${userPath}/${group}`, operation: "remove", name, type: name === "Saved" ? "Bool" : "String" }); await request({ path: userPath, operation: "import", fileData: snapshot?.userParameters ?? data }); // A legacy User-only snapshot must not reset the native root preference. if (snapshot) await request({ path: toolbarPath, operation: snapshot.enabled === undefined ? "remove" : "set", name: "Enabled", type: "Bool", ...(snapshot.enabled === undefined ? {} : { value: snapshot.enabled }) }); if (snapshot?.visibilityParameters !== undefined) { const visibility = await request({ path: toolbarVisibilityPath, operation: "read" }); for (const [type, name] of visibility.parameters ?? []) if (type === "Bool") await request({ path: toolbarVisibilityPath, operation: "remove", name, type }); await request({ path: toolbarVisibilityPath, operation: "import", fileData: snapshot.visibilityParameters }); } }; const data = latest ?? (!current.ready ? (await saved(file) ?? await saved(legacyFile)) : undefined); if (data && current.snapshot !== data) { const before = await capture(); try { await replace(data); } catch (error) { await replace(before); throw error; } current.snapshot = data; latest = data; } current.ready = true; const before = changed ? await capture() : undefined; try { const result = await action(request); if (changed) { const exported = await capture(); if (exported === before) return result; await persist(exported, file); latest = exported; current.snapshot = latest; channel?.postMessage(latest); listeners.forEach(fn => fn()); } return result; } catch (error) { if (before) await replace(before); throw error; } }; const pending = current.tail.then(run, run); current.tail = pending.catch(() => {}); return pending; } export async function restoreToolbars(router: WebModuleRouter, documentId: string, workbench: string, defaults: Placement[]): Promise { return await toolbarParameters(router, "DocumentProbe", documentId, async request => { const root = await request({ path: toolbarPath, operation: "read" }); if (enabledParameter(root) === false) return defaults.map(item => ({ ...item })); const read = async (kind: string) => Object.fromEntries((await request({ path: `${toolbarPath}/${kind}/${workbench}Workbench`, operation: "read" })).parameters!.map(([, key, value]) => [key, value])); const user = await read("User"); const parameters = user.Saved ? user : await read("System"); const result: Placement[] = []; const seen = new Set(); if (parameters.Saved) for (const area of areas) { let breakBefore = false; for (const name of String(parameters[area] ?? "").split(",")) { if (name === "Break") { breakBefore = true; continue; } if (name && !seen.has(name)) { result.push({ name, area, breakBefore }); seen.add(name); breakBefore = false; } } } for (const item of defaults) if (!seen.has(item.name)) result.push({ ...item }); return result; }) as Placement[]; } export async function saveToolbars(router: WebModuleRouter, documentId: string, workbench: string, layout: Placement[]) { return await toolbarParameters(router, "DocumentProbe", documentId, async request => { const root = await request({ path: toolbarPath, operation: "read" }); if (enabledParameter(root) === false) return false; const path = `${toolbarPath}/User/${workbench}Workbench`; for (const area of areas) await request({ path, operation: "set", name: area, type: "String", value: layout.filter(item => item.area === area).flatMap(item => item.breakBefore ? ["Break", item.name] : [item.name]).join(",") }); await request({ path, operation: "set", name: "Saved", type: "Bool", value: true }); return true; }, true); } export async function readToolbarVisibility(router: WebModuleRouter, documentId: string): Promise> { return await toolbarParameters(router, "DocumentProbe", documentId, async request => { const result = await request({ path: toolbarVisibilityPath, operation: "read" }); return Object.fromEntries((result.parameters ?? []).filter(([type]) => type === "Bool") .map(([, name, value]) => [name, value !== false && value !== 0 && !["false", "0"].includes(String(value).toLowerCase())])); }) as Record; } export async function setToolbarVisibility(router: WebModuleRouter, documentId: string, name: string, visible: boolean) { // Visibility belongs to the desktop MainWindow preferences, independent of Tux's layout gate. return toolbarParameters(router, "DocumentProbe", documentId, request => request({ path: toolbarVisibilityPath, operation: "set", name, type: "Bool", value: visible }), true); } function relatedPath(path: string, root: string) { return path === root || path.startsWith(root + "/") || root.startsWith(path.endsWith(":") ? path : path + "/"); } /** Native parameter-panel edits and imports use the same authority as toolbar moves. */ export function requestToolbarParameters(router: WebModuleRouter, owner: ViewOwner, documentId: string, args: Record) { const path = String(args.path ?? "User parameter:"); const mutates = ["set", "remove", "import"].includes(String(args.operation)); const user = `${toolbarPath}/User`; const affectsUser = relatedPath(path, user) || relatedPath(path, toolbarVisibilityPath); return toolbarParameters(router, owner, documentId, request => request(args), mutates && affectsUser) .then(result => { if (mutates && !affectsUser) listeners.forEach(fn => fn()); return createSuccess("toolbar-parameters", result); }).catch(error => createFailure("toolbar-parameters", "parameter_update_failed", String(error))); } export function isToolbarParameterPath(path: unknown) { const value = String(path ?? "User parameter:"); return relatedPath(value, toolbarPath) || relatedPath(value, toolbarVisibilityPath); }