268 lines
16 KiB
TypeScript
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}
|
|
</>;
|
|
}
|