Schnittebenen 2D↔3D Phase 1+2, native Fenster, Hell/Dunkel-Theme, SWISSIMAGE-Import

- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie
  (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im
  Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht,
  unsichtbare Ebenen blenden ihre Führungslinie aus
- Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/
  Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste
  (AppMenuBar) neben der Wortmarke
- Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert,
  flackerfrei vor erstem Render gesetzt
- SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene
- UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access-
  Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/
  Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt
- Neues Dachziegel-Material (RoofingTiles013A)
This commit is contained in:
2026-07-20 10:51:37 +02:00
parent 0ab0361e3d
commit 68a0459d0e
90 changed files with 7730 additions and 1849 deletions
+78
View File
@@ -0,0 +1,78 @@
// Eigenständiger React-Root für das native Standort-Import-Fenster (siehe
// contextImportWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
// main.tsx gerendert, wenn die URL `?window=context-import` trägt.
//
// Rendert `ContextImportDialog` UNVERÄNDERT (windowed=true) — der Dialog
// selbst braucht keinerlei Host-/Store-Zugriff (reine `io/*`-Netzabrufe),
// daher hier kein Proxy-Pattern nötig wie bei den anderen Fenstern: nur
// `onImport`/`onSetGeoAnchor` laufen als einzelne Events ans Hauptfenster.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { ContextImportDialog } from "../ui/ContextImportDialog";
import type { GeoAnchor } from "../ui/ContextImportDialog";
import type { ContextImportSyncState } from "./contextImportWindow";
import { t } from "../i18n";
export function ContextImportWindowApp() {
const [state, setState] = useState<ContextImportSyncState | null>(null);
useEffect(() => {
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<ContextImportSyncState>("context-import-sync-state", (e) =>
setState(e.payload),
),
);
await emitTo("main", "context-import-window-ready").catch(() => {});
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
const send = (name: string, ...args: unknown[]) => {
void import("@tauri-apps/api/event").then(({ emitTo }) =>
emitTo("main", "context-import-command", { name, args }).catch(() => {}),
);
};
const onClose = () => {
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow().close(),
);
};
if (!state) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
color: "var(--ink-dim, #8a8a8a)",
fontSize: 13,
}}
>
{t("resources.loading")}
</div>
);
}
return (
<ContextImportDialog
onImport={(objs) => send("onImport", objs)}
onClose={onClose}
geoAnchor={state.geoAnchor}
onSetGeoAnchor={(anchor: GeoAnchor | undefined) => send("onSetGeoAnchor", anchor)}
windowed
/>
);
}
+166
View File
@@ -0,0 +1,166 @@
// Eigenständiger React-Root für das native Zeichnungsebenen-Fenster (siehe
// drawingLevelsWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
// main.tsx gerendert, wenn die URL `?window=drawing-levels` trägt.
//
// Spiegelt die Kategorie-/Zeilen-Darstellung des Dock-Panels
// (panels/DrawingLevelsPanel.tsx) — nutzt dessen exportierte `LevelRow` +
// Kategorie-Konstanten direkt (keine Duplikation), aber NICHT `usePanelHost`
// (das ist an den riesigen, docked-Panel-spezifischen PanelHostContext
// gebunden); stattdessen der schmale Sync-/Command-Zustand von
// drawingLevelsWindow.ts, analog SettingsWindowApp.tsx.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useMemo, useState } from "react";
import {
LevelRow,
CATEGORY_ORDER,
CATEGORY_TITLE_KEY,
CATEGORY_ADD_KEY,
} from "../panels/DrawingLevelsPanel";
import type { DrawingLevelsCommandHandlers, DrawingLevelsSyncState } from "./drawingLevelsWindow";
import type { DrawingLevelKind } from "../model/types";
import { t } from "../i18n";
type HandlerName = keyof DrawingLevelsCommandHandlers;
export function DrawingLevelsWindowApp() {
const [state, setState] = useState<DrawingLevelsSyncState | null>(null);
const [collapsed, setCollapsed] = useState<Record<DrawingLevelKind, boolean>>({
floor: false,
section: false,
elevation: false,
drawing: false,
});
const toggleCategory = (kind: DrawingLevelKind) =>
setCollapsed((c) => ({ ...c, [kind]: !c[kind] }));
useEffect(() => {
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<DrawingLevelsSyncState>("drawing-levels-sync-state", (e) =>
setState(e.payload),
),
);
await emitTo("main", "drawing-levels-window-ready").catch(() => {});
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
const handlers = useMemo<DrawingLevelsCommandHandlers>(
() =>
new Proxy({} as DrawingLevelsCommandHandlers, {
get(_target, prop: string) {
return (...args: unknown[]) => {
void import("@tauri-apps/api/event").then(({ emitTo }) =>
emitTo("main", "drawing-levels-command", { name: prop as HandlerName, args }).catch(
() => {},
),
);
};
},
}),
[],
);
if (!state) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
color: "var(--ink-dim, #8a8a8a)",
fontSize: 13,
}}
>
{t("resources.loading")}
</div>
);
}
const onAddByKind: Record<DrawingLevelKind, () => void> = {
floor: handlers.onAddFloor,
section: handlers.onAddSection,
elevation: handlers.onAddElevation,
drawing: handlers.onAddDrawing,
};
// Oberstes Geschoss zuoberst (wie das Dock-Panel/DOSSIER): Anzeige umkehren.
const levelsTopDown = [...state.drawingLevels].reverse();
// Klick aufs Auge in „Alle anzeigen"/„Nur aktive" schaltet automatisch auf
// „Ausgewählte" — dieselbe DOSSIER-Regel wie im Dock-Panel.
const toggle = (id: string) => {
if (state.displayMode === "all_force" || state.displayMode === "active") {
handlers.onSetDisplayMode("all");
}
handlers.onToggleLevel(id);
};
return (
<section
className="nav-group"
style={{ height: "100vh", overflowY: "auto" }}
>
{/* Selber Kopf wie die übrigen vier Fenster (.res-head/.imp-head):
44px, border-bottom, 76px Ampel-Abstand — kein Dock-Panel-Look mehr. */}
<header className="res-head" data-tauri-drag-region style={{ paddingLeft: 88 }}>
<span className="res-head-title">{t("nav.drawingLevels")}</span>
</header>
{CATEGORY_ORDER.map((kind) => {
const levelsOfKind = levelsTopDown.filter((l) => l.kind === kind);
const isOpen = !collapsed[kind];
return (
<div className="level-cat" key={kind}>
<header className="level-cat-head" onClick={() => toggleCategory(kind)}>
<span className={`level-cat-chevron${isOpen ? " open" : ""}`} aria-hidden="true">
</span>
<span className="level-cat-title">{t(CATEGORY_TITLE_KEY[kind])}</span>
<span
className="level-cat-add"
role="button"
aria-label={t(CATEGORY_ADD_KEY[kind])}
title={t(CATEGORY_ADD_KEY[kind])}
onClick={(e) => {
e.stopPropagation();
onAddByKind[kind]();
}}
>
+
</span>
</header>
{isOpen && (
<div className="nav-list">
{levelsOfKind.map((level) => (
<LevelRow
key={level.id}
level={level}
active={level.id === state.activeLevelId}
mode={state.displayMode}
onSelect={() => handlers.onSelectLevel(level.id)}
onToggle={() => toggle(level.id)}
// Rechtsklick-Kontextmenü bewusst kein Bestandteil des
// Fensters (s. drawingLevelsWindow.ts Kopfkommentar).
onContext={(e) => e.preventDefault()}
/>
))}
</div>
)}
</div>
);
})}
</section>
);
}
+135
View File
@@ -0,0 +1,135 @@
// Eigenständiger React-Root für das native Ebeneneinstellungs-Fenster (siehe
// layerSettingsWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
// main.tsx gerendert, wenn die URL `?window=layer-settings` trägt.
//
// Dieselben Felder wie der bisherige `InlineEditor`-Popover in App.tsx
// (`editor?.kind === "layer"`): Name, Farbe, Linienstärke, Schraffur-Auswahl
// — siehe layerSettingsWindow.ts Kopfkommentar für den bewussten Scope-Schnitt
// gegenüber der Rhino-Vorlage.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { EditorField } from "../ui/EditorField";
import type { LayerSettingsCommandHandlers, LayerSettingsSyncState } from "./layerSettingsWindow";
import { t } from "../i18n";
function initialCodeFromUrl(): string {
return new URLSearchParams(window.location.search).get("code") ?? "";
}
export function LayerSettingsWindowApp() {
const [state, setState] = useState<LayerSettingsSyncState | null>(null);
useEffect(() => {
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<LayerSettingsSyncState | null>("layer-settings-sync-state", (e) =>
setState(e.payload),
),
);
// Beim Wechsel auf eine andere Ebene (erneutes Öffnen desselben
// Fensters aus dem Kontextmenü) — nur der Titel/URL-Code als Hinweis;
// der eigentliche State kommt weiterhin per Sync vom Hauptfenster.
unlisten.push(await listen<string>("layer-settings-set-code", () => {}));
await emitTo("main", "layer-settings-window-ready").catch(() => {});
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
const patch = (p: Partial<{ name: string; color: string; lw: number; hatch: string | undefined }>) => {
if (!state) return;
void import("@tauri-apps/api/event").then(({ emitTo }) =>
emitTo("main", "layer-settings-command", {
name: "onPatchCategory" as keyof LayerSettingsCommandHandlers,
args: [state.code, p],
}).catch(() => {}),
);
};
const code = state?.code ?? initialCodeFromUrl();
if (!state) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
color: "var(--ink-dim, #8a8a8a)",
fontSize: 13,
}}
>
{t("resources.loading")}
</div>
);
}
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<header
className="imp-head"
data-tauri-drag-region
style={{ paddingLeft: 88 }}
>
<span className="imp-head-title">
{t("editor.layerTitle", { code })}
</span>
</header>
<div className="imp-body" style={{ overflowY: "auto" }}>
<section className="imp-section">
<EditorField label={t("editor.name")}>
<input
type="text"
value={state.name}
autoFocus
onChange={(e) => patch({ name: e.target.value })}
/>
</EditorField>
<EditorField label={t("editor.color")}>
<input
type="color"
value={state.color}
onChange={(e) => patch({ color: e.target.value })}
/>
</EditorField>
<EditorField label={t("editor.lineWeight")}>
<span className="editor-num">
<input
type="number"
step={0.01}
min={0.01}
value={state.lw}
onChange={(e) => patch({ lw: Number(e.target.value) })}
/>
<span className="editor-unit">{t("editor.unit.mm")}</span>
</span>
</EditorField>
<EditorField label={t("editor.hatch")}>
<span className="editor-hatch">
<select
value={state.hatchId ?? ""}
onChange={(e) => patch({ hatch: e.target.value || undefined })}
>
<option value="">{t("resources.viewHatch.none")}</option>
{state.hatches.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</span>
</EditorField>
</section>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
// Eigenständiger React-Root für das native Ressourcen-Fenster (siehe
// resourceWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
// main.tsx gerendert, wenn die URL `?window=resources` trägt — KEIN Teil des
// normalen App-Baums (kein Zugriff auf den Haupt-Store, eigener Prozess).
//
// Projekt-Zustand kommt ausschliesslich per Tauri-Event vom Hauptfenster
// („resource-sync-project"); Bearbeitungen laufen NICHT lokal, sondern werden
// als „resource-command"-Events ans Hauptfenster geschickt, das die echten
// Handler aufruft und die Änderung über den nächsten Sync-Push zurückspiegelt
// — dasselbe Prinzip wie die native 2D/3D-Fensterspiegelung (nativeSync.ts),
// nur bidirektional statt nur lesend.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useMemo, useState } from "react";
import { ResourceManager } from "../ui/ResourceManager";
import type { ResourceManagerHandlers, TabId } from "../ui/ResourceManager";
import type { Project } from "../model/types";
import { t } from "../i18n";
/** Alle Felder von ResourceManagerHandlers sind Funktionen — für den Proxy unten. */
type HandlerName = keyof ResourceManagerHandlers;
/** Liest `?tab=…` aus der Fenster-URL (initialer Tab beim Öffnen aus einem Kontext). */
function initialTabFromUrl(): TabId | undefined {
const v = new URLSearchParams(window.location.search).get("tab");
return (v as TabId) || undefined;
}
/**
* Eigenständige Seite des Ressourcen-Fensters. Zeigt einen Lade-Hinweis, bis
* der erste Projekt-Sync vom Hauptfenster eintrifft (i. d. R. Millisekunden).
*/
export function ResourceWindowApp() {
const [project, setProject] = useState<Project | null>(null);
const [tab, setTab] = useState<TabId | undefined>(() => initialTabFromUrl());
useEffect(() => {
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<Project>("resource-sync-project", (e) => setProject(e.payload)),
);
unlisten.push(await listen<TabId>("resource-set-tab", (e) => setTab(e.payload)));
// Hauptfenster um den aktuellen Stand bitten — deckt den Fall ab, dass
// dieses Fenster NACH dem letzten Projekt-Push gestartet ist.
await emitTo("main", "resource-window-ready").catch(() => {});
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
// Jeder Handler-Aufruf wird zu EINEM „resource-command"-Event ans
// Hauptfenster (Name + Argumente, beides JSON-serialisierbar — alle
// ResourceManagerHandlers-Signaturen sind reine Datenpatches). Per Proxy
// statt 27 einzeln ausgeschriebenen Weiterleitungen (Drift-Gefahr).
const handlers = useMemo<ResourceManagerHandlers>(
() =>
new Proxy({} as ResourceManagerHandlers, {
get(_target, prop: string) {
return (...args: unknown[]) => {
void import("@tauri-apps/api/event").then(({ emitTo }) =>
emitTo("main", "resource-command", { name: prop as HandlerName, args }).catch(
() => {},
),
);
};
},
}),
[],
);
const onClose = () => {
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow().close(),
);
};
if (!project) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
color: "var(--ink-dim, #8a8a8a)",
fontSize: 13,
}}
>
{t("resources.loading")}
</div>
);
}
return (
<ResourceManager
project={project}
handlers={handlers}
initialTab={tab}
onClose={onClose}
windowed
/>
);
}
+105
View File
@@ -0,0 +1,105 @@
// Eigenständiger React-Root für das native Einstellungs-Fenster (siehe
// settingsWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
// main.tsx gerendert, wenn die URL `?window=settings` trägt. Gleiches Prinzip
// wie ResourceWindowApp.tsx, nur mit dem schmaleren Settings-Zustand statt
// des ganzen Projekts.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useMemo, useState } from "react";
import { SettingsDialog } from "../ui/SettingsDialog";
import type { SettingsCommandHandlers, SettingsSyncState } from "./settingsWindow";
import { t } from "../i18n";
import { applyThemeMode } from "../theme/themeMode";
type HandlerName = keyof SettingsCommandHandlers;
export function SettingsWindowApp() {
const [state, setState] = useState<SettingsSyncState | null>(null);
// Dieses Fenster ist ein eigenes Dokument — Hell/Dunkel muss hier separat
// angewendet werden, sobald der synchronisierte Zustand eintrifft.
useEffect(() => {
if (state) applyThemeMode(state.themeMode);
}, [state]);
useEffect(() => {
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<SettingsSyncState>("settings-sync-state", (e) => setState(e.payload)),
);
// Hauptfenster um den aktuellen Stand bitten (Race-Schutz: dieses
// Fenster könnte nach dem letzten Sync-Push gestartet sein).
await emitTo("main", "settings-window-ready").catch(() => {});
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
// Jeder Handler-Aufruf → EIN „settings-command"-Event ans Hauptfenster
// (Name + Argumente), analog ResourceWindowApp.tsx.
const handlers = useMemo<SettingsCommandHandlers>(
() =>
new Proxy({} as SettingsCommandHandlers, {
get(_target, prop: string) {
return (...args: unknown[]) => {
void import("@tauri-apps/api/event").then(({ emitTo }) =>
emitTo("main", "settings-command", { name: prop as HandlerName, args }).catch(
() => {},
),
);
};
},
}),
[],
);
const onClose = () => {
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow().close(),
);
};
if (!state) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
color: "var(--ink-dim, #8a8a8a)",
fontSize: 13,
}}
>
{t("resources.loading")}
</div>
);
}
return (
<SettingsDialog
themeMode={state.themeMode}
onThemeModeChange={handlers.onThemeModeChange}
marqueeColor={state.marqueeColor}
onMarqueeColorChange={handlers.onMarqueeColorChange}
snapColor={state.snapColor}
onSnapColorChange={handlers.onSnapColorChange}
referenceElevationMasl={state.referenceElevationMasl}
onReferenceElevationChange={handlers.onReferenceElevationChange}
rendererMode={state.rendererMode}
webGpuAvailable={state.webGpuAvailable}
onRendererModeChange={handlers.onRendererModeChange}
layout={state.layout}
onApplyLayout={handlers.onApplyLayout}
onClose={onClose}
windowed
/>
);
}
+379
View File
@@ -0,0 +1,379 @@
// Native macOS-Systemmenüleiste (oben am Bildschirm) — NICHT zu verwechseln mit
// AppMenuBar (die In-App-Menüleiste in der TopBar). Nutzt die Tauri-v2-Menu-API
// (@tauri-apps/api/menu), die einen echten macOS-App-Menü-Baum baut. No-op im
// reinen Browser (kein Tauri) — s. isTauriRuntime.
//
// Aufbau: EIN Menü-Baum wird beim Mount EINMAL erzeugt (MenuItem.new ist async
// und teuer genug, um es nicht bei jedem Render neu zu bauen); alle Aktionen
// laufen über eine Ref auf die jeweils aktuellen Handler (klassisches Ref-
// Pattern gegen veraltete Closures, wie `toolShortcutRef` in App.tsx). Enabled-
// /Checked-Zustand (Undo/Redo/Löschen/Ansicht) wird in einem separaten Effekt
// synchron zum React-State über `setEnabled`/`setChecked` nachgezogen.
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import { t } from "../i18n";
import { APP_VERSION } from "../ui/TopBar";
import type { View3d, ViewType } from "../ui/TopBar";
/** Alle Aktionen, die die native Menüleiste auslösen kann. */
export interface NativeAppMenuHandlers {
onOpenProject: () => void;
onSaveProject: () => void;
onImport: () => void;
onExportPdf: () => void;
onExportDxf: () => void;
onExportSchedule: () => void;
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
onUndo: () => void;
onRedo: () => void;
onDeleteSelection: () => void;
onMove: () => void;
onMirror: () => void;
onRotate: () => void;
onViewType: (v: ViewType) => void;
onView3d: (v: View3d) => void;
onReferenceLines: (v: boolean) => void;
onToggleResources: () => void;
onOpenSettings: () => void;
/** Macht das Panel mit dieser ID sichtbar (siehe `revealPanel` in panels/layout.ts). */
onOpenPanel: (panelId: string) => void;
/** Öffnet den Zeichnungsebenen-Manager als eigenes Fenster (zusätzlich zum Dock-Panel). */
onOpenDrawingLevelsWindow: () => void;
/** Öffnet die Ebeneneinstellungen für die aktuell aktive Ebene (activeCategoryCode). */
onOpenLayerSettingsWindow: () => void;
/** Öffnet den Standort-Import (swisstopo/OSM). */
onOpenContextImportWindow: () => void;
}
/** Ein Eintrag für „Fenster > Panels" — id + fertiges i18n-Label. */
export interface NativeAppMenuPanel {
id: string;
label: string;
}
/** Ortsabhängiger Zustand, der Enabled-/Checked-Häkchen der Menüpunkte treibt. */
export interface NativeAppMenuState {
canUndo: boolean;
canRedo: boolean;
hasSelection: boolean;
viewType: ViewType;
view3d: View3d;
referenceLines: boolean;
resourcesOpen: boolean;
}
const VIEW3D_IDS: View3d[] = [
"front",
"back",
"side",
"left",
"top",
"iso",
"perspective",
];
/**
* Baut die native macOS-Menüleiste einmal auf und hält Enabled-/Checked-
* Zustand synchron zum übergebenen `state`. `handlers` darf sich bei jedem
* Render ändern (wird über eine Ref gelesen, kein Neu-Erzeugen der Menüpunkte).
*/
export function useNativeAppMenu(
handlers: NativeAppMenuHandlers,
state: NativeAppMenuState,
panels: NativeAppMenuPanel[],
): void {
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
// Panel-Liste ist beim Mount bekannt (Registry ist statisch) — als Ref nur,
// damit der Aufbau-Effekt (deps: []) sie ohne Lint-Warnung lesen darf.
const panelsRef = useRef(panels);
panelsRef.current = panels;
// Menüpunkte, die Enabled-/Checked-Zustand nachgezogen bekommen — als Ref,
// damit der Sync-Effekt unten sie ohne Neu-Aufbau erreicht.
const itemsRef = useRef<{
undo?: import("@tauri-apps/api/menu").MenuItem;
redo?: import("@tauri-apps/api/menu").MenuItem;
delete?: import("@tauri-apps/api/menu").MenuItem;
floorplan?: import("@tauri-apps/api/menu").CheckMenuItem;
perspective?: import("@tauri-apps/api/menu").CheckMenuItem;
view3d: Map<View3d, import("@tauri-apps/api/menu").CheckMenuItem>;
referenceLines?: import("@tauri-apps/api/menu").CheckMenuItem;
resources?: import("@tauri-apps/api/menu").CheckMenuItem;
}>({ view3d: new Map() });
// Baut den Menü-Baum EINMAL. Aktionen rufen ausschliesslich über
// `handlersRef.current` — so bleibt der Baum stabil, während die
// eigentlichen Callbacks (aus App.tsx, jeden Render neu) trotzdem aktuell
// bleiben.
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
void (async () => {
const {
Menu,
Submenu,
MenuItem,
CheckMenuItem,
PredefinedMenuItem,
} = await import("@tauri-apps/api/menu");
if (disposed) return;
const fileMenu = await Submenu.new({
text: t("menu.native.file"),
items: [
await MenuItem.new({
text: t("menu.native.open"),
accelerator: "CmdOrCtrl+O",
action: () => handlersRef.current.onOpenProject(),
}),
await MenuItem.new({
text: t("menu.native.saveAs"),
accelerator: "CmdOrCtrl+S",
action: () => handlersRef.current.onSaveProject(),
}),
await MenuItem.new({
text: t("menu.native.import"),
action: () => handlersRef.current.onImport(),
}),
await Submenu.new({
text: t("menu.native.export"),
items: [
await MenuItem.new({
text: "PDF",
action: () => handlersRef.current.onExportPdf(),
}),
await MenuItem.new({
text: "DXF",
action: () => handlersRef.current.onExportDxf(),
}),
await MenuItem.new({
text: t("menu.native.exportSchedule"),
action: () => handlersRef.current.onExportSchedule(),
}),
await MenuItem.new({
text: "IFC",
action: () => handlersRef.current.onExportIfc(),
}),
await MenuItem.new({
text: "OBJ",
action: () => handlersRef.current.onExportObj(),
}),
await MenuItem.new({
text: "STL",
action: () => handlersRef.current.onExportStl(),
}),
],
}),
],
});
const undoItem = await MenuItem.new({
text: t("menu.edit.undo"),
accelerator: "CmdOrCtrl+Z",
action: () => handlersRef.current.onUndo(),
});
const redoItem = await MenuItem.new({
text: t("menu.edit.redo"),
accelerator: "CmdOrCtrl+Shift+Z",
action: () => handlersRef.current.onRedo(),
});
const deleteItem = await MenuItem.new({
text: t("menu.edit.delete"),
action: () => handlersRef.current.onDeleteSelection(),
});
itemsRef.current.undo = undoItem;
itemsRef.current.redo = redoItem;
itemsRef.current.delete = deleteItem;
const editMenu = await Submenu.new({
text: t("menu.native.edit"),
items: [
undoItem,
redoItem,
await PredefinedMenuItem.new({ item: "Separator" }),
deleteItem,
await PredefinedMenuItem.new({ item: "Separator" }),
await MenuItem.new({
text: t("menu.native.move"),
action: () => handlersRef.current.onMove(),
}),
await MenuItem.new({
text: t("menu.native.mirror"),
action: () => handlersRef.current.onMirror(),
}),
await MenuItem.new({
text: t("menu.native.rotate"),
action: () => handlersRef.current.onRotate(),
}),
],
});
const floorplanItem = await CheckMenuItem.new({
text: t("menu.view.floorplan"),
action: () => handlersRef.current.onViewType("grundriss"),
});
const perspectiveItem = await CheckMenuItem.new({
text: t("menu.view.perspective"),
action: () => handlersRef.current.onViewType("perspektive"),
});
itemsRef.current.floorplan = floorplanItem;
itemsRef.current.perspective = perspectiveItem;
const view3dLabels: Record<View3d, string> = {
front: t("view3d.front"),
back: t("view3d.back"),
side: t("view3d.side"),
left: t("view3d.left"),
top: t("view3d.top"),
iso: t("view3d.iso"),
isoFrontLeft: t("view3d.iso"),
isoBackRight: t("view3d.iso"),
isoBackLeft: t("view3d.iso"),
perspective: t("view3d.perspective"),
};
const view3dItems: import("@tauri-apps/api/menu").CheckMenuItem[] = [];
for (const id of VIEW3D_IDS) {
const item = await CheckMenuItem.new({
text: view3dLabels[id],
action: () => handlersRef.current.onView3d(id),
});
itemsRef.current.view3d.set(id, item);
view3dItems.push(item);
}
const referenceLinesItem = await CheckMenuItem.new({
text: t("menu.view.referenceLines"),
action: () => handlersRef.current.onReferenceLines(!state.referenceLines),
});
itemsRef.current.referenceLines = referenceLinesItem;
const viewMenu = await Submenu.new({
text: t("menu.native.view"),
items: [
floorplanItem,
perspectiveItem,
await PredefinedMenuItem.new({ item: "Separator" }),
...view3dItems,
await PredefinedMenuItem.new({ item: "Separator" }),
referenceLinesItem,
],
});
const resourcesItem = await CheckMenuItem.new({
text: t("menu.window.resources"),
action: () => handlersRef.current.onToggleResources(),
});
itemsRef.current.resources = resourcesItem;
// Ein Menüpunkt je registriertem Panel (ToolsPanel/Attribute/Objekt-Info/
// Zeichnungsebenen/Ebenen/Umgebung/Flächenbilanz/Element-Baum/Ausschnitte/
// Layouts) — macht das jeweilige Panel sichtbar (`revealPanel`), egal ob es
// gerade angedockt, schwebend oder gar nicht offen ist.
const panelItems = await Promise.all(
panelsRef.current.map((p) =>
MenuItem.new({
text: p.label,
action: () => handlersRef.current.onOpenPanel(p.id),
}),
),
);
const panelsMenu = await Submenu.new({
text: t("menu.native.panels"),
items: panelItems,
});
const windowMenu = await Submenu.new({
text: t("menu.native.window"),
items: [
resourcesItem,
await PredefinedMenuItem.new({ item: "Separator" }),
panelsMenu,
await MenuItem.new({
text: t("menu.window.drawingLevels"),
action: () => handlersRef.current.onOpenDrawingLevelsWindow(),
}),
await MenuItem.new({
text: t("menu.window.layerSettings"),
action: () => handlersRef.current.onOpenLayerSettingsWindow(),
}),
await MenuItem.new({
text: t("menu.window.contextImport"),
action: () => handlersRef.current.onOpenContextImportWindow(),
}),
await PredefinedMenuItem.new({ item: "Separator" }),
await MenuItem.new({
text: t("menu.window.settings"),
action: () => handlersRef.current.onOpenSettings(),
}),
],
});
// macOS: der allererste Eintrag ist das App-Menü (Über/Beenden). Ohne ihn
// rutscht „Datei" an die Stelle, an der macOS sonst den App-Namen zeigt.
// „Über Dossier" nutzt das NATIVE macOS-Info-Fenster (PredefinedMenuItem
// „About") statt des eigenen In-App-Dialogs (AboutDialog in TopBar.tsx) —
// an dieser Stelle erwartet macOS exakt diesen Eintrag, kein Custom-Dialog.
const appMenu = await Submenu.new({
text: "Dossier",
items: [
await PredefinedMenuItem.new({
item: { About: { name: "Dossier", version: APP_VERSION } },
text: t("menu.native.about"),
}),
await PredefinedMenuItem.new({ item: "Separator" }),
await PredefinedMenuItem.new({ item: "Quit", text: t("menu.native.quit") }),
],
});
if (disposed) return;
const menu = await Menu.new({
items: [appMenu, fileMenu, editMenu, viewMenu, windowMenu],
});
await menu.setAsAppMenu();
})().catch((e) => {
console.debug("useNativeAppMenu: Aufbau fehlgeschlagen (kein Tauri-Menu?)", e);
});
return () => {
disposed = true;
};
// Nur beim Mount aufbauen — Aktionen laufen über handlersRef, State-
// Synchronisation über den Effekt unten. Ein Neu-Aufbau bei jedem Render
// wäre teuer (mehrere async IPC-Aufrufe) und unnötig.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Enabled-/Checked-Zustand synchron zum React-State halten (billig, kein
// Neu-Aufbau der Menüpunkte).
useEffect(() => {
if (!isTauriRuntime()) return;
const items = itemsRef.current;
void items.undo?.setEnabled(state.canUndo);
void items.redo?.setEnabled(state.canRedo);
void items.delete?.setEnabled(state.hasSelection);
void items.floorplan?.setChecked(state.viewType === "grundriss");
void items.perspective?.setChecked(state.viewType === "perspektive");
for (const [id, item] of items.view3d) {
void item.setChecked(state.view3d === id);
void item.setEnabled(state.viewType === "perspektive");
}
void items.referenceLines?.setChecked(state.referenceLines);
void items.resources?.setChecked(state.resourcesOpen);
}, [
state.canUndo,
state.canRedo,
state.hasSelection,
state.viewType,
state.view3d,
state.referenceLines,
state.resourcesOpen,
]);
}
+99
View File
@@ -0,0 +1,99 @@
// Standort-Import ("Standort importieren…", geo.admin/swisstopo/OSM) als
// EIGENES natives Tauri-Fenster statt Overlay-Dialog im Hauptfenster.
//
// Anders als Ressourcen/Einstellungen/Ebenen braucht dieser Dialog fast
// keinen geteilten Zustand: der gesamte Netz-Abruf (Geocoding, swissBUILDINGS3D/
// ALTI3D/SWISSIMAGE, OSM) läuft komplett INNERHALB von ContextImportDialog
// selbst (reine `io/*`-Funktionen, kein Store-Zugriff) — ein eigenes Fenster
// ist hier sogar naheliegender als beim Rest: ein langer Netz-Abruf blockiert
// so nicht einmal gedanklich das Hauptfenster. Nur das ENDERGEBNIS
// (fertige Kontext-Objekte + optionaler Georeferenzierungs-Bezug) muss zurück.
//
// Hauptfenster → Fenster: „context-import-sync-state" mit `{ geoAnchor }`.
// Fenster → Hauptfenster: „context-import-command" mit `{name,args}` — wie
// die übrigen Brücken (resourceWindow.ts etc.).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import type { GeoAnchor } from "../ui/ContextImportDialog";
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
const CONTEXT_IMPORT_WINDOW_LABEL = "context-import";
export interface ContextImportSyncState {
geoAnchor: GeoAnchor | undefined;
}
export interface ContextImportCommandHandlers {
onImport: (objs: unknown[]) => void;
onSetGeoAnchor: (anchor: GeoAnchor | undefined) => void;
}
/** Öffnet das native Standort-Import-Fenster (bzw. holt es nach vorne). */
export async function openContextImportWindowNative(): Promise<void> {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const existing = await WebviewWindow.getByLabel(CONTEXT_IMPORT_WINDOW_LABEL);
if (existing) {
await existing.setFocus();
return;
}
new WebviewWindow(CONTEXT_IMPORT_WINDOW_LABEL, {
url: "index.html?window=context-import",
title: "Standort importieren — Dossier",
width: 480,
height: 680,
minWidth: 400,
minHeight: 480,
...BORDERLESS_WINDOW_CHROME,
});
}
/** Hauptfenster-Seite der Sync-Brücke. */
export function useContextImportWindowHost(
geoAnchor: GeoAnchor | undefined,
handlers: ContextImportCommandHandlers,
): void {
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
const geoAnchorRef = useRef(geoAnchor);
geoAnchorRef.current = geoAnchor;
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<{ name: string; args: unknown[] }>("context-import-command", (e) => {
const fn = (handlersRef.current as unknown as Record<string, unknown>)[e.payload.name];
if (typeof fn === "function") (fn as (...a: unknown[]) => void)(...e.payload.args);
}),
);
unlisten.push(
await listen("context-import-window-ready", () => {
void emitTo(CONTEXT_IMPORT_WINDOW_LABEL, "context-import-sync-state", {
geoAnchor: geoAnchorRef.current,
}).catch(() => {});
}),
);
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
useEffect(() => {
if (!isTauriRuntime()) return;
void (async () => {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(CONTEXT_IMPORT_WINDOW_LABEL, "context-import-sync-state", {
geoAnchor,
}).catch(() => {});
})();
}, [geoAnchor]);
}
+111
View File
@@ -0,0 +1,111 @@
// Zeichnungsebenen-Manager als EIGENES natives Tauri-Fenster — Referenz:
// DOSSIER-Rhino-Plugin (`git.kgva.ch/karim/DOSSIER`, `src/DrawingLevelsApp.jsx`
// + `rhino/layers_panel.py`), dort ein eigenständiges Rhino-Panel statt
// angedockt. Hier: dieselbe Liste wie das bestehende Dock-Panel
// (DrawingLevelsPanel.tsx), zusätzlich als Fenster öffenbar — das Dock-Panel
// bleibt bestehen (bewusst additiv, nicht ersetzt, anders als beim
// Ressourcen-Manager, der nie ein Dock-Panel war).
//
// Gleiches Sync-/Command-Prinzip wie settingsWindow.ts: ein expliziter, enger
// Zustand (nicht das ganze Project) + eine {name,args}-Command-RPC.
// **Bewusst NICHT unterstützt** (Scope-Entscheidung, s. PENDENZEN.md): das
// Rechtsklick-Kontextmenü einer Zeile (Duplizieren/Löschen/Einstellungen…) —
// das lebt heute als App-weites Overlay im HAUPTFENSTER; ein Klick im
// Zeichnungsebenen-FENSTER kann es dort nicht sinnvoll positionieren. Bis das
// eigens gelöst ist, bleibt Rechtsklick im Fenster ein No-op (Auswahl/
// Sichtbarkeit/Hinzufügen funktionieren voll).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import type { DrawingLevel } from "../model/types";
import type { DisplayMode } from "../panels/types";
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
const DRAWING_LEVELS_WINDOW_LABEL = "drawing-levels";
export interface DrawingLevelsSyncState {
drawingLevels: DrawingLevel[];
activeLevelId: string;
displayMode: DisplayMode;
}
export interface DrawingLevelsCommandHandlers {
onSelectLevel: (id: string) => void;
onToggleLevel: (id: string) => void;
onAddFloor: () => void;
onAddSection: () => void;
onAddElevation: () => void;
onAddDrawing: () => void;
onSetDisplayMode: (mode: DisplayMode) => void;
}
/** Öffnet das native Zeichnungsebenen-Fenster (bzw. holt es nach vorne). */
export async function openDrawingLevelsWindowNative(): Promise<void> {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const existing = await WebviewWindow.getByLabel(DRAWING_LEVELS_WINDOW_LABEL);
if (existing) {
await existing.setFocus();
return;
}
new WebviewWindow(DRAWING_LEVELS_WINDOW_LABEL, {
url: "index.html?window=drawing-levels",
title: "Zeichnungsebenen — Dossier",
width: 340,
height: 560,
minWidth: 260,
minHeight: 300,
...BORDERLESS_WINDOW_CHROME,
});
}
/** Hauptfenster-Seite der Sync-Brücke — Pendant zu useSettingsWindowHost. */
export function useDrawingLevelsWindowHost(
state: DrawingLevelsSyncState,
handlers: DrawingLevelsCommandHandlers,
): void {
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<{ name: string; args: unknown[] }>("drawing-levels-command", (e) => {
const fn = (handlersRef.current as unknown as Record<string, unknown>)[e.payload.name];
if (typeof fn === "function") (fn as (...a: unknown[]) => void)(...e.payload.args);
}),
);
unlisten.push(
await listen("drawing-levels-window-ready", () => {
void emitTo(
DRAWING_LEVELS_WINDOW_LABEL,
"drawing-levels-sync-state",
stateRef.current,
).catch(() => {});
}),
);
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
useEffect(() => {
if (!isTauriRuntime()) return;
void (async () => {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(DRAWING_LEVELS_WINDOW_LABEL, "drawing-levels-sync-state", state).catch(
() => {},
);
})();
}, [state]);
}
+134
View File
@@ -0,0 +1,134 @@
// Ebeneneinstellungen als EIGENES natives Tauri-Fenster — Referenz: DOSSIER-
// Rhino-Plugin (`git.kgva.ch/karim/DOSSIER`, `rhino/layers_panel.py`
// `_open_ebenen_settings`), das dort exakt so als „Satelliten-Fenster"
// (Eto-Form, kein Docked-Panel) läuft. Ersetzt unter Tauri den bisherigen
// `InlineEditor`-Popover (App.tsx, `editor?.kind === "layer"`); im reinen
// Browser bleibt der Popover (kein OS-Fenster-Konzept dort).
//
// **Bewusst NUR die heute schon im Modell vorhandenen Felder** (Name/Farbe/
// Linienstärke/Schraffur-Auswahl) — die Rhino-Version kennt zusätzlich eine
// PRO-EBENE Schraffur-Skalierung/-Rotation/-Stiftstärke (`section.hatchScale`
// etc.), die es in `LayerCategory` (model/types.ts) schlicht nicht gibt
// (dort nur `hatch?: string`, eine Referenz auf die geteilte Hatch-Resource
// ohne Per-Kategorie-Override). Das nachzurüsten wäre eine eigene
// Modell-Erweiterung + generatePlan.ts-Verdrahtung, keine reine Fenster-
// Frage — bewusst nicht mitgemacht, um hier nichts zu erfinden, das der Rest
// der App nicht einlöst.
//
// Nur EIN Fenster gleichzeitig (Label fix "layer-settings", wie Rhinos
// Satellit): erneutes Öffnen für eine andere Ebene schiebt nur den neuen Code
// nach (kein Fenster-Zoo), analog dem Dropdown-Wechsel im Original.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import { findCategory } from "../state/projectSlice";
import type { HatchStyle, LayerCategory, Project } from "../model/types";
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
const LAYER_SETTINGS_WINDOW_LABEL = "layer-settings";
export interface LayerSettingsSyncState {
code: string;
name: string;
color: string;
lw: number;
hatchId: string | undefined;
hatches: { id: string; name: string }[];
}
export interface LayerSettingsCommandHandlers {
onPatchCategory: (code: string, patch: Partial<LayerCategory>) => void;
}
/** Öffnet das native Ebeneneinstellungs-Fenster für `code` (bzw. schiebt den Code nach, falls schon offen). */
export async function openLayerSettingsWindowNative(code: string): Promise<void> {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const existing = await WebviewWindow.getByLabel(LAYER_SETTINGS_WINDOW_LABEL);
if (existing) {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(LAYER_SETTINGS_WINDOW_LABEL, "layer-settings-set-code", code).catch(() => {});
await existing.setFocus();
return;
}
new WebviewWindow(LAYER_SETTINGS_WINDOW_LABEL, {
url: `index.html?window=layer-settings&code=${encodeURIComponent(code)}`,
title: "Ebeneneinstellungen — Dossier",
width: 360,
height: 420,
minWidth: 300,
minHeight: 340,
...BORDERLESS_WINDOW_CHROME,
});
}
/** Baut den Sync-Zustand für eine Kategorie (bzw. `null`, wenn der Code nicht mehr existiert — z. B. gelöscht). */
function stateFor(project: Project, code: string): LayerSettingsSyncState | null {
const cat = findCategory(project.layers, code);
if (!cat) return null;
return {
code: cat.code,
name: cat.name,
color: cat.color,
lw: cat.lw,
hatchId: cat.hatch,
hatches: project.hatches.map((h: HatchStyle) => ({ id: h.id, name: h.name })),
};
}
/** Hauptfenster-Seite der Sync-Brücke. `code` = aktuell im Fenster editierte Ebene (folgt `openLayerSettingsWindowNative`-Aufrufen). */
export function useLayerSettingsWindowHost(
project: Project,
code: string | null,
onPatchCategory: (code: string, patch: Partial<LayerCategory>) => void,
): void {
const stateRef = useRef<LayerSettingsSyncState | null>(code ? stateFor(project, code) : null);
stateRef.current = code ? stateFor(project, code) : null;
const onPatchRef = useRef(onPatchCategory);
onPatchRef.current = onPatchCategory;
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<{ name: string; args: unknown[] }>("layer-settings-command", (e) => {
if (e.payload.name === "onPatchCategory") {
const [c, patch] = e.payload.args as [string, Partial<LayerCategory>];
onPatchRef.current(c, patch);
}
}),
);
unlisten.push(
await listen("layer-settings-window-ready", () => {
void emitTo(
LAYER_SETTINGS_WINDOW_LABEL,
"layer-settings-sync-state",
stateRef.current,
).catch(() => {});
}),
);
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
useEffect(() => {
if (!isTauriRuntime()) return;
void (async () => {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(
LAYER_SETTINGS_WINDOW_LABEL,
"layer-settings-sync-state",
stateRef.current,
).catch(() => {});
})();
// eslint-disable-next-line react-hooks/exhaustive-deps -- stateRef.current ist bereits die aktuelle Ableitung
}, [project, code]);
}
+114
View File
@@ -0,0 +1,114 @@
// Ressourcen-Manager als EIGENES natives Tauri-Fenster (statt schwebendes
// Overlay im Hauptfenster) — Hauptfenster-Seite der Brücke.
//
// Ein natives Zweitfenster ist ein separater Webview-Prozesskontext: KEIN
// gemeinsamer JS-Speicher, also kein direkter Store-Zugriff. Die Brücke
// synchronisiert daher explizit über Tauri-Events (@tauri-apps/api/event):
// • Hauptfenster → Ressourcen-Fenster: „resource-sync-project" mit dem
// kompletten (JSON-serialisierbaren) Project bei jeder Änderung + einmal
// sofort, sobald das Ressourcen-Fenster „resource-window-ready" meldet
// (Race-Schutz: das Fenster könnte später starten als der erste Push).
// • Ressourcen-Fenster → Hauptfenster: „resource-command" mit
// `{ name, args }` — der Name eines ResourceManagerHandlers-Feldes plus
// seine Argumente. Das Hauptfenster ruft den ECHTEN Handler auf (der
// Projekt-Patches über setProject anwendet); die Antwort kommt implizit
// über den nächsten „resource-sync-project"-Push zurück.
// Das Gegenstück (Fenster-Seite: Proxy-Handlers + Projekt-State) lebt in
// ResourceWindowApp.tsx.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import type { Project } from "../model/types";
import type { ResourceManagerHandlers, TabId } from "../ui/ResourceManager";
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
/** Fenster-Label des Ressourcen-Fensters (für emitTo/getByLabel). */
const RESOURCE_WINDOW_LABEL = "resources";
/**
* Öffnet das native Ressourcen-Fenster (bzw. holt es nach vorne, falls schon
* offen). No-op-Fallback existiert NICHT hier — Aufrufer prüfen
* `isTauriRuntime()` selbst und nutzen sonst das alte Overlay (App.tsx).
*/
export async function openResourceWindowNative(initialTab?: TabId): Promise<void> {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const existing = await WebviewWindow.getByLabel(RESOURCE_WINDOW_LABEL);
if (existing) {
if (initialTab) {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(RESOURCE_WINDOW_LABEL, "resource-set-tab", initialTab).catch(() => {});
}
await existing.setFocus();
return;
}
const url =
"index.html?window=resources" + (initialTab ? `&tab=${encodeURIComponent(initialTab)}` : "");
new WebviewWindow(RESOURCE_WINDOW_LABEL, {
url,
title: "Ressourcen — Dossier",
width: 780,
height: 660,
minWidth: 480,
minHeight: 360,
...BORDERLESS_WINDOW_CHROME,
});
}
/**
* Hauptfenster-Seite der Sync-Brücke: hält das Ressourcen-Fenster (falls
* offen) mit dem aktuellen Projekt synchron und wendet dessen Befehle auf die
* ECHTEN Handler an. No-op ausserhalb von Tauri. In App.tsx neben der
* bestehenden `<ResourceManager>`-Einbettung aufrufen (dieselben
* `resourceHandlers`).
*/
export function useResourceWindowHost(
project: Project,
handlers: ResourceManagerHandlers,
): void {
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
const projectRef = useRef(project);
projectRef.current = project;
// Befehle vom Ressourcen-Fenster entgegennehmen + auf die echten Handler
// anwenden, UND bei „ready" (Fenster gerade erst gestartet) sofort das
// aktuelle Projekt nachschieben.
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<{ name: string; args: unknown[] }>("resource-command", (e) => {
const fn = (handlersRef.current as unknown as Record<string, unknown>)[e.payload.name];
if (typeof fn === "function") (fn as (...a: unknown[]) => void)(...e.payload.args);
}),
);
unlisten.push(
await listen("resource-window-ready", () => {
void emitTo(RESOURCE_WINDOW_LABEL, "resource-sync-project", projectRef.current).catch(
() => {},
);
}),
);
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
// Bei jeder Projektänderung ans (evtl. offene) Ressourcen-Fenster pushen.
// `emitTo` auf ein nicht existierendes Fenster ist harmlos (kein Empfänger).
useEffect(() => {
if (!isTauriRuntime()) return;
void (async () => {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(RESOURCE_WINDOW_LABEL, "resource-sync-project", project).catch(() => {});
})();
}, [project]);
}
+112
View File
@@ -0,0 +1,112 @@
// Einstellungen als EIGENES natives Tauri-Fenster (statt Overlay-Dialog im
// Hauptfenster) — Hauptfenster-Seite der Brücke. Gleiches Prinzip wie
// resourceWindow.ts, aber schmaler: SettingsDialog braucht nur eine Handvoll
// einzelner Felder (nicht das ganze Project), daher ein explizites
// Sync-/Command-Paar statt eines generischen Proxys über 27 Handler.
//
// Hauptfenster → Einstellungs-Fenster: „settings-sync-state" mit
// { marqueeColor, snapColor, referenceElevationMasl, rendererMode,
// webGpuAvailable, layout } bei jeder Änderung + auf „settings-window-ready".
// Einstellungs-Fenster → Hauptfenster: „settings-command" mit
// { name, args } — dieselbe {name,args}-RPC-Form wie resource-command.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useRef } from "react";
import { isTauriRuntime } from "../io/projectFile";
import type { LayoutState } from "../panels/types";
import type { RendererMode } from "../ui/StatusBar";
import type { ThemeMode } from "../theme/themeMode";
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
const SETTINGS_WINDOW_LABEL = "settings";
/** Alles, was SettingsDialog zum Rendern braucht — wandert 1:1 rüber. */
export interface SettingsSyncState {
themeMode: ThemeMode;
marqueeColor: string;
snapColor: string;
referenceElevationMasl: number | undefined;
rendererMode: RendererMode;
webGpuAvailable: boolean;
layout: LayoutState;
}
/** Alle Aktionen, die das Einstellungs-Fenster auslösen kann. */
export interface SettingsCommandHandlers {
onThemeModeChange: (mode: ThemeMode) => void;
onMarqueeColorChange: (hex: string) => void;
onSnapColorChange: (hex: string) => void;
onReferenceElevationChange: (n: number | undefined) => void;
onRendererModeChange: (mode: RendererMode) => void;
onApplyLayout: (layout: LayoutState) => void;
}
/** Öffnet das native Einstellungs-Fenster (bzw. holt es nach vorne). */
export async function openSettingsWindowNative(): Promise<void> {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const existing = await WebviewWindow.getByLabel(SETTINGS_WINDOW_LABEL);
if (existing) {
await existing.setFocus();
return;
}
new WebviewWindow(SETTINGS_WINDOW_LABEL, {
url: "index.html?window=settings",
title: "Einstellungen — Dossier",
width: 520,
height: 640,
minWidth: 420,
minHeight: 420,
...BORDERLESS_WINDOW_CHROME,
});
}
/**
* Hauptfenster-Seite der Sync-Brücke: hält das Einstellungs-Fenster (falls
* offen) synchron zu `state` und wendet dessen Befehle auf `handlers` an.
* No-op ausserhalb von Tauri.
*/
export function useSettingsWindowHost(
state: SettingsSyncState,
handlers: SettingsCommandHandlers,
): void {
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
if (!isTauriRuntime()) return;
let disposed = false;
const unlisten: Array<() => void> = [];
void (async () => {
const { listen, emitTo } = await import("@tauri-apps/api/event");
if (disposed) return;
unlisten.push(
await listen<{ name: string; args: unknown[] }>("settings-command", (e) => {
const fn = (handlersRef.current as unknown as Record<string, unknown>)[e.payload.name];
if (typeof fn === "function") (fn as (...a: unknown[]) => void)(...e.payload.args);
}),
);
unlisten.push(
await listen("settings-window-ready", () => {
void emitTo(SETTINGS_WINDOW_LABEL, "settings-sync-state", stateRef.current).catch(
() => {},
);
}),
);
})();
return () => {
disposed = true;
unlisten.forEach((u) => u());
};
}, []);
useEffect(() => {
if (!isTauriRuntime()) return;
void (async () => {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo(SETTINGS_WINDOW_LABEL, "settings-sync-state", state).catch(() => {});
})();
}, [state]);
}
+19
View File
@@ -0,0 +1,19 @@
// Gemeinsame Fenster-Chrome für native Nebenfenster (Ressourcen, Einstellungen,
// künftige weitere) — spiegelt das Hauptfenster (tauri.conf.json: randlos, macOS-
// Overlay-Titelleiste, eigener Drag-Header) statt der nativen OS-Titelzeile samt
// Titeltext. Ohne das trägt jedes Nebenfenster ZWEI optisch übereinander
// liegende „Kopfzeilen" (OS-Titel + unser eigener `res-head`/`.win-head`) — genau
// das vom Nutzer bemängelte „wirkt am Header etwas weird".
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { LogicalPosition } from "@tauri-apps/api/dpi";
/** In `new WebviewWindow(label, { ...BORDERLESS_WINDOW_CHROME, ... })` mergen.
* `trafficLightPosition` zentriert die macOS-Ampeln vertikal in unserer 40px-
* Kopfzeile (sonst sitzen sie höher als der Titeltext). */
export const BORDERLESS_WINDOW_CHROME = {
titleBarStyle: "overlay" as const,
hiddenTitle: true,
trafficLightPosition: new LogicalPosition(20, 22),
};