TopBar-Redesign: Marke, Zoom-Cluster, Datei-Menü, Fenstersteuerung, Dark-Theme fest

- Marke (DOSSIER-Wortmarke) + Ressourcen-Icon ganz links, wie im
  Rhino-Plugin; Settings-Icon folgt über den neuen Einstellungs-Dialog.
- Massstab/Zoom-Cluster bereinigt: doppelte Massstab-Dropdown entfernt,
  Zoom-Aktionen unter dem Massstab-Dropdown gestapelt (gleiche Breite),
  Ring bei "am Massstab" statt Flächen-Aufhellung.
- PDF/DXF-Export aus der Leiste raus, zusammen mit Speichern/Öffnen/
  Import in einem neuen Datei-Burger-Menü ganz rechts. Speichern/Öffnen
  sind echte JSON-Download/-Upload-Handler fürs ganze Projekt.
- Einstellungs-Fenster (neu): Accent-Palette, Auswahlrahmen-/Snap-Farbe
  editierbar (freier Picker + Presets), Projekt-Referenzhöhe (m ü. M.)
  als Feld.
- LayoutMenu auf die eigene Dropdown-Komponente umgestellt (war noch
  natives <select>).
- Custom Fenstersteuerung (_/□/X) für die randlose Electron-Shell via
  Preload+IPC, eigener Look statt OS-Chrome; Oberleiste als Drag-Region.
- Dark-Theme ist jetzt fester Standard (vorher an OS-Präferenz
  gekoppelt, zeigte je nach Umgebung fälschlich Light) — Light bleibt
  als Opt-in (`[data-theme="light"]`) für einen künftigen Umschalter.
  Alle Oberleisten-Pillen (Dropdown-Trigger, Segmente, Icon-Knöpfe,
  Ansichts-Icons) auf dieselbe dunkle Fläche wie das Kontextmenü
  vereinheitlicht. Panel-Köpfe nicht mehr separat aufgehellt, feste
  Höhe (40px) unabhängig von optionalem Darstellungsmodus-Dropdown.
