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
171 lines
10 KiB
TypeScript
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);
|
|
}
|