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 3d3bbdf64c
commit d9937e5afa
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 // (render2d-WASM-Engine, ?engine=wasm) zuverlässig läuft. Kein Rust-Backend
// nötig: compute_joins hat einen TS-Fallback (src/compute/index.ts). // 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 // 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); Menu.setApplicationMenu(null);
// Gleiche Flags wie scripts/chromium-shell.sh — WebGPU liegt unter Linux // Gleiche Flags wie scripts/chromium-shell.sh — WebGPU liegt unter Linux
@@ -27,9 +31,16 @@ function createWindow() {
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, 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) { if (isDev) {
win.loadURL(DEV_URL); win.loadURL(DEV_URL);
} else { } 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.whenReady().then(createWindow);
app.on("window-all-closed", () => { 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);
},
});
+154 -48
View File
@@ -53,6 +53,7 @@ import { savePlanPdf } from "./export/exportPdf";
import { ExportDxfDialog } from "./ui/ExportDxfDialog"; import { ExportDxfDialog } from "./ui/ExportDxfDialog";
import type { ExportDxfDecision } from "./ui/ExportDxfDialog"; import type { ExportDxfDecision } from "./ui/ExportDxfDialog";
import { savePlanDxf } from "./export/exportDxf"; import { savePlanDxf } from "./export/exportDxf";
import { SettingsDialog } from "./ui/SettingsDialog";
import { PlanView } from "./plan/PlanView"; import { PlanView } from "./plan/PlanView";
import type { import type {
PlanViewHandle, PlanViewHandle,
@@ -66,6 +67,8 @@ import { ResourceManager } from "./ui/ResourceManager";
import type { ResourceManagerHandlers } from "./ui/ResourceManager"; import type { ResourceManagerHandlers } from "./ui/ResourceManager";
import { HatchSwatch } from "./ui/hatchPreview"; import { HatchSwatch } from "./ui/hatchPreview";
import { TopBar } from "./ui/TopBar"; import { TopBar } from "./ui/TopBar";
import { Dropdown } from "./ui/Dropdown";
import type { DropdownItem } from "./ui/Dropdown";
import { TextEditorDialog } from "./ui/TextEditorDialog"; import { TextEditorDialog } from "./ui/TextEditorDialog";
import { RoomStampEditor } from "./panels/RoomStampEditor"; import { RoomStampEditor } from "./panels/RoomStampEditor";
import { defaultRoomStamp } from "./model/roomStamp"; import { defaultRoomStamp } from "./model/roomStamp";
@@ -223,6 +226,8 @@ export default function App() {
// Endlosschleife). Actions leben ebenfalls im Zustand und sind referenzstabil. // Endlosschleife). Actions leben ebenfalls im Zustand und sind referenzstabil.
const project = useStore((s) => s.project); const project = useStore((s) => s.project);
const setProject = useStore((s) => s.setProject); 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 activeLevelId = useStore((s) => s.activeLevelId);
const setActiveLevelId = useStore((s) => s.setActiveLevelId); const setActiveLevelId = useStore((s) => s.setActiveLevelId);
const viewType = useStore((s) => s.viewType); const viewType = useStore((s) => s.viewType);
@@ -246,6 +251,8 @@ export default function App() {
// Modaler PDF-Export-Dialog (Grundriss → Vektor-PDF). true = offen. // Modaler PDF-Export-Dialog (Grundriss → Vektor-PDF). true = offen.
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [exportDxfOpen, setExportDxfOpen] = 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 // Visuelles Drop-Feedback (dezenter Rahmen), solange eine Datei über die App
// gezogen wird. Zählt dragenter/dragleave aus, um Flackern bei Kind-Elementen // gezogen wird. Zählt dragenter/dragleave aus, um Flackern bei Kind-Elementen
// zu vermeiden. // zu vermeiden.
@@ -268,6 +275,10 @@ export default function App() {
const setReferenceLines = useStore((s) => s.setReferenceLines); const setReferenceLines = useStore((s) => s.setReferenceLines);
const lineMode = useStore((s) => s.lineMode); const lineMode = useStore((s) => s.lineMode);
const setLineMode = useStore((s) => s.setLineMode); 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 scaleDenominator = useStore((s) => s.scaleDenominator);
const setScaleDenominator = useStore((s) => s.setScaleDenominator); const setScaleDenominator = useStore((s) => s.setScaleDenominator);
// LIVE Papier-Massstab-Nenner aus der View-Transform (PlanView meldet ihn); // 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 // Der Klick auf „DXF importieren" triggert .click() dieses Inputs; onChange
// liest die Datei als Text und reicht sie durch parseDxf → addContextObjects. // liest die Datei als Text und reicht sie durch parseDxf → addContextObjects.
const dxfInputRef = useRef<HTMLInputElement>(null); 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 // Den DXF-Datei-Dialog-Öffner für den „import"-Befehl registrieren (entkoppelt
// über den Modul-Hook). Beim Unmount wieder abmelden. // über den Modul-Hook). Beim Unmount wieder abmelden.
@@ -791,6 +804,29 @@ export default function App() {
return () => window.removeEventListener("keydown", onKey); 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 // 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 // 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 // Geschoss-Tabs, nicht beim Tippen in Feldern und nicht mit Modifikator
@@ -1981,6 +2017,41 @@ export default function App() {
r.readAsText(file); 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 // Bestätigte Import-Entscheidung ausführen: ggf. neue Zeichnung anlegen, die
// 2D-Konturen als Drawing2D anhängen, optional die Meshes als Kontext // 2D-Konturen als Drawing2D anhängen, optional die Meshes als Kontext
// übernehmen. Reihenfolge: erst Ebene (echte ID), dann Drawings/Kontext. // übernehmen. Reihenfolge: erst Ebene (echte ID), dann Drawings/Kontext.
@@ -2917,6 +2988,18 @@ export default function App() {
e.target.value = ""; 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 <TopBar
project={project} project={project}
viewToggleEnabled={activeLevel.kind === "floor"} viewToggleEnabled={activeLevel.kind === "floor"}
@@ -2967,6 +3050,10 @@ export default function App() {
}} }}
resourcesOpen={resourcesOpen} resourcesOpen={resourcesOpen}
onToggleResources={() => setResourcesOpen((v) => !v)} onToggleResources={() => setResourcesOpen((v) => !v)}
onOpenSettings={() => setSettingsOpen(true)}
onImport={() => dxfInputRef.current?.click()}
onSaveProject={onSaveProject}
onOpenProject={onOpenProject}
layoutMenu={ layoutMenu={
<LayoutMenu <LayoutMenu
layout={layout} layout={layout}
@@ -3008,6 +3095,8 @@ export default function App() {
detail={detail} detail={detail}
referenceLines={referenceLines} referenceLines={referenceLines}
hairline={lineMode === "display"} hairline={lineMode === "display"}
marqueeColor={marqueeColor}
snapColor={snapColor}
renderMode={renderMode} renderMode={renderMode}
view3d={view3d} view3d={view3d}
fov={fov} 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. {/* Text-Editor (schwebendes Fenster): Doppelklick auf einen Raumstempel bzw.
Button in der Objekt-Info öffnet ihn. „Übernehmen" schreibt das Button in der Objekt-Info öffnet ihn. „Übernehmen" schreibt das
bearbeitete Rich-Text-Dokument über setRoomStampDoc. Die Formatier- bearbeitete Rich-Text-Dokument über setRoomStampDoc. Die Formatier-
@@ -3908,58 +4009,39 @@ function LayoutMenu({
refresh(); refresh();
}; };
const onPick = (value: string) => { // Aktions-Menü (analog ComboMenu in TopBar.tsx): „Aktuelles speichern" oben,
if (value === "__save__") { // dann je eine Gruppe „Laden" und „Löschen" (Trenner statt <optgroup>, die
onSave(); // die Dropdown-Komponente nicht kennt).
return; 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);
},
});
} }
if (value.startsWith("__del__:")) { items.push({ divider: true });
const name = value.slice("__del__:".length); for (const n of names) {
deleteNamedLayout(name); items.push({
refresh(); label: `${n}`,
return; danger: true,
onSelect: () => {
deleteNamedLayout(n);
refresh();
},
});
} }
const loaded = loadNamedLayout(value); }
if (loaded) onApply(loaded);
};
return ( return (
<select <span onPointerDownCapture={refresh}>
className="layout-menu" <Dropdown items={items} placeholder={t("layout.placeholder")} title={t("layout.title")} />
title={t("layout.title")} </span>
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>
); );
} }
@@ -3974,6 +4056,8 @@ function Content({
detail, detail,
referenceLines, referenceLines,
hairline, hairline,
marqueeColor,
snapColor,
renderMode, renderMode,
view3d, view3d,
fov, fov,
@@ -4029,6 +4113,10 @@ function Content({
detail: DetailLevel; detail: DetailLevel;
referenceLines: boolean; referenceLines: boolean;
hairline: boolean; hairline: boolean;
/** Farbe des Auswahlrahmens (Marquee) im Grundriss. */
marqueeColor: string;
/** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */
snapColor: string;
renderMode: RenderMode; renderMode: RenderMode;
/** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */ /** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */
view3d: View3d; view3d: View3d;
@@ -4111,6 +4199,8 @@ function Content({
project={project} project={project}
level={level} level={level}
hairline={hairline} hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
mono={mono} mono={mono}
planViewRef={planViewRef} planViewRef={planViewRef}
onCursor={onCursor} onCursor={onCursor}
@@ -4160,6 +4250,8 @@ function Content({
detail={detail} detail={detail}
referenceLines={referenceLines} referenceLines={referenceLines}
hairline={hairline} hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
mono={mono} mono={mono}
planViewRef={planViewRef} planViewRef={planViewRef}
onCursor={onCursor} onCursor={onCursor}
@@ -4240,6 +4332,8 @@ function Content({
detail={detail} detail={detail}
referenceLines={referenceLines} referenceLines={referenceLines}
hairline={hairline} hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
mono={mono} mono={mono}
planViewRef={planViewRef} planViewRef={planViewRef}
onCursor={onCursor} onCursor={onCursor}
@@ -4279,6 +4373,8 @@ function LevelPlanView({
detail, detail,
referenceLines, referenceLines,
hairline, hairline,
marqueeColor,
snapColor,
mono, mono,
planViewRef, planViewRef,
onCursor, onCursor,
@@ -4311,6 +4407,8 @@ function LevelPlanView({
detail: DetailLevel; detail: DetailLevel;
referenceLines: boolean; referenceLines: boolean;
hairline: boolean; hairline: boolean;
marqueeColor: string;
snapColor: string;
mono: boolean; mono: boolean;
planViewRef: React.RefObject<PlanViewHandle>; planViewRef: React.RefObject<PlanViewHandle>;
onCursor: (model: Vec2 | null) => void; onCursor: (model: Vec2 | null) => void;
@@ -4406,6 +4504,8 @@ function LevelPlanView({
grips={grips} grips={grips}
edgeGrips={edgeGrips} edgeGrips={edgeGrips}
hairline={hairline} hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
selectedDrawingId={selectedDrawingId} selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds} selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers} gripHandlers={gripHandlers}
@@ -4429,6 +4529,8 @@ function SectionPlanView({
project, project,
level, level,
hairline, hairline,
marqueeColor,
snapColor,
mono, mono,
planViewRef, planViewRef,
onCursor, onCursor,
@@ -4457,6 +4559,8 @@ function SectionPlanView({
project: Project; project: Project;
level: DrawingLevel; level: DrawingLevel;
hairline: boolean; hairline: boolean;
marqueeColor: string;
snapColor: string;
mono: boolean; mono: boolean;
planViewRef: React.RefObject<PlanViewHandle>; planViewRef: React.RefObject<PlanViewHandle>;
onCursor: (model: Vec2 | null) => void; onCursor: (model: Vec2 | null) => void;
@@ -4562,6 +4666,8 @@ function SectionPlanView({
grips={grips} grips={grips}
edgeGrips={edgeGrips} edgeGrips={edgeGrips}
hairline={hairline} hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
selectedDrawingId={selectedDrawingId} selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds} selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers} gripHandlers={gripHandlers}
+24
View File
@@ -802,6 +802,30 @@ export const de = {
"exportDxf.unitsNote": "exportDxf.unitsNote":
"Modell-Space in Metern (1:1). Layer entsprechen den Ebenen des Projekts; beim Plot beliebig skalierbar.", "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) ────────────────────────────────── // ── Drag & Drop (App-weiter Datei-Drop) ──────────────────────────────────
"drop.hint": "DXF/DWG-Datei hier ablegen", "drop.hint": "DXF/DWG-Datei hier ablegen",
+24
View File
@@ -793,6 +793,30 @@ export const en: Record<TranslationKey, string> = {
"exportDxf.unitsNote": "exportDxf.unitsNote":
"Model space in meters (1:1). Layers mirror the project's categories; scale freely on plot.", "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). // Drag & drop (app-wide file drop).
"drop.hint": "Drop a DXF/DWG file here", "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. * aufgelöst — generiert Wall[]-Objekte bei Bedarf.
*/ */
parametricWalls?: ParametricWall[]; 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 ─────────────────────────────────────────────────────────────── // ── Helfer ───────────────────────────────────────────────────────────────
+47 -7
View File
@@ -22,6 +22,7 @@ import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
import { quantizePen } from "../export/sceneToPrintSvg"; import { quantizePen } from "../export/sceneToPrintSvg";
import { buildRandomHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch"; import { buildRandomHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch";
import { dashHasDot } from "../ui/lineSegments"; 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 PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum)
const PAD = 60; // Rand in viewBox-Einheiten 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- // Zoom-Grenzen relativ zur eingepassten Default-Ansicht. Kleinere viewBox-
// Breite = stärkerer Zoom. ZOOM_MAX → max. Hineinzoomen, ZOOM_MIN → Herauszoomen. // Breite = stärkerer Zoom. ZOOM_MAX → max. Hineinzoomen, ZOOM_MIN → Herauszoomen.
const ZOOM_MAX = 50; // bis 50× hinein // Grosszügig bemessen (CAD-Anspruch: vom Milimeter-Detail bis zum Siedlungs-
const ZOOM_MIN = 0.2; // bis 5× heraus // 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 const WHEEL_STEP = 0.0015; // Empfindlichkeit des Mausrads
// Schwelle (Bildschirm-Pixel), ab der ein Links-Ziehen als Marquee gilt; darunter // Schwelle (Bildschirm-Pixel), ab der ein Links-Ziehen als Marquee gilt; darunter
@@ -320,6 +324,16 @@ export interface PlanViewProps {
selectedDrawingIds?: string[]; selectedDrawingIds?: string[];
/** Griff-Zieh-Treiber: Live-Bewegung (gesnappt) + Abschluss. */ /** Griff-Zieh-Treiber: Live-Bewegung (gesnappt) + Abschluss. */
gripHandlers?: GripHandlers; 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). */ /** Treiber für das Ziehen von Editier-Griffen (von App bereitgestellt). */
@@ -370,6 +384,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
selectedDrawingId, selectedDrawingId,
selectedDrawingIds, selectedDrawingIds,
gripHandlers, gripHandlers,
marqueeColor = DEFAULT_MARQUEE_COLOR,
snapColor = DEFAULT_SNAP_COLOR,
}, },
ref, ref,
) { ) {
@@ -1884,6 +1900,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
y={marqueeRect.y} y={marqueeRect.y}
width={marqueeRect.w} width={marqueeRect.w}
height={marqueeRect.h} height={marqueeRect.h}
style={{ stroke: marqueeColor, fill: marqueeColor }}
pointerEvents="none" pointerEvents="none"
/> />
)} )}
@@ -1965,6 +1982,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
draft={toolHandlers.draft} draft={toolHandlers.draft}
toScreen={toScreen} toScreen={toScreen}
vbPerPx={1 / (meetScale(view, svgRef.current) ?? 1)} vbPerPx={1 / (meetScale(view, svgRef.current) ?? 1)}
snapColor={snapColor}
/> />
)} )}
</svg> </svg>
@@ -1983,10 +2001,12 @@ function DraftOverlay({
draft, draft,
toScreen, toScreen,
vbPerPx, vbPerPx,
snapColor,
}: { }: {
draft: { preview: DraftShape[]; vertices: Vec2[]; snap?: SnapResult | null }; draft: { preview: DraftShape[]; vertices: Vec2[]; snap?: SnapResult | null };
toScreen: (v: Vec2) => Vec2; toScreen: (v: Vec2) => Vec2;
vbPerPx: number; vbPerPx: number;
snapColor: string;
}) { }) {
const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich) 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) 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. */} {/* Snap-Marker + optionale Ortho-Hilfslinie. */}
{draft.snap && ( {draft.snap && (
<SnapMarker snap={draft.snap} toScreen={toScreen} size={markSize} /> <SnapMarker snap={draft.snap} toScreen={toScreen} size={markSize} color={snapColor} />
)} )}
</g> </g>
); );
@@ -2041,13 +2061,22 @@ function SnapMarker({
snap, snap,
toScreen, toScreen,
size, size,
color,
}: { }: {
snap: SnapResult; snap: SnapResult;
toScreen: (v: Vec2) => Vec2; toScreen: (v: Vec2) => Vec2;
size: number; size: number;
/** Farbe des Snap-Glyphs (einstellbar, Default = Akzent „Sora"). */
color: string;
}) { }) {
const s = toScreen(snap.point); const s = toScreen(snap.point);
const r = size; 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 ( return (
<g className="snap-marker"> <g className="snap-marker">
{(snap.kind === "ortho" || snap.kind === "extension") && snap.refA && ( {(snap.kind === "ortho" || snap.kind === "extension") && snap.refA && (
@@ -2057,11 +2086,19 @@ function SnapMarker({
y1={toScreen(snap.refA).y} y1={toScreen(snap.refA).y}
x2={s.x} x2={s.x}
y2={s.y} y2={s.y}
style={helperStyle}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
)} )}
{snap.kind === "grid" && ( {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" && ( {snap.kind === "midpoint" && (
<line <line
@@ -2070,22 +2107,24 @@ function SnapMarker({
y1={s.y} y1={s.y}
x2={s.x + r} x2={s.x + r}
y2={s.y} y2={s.y}
style={glyphStyle}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
)} )}
{snap.kind === "intersection" && ( {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} 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} 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") && ( {(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" && ( {snap.kind === "onEdge" && (
<polygon <polygon
className="snap-glyph" className="snap-glyph"
points={`${s.x},${s.y - r} ${s.x + r},${s.y} ${s.x},${s.y + r} ${s.x - r},${s.y}`} 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" vectorEffect="non-scaling-stroke"
/> />
)} )}
@@ -2095,6 +2134,7 @@ function SnapMarker({
cx={s.x} cx={s.x}
cy={s.y} cy={s.y}
r={r} r={r}
style={glyphStyle}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
)} )}
+14
View File
@@ -10,6 +10,7 @@
import { sampleProject } from "../model/sampleProject"; import { sampleProject } from "../model/sampleProject";
import type { DetailLevel, RenderMode, View3d, ViewType } from "../ui/TopBar"; import type { DetailLevel, RenderMode, View3d, ViewType } from "../ui/TopBar";
import type { DisplayMode } from "../panels/types"; import type { DisplayMode } from "../panels/types";
import { DEFAULT_MARQUEE_COLOR, DEFAULT_SNAP_COLOR } from "../theme/accents";
import type { StoreApi } from "./store"; import type { StoreApi } from "./store";
/** Standard-Darstellungsmodus je Panel (wie bisher: alles sichtbar normal). */ /** Standard-Darstellungsmodus je Panel (wie bisher: alles sichtbar normal). */
@@ -45,6 +46,13 @@ export interface ViewSlice {
activeCategoryCode: string; activeCategoryCode: string;
// Darstellungsmodus je Panel-ID (z. B. "drawing-levels", "layers"). // Darstellungsmodus je Panel-ID (z. B. "drawing-levels", "layers").
displayModes: Record<string, DisplayMode>; 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; setActiveLevelId: (id: string) => void;
setViewType: (v: ViewType) => void; setViewType: (v: ViewType) => void;
@@ -61,6 +69,8 @@ export interface ViewSlice {
modeFor: (id: string | null) => DisplayMode; modeFor: (id: string | null) => DisplayMode;
/** Darstellungsmodus eines Panels setzen (No-op bei null id). */ /** Darstellungsmodus eines Panels setzen (No-op bei null id). */
setModeFor: (id: string | null, mode: DisplayMode) => void; setModeFor: (id: string | null, mode: DisplayMode) => void;
setMarqueeColor: (hex: string) => void;
setSnapColor: (hex: string) => void;
} }
export function createViewSlice(api: StoreApi<ViewSlice>): ViewSlice { export function createViewSlice(api: StoreApi<ViewSlice>): ViewSlice {
@@ -80,6 +90,8 @@ export function createViewSlice(api: StoreApi<ViewSlice>): ViewSlice {
activeCategoryCode: activeCategoryCode:
sampleProject.walls[0]?.categoryCode ?? sampleProject.layers[0].code, sampleProject.walls[0]?.categoryCode ?? sampleProject.layers[0].code,
displayModes: {}, displayModes: {},
marqueeColor: DEFAULT_MARQUEE_COLOR,
snapColor: DEFAULT_SNAP_COLOR,
setActiveLevelId: (id) => setActiveLevelId: (id) =>
set((s) => ({ set((s) => ({
@@ -105,5 +117,7 @@ export function createViewSlice(api: StoreApi<ViewSlice>): ViewSlice {
if (!id) return; if (!id) return;
set((s) => ({ displayModes: { ...s.displayModes, [id]: mode } })); set((s) => ({ displayModes: { ...s.displayModes, [id]: mode } }));
}, },
setMarqueeColor: (hex) => set({ marqueeColor: hex }),
setSnapColor: (hex) => set({ snapColor: hex }),
}; };
} }
+291 -154
View File
@@ -3,10 +3,69 @@
das DOSSIER-Look: aktive Zeile als Petrol-Pille, nicht-aktive gedimmt, das DOSSIER-Look: aktive Zeile als Petrol-Pille, nicht-aktive gedimmt,
ausgeblendete grau; Augen-Schalter, Farbtupfer, eingerückte Kinder. */ 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 { :root {
color-scheme: dark;
/* Gestufte Elevations-Palette (Wunsch: dunkler + mehr Tiefe). Tiefster
Grund hinten, Flächen werden nach vorn schrittweise heller — leicht
kühl-neutral, harmoniert mit dem Petrol-Akzent.
--bg Ebene 0 — App-Grund / Rinnen hinter allem (am dunkelsten)
--panel Ebene 1 — Oberleiste, Docks, Panel-Körper
--panel-2 Ebene 2 — angehobene Flächen: Panel-Köpfe, Hover-Zeilen
--input versenkt — Eingabefelder (dunkler als die Fläche → Inset) */
/* Zwei-Ton-Grundlage (Nutzer-Vorgabe): Fast-Schwarz hinten, dunkles
Neutralgrau für die Flächen. Neutral (kein Blaustich), mit dezenten
Elevations-Schritten für Tiefe. */
--bg: #0e0e0e;
--panel: #1d1d1d;
--panel-2: #262626;
--input: #0a0a0a;
--line: #2b2b2b;
--border: #383838;
/* --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, 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);
--accent-border: rgba(255, 255, 255, 0.22);
--active: #6a6a6a;
--active-light: #7d7d7d;
--active-dim: rgba(255, 255, 255, 0.1);
--wall: #c8ccd2;
--swing: #7e8794;
--danger: #c85050;
--danger-light: #d86060;
/* Tiefere Schatten für klare Schichtung auf dunklem Grund. */
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.5);
--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; color-scheme: light;
/* Flächen */
--bg: #e0dbd4; --bg: #e0dbd4;
--panel: #e0dbd4; --panel: #e0dbd4;
--panel-2: #d4cfc8; --panel-2: #d4cfc8;
@@ -14,17 +73,13 @@
--line: #d4cfc8; --line: #d4cfc8;
--border: #c8c2ba; --border: #c8c2ba;
/* Zeichenblatt-Hintergrund (Plan) — bewusst fix, unabhängig von der Panel-
Farbe: helles „Papier" im Light-Mode. */
--sheet: #f0f0f0; --sheet: #f0f0f0;
/* Schrift */
--ink: #1a1a18; --ink: #1a1a18;
--ink-2: #555550; --ink-2: #555550;
--muted: #8a8580; --muted: #8a8580;
--label: #1a1a18; --label: #1a1a18;
/* Petrol-Akzent + aktiver Marker */
--accent: #2f5d54; --accent: #2f5d54;
--accent-light: #4a8a7c; --accent-light: #4a8a7c;
--accent-dim: #e6efed; --accent-dim: #e6efed;
@@ -33,11 +88,9 @@
--active-light: #2f8275; --active-light: #2f8275;
--active-dim: rgba(26, 101, 90, 0.12); --active-dim: rgba(26, 101, 90, 0.12);
/* Plan-/3D-Töne */
--wall: #2b3039; --wall: #2b3039;
--swing: #9aa3b2; --swing: #9aa3b2;
/* Warn-/Gefahr-Akzente (z. B. Löschen). */
--danger: #8a1a1a; --danger: #8a1a1a;
--danger-light: #b03030; --danger-light: #b03030;
@@ -45,61 +98,6 @@
--shadow-2: 0 2px 8px rgba(26, 26, 24, 0.14); --shadow-2: 0 2px 8px rgba(26, 26, 24, 0.14);
--shadow-3: 0 6px 24px rgba(26, 26, 24, 0.18); --shadow-3: 0 6px 24px rgba(26, 26, 24, 0.18);
--overlay: rgba(26, 26, 24, 0.36); --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
kühl-neutral, harmoniert mit dem Petrol-Akzent.
--bg Ebene 0 — App-Grund / Rinnen hinter allem (am dunkelsten)
--panel Ebene 1 — Oberleiste, Docks, Panel-Körper
--panel-2 Ebene 2 — angehobene Flächen: Panel-Köpfe, Hover-Zeilen
--input versenkt — Eingabefelder (dunkler als die Fläche → Inset) */
/* Zwei-Ton-Grundlage (Nutzer-Vorgabe): Fast-Schwarz hinten, dunkles
Neutralgrau für die Flächen. Neutral (kein Blaustich), mit dezenten
Elevations-Schritten für Tiefe. */
--bg: #0e0e0e;
--panel: #1d1d1d;
--panel-2: #262626;
--input: #0a0a0a;
--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). */
--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. */
--accent: #4d4d4d;
--accent-light: #5e5e5e;
--accent-dim: rgba(255, 255, 255, 0.07);
--accent-border: rgba(255, 255, 255, 0.22);
--active: #6a6a6a;
--active-light: #7d7d7d;
--active-dim: rgba(255, 255, 255, 0.1);
--wall: #c8ccd2;
--swing: #7e8794;
--danger: #c85050;
--danger-light: #d86060;
/* Tiefere Schatten für klare Schichtung auf dunklem Grund. */
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.5);
--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);
}
} }
* { * {
@@ -155,7 +153,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 0 14px; padding: 4px 14px;
height: 56px; height: 56px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
/* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten /* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten
@@ -254,15 +252,17 @@ body {
.tb-btn { .tb-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
border: 1px solid var(--border); /* Gleiche stets-dunkle Fläche wie .tb-dd-trigger — einheitlicher Pillen-
background: var(--input); Look über die ganze Oberleiste. */
color: var(--ink); border: 1px solid #4a4a4a;
background: #2c2c2c;
color: #e8e8e8;
font-family: inherit; font-family: inherit;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
padding: 3px 10px; padding: 3px 10px;
border-radius: 999px; border-radius: 8px;
cursor: pointer; cursor: pointer;
outline: none; outline: none;
transition: border-color 0.14s, background-color 0.14s, color 0.14s, transition: border-color 0.14s, background-color 0.14s, color 0.14s,
@@ -270,7 +270,7 @@ body {
} }
.tb-btn:hover:not(:disabled) { .tb-btn:hover:not(:disabled) {
border-color: var(--accent-border); border-color: var(--accent-border);
background-color: var(--accent-dim); background-color: rgba(255, 255, 255, 0.1);
color: var(--accent); color: var(--accent);
} }
.tb-btn:focus-visible { .tb-btn:focus-visible {
@@ -278,7 +278,7 @@ body {
box-shadow: 0 0 0 2px var(--accent-dim); box-shadow: 0 0 0 2px var(--accent-dim);
} }
.tb-btn:disabled { .tb-btn:disabled {
color: var(--muted); color: #8a8a8a;
opacity: 0.55; opacity: 0.55;
cursor: default; cursor: default;
} }
@@ -320,20 +320,20 @@ body {
/* Pillen-Toggle der Oberleiste (Referenzlinien). */ /* Pillen-Toggle der Oberleiste (Referenzlinien). */
.tb-toggle { .tb-toggle {
border: 1px solid var(--border); border: 1px solid #4a4a4a;
background: var(--input); background: #2c2c2c;
color: var(--ink-2); color: #e8e8e8;
font-family: inherit; font-family: inherit;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.03em; letter-spacing: 0.03em;
padding: 6px 12px; padding: 6px 12px;
border-radius: 999px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: background 0.14s, color 0.14s, border-color 0.14s; transition: background 0.14s, color 0.14s, border-color 0.14s;
} }
.tb-toggle:hover { .tb-toggle:hover {
background: var(--accent-dim); background: rgba(255, 255, 255, 0.1);
border-color: var(--accent-border); border-color: var(--accent-border);
color: var(--accent); color: var(--accent);
} }
@@ -346,16 +346,17 @@ body {
/* ── DOSSIER-Grundprimitive: Segmentpille · Icon-Knopf · Zoom-Cluster ──────── */ /* ── DOSSIER-Grundprimitive: Segmentpille · Icon-Knopf · Zoom-Cluster ──────── */
/* Segmentpille (3er/4er): Aussencontainer rund, Zellen gleich hoch, interne /* 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 { .tb-seg {
display: inline-flex; display: inline-flex;
align-items: stretch; align-items: stretch;
height: 24px; height: 24px;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid var(--border); border: 1px solid #4a4a4a;
border-radius: 999px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background: var(--input); background: #2c2c2c;
flex: 0 0 auto; flex: 0 0 auto;
} }
/* Akzent-Rand, wenn ein Text-Ziel selektiert ist (Auswahl-Bewusstsein). */ /* Akzent-Rand, wenn ein Text-Ziel selektiert ist (Auswahl-Bewusstsein). */
@@ -370,9 +371,9 @@ body {
min-width: 26px; min-width: 26px;
padding: 0 6px; padding: 0 6px;
border: none; border: none;
border-left: 1px solid var(--border); border-left: 1px solid #4a4a4a;
background: transparent; background: transparent;
color: var(--ink-2); color: #b0b0b0;
cursor: pointer; cursor: pointer;
transition: background 0.14s, color 0.14s; transition: background 0.14s, color 0.14s;
} }
@@ -380,8 +381,8 @@ body {
border-left: none; border-left: none;
} }
.tb-seg-cell:hover:not(:disabled) { .tb-seg-cell:hover:not(:disabled) {
background: var(--accent-dim); background: rgba(255, 255, 255, 0.1);
color: var(--accent); color: #f0f0f0;
} }
.tb-seg-cell.active { .tb-seg-cell.active {
background: var(--accent); background: var(--accent);
@@ -392,7 +393,7 @@ body {
cursor: default; cursor: default;
} }
/* Quadratischer Icon-Knopf (BarButton) — Export-Pillen (PDF/DXF). */ /* Quadratischer Icon-Knopf (BarButton). */
.tb-iconbtn { .tb-iconbtn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -401,15 +402,15 @@ body {
height: 24px; height: 24px;
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
border: 1px solid var(--border); border: 1px solid #4a4a4a;
border-radius: 999px; border-radius: 8px;
background: var(--input); background: #2c2c2c;
color: var(--ink-2); color: #b0b0b0;
cursor: pointer; cursor: pointer;
transition: background 0.14s, color 0.14s, border-color 0.14s; transition: background 0.14s, color 0.14s, border-color 0.14s;
} }
.tb-iconbtn:hover:not(:disabled) { .tb-iconbtn:hover:not(:disabled) {
background: var(--accent-dim); background: rgba(255, 255, 255, 0.1);
border-color: var(--accent-border); border-color: var(--accent-border);
color: var(--accent); color: var(--accent);
} }
@@ -422,7 +423,7 @@ body {
.tb-zoomcluster { .tb-zoomcluster {
display: grid; display: grid;
grid-template-columns: auto auto; grid-template-columns: auto auto;
gap: 4px 6px; gap: 6px;
align-items: center; align-items: center;
} }
.tb-stat { .tb-stat {
@@ -437,6 +438,10 @@ body {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 14px; border-radius: 14px;
background: var(--input); 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 { .tb-stat-scale {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -456,20 +461,16 @@ body {
width: 100%; width: 100%;
text-align: center; 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 { .tb-stat-atscale {
background: var(--accent-dim); box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.9);
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);
} }
.tb-zoomcluster-right { .tb-zoomcluster-right {
display: inline-flex; display: inline-flex;
align-items: center; flex-direction: column;
align-items: stretch;
gap: 6px; gap: 6px;
} }
@@ -477,7 +478,7 @@ body {
.tb-textgroup { .tb-textgroup {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 4px; gap: 6px;
} }
.tb-textgroup-row { .tb-textgroup-row {
display: inline-flex; display: inline-flex;
@@ -517,27 +518,71 @@ body {
text-align: left; 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; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
font-weight: 650; flex: 0 0 auto;
font-size: 16px;
letter-spacing: 0.2px;
} }
.brand {
.logo { 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); color: var(--accent);
font-size: 18px;
} }
.tag { .tag {
font-weight: 500; font-weight: 500;
font-size: 11px; font-size: 10px;
color: var(--muted); color: var(--muted);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 999px; 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 { .project-name {
@@ -625,9 +670,9 @@ body {
grid-template-columns: repeat(4, auto); grid-template-columns: repeat(4, auto);
} }
.view-icon { .view-icon {
border: 1px solid var(--border); border: 1px solid #4a4a4a;
background: var(--input); background: #2c2c2c;
color: var(--ink-2); color: #b0b0b0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -639,8 +684,8 @@ body {
transition: background 0.14s, color 0.14s, border-color 0.14s; transition: background 0.14s, color 0.14s, border-color 0.14s;
} }
.view-icon:hover:not(:disabled) { .view-icon:hover:not(:disabled) {
background: var(--accent-dim); background: rgba(255, 255, 255, 0.1);
color: var(--ink); color: #f0f0f0;
border-color: var(--accent-border); border-color: var(--accent-border);
} }
.view-icon.active { .view-icon.active {
@@ -681,7 +726,7 @@ body {
.tb-group.tb-stack { .tb-group.tb-stack {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 4px; gap: 6px;
} }
.tb-field-row { .tb-field-row {
justify-content: space-between; justify-content: space-between;
@@ -2852,10 +2897,18 @@ body {
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
flex: 0 0 auto; 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); border-bottom: 1px solid var(--line);
/* Panel-Kopf leicht angehoben (Ebene 2) → setzt sich vom Körper ab. */ /* Uniform mit dem Panel-Körper (Nutzer-Wunsch): kein separater hellerer
background: var(--panel-2); Kopf-Balken mehr, nur die Trennlinie grenzt Kopf/Körper ab. */
background: var(--panel);
} }
.panel-title { .panel-title {
min-width: 0; min-width: 0;
@@ -2863,7 +2916,7 @@ body {
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: #f0f0f0;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -2876,38 +2929,15 @@ body {
padding: 10px 8px 16px; 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 { .display-mode {
flex: 0 0 auto; flex: 0 0 auto;
appearance: none;
-webkit-appearance: none;
max-width: 160px; max-width: 160px;
font-family: var(--font);
font-size: 10px; font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em; 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) ─────────────── */ /* ── Layout-Menü (Oberleiste: benannte Layouts sichern/laden) ─────────────── */
@@ -2952,14 +2982,18 @@ body {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
border: 1px solid var(--border); /* Trigger trägt dieselbe stets-dunkle Fläche wie sein Popover (.ctx-menu) —
background: var(--input); beide sollen wie EIN durchgehendes Element wirken statt heller Knopf +
color: var(--ink); 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-family: inherit;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
padding: 3px 8px 3px 8px; padding: 3px 8px 3px 8px;
border-radius: 999px; border-radius: 8px;
cursor: pointer; cursor: pointer;
outline: none; outline: none;
transition: border-color 0.14s, background-color 0.14s, color 0.14s, transition: border-color 0.14s, background-color 0.14s, color 0.14s,
@@ -2967,7 +3001,7 @@ body {
} }
.tb-dd-trigger:hover:not(:disabled) { .tb-dd-trigger:hover:not(:disabled) {
border-color: var(--accent-border); border-color: var(--accent-border);
background-color: var(--accent-dim); background-color: rgba(255, 255, 255, 0.1);
color: var(--accent); color: var(--accent);
} }
.tb-dd-trigger.active, .tb-dd-trigger.active,
@@ -2976,7 +3010,7 @@ body {
box-shadow: 0 0 0 2px var(--accent-dim); box-shadow: 0 0 0 2px var(--accent-dim);
} }
.tb-dd-trigger:disabled { .tb-dd-trigger:disabled {
color: var(--muted); color: #8a8a8a;
opacity: 0.55; opacity: 0.55;
cursor: default; cursor: default;
} }
@@ -2989,7 +3023,7 @@ body {
} }
.tb-dd-chevron { .tb-dd-chevron {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--muted); color: #8a8a8a;
} }
.tb-dd-trigger:hover:not(:disabled) .tb-dd-chevron { .tb-dd-trigger:hover:not(:disabled) .tb-dd-chevron {
color: var(--accent); color: var(--accent);
@@ -4212,6 +4246,59 @@ body {
cursor: not-allowed; 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 ────────────────────────────────────────────── /* ── App-weites Drop-Feedback ──────────────────────────────────────────────
Dezenter Rahmen + Hinweis, solange eine Datei über die App gezogen wird. */ Dezenter Rahmen + Hinweis, solange eine Datei über die App gezogen wird. */
.drop-feedback { .drop-feedback {
@@ -5122,3 +5209,53 @@ body {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-align: right; 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>
);
}
+106 -54
View File
@@ -14,6 +14,7 @@ import { t } from "../i18n";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Dropdown } from "./Dropdown"; import { Dropdown } from "./Dropdown";
import type { DropdownItem, DropdownOption } from "./Dropdown"; import type { DropdownItem, DropdownOption } from "./Dropdown";
import { WindowControls } from "./WindowControls";
import { import {
applyMark, applyMark,
applyPreset, applyPreset,
@@ -73,6 +74,12 @@ export type PlanColorMode = "color" | "mono";
*/ */
export const SCALE_PRESETS: number[] = [1, 5, 10, 20, 50, 100, 200, 500, 1000]; 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 { export interface TopBarProps {
project: Project; project: Project;
@@ -143,9 +150,20 @@ export interface TopBarProps {
resourcesOpen: boolean; resourcesOpen: boolean;
onToggleResources: () => void; onToggleResources: () => void;
/** Öffnet das Einstellungs-Fenster (Darstellungsfarben, Projekt-Felder). */
onOpenSettings: () => void;
/** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */ /** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */
layoutMenu: ReactNode; 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) ─────────────────────────────── // ── Text-Gruppe (Stempel-/Text-Formatierung) ───────────────────────────────
/** /**
* Ziel der Text-Formatierung: `null` = nichts Text-artiges selektiert (die * Ziel der Text-Formatierung: `null` = nichts Text-artiges selektiert (die
@@ -813,6 +831,51 @@ function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[]
return out; 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({ export function TopBar({
project, project,
viewToggleEnabled, viewToggleEnabled,
@@ -843,7 +906,11 @@ export function TopBar({
combos, combos,
resourcesOpen, resourcesOpen,
onToggleResources, onToggleResources,
onOpenSettings,
layoutMenu, layoutMenu,
onImport,
onSaveProject,
onOpenProject,
textTarget, textTarget,
onAddText, onAddText,
}: TopBarProps) { }: TopBarProps) {
@@ -899,10 +966,28 @@ export function TopBar({
return ( return (
<header className="topbar" ref={headerRef}> <header className="topbar" ref={headerRef}>
{/* Marke/Logo. */} {/* Marke (DOSSIER-Wortmarke) + Settings/Ressourcen-Icons daneben,
<div className="brand"> gestapelt wie im Rhino-Plugin (ToolbarApp.jsx: Wortmarke links,
<span className="logo"></span> cad{" "} rechts daneben ein schmaler Icon-Stapel). */}
<span className="tag">{t("brand.tag")}</span> <div className="tb-brandgroup">
<div className="brand">
<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> </div>
<Sep /> <Sep />
@@ -1017,9 +1102,9 @@ export function TopBar({
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss auf Symbole + {/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss auf Symbole +
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung → dort Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung → dort
deaktiviert mit Tooltip statt stiller No-Op. */} deaktiviert mit Tooltip statt stiller No-Op. */}
{/* Detailgrad + Massstab gestapelt — eine Spalte, platzsparend (wie die {/* Detailgrad Massstab lebt nur noch EINMAL im Zoom-Cluster (die
Ebenen-/Zeichnungs-Kombis). */} Dropdown hier war ein redundantes Relikt der TopBar-Reorg). */}
<div className="tb-group tb-stack" role="group" aria-label={t("topbar.detailGroup")}> <div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field tb-field-row"> <label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.detail")}</span> <span className="tb-label">{t("topbar.detail")}</span>
<Dropdown <Dropdown
@@ -1038,17 +1123,6 @@ export function TopBar({
]} ]}
/> />
</label> </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> </div>
<Sep /> <Sep />
@@ -1081,11 +1155,12 @@ export function TopBar({
onChange={onScaleChange} onChange={onScaleChange}
title={t("topbar.scale.apply")} title={t("topbar.scale.apply")}
triggerClassName="tb-dd-mono" triggerClassName="tb-dd-mono"
width={140} width={ZOOM_CLUSTER_WIDTH}
options={scaleOptions} options={scaleOptions}
/> />
<Segment <Segment
ariaLabel={t("topbar.scaleGroup")} ariaLabel={t("topbar.scaleGroup")}
style={{ width: ZOOM_CLUSTER_WIDTH }}
cells={[ cells={[
{ {
key: "zoom100", key: "zoom100",
@@ -1113,31 +1188,6 @@ export function TopBar({
</div> </div>
</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 /> <Sep />
{/* Darstellungsart (Vectorworks-Stil) — EIN Dropdown, dessen Optionen vom {/* Darstellungsart (Vectorworks-Stil) — EIN Dropdown, dessen Optionen vom
@@ -1177,19 +1227,21 @@ export function TopBar({
<Sep /> <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"> <div className="topbar-right">
{layoutMenu} {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> <span className="project-name">{project.name}</span>
<AppMenu
onImport={onImport}
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onSaveProject={onSaveProject}
onOpenProject={onOpenProject}
onOpenSettings={onOpenSettings}
exportEnabled={zoomEnabled}
/>
<WindowControls />
</div> </div>
</header> </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>
);
}