Continue FreeCAD Web UI parity repairs
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
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
This commit is contained in:
170
web/src/toolbar-preferences.ts
Normal file
170
web/src/toolbar-preferences.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user