Files
FreeCADWasmDev/web/src/toolbar-preferences.ts
wangdequan 789e18ca3e
Some checks failed
FreeCAD master CI / Prepare (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
FreeCAD master CI / Pixi (push) Has been cancelled
FreeCAD master CI / Ubuntu (push) Has been cancelled
FreeCAD master CI / Windows (push) Has been cancelled
FreeCAD master CI / Lint (push) Has been cancelled
FreeCAD master CI / WrapUp (push) Has been cancelled
Stale Issues / stale (push) Has been cancelled
CodeQL Advanced (c-cpp) / Analyze (c-cpp) (push) Has been cancelled
Push Crowdin Translations / update-crowdin (push) Has been cancelled
Fetch Crowdin Translations / update-crowdin (push) Has been cancelled
Build Release / upload_src (push) Has been cancelled
Build Release / build (10.13, macos-15-intel, osx-64) (push) Has been cancelled
Build Release / build (11.0, macos-latest, osx-arm64) (push) Has been cancelled
Build Release / build (15.0, macos-latest, osx-arm64) (push) Has been cancelled
Build Release / build (ubuntu-22.04, linux-64) (push) Has been cancelled
Build Release / build (ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Build Release / build (windows-latest, win-64) (push) Has been cancelled
Monthly issue metrics / issue metrics (push) Has been cancelled
Continue FreeCAD Web UI parity repairs
2026-09-23 14:03:56 +08:00

171 lines
10 KiB
TypeScript

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<WebModuleRouter, Map<ViewOwner, { ready: boolean; snapshot?: string; tail: Promise<unknown> }>>();
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<string, unknown>) => Promise<StandardCommandResult>) => Promise<unknown>, 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<string, unknown>) => {
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<Placement[]> {
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<string>();
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<Record<string, boolean>> {
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<string, boolean>;
}
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<string, unknown>) {
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);
}