- 2D-Zoom-Grenzen deutlich erweitert (20000x/0.005x statt 50x/0.2x).
This commit is contained in:
2026-07-04 05:10:44 +02:00
parent 44942e6976
commit f724c088e0
13 changed files with 1052 additions and 265 deletions
+31 -2
View File
@@ -4,10 +4,14 @@
// (render2d-WASM-Engine, ?engine=wasm) zuverlässig läuft. Kein Rust-Backend
// nötig: compute_joins hat einen TS-Fallback (src/compute/index.ts).
const { app, BrowserWindow, Menu } = require("electron");
const { app, BrowserWindow, Menu, ipcMain } = require("electron");
const path = require("node:path");
// Randloses App-Fenster wie chromium-shell.sh (--app=…): keine native
// Menüleiste (File/Edit/View/Window), kein Fensterrahmen.
// Menüleiste (File/Edit/View/Window), kein Fensterrahmen. Minimieren/
// Maximieren/Schliessen laufen darum über eine eigene Fenstersteuerung im
// Renderer (WindowControls.tsx) + IPC hier unten — ohne frame gibt es sonst
// gar keine Möglichkeit, das Fenster zu steuern.
Menu.setApplicationMenu(null);
// Gleiche Flags wie scripts/chromium-shell.sh — WebGPU liegt unter Linux
@@ -27,9 +31,16 @@ function createWindow() {
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, "electron-preload.cjs"),
},
});
const notifyMaximized = () => {
win.webContents.send("window:maximized-changed", win.isMaximized());
};
win.on("maximize", notifyMaximized);
win.on("unmaximize", notifyMaximized);
if (isDev) {
win.loadURL(DEV_URL);
} else {
@@ -37,6 +48,24 @@ function createWindow() {
}
}
// Fenstersteuerung vom Renderer (WindowControls.tsx über electron-preload.cjs).
// Wirkt auf das Fenster, aus dem das Event kam (mehrfachfenster-sicher).
ipcMain.on("window:minimize", (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize();
});
ipcMain.on("window:toggle-maximize", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
if (win.isMaximized()) win.unmaximize();
else win.maximize();
});
ipcMain.on("window:close", (event) => {
BrowserWindow.fromWebContents(event.sender)?.close();
});
ipcMain.handle("window:is-maximized", (event) => {
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false;
});
app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
+19
View File
@@ -0,0 +1,19 @@
// electron-preload.cjs — Bruecke zwischen dem isolierten Renderer (contextIsolation)
// und dem Electron-Hauptprozess. Braucht es NUR fuer die custom Fenstersteuerung
// (Minimieren/Maximieren/Schliessen) im randlosen Fenster (frame:false, siehe
// electron-main.cjs) — sonst gaebe es keine Moeglichkeit, das Fenster ueberhaupt
// zu steuern.
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("dossierWindow", {
minimize: () => ipcRenderer.send("window:minimize"),
toggleMaximize: () => ipcRenderer.send("window:toggle-maximize"),
close: () => ipcRenderer.send("window:close"),
isMaximized: () => ipcRenderer.invoke("window:is-maximized"),
onMaximizedChange: (callback) => {
const listener = (_event, isMaximized) => callback(isMaximized);
ipcRenderer.on("window:maximized-changed", listener);
return () => ipcRenderer.removeListener("window:maximized-changed", listener);
},
});
+155 -49
View File
@@ -53,6 +53,7 @@ import { savePlanPdf } from "./export/exportPdf";
import { ExportDxfDialog } from "./ui/ExportDxfDialog";
import type { ExportDxfDecision } from "./ui/ExportDxfDialog";
import { savePlanDxf } from "./export/exportDxf";
import { SettingsDialog } from "./ui/SettingsDialog";
import { PlanView } from "./plan/PlanView";
import type {
PlanViewHandle,
@@ -66,6 +67,8 @@ import { ResourceManager } from "./ui/ResourceManager";
import type { ResourceManagerHandlers } from "./ui/ResourceManager";
import { HatchSwatch } from "./ui/hatchPreview";
import { TopBar } from "./ui/TopBar";
import { Dropdown } from "./ui/Dropdown";
import type { DropdownItem } from "./ui/Dropdown";
import { TextEditorDialog } from "./ui/TextEditorDialog";
import { RoomStampEditor } from "./panels/RoomStampEditor";
import { defaultRoomStamp } from "./model/roomStamp";
@@ -223,6 +226,8 @@ export default function App() {
// Endlosschleife). Actions leben ebenfalls im Zustand und sind referenzstabil.
const project = useStore((s) => s.project);
const setProject = useStore((s) => s.setProject);
const undo = useStore((s) => s.undo);
const redo = useStore((s) => s.redo);
const activeLevelId = useStore((s) => s.activeLevelId);
const setActiveLevelId = useStore((s) => s.setActiveLevelId);
const viewType = useStore((s) => s.viewType);
@@ -246,6 +251,8 @@ export default function App() {
// Modaler PDF-Export-Dialog (Grundriss → Vektor-PDF). true = offen.
const [exportOpen, setExportOpen] = useState(false);
const [exportDxfOpen, setExportDxfOpen] = useState(false);
// Modales Einstellungs-Fenster (Darstellungsfarben + Projekt-Felder).
const [settingsOpen, setSettingsOpen] = useState(false);
// Visuelles Drop-Feedback (dezenter Rahmen), solange eine Datei über die App
// gezogen wird. Zählt dragenter/dragleave aus, um Flackern bei Kind-Elementen
// zu vermeiden.
@@ -268,6 +275,10 @@ export default function App() {
const setReferenceLines = useStore((s) => s.setReferenceLines);
const lineMode = useStore((s) => s.lineMode);
const setLineMode = useStore((s) => s.setLineMode);
const marqueeColor = useStore((s) => s.marqueeColor);
const setMarqueeColor = useStore((s) => s.setMarqueeColor);
const snapColor = useStore((s) => s.snapColor);
const setSnapColor = useStore((s) => s.setSnapColor);
const scaleDenominator = useStore((s) => s.scaleDenominator);
const setScaleDenominator = useStore((s) => s.setScaleDenominator);
// LIVE Papier-Massstab-Nenner aus der View-Transform (PlanView meldet ihn);
@@ -387,6 +398,8 @@ export default function App() {
// Der Klick auf „DXF importieren" triggert .click() dieses Inputs; onChange
// liest die Datei als Text und reicht sie durch parseDxf → addContextObjects.
const dxfInputRef = useRef<HTMLInputElement>(null);
// Verstecktes Datei-Input zum Laden eines Projekts (Datei-Burger „Öffnen").
const projectFileInputRef = useRef<HTMLInputElement>(null);
// Den DXF-Datei-Dialog-Öffner für den „import"-Befehl registrieren (entkoppelt
// über den Modul-Hook). Beim Unmount wieder abmelden.
@@ -791,6 +804,29 @@ export default function App() {
return () => window.removeEventListener("keydown", onKey);
}, []);
// Globaler Undo/Redo-Shortcut: Ctrl+Z / Cmd+Z, Ctrl+Shift+Z / Cmd+Shift+Z
// bzw. Ctrl+Y für redo. Gleicher Eingabe-Guard wie die übrigen globalen
// keydown-Handler (Tab-Zyklus, Zahlen-Shortcuts).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
const key = e.key.toLowerCase();
if (!mod || (key !== "z" && key !== "y")) return;
const el = e.target as HTMLElement | null;
if (
el &&
(el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)
) {
return;
}
e.preventDefault();
if (key === "y" || (key === "z" && e.shiftKey)) redo();
else undo();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [undo, redo]);
// Nummern-Shortcuts für Zeichenwerkzeuge (Vectorworks-Stil): 2 Linie, 4
// Rechteck, 5 Polylinie, 6 Wand, 7 Decke, 8 Fenster, 9 Türe, 0 Raum. Nur auf
// Geschoss-Tabs, nicht beim Tippen in Feldern und nicht mit Modifikator
@@ -1981,6 +2017,41 @@ export default function App() {
r.readAsText(file);
};
// Projekt als Datei speichern/laden (Datei-Burger-Menü, TopBar/AppMenu).
// Reines JSON des ProjectSlice-Zustands (gleiche Blob+Anchor-Download-Technik
// wie savePlanDxf/savePlanPdf); Laden liest die Datei als Text, parst sie und
// ersetzt das Projekt — nur ein grobes Shape-Sanity-Check (walls/layers als
// Array), damit ein fremdes/kaputtes JSON nicht die App zerlegt.
const onSaveProject = () => {
const json = JSON.stringify(project, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${project.name || "projekt"}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const onOpenProject = () => projectFileInputRef.current?.click();
const onProjectFileChosen = (file: File) => {
const r = new FileReader();
r.onload = () => {
try {
const parsed = JSON.parse(String(r.result ?? "")) as Partial<Project>;
if (!Array.isArray(parsed.walls) || !Array.isArray(parsed.layers)) {
console.warn("Projekt-Datei hat kein gültiges Projekt-Format (walls/layers fehlen).");
return;
}
setProject(parsed as Project);
} catch (err) {
console.error("Projekt-Import fehlgeschlagen:", err);
}
};
r.readAsText(file);
};
// Bestätigte Import-Entscheidung ausführen: ggf. neue Zeichnung anlegen, die
// 2D-Konturen als Drawing2D anhängen, optional die Meshes als Kontext
// übernehmen. Reihenfolge: erst Ebene (echte ID), dann Drawings/Kontext.
@@ -2917,6 +2988,18 @@ export default function App() {
e.target.value = "";
}}
/>
{/* Verstecktes Projekt-Datei-Input (Datei-Burger „Öffnen"). */}
<input
ref={projectFileInputRef}
type="file"
accept="application/json,.json"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onProjectFileChosen(f);
e.target.value = "";
}}
/>
<TopBar
project={project}
viewToggleEnabled={activeLevel.kind === "floor"}
@@ -2967,6 +3050,10 @@ export default function App() {
}}
resourcesOpen={resourcesOpen}
onToggleResources={() => setResourcesOpen((v) => !v)}
onOpenSettings={() => setSettingsOpen(true)}
onImport={() => dxfInputRef.current?.click()}
onSaveProject={onSaveProject}
onOpenProject={onOpenProject}
layoutMenu={
<LayoutMenu
layout={layout}
@@ -3008,6 +3095,8 @@ export default function App() {
detail={detail}
referenceLines={referenceLines}
hairline={lineMode === "display"}
marqueeColor={marqueeColor}
snapColor={snapColor}
renderMode={renderMode}
view3d={view3d}
fov={fov}
@@ -3243,6 +3332,18 @@ export default function App() {
/>
)}
{settingsOpen && (
<SettingsDialog
marqueeColor={marqueeColor}
onMarqueeColorChange={setMarqueeColor}
snapColor={snapColor}
onSnapColorChange={setSnapColor}
project={project}
setProject={setProject}
onClose={() => setSettingsOpen(false)}
/>
)}
{/* Text-Editor (schwebendes Fenster): Doppelklick auf einen Raumstempel bzw.
Button in der Objekt-Info öffnet ihn. „Übernehmen" schreibt das
bearbeitete Rich-Text-Dokument über setRoomStampDoc. Die Formatier-
@@ -3908,58 +4009,39 @@ function LayoutMenu({
refresh();
};
const onPick = (value: string) => {
if (value === "__save__") {
onSave();
return;
}
if (value.startsWith("__del__:")) {
const name = value.slice("__del__:".length);
deleteNamedLayout(name);
refresh();
return;
}
const loaded = loadNamedLayout(value);
// Aktions-Menü (analog ComboMenu in TopBar.tsx): „Aktuelles speichern" oben,
// dann je eine Gruppe „Laden" und „Löschen" (Trenner statt <optgroup>, die
// die Dropdown-Komponente nicht kennt).
const items: DropdownItem[] = [];
items.push({ label: t("layout.saveCurrent"), onSelect: onSave });
if (names.length > 0) {
items.push({ divider: true });
for (const n of names) {
items.push({
label: n,
onSelect: () => {
const loaded = loadNamedLayout(n);
if (loaded) onApply(loaded);
};
},
});
}
items.push({ divider: true });
for (const n of names) {
items.push({
label: `${n}`,
danger: true,
onSelect: () => {
deleteNamedLayout(n);
refresh();
},
});
}
}
return (
<select
className="layout-menu"
title={t("layout.title")}
aria-label={t("layout.title")}
value=""
onFocus={refresh}
onMouseDown={refresh}
onChange={(e) => {
onPick(e.target.value);
// Auswahl-Anzeige zurücksetzen (Menü ist ein Aktions-Trigger).
e.currentTarget.value = "";
}}
>
<option value="" disabled>
{t("layout.placeholder")}
</option>
<option value="__save__">{t("layout.saveCurrent")}</option>
{names.length > 0 && (
<optgroup label={t("layout.loadGroup")}>
{names.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</optgroup>
)}
{names.length > 0 && (
<optgroup label={t("layout.deleteGroup")}>
{names.map((n) => (
<option key={`del-${n}`} value={`__del__:${n}`}>
{n}
</option>
))}
</optgroup>
)}
</select>
<span onPointerDownCapture={refresh}>
<Dropdown items={items} placeholder={t("layout.placeholder")} title={t("layout.title")} />
</span>
);
}
@@ -3974,6 +4056,8 @@ function Content({
detail,
referenceLines,
hairline,
marqueeColor,
snapColor,
renderMode,
view3d,
fov,
@@ -4029,6 +4113,10 @@ function Content({
detail: DetailLevel;
referenceLines: boolean;
hairline: boolean;
/** Farbe des Auswahlrahmens (Marquee) im Grundriss. */
marqueeColor: string;
/** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */
snapColor: string;
renderMode: RenderMode;
/** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */
view3d: View3d;
@@ -4111,6 +4199,8 @@ function Content({
project={project}
level={level}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
mono={mono}
planViewRef={planViewRef}
onCursor={onCursor}
@@ -4160,6 +4250,8 @@ function Content({
detail={detail}
referenceLines={referenceLines}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
mono={mono}
planViewRef={planViewRef}
onCursor={onCursor}
@@ -4240,6 +4332,8 @@ function Content({
detail={detail}
referenceLines={referenceLines}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
mono={mono}
planViewRef={planViewRef}
onCursor={onCursor}
@@ -4279,6 +4373,8 @@ function LevelPlanView({
detail,
referenceLines,
hairline,
marqueeColor,
snapColor,
mono,
planViewRef,
onCursor,
@@ -4311,6 +4407,8 @@ function LevelPlanView({
detail: DetailLevel;
referenceLines: boolean;
hairline: boolean;
marqueeColor: string;
snapColor: string;
mono: boolean;
planViewRef: React.RefObject<PlanViewHandle>;
onCursor: (model: Vec2 | null) => void;
@@ -4406,6 +4504,8 @@ function LevelPlanView({
grips={grips}
edgeGrips={edgeGrips}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers}
@@ -4429,6 +4529,8 @@ function SectionPlanView({
project,
level,
hairline,
marqueeColor,
snapColor,
mono,
planViewRef,
onCursor,
@@ -4457,6 +4559,8 @@ function SectionPlanView({
project: Project;
level: DrawingLevel;
hairline: boolean;
marqueeColor: string;
snapColor: string;
mono: boolean;
planViewRef: React.RefObject<PlanViewHandle>;
onCursor: (model: Vec2 | null) => void;
@@ -4562,6 +4666,8 @@ function SectionPlanView({
grips={grips}
edgeGrips={edgeGrips}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers}
+24
View File
@@ -802,6 +802,30 @@ export const de = {
"exportDxf.unitsNote":
"Modell-Space in Metern (1:1). Layer entsprechen den Ebenen des Projekts; beim Plot beliebig skalierbar.",
// ── Datei-Menü (Burger, ganz rechts der Oberleiste) ──────────────────────
"file.menu": "Datei",
"file.save": "Projekt speichern",
"file.open": "Projekt öffnen",
"file.import": "DXF/DWG importieren…",
"file.exportPdf": "Als PDF exportieren…",
"file.exportDxf": "Als DXF exportieren…",
// ── Einstellungs-Fenster ──────────────────────────────────────────────────
"topbar.settings": "Einstellungen",
"topbar.settings.hint": "Einstellungen öffnen (Darstellungsfarben, Projekt)",
"settings.title": "Einstellungen",
"settings.section.display": "Darstellung",
"settings.marqueeColor": "Auswahlrahmen-Farbe",
"settings.marqueeColor.hint":
"Farbe des Auswahlrahmens beim Aufziehen einer Auswahl (Marquee) im Grundriss.",
"settings.snapColor": "Snap-/Endpunkt-Farbe",
"settings.snapColor.hint":
"Farbe der Snap-/Endpunkt-Hervorhebung beim Zeichnen/Editieren im Grundriss.",
"settings.section.project": "Projekt",
"settings.referenceElevation": "Referenzhöhe EG (m ü. M.)",
"settings.referenceElevation.hint":
"Nur gespeichert — wird noch nicht verwendet. Folgt für Terrain-Draping/reale Höhen relativ zum Erdgeschoss.",
// ── Drag & Drop (App-weiter Datei-Drop) ──────────────────────────────────
"drop.hint": "DXF/DWG-Datei hier ablegen",
+24
View File
@@ -793,6 +793,30 @@ export const en: Record<TranslationKey, string> = {
"exportDxf.unitsNote":
"Model space in meters (1:1). Layers mirror the project's categories; scale freely on plot.",
// File menu (burger, far right of the top bar).
"file.menu": "File",
"file.save": "Save project",
"file.open": "Open project",
"file.import": "Import DXF/DWG…",
"file.exportPdf": "Export as PDF…",
"file.exportDxf": "Export as DXF…",
// Settings dialog.
"topbar.settings": "Settings",
"topbar.settings.hint": "Open settings (display colors, project)",
"settings.title": "Settings",
"settings.section.display": "Display",
"settings.marqueeColor": "Selection box color",
"settings.marqueeColor.hint":
"Color of the marquee selection box drawn while dragging a selection in the plan view.",
"settings.snapColor": "Snap/endpoint color",
"settings.snapColor.hint":
"Color of the snap/endpoint highlight while drawing/editing in the plan view.",
"settings.section.project": "Project",
"settings.referenceElevation": "Ground floor reference elevation (m a.s.l.)",
"settings.referenceElevation.hint":
"Stored only — not used yet. Terrain draping / real-world elevations relative to the ground floor will follow.",
// Drag & drop (app-wide file drop).
"drop.hint": "Drop a DXF/DWG file here",
+9
View File
@@ -1144,6 +1144,15 @@ export interface Project {
* aufgelöst generiert Wall[]-Objekte bei Bedarf.
*/
parametricWalls?: ParametricWall[];
/**
* Referenzhöhe des Erdgeschosses in Metern über Meer (m ü. M.), editierbar
* im Einstellungs-Fenster. Optional, damit bestehende Projekte ohne diesen
* Wert gültig bleiben (Default: unbestimmt/0). NUR Speicherfeld die
* eigentliche Verwendung (Terrain-Draping/reale Höhen relativ dazu, siehe
* HANDOVER GEO-BLOCK) ist ein separater, späterer Task und liest dieses
* Feld noch nicht.
*/
referenceElevationMasl?: number;
}
// ── Helfer ───────────────────────────────────────────────────────────────
+47 -7
View File
@@ -22,6 +22,7 @@ import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
import { quantizePen } from "../export/sceneToPrintSvg";
import { buildRandomHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch";
import { dashHasDot } from "../ui/lineSegments";
import { DEFAULT_MARQUEE_COLOR, DEFAULT_SNAP_COLOR } from "../theme/accents";
const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum)
const PAD = 60; // Rand in viewBox-Einheiten
@@ -79,8 +80,11 @@ const mmToPx = (mm: number): number => (mm / 25.4) * dpi();
// Zoom-Grenzen relativ zur eingepassten Default-Ansicht. Kleinere viewBox-
// Breite = stärkerer Zoom. ZOOM_MAX → max. Hineinzoomen, ZOOM_MIN → Herauszoomen.
const ZOOM_MAX = 50; // bis 50× hinein
const ZOOM_MIN = 0.2; // bis 5× heraus
// Grosszügig bemessen (CAD-Anspruch: vom Milimeter-Detail bis zum Siedlungs-
// Kontext) statt der früheren 250×-Spanne (50/0.2), die für Detailarbeit an
// einem Gebäude deutlich zu eng war.
const ZOOM_MAX = 20000; // bis 20 000× hinein (Sub-Millimeter-Details)
const ZOOM_MIN = 0.005; // bis 200× heraus (Siedlungs-/Site-Kontext)
const WHEEL_STEP = 0.0015; // Empfindlichkeit des Mausrads
// Schwelle (Bildschirm-Pixel), ab der ein Links-Ziehen als Marquee gilt; darunter
@@ -320,6 +324,16 @@ export interface PlanViewProps {
selectedDrawingIds?: string[];
/** Griff-Zieh-Treiber: Live-Bewegung (gesnappt) + Abschluss. */
gripHandlers?: GripHandlers;
/**
* Farbe des Auswahlrahmens (Marquee-Ziehauswahl). Einstellbar im
* Einstellungs-Fenster (State in ViewSlice); Default = Akzent Yuyake".
*/
marqueeColor?: string;
/**
* Farbe der Snap-/Endpunkt-Hervorhebung (Werkzeug-Overlay). Einstellbar im
* Einstellungs-Fenster (State in ViewSlice); Default = Akzent Sora".
*/
snapColor?: string;
}
/** Treiber für das Ziehen von Editier-Griffen (von App bereitgestellt). */
@@ -370,6 +384,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
selectedDrawingId,
selectedDrawingIds,
gripHandlers,
marqueeColor = DEFAULT_MARQUEE_COLOR,
snapColor = DEFAULT_SNAP_COLOR,
},
ref,
) {
@@ -1884,6 +1900,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
y={marqueeRect.y}
width={marqueeRect.w}
height={marqueeRect.h}
style={{ stroke: marqueeColor, fill: marqueeColor }}
pointerEvents="none"
/>
)}
@@ -1965,6 +1982,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
draft={toolHandlers.draft}
toScreen={toScreen}
vbPerPx={1 / (meetScale(view, svgRef.current) ?? 1)}
snapColor={snapColor}
/>
)}
</svg>
@@ -1983,10 +2001,12 @@ function DraftOverlay({
draft,
toScreen,
vbPerPx,
snapColor,
}: {
draft: { preview: DraftShape[]; vertices: Vec2[]; snap?: SnapResult | null };
toScreen: (v: Vec2) => Vec2;
vbPerPx: number;
snapColor: string;
}) {
const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich)
const markSize = 4.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (gleich groß wie Stützpunkt)
@@ -2030,7 +2050,7 @@ function DraftOverlay({
})}
{/* Snap-Marker + optionale Ortho-Hilfslinie. */}
{draft.snap && (
<SnapMarker snap={draft.snap} toScreen={toScreen} size={markSize} />
<SnapMarker snap={draft.snap} toScreen={toScreen} size={markSize} color={snapColor} />
)}
</g>
);
@@ -2041,13 +2061,22 @@ function SnapMarker({
snap,
toScreen,
size,
color,
}: {
snap: SnapResult;
toScreen: (v: Vec2) => Vec2;
size: number;
/** Farbe des Snap-Glyphs (einstellbar, Default = Akzent „Sora"). */
color: string;
}) {
const s = toScreen(snap.point);
const r = size;
// Inline `style` überschreibt gezielt die CSS-Klassenfarbe (eine am Element
// selbst deklarierte Klassenregel gewinnt sonst gegen eine geerbte Farbe von
// einer umschließenden Gruppe — daher pro Glyph statt einmal an der Gruppe).
const glyphStyle = { stroke: color };
const helperStyle = { stroke: color };
const fillStyle = { stroke: color, fill: color };
return (
<g className="snap-marker">
{(snap.kind === "ortho" || snap.kind === "extension") && snap.refA && (
@@ -2057,11 +2086,19 @@ function SnapMarker({
y1={toScreen(snap.refA).y}
x2={s.x}
y2={s.y}
style={helperStyle}
vectorEffect="non-scaling-stroke"
/>
)}
{snap.kind === "grid" && (
<circle className="snap-glyph snap-fill" cx={s.x} cy={s.y} r={r * 0.5} vectorEffect="non-scaling-stroke" />
<circle
className="snap-glyph snap-fill"
cx={s.x}
cy={s.y}
r={r * 0.5}
style={fillStyle}
vectorEffect="non-scaling-stroke"
/>
)}
{snap.kind === "midpoint" && (
<line
@@ -2070,22 +2107,24 @@ function SnapMarker({
y1={s.y}
x2={s.x + r}
y2={s.y}
style={glyphStyle}
vectorEffect="non-scaling-stroke"
/>
)}
{snap.kind === "intersection" && (
<>
<line className="snap-glyph" x1={s.x - r} y1={s.y - r} x2={s.x + r} y2={s.y + r} vectorEffect="non-scaling-stroke" />
<line className="snap-glyph" x1={s.x - r} y1={s.y + r} x2={s.x + r} y2={s.y - r} vectorEffect="non-scaling-stroke" />
<line className="snap-glyph" x1={s.x - r} y1={s.y - r} x2={s.x + r} y2={s.y + r} style={glyphStyle} vectorEffect="non-scaling-stroke" />
<line className="snap-glyph" x1={s.x - r} y1={s.y + r} x2={s.x + r} y2={s.y - r} style={glyphStyle} vectorEffect="non-scaling-stroke" />
</>
)}
{(snap.kind === "center" || snap.kind === "quadrant") && (
<circle className="snap-glyph" cx={s.x} cy={s.y} r={r} vectorEffect="non-scaling-stroke" />
<circle className="snap-glyph" cx={s.x} cy={s.y} r={r} style={glyphStyle} vectorEffect="non-scaling-stroke" />
)}
{snap.kind === "onEdge" && (
<polygon
className="snap-glyph"
points={`${s.x},${s.y - r} ${s.x + r},${s.y} ${s.x},${s.y + r} ${s.x - r},${s.y}`}
style={glyphStyle}
vectorEffect="non-scaling-stroke"
/>
)}
@@ -2095,6 +2134,7 @@ function SnapMarker({
cx={s.x}
cy={s.y}
r={r}
style={glyphStyle}
vectorEffect="non-scaling-stroke"
/>
)}
+14
View File
@@ -10,6 +10,7 @@
import { sampleProject } from "../model/sampleProject";
import type { DetailLevel, RenderMode, View3d, ViewType } from "../ui/TopBar";
import type { DisplayMode } from "../panels/types";
import { DEFAULT_MARQUEE_COLOR, DEFAULT_SNAP_COLOR } from "../theme/accents";
import type { StoreApi } from "./store";
/** Standard-Darstellungsmodus je Panel (wie bisher: alles sichtbar normal). */
@@ -45,6 +46,13 @@ export interface ViewSlice {
activeCategoryCode: string;
// Darstellungsmodus je Panel-ID (z. B. "drawing-levels", "layers").
displayModes: Record<string, DisplayMode>;
// Farbe des Auswahlrahmens (Marquee-Ziehauswahl) im Grundriss. Einstellbar
// im Einstellungs-Fenster; Default = Akzent „Yuyake" (theme/accents.ts).
marqueeColor: string;
// Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss (Zeichnen/Editieren).
// Einstellbar im Einstellungs-Fenster; Default = Akzent „Sora" (vorläufig,
// siehe HANDOVER-Rückfrage „aki").
snapColor: string;
setActiveLevelId: (id: string) => void;
setViewType: (v: ViewType) => void;
@@ -61,6 +69,8 @@ export interface ViewSlice {
modeFor: (id: string | null) => DisplayMode;
/** Darstellungsmodus eines Panels setzen (No-op bei null id). */
setModeFor: (id: string | null, mode: DisplayMode) => void;
setMarqueeColor: (hex: string) => void;
setSnapColor: (hex: string) => void;
}
export function createViewSlice(api: StoreApi<ViewSlice>): ViewSlice {
@@ -80,6 +90,8 @@ export function createViewSlice(api: StoreApi<ViewSlice>): ViewSlice {
activeCategoryCode:
sampleProject.walls[0]?.categoryCode ?? sampleProject.layers[0].code,
displayModes: {},
marqueeColor: DEFAULT_MARQUEE_COLOR,
snapColor: DEFAULT_SNAP_COLOR,
setActiveLevelId: (id) =>
set((s) => ({
@@ -105,5 +117,7 @@ export function createViewSlice(api: StoreApi<ViewSlice>): ViewSlice {
if (!id) return;
set((s) => ({ displayModes: { ...s.displayModes, [id]: mode } }));
},
setMarqueeColor: (hex) => set({ marqueeColor: hex }),
setSnapColor: (hex) => set({ snapColor: hex }),
};
}
+284 -147
View File
@@ -3,56 +3,11 @@
das DOSSIER-Look: aktive Zeile als Petrol-Pille, nicht-aktive gedimmt,
ausgeblendete grau; Augen-Schalter, Farbtupfer, eingerückte Kinder. */
/* Dark ist der feste Standard (Nutzer-Wunsch, unabhängig von OS/Browser-
Präferenz vorher hing das an `prefers-color-scheme` und zeigte je nach
Umgebung fälschlich Light). Light bleibt als Opt-in erhalten (`[data-theme=
"light"]`) für ein späteres manuelles Umschalt-Feature. */
:root {
color-scheme: light;
/* Flächen */
--bg: #e0dbd4;
--panel: #e0dbd4;
--panel-2: #d4cfc8;
--input: #ece8e2;
--line: #d4cfc8;
--border: #c8c2ba;
/* Zeichenblatt-Hintergrund (Plan) bewusst fix, unabhängig von der Panel-
Farbe: helles Papier" im Light-Mode. */
--sheet: #f0f0f0;
/* Schrift */
--ink: #1a1a18;
--ink-2: #555550;
--muted: #8a8580;
--label: #1a1a18;
/* Petrol-Akzent + aktiver Marker */
--accent: #2f5d54;
--accent-light: #4a8a7c;
--accent-dim: #e6efed;
--accent-border: rgba(47, 93, 84, 0.35);
--active: #1a655a;
--active-light: #2f8275;
--active-dim: rgba(26, 101, 90, 0.12);
/* Plan-/3D-Töne */
--wall: #2b3039;
--swing: #9aa3b2;
/* Warn-/Gefahr-Akzente (z. B. Löschen). */
--danger: #8a1a1a;
--danger-light: #b03030;
--shadow-1: 0 1px 2px rgba(26, 26, 24, 0.08);
--shadow-2: 0 2px 8px rgba(26, 26, 24, 0.14);
--shadow-3: 0 6px 24px rgba(26, 26, 24, 0.18);
--overlay: rgba(26, 26, 24, 0.36);
--font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
sans-serif;
--font-mono: "DM Mono", ui-monospace, "SF Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
/* Gestufte Elevations-Palette (Wunsch: dunkler + mehr Tiefe). Tiefster
Grund hinten, Flächen werden nach vorn schrittweise heller leicht
@@ -71,17 +26,18 @@
--line: #2b2b2b;
--border: #383838;
/* --sheet bleibt bewusst hell (#f0f0f0) wie im Light-Mode: das Zeichenblatt
ist immer Papier", unabhängig vom UI-Theme (Wunsch des Nutzers). */
/* --sheet bleibt bewusst hell (#f0f0f0): das Zeichenblatt ist immer
Papier", unabhängig vom UI-Theme (Wunsch des Nutzers). */
--sheet: #f0f0f0;
--ink: #ededed;
--ink-2: #a8a8a8;
--muted: #6e6e6e;
--label: #c8c8c8;
/* Akzent NEUTRAL (kein Petrol-Grün mehr, Nutzer-Vorgabe): helles Neutralgrau
für aktive/ausgewählte Flächen; weiße Schrift darauf bleibt lesbar. Hover/
Dim sind dezente weiße Aufhellungen. */
/* Akzent NEUTRAL (kein Petrol-Grün, Nutzer-Vorgabe): helles Neutralgrau für
aktive/ausgewählte Flächen; weiße Schrift darauf bleibt lesbar. Hover/Dim
sind dezente weiße Aufhellungen. */
--accent: #4d4d4d;
--accent-light: #5e5e5e;
--accent-dim: rgba(255, 255, 255, 0.07);
@@ -99,7 +55,49 @@
--shadow-2: 0 3px 12px rgba(0, 0, 0, 0.55);
--shadow-3: 0 12px 34px rgba(0, 0, 0, 0.62);
--overlay: rgba(0, 0, 0, 0.62);
}
--font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
sans-serif;
--font-mono: "DM Mono", ui-monospace, "SF Mono", monospace;
}
/* Light-Palette als Opt-in (aktuell von nichts gesetzt bereit für einen
künftigen manuellen Theme-Umschalter in den Einstellungen). */
:root[data-theme="light"] {
color-scheme: light;
--bg: #e0dbd4;
--panel: #e0dbd4;
--panel-2: #d4cfc8;
--input: #ece8e2;
--line: #d4cfc8;
--border: #c8c2ba;
--sheet: #f0f0f0;
--ink: #1a1a18;
--ink-2: #555550;
--muted: #8a8580;
--label: #1a1a18;
--accent: #2f5d54;
--accent-light: #4a8a7c;
--accent-dim: #e6efed;
--accent-border: rgba(47, 93, 84, 0.35);
--active: #1a655a;
--active-light: #2f8275;
--active-dim: rgba(26, 101, 90, 0.12);
--wall: #2b3039;
--swing: #9aa3b2;
--danger: #8a1a1a;
--danger-light: #b03030;
--shadow-1: 0 1px 2px rgba(26, 26, 24, 0.08);
--shadow-2: 0 2px 8px rgba(26, 26, 24, 0.14);
--shadow-3: 0 6px 24px rgba(26, 26, 24, 0.18);
--overlay: rgba(26, 26, 24, 0.36);
}
* {
@@ -155,7 +153,7 @@ body {
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
padding: 4px 14px;
height: 56px;
border-bottom: 1px solid var(--line);
/* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten
@@ -254,15 +252,17 @@ body {
.tb-btn {
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
background: var(--input);
color: var(--ink);
/* Gleiche stets-dunkle Fläche wie .tb-dd-trigger einheitlicher Pillen-
Look über die ganze Oberleiste. */
border: 1px solid #4a4a4a;
background: #2c2c2c;
color: #e8e8e8;
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 3px 10px;
border-radius: 999px;
border-radius: 8px;
cursor: pointer;
outline: none;
transition: border-color 0.14s, background-color 0.14s, color 0.14s,
@@ -270,7 +270,7 @@ body {
}
.tb-btn:hover:not(:disabled) {
border-color: var(--accent-border);
background-color: var(--accent-dim);
background-color: rgba(255, 255, 255, 0.1);
color: var(--accent);
}
.tb-btn:focus-visible {
@@ -278,7 +278,7 @@ body {
box-shadow: 0 0 0 2px var(--accent-dim);
}
.tb-btn:disabled {
color: var(--muted);
color: #8a8a8a;
opacity: 0.55;
cursor: default;
}
@@ -320,20 +320,20 @@ body {
/* Pillen-Toggle der Oberleiste (Referenzlinien). */
.tb-toggle {
border: 1px solid var(--border);
background: var(--input);
color: var(--ink-2);
border: 1px solid #4a4a4a;
background: #2c2c2c;
color: #e8e8e8;
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
padding: 6px 12px;
border-radius: 999px;
border-radius: 8px;
cursor: pointer;
transition: background 0.14s, color 0.14s, border-color 0.14s;
}
.tb-toggle:hover {
background: var(--accent-dim);
background: rgba(255, 255, 255, 0.1);
border-color: var(--accent-border);
color: var(--accent);
}
@@ -346,16 +346,17 @@ body {
/* ── DOSSIER-Grundprimitive: Segmentpille · Icon-Knopf · Zoom-Cluster ──────── */
/* Segmentpille (3er/4er): Aussencontainer rund, Zellen gleich hoch, interne
Trenner über border-left. Genutzt für Zoom-Aktionen, B/I/U, L/C/R. */
Trenner über border-left. Genutzt für Zoom-Aktionen, B/I/U, L/C/R. Gleiche
stets-dunkle Fläche wie .tb-dd-trigger/.tb-btn (einheitlicher Pillen-Look). */
.tb-seg {
display: inline-flex;
align-items: stretch;
height: 24px;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 999px;
border: 1px solid #4a4a4a;
border-radius: 8px;
overflow: hidden;
background: var(--input);
background: #2c2c2c;
flex: 0 0 auto;
}
/* Akzent-Rand, wenn ein Text-Ziel selektiert ist (Auswahl-Bewusstsein). */
@@ -370,9 +371,9 @@ body {
min-width: 26px;
padding: 0 6px;
border: none;
border-left: 1px solid var(--border);
border-left: 1px solid #4a4a4a;
background: transparent;
color: var(--ink-2);
color: #b0b0b0;
cursor: pointer;
transition: background 0.14s, color 0.14s;
}
@@ -380,8 +381,8 @@ body {
border-left: none;
}
.tb-seg-cell:hover:not(:disabled) {
background: var(--accent-dim);
color: var(--accent);
background: rgba(255, 255, 255, 0.1);
color: #f0f0f0;
}
.tb-seg-cell.active {
background: var(--accent);
@@ -392,7 +393,7 @@ body {
cursor: default;
}
/* Quadratischer Icon-Knopf (BarButton) — Export-Pillen (PDF/DXF). */
/* Quadratischer Icon-Knopf (BarButton). */
.tb-iconbtn {
display: inline-flex;
align-items: center;
@@ -401,15 +402,15 @@ body {
height: 24px;
box-sizing: border-box;
padding: 0;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--input);
color: var(--ink-2);
border: 1px solid #4a4a4a;
border-radius: 8px;
background: #2c2c2c;
color: #b0b0b0;
cursor: pointer;
transition: background 0.14s, color 0.14s, border-color 0.14s;
}
.tb-iconbtn:hover:not(:disabled) {
background: var(--accent-dim);
background: rgba(255, 255, 255, 0.1);
border-color: var(--accent-border);
color: var(--accent);
}
@@ -422,7 +423,7 @@ body {
.tb-zoomcluster {
display: grid;
grid-template-columns: auto auto;
gap: 4px 6px;
gap: 6px;
align-items: center;
}
.tb-stat {
@@ -437,6 +438,10 @@ body {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--input);
/* Ring (Box-Shadow statt Border) für am Massstab" startet unsichtbar
(Alpha 0) und blendet weich ein/aus, statt die Fläche aufzuhellen. */
box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0);
transition: box-shadow 0.25s ease;
}
.tb-stat-scale {
font-family: var(--font-mono);
@@ -456,20 +461,16 @@ body {
width: 100%;
text-align: center;
}
/* „am Massstab" (Live == gewählter Massstab): Akzent-Hervorhebung. */
/* am Massstab" (Live == gewählter Massstab): weisser Ring statt aufgehellter
Fläche blendet dynamisch ein/aus (s. transition oben), Fläche bleibt
unverändert. */
.tb-stat-atscale {
background: var(--accent-dim);
border-color: var(--accent);
}
.tb-stat-atscale .tb-stat-scale {
border-bottom-color: var(--accent-border);
}
.tb-stat-atscale .tb-stat-zoom {
color: var(--accent);
box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.9);
}
.tb-zoomcluster-right {
display: inline-flex;
align-items: center;
flex-direction: column;
align-items: stretch;
gap: 6px;
}
@@ -477,7 +478,7 @@ body {
.tb-textgroup {
flex-direction: column;
align-items: stretch;
gap: 4px;
gap: 6px;
}
.tb-textgroup-row {
display: inline-flex;
@@ -517,27 +518,71 @@ body {
text-align: left;
}
.brand {
/* Marken-Gruppe: Wortmarke + kleiner Icon-Stapel daneben (Settings/Ressourcen),
wie im DOSSIER-Rhino-Plugin (ToolbarApp.jsx: Logo + vertikal gestapelte
Settings-Icons direkt daneben, vor dem ersten Trenner). */
.tb-brandgroup {
display: flex;
align-items: center;
gap: 8px;
font-weight: 650;
font-size: 16px;
letter-spacing: 0.2px;
gap: 6px;
flex: 0 0 auto;
}
.logo {
.brand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
line-height: 1;
}
.brand-word {
font-weight: 800;
font-size: 15px;
letter-spacing: -0.01em;
color: var(--ink);
}
.brand-dot {
color: var(--accent);
font-size: 18px;
}
.tag {
font-weight: 500;
font-size: 11px;
font-size: 10px;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 8px;
padding: 1px 7px;
}
/* Icon-Stapel neben der Wortmarke (Settings/Ressourcen) — schmal, dezent. */
.tb-brandicons {
display: flex;
flex-direction: column;
gap: 2px;
}
.tb-brandicon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 17px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: var(--muted);
cursor: pointer;
transition: background 0.14s, color 0.14s;
}
.tb-brandicon .tb-ico {
font-size: 13px;
}
.tb-brandicon:hover {
background: var(--accent-dim);
color: var(--ink);
}
.tb-brandicon.active {
background: var(--accent-dim);
color: var(--accent);
}
.project-name {
@@ -625,9 +670,9 @@ body {
grid-template-columns: repeat(4, auto);
}
.view-icon {
border: 1px solid var(--border);
background: var(--input);
color: var(--ink-2);
border: 1px solid #4a4a4a;
background: #2c2c2c;
color: #b0b0b0;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -639,8 +684,8 @@ body {
transition: background 0.14s, color 0.14s, border-color 0.14s;
}
.view-icon:hover:not(:disabled) {
background: var(--accent-dim);
color: var(--ink);
background: rgba(255, 255, 255, 0.1);
color: #f0f0f0;
border-color: var(--accent-border);
}
.view-icon.active {
@@ -681,7 +726,7 @@ body {
.tb-group.tb-stack {
flex-direction: column;
align-items: stretch;
gap: 4px;
gap: 6px;
}
.tb-field-row {
justify-content: space-between;
@@ -2852,10 +2897,18 @@ body {
justify-content: space-between;
gap: 8px;
flex: 0 0 auto;
padding: 9px 12px;
/* Feste Höhe statt nur Padding: Panels OHNE Darstellungsmodus-Dropdown
(nur Titel) hatten sonst einen niedrigeren Kopf als Panels MIT Dropdown
beim Wechseln/Stapeln "sprang" die Kopfzeile sichtbar. box-sizing
zieht das Padding von der festen Höhe ab, align-items:center zentriert
Titel bzw. Titel+Dropdown gleichermassen. */
height: 40px;
box-sizing: border-box;
padding: 0 12px;
border-bottom: 1px solid var(--line);
/* Panel-Kopf leicht angehoben (Ebene 2) → setzt sich vom Körper ab. */
background: var(--panel-2);
/* Uniform mit dem Panel-Körper (Nutzer-Wunsch): kein separater hellerer
Kopf-Balken mehr, nur die Trennlinie grenzt Kopf/Körper ab. */
background: var(--panel);
}
.panel-title {
min-width: 0;
@@ -2863,7 +2916,7 @@ body {
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
color: #f0f0f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -2876,38 +2929,15 @@ body {
padding: 10px 8px 16px;
}
/* ── Darstellungsmodus-Dropdown (kompaktes Pill, vgl. .res-select) ────────── */
/* Darstellungsmodus-Dropdown (Panel-Kopf)
Nutzt jetzt die gemeinsame Dropdown-Komponente (.tb-dd-trigger) für den
dunklen, abgerundeten Look; hier nur noch die Breitenbegrenzung + kompakte
Panel-Kopf-Typografie, die dem generischen Trigger fehlen. */
.display-mode {
flex: 0 0 auto;
appearance: none;
-webkit-appearance: none;
max-width: 160px;
font-family: var(--font);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink-2);
background-color: var(--input);
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M0 0l5 6 5-6z' fill='%23888'/></svg>");
background-repeat: no-repeat;
background-position: right 8px center;
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 22px 4px 10px;
cursor: pointer;
outline: none;
text-overflow: ellipsis;
transition: border-color 0.14s, background-color 0.14s, color 0.14s,
box-shadow 0.14s;
}
.display-mode:hover {
border-color: var(--accent);
color: var(--accent);
background-color: var(--panel);
}
.display-mode:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
}
/* ── Layout-Menü (Oberleiste: benannte Layouts sichern/laden) ─────────────── */
@@ -2952,14 +2982,18 @@ body {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border);
background: var(--input);
color: var(--ink);
/* Trigger trägt dieselbe stets-dunkle Fläche wie sein Popover (.ctx-menu)
beide sollen wie EIN durchgehendes Element wirken statt heller Knopf +
dunkles Menü darunter. .ctx-menu hängt per Portal ausserhalb dieses
DOM-Zweigs, darum hier dieselben Werte dupliziert statt geerbt. */
border: 1px solid #4a4a4a;
background: #2c2c2c;
color: #e8e8e8;
font-family: inherit;
font-size: 11px;
font-weight: 600;
padding: 3px 8px 3px 8px;
border-radius: 999px;
border-radius: 8px;
cursor: pointer;
outline: none;
transition: border-color 0.14s, background-color 0.14s, color 0.14s,
@@ -2967,7 +3001,7 @@ body {
}
.tb-dd-trigger:hover:not(:disabled) {
border-color: var(--accent-border);
background-color: var(--accent-dim);
background-color: rgba(255, 255, 255, 0.1);
color: var(--accent);
}
.tb-dd-trigger.active,
@@ -2976,7 +3010,7 @@ body {
box-shadow: 0 0 0 2px var(--accent-dim);
}
.tb-dd-trigger:disabled {
color: var(--muted);
color: #8a8a8a;
opacity: 0.55;
cursor: default;
}
@@ -2989,7 +3023,7 @@ body {
}
.tb-dd-chevron {
flex: 0 0 auto;
color: var(--muted);
color: #8a8a8a;
}
.tb-dd-trigger:hover:not(:disabled) .tb-dd-chevron {
color: var(--accent);
@@ -4212,6 +4246,59 @@ body {
cursor: not-allowed;
}
/* Einstellungs-Fenster (SettingsDialog): Farbfelder + Akzent-Presets
Nutzt sonst dieselben imp-*-Klassen wie die anderen Dialoge. */
.settings-field-label {
font-size: 12px;
color: var(--ink-2);
}
.settings-color-field {
display: flex;
flex-direction: column;
gap: 7px;
}
.settings-color-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.settings-accent-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.settings-accent-btn {
width: 22px;
height: 22px;
padding: 0;
border-radius: 6px;
border: 1px solid var(--line);
cursor: pointer;
transition: transform 0.1s, border-color 0.12s;
}
.settings-accent-btn:hover {
transform: scale(1.08);
}
.settings-accent-btn.active {
border: 2px solid var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
}
.settings-num-input {
font-family: inherit;
font-size: 13px;
padding: 6px 9px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--input);
color: var(--ink);
width: 120px;
}
.settings-num-input:focus {
outline: none;
border-color: var(--accent-border);
}
/* App-weites Drop-Feedback
Dezenter Rahmen + Hinweis, solange eine Datei über die App gezogen wird. */
.drop-feedback {
@@ -5122,3 +5209,53 @@ body {
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Fenster-Drag-Region (randloses Electron-Fenster)
Ohne native Titelleiste (frame:false) muss die Oberleiste als Drag-Handle
dienen; alle interaktiven Elemente darin bekommen `no-drag` zurück, sonst
liesse sich nichts mehr anklicken. Wirkt nur unter Electron/Chromium
(WebKit/Firefox ignorieren `-webkit-app-region` ergebnislos, harmlos). */
.topbar {
-webkit-app-region: drag;
}
.topbar button,
.topbar select,
.topbar input,
.topbar a,
.topbar [role="button"],
.topbar [role="listbox"] {
-webkit-app-region: no-drag;
}
/* Custom Fenstersteuerung (WindowControls.tsx) eigener Look statt OS-
Fensterknöpfen, ganz rechts in der Oberleiste (nur sichtbar unter
Electron/randlosem Fenster). */
.win-controls {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 6px;
flex: 0 0 auto;
}
.win-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--muted);
border-radius: 6px;
cursor: pointer;
transition: background 0.14s, color 0.14s;
}
.win-btn:hover {
background: var(--accent-dim);
color: var(--ink);
}
.win-btn-close:hover {
background: #e5484d;
color: #fff;
}
+53
View File
@@ -0,0 +1,53 @@
// Accent-Farbpalette (Schnellauswahl) für einstellbare UI-Akzente wie den
// Auswahlrahmen (Marquee) oder die Snap-/Endpunkt-Hervorhebung im Grundriss.
// Zehn benannte Töne (japanische Wörter, wie im HANDOVER-Backlog vorgesehen);
// jede Einstellung bleibt trotzdem ein FREIER Farbpicker — diese Liste liefert
// nur die Schnellauswahl-Buttons daneben.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
/** Ein Akzent-Farbton der Schnellauswahl-Palette. */
export interface AccentSwatch {
/** Stabiler Schlüssel (React-key, Vergleich). */
id: string;
/** Anzeigename (japanisch, wie im Backlog benannt). */
name: string;
/** Hex-Farbwert. */
hex: string;
}
/**
* Die zehn benannten Akzenttöne. Reihenfolge wie im HANDOVER-Backlog genannt.
* Nur `yuyake` hat einen vom Nutzer vorgegebenen Wert (#CEB188); die übrigen
* sind plausible, klar unterscheidbare Töne passend zum Namen bei Bedarf
* später anpassbar, ohne dass Aufrufer sich ändern (Verweis über `id`).
*/
export const ACCENT_SWATCHES: AccentSwatch[] = [
{ id: "ajisai", name: "Ajisai", hex: "#7C86C4" }, // 紫陽花 Hortensie — Blau-Violett
{ id: "sakura", name: "Sakura", hex: "#E3A8B8" }, // 桜 Kirschblüte — zartes Rosa
{ id: "suna", name: "Suna", hex: "#CBB994" }, // 砂 Sand — helles Beige
{ id: "ichigo", name: "Ichigo", hex: "#C2485C" }, // 苺 Erdbeere — Rot
{ id: "yuyake", name: "Yuyake", hex: "#CEB188" }, // 夕焼け Abendrot — warmes Gold-Beige
{ id: "sora", name: "Sora", hex: "#5FA1C9" }, // 空 Himmel — kühles Blau
{ id: "kusa", name: "Kusa", hex: "#7FA65A" }, // 草 Gras — Grün
{ id: "kori", name: "Kori", hex: "#A8D4D8" }, // 氷 Eis — blasses Zyan
{ id: "amagumo", name: "Amagumo", hex: "#6E7C8C" }, // 雨雲 Regenwolke — Blaugrau
{ id: "yuki", name: "Yuki", hex: "#E7E5DE" }, // 雪 Schnee — nahezu Weiss
];
/** Auswahlrahmen-Default (Marquee, 2D-Planansicht): Akzent „Yuyake". */
export const DEFAULT_MARQUEE_COLOR = "#CEB188";
/**
* Snap-/Endpunkt-Default: Akzent Sora" (vorläufig die offene Rückfrage
* aki" im HANDOVER-Backlog ist noch nicht geklärt). Bewusst ein kühler,
* heller Ton: hebt sich klar vom warmen Yuyake-Auswahlrahmen UND von den
* meist neutral-dunklen Wand-/Linienfarben ab, bleibt aber (anders als das
* sehr blasse Kori) auch auf hellem Papier-Hintergrund gut sichtbar.
*/
export const DEFAULT_SNAP_COLOR = "#5FA1C9";
/** Liefert einen Swatch nach `id`, oder `undefined`. */
export function findAccentSwatch(id: string): AccentSwatch | undefined {
return ACCENT_SWATCHES.find((s) => s.id === id);
}
+173
View File
@@ -0,0 +1,173 @@
// Einstellungs-Fenster (modal). HANDOVER-Backlog Punkt 4: Default-Farbe des
// Auswahlrahmens (Marquee) + der Snap-/Endpunkt-Hervorhebung im Grundriss,
// beide mit freiem Farbpicker + Akzent-Schnellauswahl; dazu ein Projekt-Feld
// für die Referenzhöhe des Erdgeschosses (m ü. M.).
//
// Rein gesteuert: Marquee-/Snap-Farbe kommen aus der ViewSlice (App reicht
// Wert + Setter durch), das MüM-Feld schreibt direkt ins Projekt über die
// bestehende `setProject`-Aktion. Muster wie ExportDxfDialog (abgedunkeltes
// Overlay, Klick auf Hintergrund + Esc schliessen).
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import { ACCENT_SWATCHES } from "../theme/accents";
import type { Project } from "../model/types";
export interface SettingsDialogProps {
marqueeColor: string;
onMarqueeColorChange: (hex: string) => void;
snapColor: string;
onSnapColorChange: (hex: string) => void;
project: Project;
setProject: (next: Project | ((p: Project) => Project)) => void;
onClose: () => void;
}
export function SettingsDialog({
marqueeColor,
onMarqueeColorChange,
snapColor,
onSnapColorChange,
project,
setProject,
onClose,
}: SettingsDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Lokaler Textzustand fürs MüM-Feld: erlaubt Zwischenzustände beim Tippen
// (leer, "412." etc.), ohne bei jedem Tastendruck einen ungültigen/
// gerundeten Projektwert zu schreiben. Synchronisiert sich, wenn sich das
// Projekt von aussen ändert (z. B. Undo/Import).
const [elevationText, setElevationText] = useState(
project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "",
);
useEffect(() => {
setElevationText(
project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "",
);
}, [project.referenceElevationMasl]);
const commitElevation = (raw: string) => {
setElevationText(raw);
const trimmed = raw.trim();
if (trimmed === "") {
setProject((p) => ({ ...p, referenceElevationMasl: undefined }));
return;
}
const n = Number(trimmed);
if (Number.isFinite(n)) {
setProject((p) => ({ ...p, referenceElevationMasl: n }));
}
};
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("settings.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("settings.title")}</span>
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
×
</button>
</header>
<div className="imp-body">
<section className="imp-section">
<div className="imp-section-title">{t("settings.section.display")}</div>
<AccentColorField
label={t("settings.marqueeColor")}
hint={t("settings.marqueeColor.hint")}
value={marqueeColor}
onChange={onMarqueeColorChange}
/>
<AccentColorField
label={t("settings.snapColor")}
hint={t("settings.snapColor.hint")}
value={snapColor}
onChange={onSnapColorChange}
/>
</section>
<section className="imp-section">
<div className="imp-section-title">{t("settings.section.project")}</div>
<label className="settings-field-label" htmlFor="settings-reference-elevation">
{t("settings.referenceElevation")}
</label>
<input
id="settings-reference-elevation"
className="settings-num-input"
type="number"
step="0.1"
inputMode="decimal"
value={elevationText}
onChange={(e) => commitElevation(e.target.value)}
placeholder="0.0"
/>
<div className="imp-note">{t("settings.referenceElevation.hint")}</div>
</section>
</div>
<footer className="imp-foot">
<button className="imp-btn primary" onClick={onClose}>
{t("export.close")}
</button>
</footer>
</div>
</div>
);
}
/** Farbfeld: freier Picker (Swatch + Hex) + Akzent-Presets als Schnellauswahl. */
function AccentColorField({
label,
hint,
value,
onChange,
}: {
label: string;
hint: string;
value: string;
onChange: (hex: string) => void;
}) {
return (
<div className="settings-color-field">
<div className="settings-color-head">
<span className="settings-field-label">{label}</span>
<label className="attr-color">
<span className="attr-color-swatch" style={{ background: value }} />
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} />
<span className="attr-color-hex">{value.toUpperCase()}</span>
</label>
</div>
<div className="settings-accent-presets" role="group" aria-label={label}>
{ACCENT_SWATCHES.map((a) => (
<button
key={a.id}
type="button"
className={`settings-accent-btn${
value.toLowerCase() === a.hex.toLowerCase() ? " active" : ""
}`}
style={{ background: a.hex }}
title={a.name}
aria-label={a.name}
aria-pressed={value.toLowerCase() === a.hex.toLowerCase()}
onClick={() => onChange(a.hex)}
/>
))}
</div>
<div className="imp-note">{hint}</div>
</div>
);
}
+104 -52
View File
@@ -14,6 +14,7 @@ import { t } from "../i18n";
import { useEffect, useRef, useState } from "react";
import { Dropdown } from "./Dropdown";
import type { DropdownItem, DropdownOption } from "./Dropdown";
import { WindowControls } from "./WindowControls";
import {
applyMark,
applyPreset,
@@ -73,6 +74,12 @@ export type PlanColorMode = "color" | "mono";
*/
export const SCALE_PRESETS: number[] = [1, 5, 10, 20, 50, 100, 200, 500, 1000];
/**
* Gemeinsame Breite (px) von Massstab-Dropdown und Zoom-Segmentpille im
* Zoom-Cluster beide sollen exakt gleich breit sein (Nutzer-Wunsch).
*/
const ZOOM_CLUSTER_WIDTH = 140;
export interface TopBarProps {
project: Project;
@@ -143,9 +150,20 @@ export interface TopBarProps {
resourcesOpen: boolean;
onToggleResources: () => void;
/** Öffnet das Einstellungs-Fenster (Darstellungsfarben, Projekt-Felder). */
onOpenSettings: () => void;
/** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */
layoutMenu: ReactNode;
// ── Datei-Menü (Burger, ganz rechts) ───────────────────────────────────────
/** Öffnet den DXF/DWG-Import-Datei-Dialog. */
onImport: () => void;
/** Projekt als Datei speichern (JSON-Export des gesamten Projekts). */
onSaveProject: () => void;
/** Projekt aus einer Datei laden. */
onOpenProject: () => void;
// ── Text-Gruppe (Stempel-/Text-Formatierung) ───────────────────────────────
/**
* Ziel der Text-Formatierung: `null` = nichts Text-artiges selektiert (die
@@ -813,6 +831,51 @@ function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[]
return out;
}
/**
* Datei-Menü der Oberleiste (Burger, ganz rechts): Speichern/Öffnen des
* Projekts sowie Import (DXF/DWG) und Export (PDF/DXF) vorher eigene
* Icon-Pillen im Zoom-Bereich, jetzt gebündelt hinter einem Menü, damit die
* Leiste ruhiger bleibt (GTK-Burger-Stil).
*/
function AppMenu({
onImport,
onExportPdf,
onExportDxf,
onSaveProject,
onOpenProject,
onOpenSettings,
exportEnabled,
}: {
onImport: () => void;
onExportPdf: () => void;
onExportDxf: () => void;
onSaveProject: () => void;
onOpenProject: () => void;
onOpenSettings: () => void;
exportEnabled: boolean;
}) {
const items: DropdownItem[] = [
{ label: t("file.save"), onSelect: onSaveProject },
{ label: t("file.open"), onSelect: onOpenProject },
{ divider: true },
{ label: t("file.import"), onSelect: onImport },
{ divider: true },
{ label: t("file.exportPdf"), onSelect: onExportPdf, disabled: !exportEnabled },
{ label: t("file.exportDxf"), onSelect: onExportDxf, disabled: !exportEnabled },
{ divider: true },
{ label: t("topbar.settings"), onSelect: onOpenSettings },
];
return (
<Dropdown
items={items}
placeholder=""
title={t("file.menu")}
triggerClassName="tb-burger"
triggerSuffix={<TbIcon name="menu" />}
/>
);
}
export function TopBar({
project,
viewToggleEnabled,
@@ -843,7 +906,11 @@ export function TopBar({
combos,
resourcesOpen,
onToggleResources,
onOpenSettings,
layoutMenu,
onImport,
onSaveProject,
onOpenProject,
textTarget,
onAddText,
}: TopBarProps) {
@@ -899,11 +966,29 @@ export function TopBar({
return (
<header className="topbar" ref={headerRef}>
{/* Marke/Logo. */}
{/* Marke (DOSSIER-Wortmarke) + Settings/Ressourcen-Icons daneben,
gestapelt wie im Rhino-Plugin (ToolbarApp.jsx: Wortmarke links,
rechts daneben ein schmaler Icon-Stapel). */}
<div className="tb-brandgroup">
<div className="brand">
<span className="logo"></span> cad{" "}
<span className="brand-word">
DOSSIER<span className="brand-dot">.</span>
</span>
<span className="tag">{t("brand.tag")}</span>
</div>
<div className="tb-brandicons">
<button
type="button"
className={`tb-brandicon${resourcesOpen ? " active" : ""}`}
onClick={onToggleResources}
aria-pressed={resourcesOpen}
aria-label={t("topbar.resources")}
title={`${t("topbar.resources")}${t("topbar.resources.hint")}`}
>
<TbIcon name="widgets" />
</button>
</div>
</div>
<Sep />
@@ -1017,9 +1102,9 @@ export function TopBar({
{/* Detailgrad (grob/mittel/fein) wirkt im Grundriss auf Symbole +
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung dort
deaktiviert mit Tooltip statt stiller No-Op. */}
{/* Detailgrad + Massstab gestapelt eine Spalte, platzsparend (wie die
Ebenen-/Zeichnungs-Kombis). */}
<div className="tb-group tb-stack" role="group" aria-label={t("topbar.detailGroup")}>
{/* Detailgrad Massstab lebt nur noch EINMAL im Zoom-Cluster (die
Dropdown hier war ein redundantes Relikt der TopBar-Reorg). */}
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.detail")}</span>
<Dropdown
@@ -1038,17 +1123,6 @@ export function TopBar({
]}
/>
</label>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.scale")}</span>
<Dropdown
value={inPresets ? String(scaleDenominator) : "__current__"}
disabled={!zoomEnabled}
onChange={onScaleChange}
title={t("topbar.scale.apply")}
triggerClassName="tb-dd-mono"
options={scaleOptions}
/>
</label>
</div>
<Sep />
@@ -1081,11 +1155,12 @@ export function TopBar({
onChange={onScaleChange}
title={t("topbar.scale.apply")}
triggerClassName="tb-dd-mono"
width={140}
width={ZOOM_CLUSTER_WIDTH}
options={scaleOptions}
/>
<Segment
ariaLabel={t("topbar.scaleGroup")}
style={{ width: ZOOM_CLUSTER_WIDTH }}
cells={[
{
key: "zoom100",
@@ -1113,31 +1188,6 @@ export function TopBar({
</div>
</div>
{/* Export als eigene kleine Pillen-Reihe (nicht mehr Teil des Zoom-Blocks,
damit der Cluster ruhig bleibt). */}
<div className="tb-group tb-btns" role="group" aria-label={t("topbar.exportPdf")}>
<button
type="button"
className="tb-iconbtn"
onClick={onExportPdf}
disabled={!zoomEnabled}
title={t("topbar.exportPdf.hint")}
aria-label={t("topbar.exportPdf")}
>
<TbIcon name="picture_as_pdf" />
</button>
<button
type="button"
className="tb-iconbtn"
onClick={onExportDxf}
disabled={!zoomEnabled}
title={t("topbar.exportDxf.hint")}
aria-label={t("topbar.exportDxf")}
>
<TbIcon name="download" />
</button>
</div>
<Sep />
{/* Darstellungsart (Vectorworks-Stil) EIN Dropdown, dessen Optionen vom
@@ -1177,19 +1227,21 @@ export function TopBar({
<Sep />
{/* Rechte Gruppe: Layout-Menü · Ressourcen · Projektname. */}
{/* Rechte Gruppe: Layout-Menü · Projektname · Datei-Burger (ganz rechts,
GTK-Stil) Ressourcen sitzt jetzt bei der Marke (s. o.). */}
<div className="topbar-right">
{layoutMenu}
<button
className={`view-icon${resourcesOpen ? " active" : ""}`}
onClick={onToggleResources}
aria-pressed={resourcesOpen}
aria-label={t("topbar.resources")}
title={`${t("topbar.resources")}${t("topbar.resources.hint")}`}
>
<TbIcon name="widgets" />
</button>
<span className="project-name">{project.name}</span>
<AppMenu
onImport={onImport}
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onSaveProject={onSaveProject}
onOpenProject={onOpenProject}
onOpenSettings={onOpenSettings}
exportEnabled={zoomEnabled}
/>
<WindowControls />
</div>
</header>
);
+107
View File
@@ -0,0 +1,107 @@
// Eigene Fenstersteuerung (Minimieren/Maximieren/Schliessen) für das randlose
// Electron-Fenster (frame:false, s. scripts/electron-main.cjs) — ohne native
// Titelleiste gibt es sonst keine Möglichkeit, das Fenster zu steuern.
//
// Rendert NUR, wenn `window.dossierWindow` existiert (vom Preload-Skript
// injiziert) — also nur unter Electron. In Tauri (native Fensterdekoration)
// und im Browser-Dev-Modus (?vite) bleibt die Komponente unsichtbar (`null`),
// dort steuert das Betriebssystem bzw. der Browser das Fenster.
import { useEffect, useState } from "react";
/** Von electron-preload.cjs via contextBridge injiziert (nur unter Electron). */
interface DossierWindowApi {
minimize: () => void;
toggleMaximize: () => void;
close: () => void;
isMaximized: () => Promise<boolean>;
onMaximizedChange: (callback: (isMaximized: boolean) => void) => () => void;
}
declare global {
interface Window {
dossierWindow?: DossierWindowApi;
}
}
export function WindowControls() {
const api = window.dossierWindow;
const [maximized, setMaximized] = useState(false);
useEffect(() => {
if (!api) return;
let cancelled = false;
api.isMaximized().then((v) => {
if (!cancelled) setMaximized(v);
});
const unsubscribe = api.onMaximizedChange((v) => setMaximized(v));
return () => {
cancelled = true;
unsubscribe();
};
}, [api]);
if (!api) return null;
return (
<div className="win-controls" role="group" aria-label="Fenster">
<button
type="button"
className="win-btn"
onClick={() => api.minimize()}
title="Minimieren"
aria-label="Minimieren"
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path d="M1 5 L9 5" stroke="currentColor" strokeWidth="1.2" />
</svg>
</button>
<button
type="button"
className="win-btn"
onClick={() => api.toggleMaximize()}
title={maximized ? "Wiederherstellen" : "Maximieren"}
aria-label={maximized ? "Wiederherstellen" : "Maximieren"}
>
{maximized ? (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path
d="M2.5 1.5 H8.5 V7.5 H2.5 Z M2.5 1.5 V3 M1.5 3 H2.5 M1.5 3 V9 H7.5 V7.5"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<rect
x="1.5"
y="1.5"
width="7"
height="7"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
)}
</button>
<button
type="button"
className="win-btn win-btn-close"
onClick={() => api.close()}
title="Schliessen"
aria-label="Schliessen"
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path
d="M1 1 L9 9 M9 1 L1 9"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
}