Files
FreeCADWasmDev/web/src/dock-overlay-host.tsx
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

268 lines
16 KiB
TypeScript

import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { DOCK_OVERLAY_COMMANDS, executeDockOverlayCommand, registerDockOverlayHost, type DockCommand, type DockPanelState, type DockSide, type DockState } from "./dock-overlay-adapter.js";
import { registerWebWindowHost, type WindowLayout, type WindowPanel } from "./web-window-host-bridge.js";
import { executeWebDesktop } from "./web-desktop-adapter.js";
import { WebViewTask } from "./web-view-task.js";
import type { WebModuleRouter } from "./module-router.js";
import type { ViewOwner } from "./part-view-facade.js";
import "./dock-overlay.css";
const sides: DockSide[] = ["left", "right", "top", "bottom"];
const overlayLabels: Record<DockCommand, string> = {
Std_DockOverlay: "Overlay docked panel",
Std_DockOverlayAll: "Toggle all overlays",
Std_DockOverlayTransparentAll: "Toggle transparent panels",
Std_DockOverlayToggle: "Toggle panel overlay",
Std_DockOverlayToggleTransparent: "Toggle panel transparency",
Std_DockOverlayMouseTransparent: "Toggle mouse transparency",
Std_DockOverlayToggleLeft: "Toggle left overlay",
Std_DockOverlayToggleRight: "Toggle right overlay",
Std_DockOverlayToggleTop: "Toggle top overlay",
Std_DockOverlayToggleBottom: "Toggle bottom overlay",
};
const initial = (): DockState => ({ active: "modules", mouseTransparent: false, panels: {
modules: { side: "left", overlay: false, transparent: false, visible: true },
commands: { side: "right", overlay: false, transparent: false, visible: true },
} });
const storageKey = "freecad-workbench-dock-layout";
function restore(): DockState {
const defaults = initial();
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || "null");
if (!saved || !Object.hasOwn(defaults.panels, saved.active) || typeof saved.mouseTransparent !== "boolean") return defaults;
for (const name of Object.keys(defaults.panels)) {
const panel = saved.panels?.[name];
if (!panel || !sides.includes(panel.side) || [panel.overlay, panel.transparent, panel.visible].some(v => typeof v !== "boolean")) return defaults;
}
if (saved.panels.modules.side === saved.panels.commands.side) return defaults;
return { active: saved.active, mouseTransparent: saved.mouseTransparent, panels: {
modules: { ...saved.panels.modules }, commands: { ...saved.panels.commands },
} };
} catch { return defaults; }
}
export function DockOverlayHost({ children, router, documentId, onSelection }: {
children: React.ReactNode; router: WebModuleRouter; documentId: string;
onSelection: (documentId: string, selection: Array<{ documentId: string; object: string; subelements?: string[] }>) => void;
}) {
const root = useRef<HTMLDivElement>(null);
const [state, setState] = useState<DockState>(restore);
const current = useRef(state);
const hoveredPanel = useRef<string>();
const overlayQueue = useRef<Promise<unknown>>(Promise.resolve());
const [overlayCommand, setOverlayCommand] = useState<DockCommand>("Std_DockOverlayAll");
const [overlayPanel, setOverlayPanel] = useState("modules");
const [windowLayout, setWindowLayout] = useState<WindowLayout>();
const [windowOwner, setWindowOwner] = useState<ViewOwner>("PartBox");
const [locked, setLocked] = useState(true);
const [toolbarPosition, setToolbarPosition] = useState<"top" | "bottom">("top");
const [windowBusy, setWindowBusy] = useState(false);
const [windowReady, setWindowReady] = useState(false);
const [windowError, setWindowError] = useState("");
const [dockMenu, setDockMenu] = useState<WindowPanel[]>([]);
const windowBusyRef = useRef(false);
const runWindow = async (command: string, args: Record<string, unknown> = {}) => {
if (windowBusyRef.current) return false;
windowBusyRef.current = true; setWindowBusy(true); setWindowError("");
try {
const response = await executeWebDesktop(router, command, { runtimeOwner: windowOwner, ...args },
documentId || "workbench-document", onSelection);
if (response?.kind === "error") setWindowError(response.error.message);
return response?.kind === "result";
} catch (error) { setWindowError(String(error)); return false; }
finally { windowBusyRef.current = false; setWindowBusy(false); }
};
const commit = (next: DockState) => {
current.current = next;
setState(next);
try { localStorage.setItem(storageKey, JSON.stringify(next)); } catch { /* Layout remains usable without preference storage. */ }
return next;
};
useLayoutEffect(() => registerWebWindowHost({
snapshot: () => ({ toolbar: "window-commands", panels: Object.entries(current.current.panels).map(([objectName, panel]) => ({
objectName, title: objectName === "modules" ? "模块" : "命令与参数", visible: panel.visible,
})) }),
apply: (value, owner) => {
setWindowOwner(owner);
if (value.items) {
const next = { ...current.current, panels: { ...current.current.panels } };
for (const item of value.items) {
if (!Object.hasOwn(next.panels, item.objectName)) throw new Error("Dock panel is no longer mounted");
next.panels[item.objectName] = { ...next.panels[item.objectName], visible: item.visible };
}
commit(next); setDockMenu(value.items);
}
if (value.locked !== undefined) setLocked(value.locked);
if (value.windows && value.columns && value.rows)
setWindowLayout({ windows: value.windows, columns: value.columns, rows: value.rows, activeDocument: value.activeDocument ?? null });
},
}), []);
useEffect(() => {
let disposed = false;
setWindowReady(false);
void (async () => {
let ready = true;
for (const command of ["Std_DockViewMenu", "Std_ToggleToolBarLock"]) {
if (disposed) return;
if (!await runWindow(command, { operation: "read" })) ready = false;
}
if (!disposed) setWindowReady(ready);
})();
const close = (event: Event) => {
const detail = (event as CustomEvent<{ owner: ViewOwner; documents: string[] }>).detail;
if (detail?.owner !== windowOwner) return;
setWindowLayout(previous => {
if (!previous) return previous;
const windows = previous.windows.filter(item => !detail.documents.includes(item.documentId));
return windows.length ? { ...previous, windows } : undefined;
});
};
window.addEventListener("freecad-documents-closed", close);
return () => { disposed = true; window.removeEventListener("freecad-documents-closed", close); };
}, [windowOwner]);
useLayoutEffect(() => registerDockOverlayHost((requested, args) => {
const run = async () => {
let command = requested;
if (requested === "Std_DockOverlay") {
const child = args.childCommand ?? "Std_DockOverlayAll";
if (typeof child !== "string" || child === requested || !Object.hasOwn(DOCK_OVERLAY_COMMANDS, child)) throw new Error("Choose a dock overlay child command");
command = child as DockCommand;
}
const next: DockState = { ...current.current, panels: Object.fromEntries(Object.entries(current.current.panels).map(([key, panel]) => [key, { ...panel }])) };
if (args.panel !== undefined) {
if (typeof args.panel !== "string" || !Object.hasOwn(next.panels, args.panel)) throw new Error("Unknown dock panel");
next.active = args.panel;
}
const operation = DOCK_OVERLAY_COMMANDS[command];
// Side toggles consume the current snapshot; WASM owns the visibility change.
if (operation === "toggleActive" || operation === "transparentActive") {
const focused = document.activeElement instanceof HTMLElement
? document.activeElement.closest<HTMLElement>("[data-dock-panel]") : null;
const target = args.panel ?? hoveredPanel.current
?? (focused && root.current?.contains(focused) ? focused.dataset.dockPanel : undefined);
// OverlayManager resolves the dock under the cursor, then the focused dock.
// Clear a stale active dock so the native handler preserves the desktop
// no-target behavior without toggling the previously focused panel.
next.active = typeof target === "string" && Object.hasOwn(next.panels, target)
? target : "";
}
if (operation === "undock") {
next.panels[next.active] = { ...next.panels[next.active], overlay: false, visible: true };
return commit(next);
}
if (operation === "vr") {
window.dispatchEvent(new CustomEvent("freecad-view-vr", { detail: { active: true, panel: next.active } }));
return commit(next);
}
const response = await router.request("Web", "web.dockOverlay", {
command: requested, args, state: next,
}, documentId || "workbench-document");
if (response.kind === "error") throw new Error(response.error.message);
const value = response.result as { command: string; state: DockState };
if (value.command !== requested || !value.state) throw new Error("Invalid native dock response");
return commit(value.state);
};
const pending = overlayQueue.current.then(run);
overlayQueue.current = pending.catch(() => {});
return pending;
}), [router, documentId]);
useLayoutEffect(() => {
if (!root.current) return;
const panels = Array.from(root.current.children).filter((element): element is HTMLElement => element instanceof HTMLElement && element.tagName === "ASIDE");
const cleanup: Array<() => void> = [];
panels.forEach((element, index) => {
const name = index === 0 ? "modules" : "commands";
const panel = state.panels[name];
element.dataset.dockPanel = name; element.dataset.dockSide = panel.side;
element.dataset.overlay = String(panel.overlay); element.dataset.transparent = String(panel.transparent);
element.dataset.mouseTransparent = String(state.mouseTransparent && panel.overlay);
element.hidden = !panel.visible;
element.style.display = panel.visible ? "" : "none";
const activate = () => { current.current = { ...current.current, active: name }; };
const enter = () => { hoveredPanel.current = name; activate(); };
const leave = () => { if (hoveredPanel.current === name) hoveredPanel.current = undefined; };
element.addEventListener("pointerenter", enter);
element.addEventListener("pointerleave", leave);
element.addEventListener("focusin", activate);
cleanup.push(() => { element.removeEventListener("pointerenter", enter); element.removeEventListener("pointerleave", leave); element.removeEventListener("focusin", activate); });
});
return () => cleanup.forEach(release => release());
}, [state]);
const patch = (name: string, patch: Partial<DockPanelState>) => {
const previous = current.current;
const panels = { ...previous.panels, [name]: { ...previous.panels[name], ...patch } };
if (patch.side) {
for (const [other, panel] of Object.entries(previous.panels)) {
if (other !== name && panel.side === patch.side) panels[other] = { ...panel, side: previous.panels[name].side };
}
}
return commit({ ...previous, active: name, panels });
};
const docked = (side: DockSide) => Object.values(state.panels).some(panel => panel.visible && !panel.overlay && panel.side === side);
const style = { "--dock-left": docked("left") ? "260px" : "0px", "--dock-right": docked("right") ? "340px" : "0px" } as React.CSSProperties;
const toolbar = <nav aria-label="Window toolbar" data-window-ready={windowReady} style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
<select aria-label="Overlay docked panel command" value={overlayCommand}
onChange={event => setOverlayCommand(event.target.value as DockCommand)}>
{Object.keys(DOCK_OVERLAY_COMMANDS).filter(command => command !== "Std_DockOverlay").map(command =>
<option key={command} value={command}>{overlayLabels[command as DockCommand]}</option>)}
</select>
{(overlayCommand === "Std_DockOverlayToggle" || overlayCommand === "Std_DockOverlayToggleTransparent") &&
<select aria-label="Overlay target panel" value={overlayPanel} onChange={event => setOverlayPanel(event.target.value)}>
{Object.keys(state.panels).map(name => <option key={name} value={name}>{name === "modules" ? "模块" : "命令与参数"}</option>)}
</select>}
<button type="button" onClick={async () => {
setWindowError("");
const response = await executeDockOverlayCommand("Std_DockOverlay", {
childCommand: overlayCommand,
...(["Std_DockOverlayToggle", "Std_DockOverlayToggleTransparent"].includes(overlayCommand) ? { panel: overlayPanel } : {}),
});
if (response?.kind === "error") setWindowError(response.error.message);
}}>应用面板命令</button>
<button type="button" disabled={windowBusy} onClick={() => void runWindow("Std_CloseAllWindows")}>关闭全部窗口</button>
<button type="button" disabled={windowBusy} onClick={() => void runWindow("Std_TileWindows")}>平铺窗口</button>
<details onToggle={event => { if (event.currentTarget.open) void runWindow("Std_DockViewMenu"); }}>
<summary>面板</summary>
{dockMenu.map(panel => <label key={panel.objectName} style={{ display: "block" }}>
<input type="checkbox" checked={state.panels[panel.objectName]?.visible ?? panel.visible}
disabled={windowBusy || !panel.enabled} onChange={event => void runWindow("Std_DockViewMenu", {
objectName: panel.objectName, visible: event.target.checked,
})} />{panel.title}
</label>)}
</details>
<label><input type="checkbox" checked={locked} disabled={windowBusy}
onChange={() => void runWindow("Std_ToggleToolBarLock")} />锁定工具栏</label>
<select aria-label="工具栏位置" disabled={locked || windowBusy} value={toolbarPosition}
onChange={event => setToolbarPosition(event.target.value as "top" | "bottom")}>
<option value="top">顶部</option><option value="bottom">底部</option>
</select>
</nav>;
return <>
{toolbarPosition === "top" && toolbar}
{windowError && <output role="alert">{windowError}</output>}
{windowLayout && <section aria-label="Document windows" style={{ display: "grid",
gridTemplateColumns: `repeat(auto-fit, minmax(min(100%, ${Math.max(280, Math.floor(960 / windowLayout.columns))}px), 1fr))`, gap: 8 }}>
{windowLayout.windows.map(item => <section key={`${windowOwner}:${item.documentId}`} aria-label={item.label} style={{ minWidth: 0 }}>
<button type="button" disabled={windowBusy} aria-pressed={windowLayout.activeDocument === item.documentId}
onClick={() => void runWindow("Std_TileWindows", { activateDocument: item.documentId }).then(applied => {
if (applied) onSelection(item.documentId, []);
})}>{item.label}</button>
<WebViewTask router={router} command="Std_SceneInspector" documentId={item.documentId}
runtimeOwner={windowOwner} selection={[]} onSelection={onSelection} disabled={windowBusy} />
</section>)}
</section>}
<details className="dock-layout-controls"><summary>面板布局</summary>
{Object.entries(state.panels).map(([name, panel]) => <div className="dock-panel-controls" key={name}>
<strong>{name === "modules" ? "模块" : "命令与参数"}</strong>
<select aria-label={name + " dock side"} value={panel.side} onChange={event => patch(name, { side: event.target.value as DockSide })}>{sides.map(side => <option key={side}>{side}</option>)}</select>
<label><input type="checkbox" checked={panel.overlay} onChange={event => patch(name, { overlay: event.target.checked, visible: true })} />叠放</label>
<label><input type="checkbox" checked={panel.visible} disabled={windowBusy} onChange={event => void runWindow("Std_DockViewMenu", { objectName: name, visible: event.target.checked })} />显示</label>
<label><input type="checkbox" checked={panel.transparent} disabled={!panel.overlay} onChange={event => patch(name, { transparent: event.target.checked })} />透明</label>
</div>)}
<label><input type="checkbox" checked={state.mouseTransparent} onChange={event => commit({ ...current.current, mouseTransparent: event.target.checked })} />鼠标穿透叠放面板</label>
<button type="button" onClick={() => commit(initial())}>恢复面板布局</button>
</details>
<div ref={root} className="layout dock-workbench" style={style}>{children}</div>
{toolbarPosition === "bottom" && toolbar}
</>;
}