diff --git a/scripts/electron-main.cjs b/scripts/electron-main.cjs index 94b08c0..22961d8 100644 --- a/scripts/electron-main.cjs +++ b/scripts/electron-main.cjs @@ -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", () => { diff --git a/scripts/electron-preload.cjs b/scripts/electron-preload.cjs new file mode 100644 index 0000000..54682cb --- /dev/null +++ b/scripts/electron-preload.cjs @@ -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); + }, +}); diff --git a/src/App.tsx b/src/App.tsx index 47001c0..461b082 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); + // Verstecktes Datei-Input zum Laden eines Projekts (Datei-Burger „Öffnen"). + const projectFileInputRef = useRef(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; + 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"). */} + { + const f = e.target.files?.[0]; + if (f) onProjectFileChosen(f); + e.target.value = ""; + }} + /> setResourcesOpen((v) => !v)} + onOpenSettings={() => setSettingsOpen(true)} + onImport={() => dxfInputRef.current?.click()} + onSaveProject={onSaveProject} + onOpenProject={onOpenProject} layoutMenu={ )} + {settingsOpen && ( + 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; + // Aktions-Menü (analog ComboMenu in TopBar.tsx): „Aktuelles speichern" oben, + // dann je eine Gruppe „Laden" und „Löschen" (Trenner statt , 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); + }, + }); } - if (value.startsWith("__del__:")) { - const name = value.slice("__del__:".length); - deleteNamedLayout(name); - refresh(); - return; + items.push({ divider: true }); + for (const n of names) { + items.push({ + label: `✕ ${n}`, + danger: true, + onSelect: () => { + deleteNamedLayout(n); + refresh(); + }, + }); } - const loaded = loadNamedLayout(value); - if (loaded) onApply(loaded); - }; + } return ( - + + + ); } @@ -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; 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; 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} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 9d42ac1..4bdfc18 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -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", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 83c1f84..9e7cb44 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -793,6 +793,30 @@ export const en: Record = { "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", diff --git a/src/model/types.ts b/src/model/types.ts index 0908461..c6a60c6 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -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 ─────────────────────────────────────────────────────────────── diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 2756657..9c42832 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -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( selectedDrawingId, selectedDrawingIds, gripHandlers, + marqueeColor = DEFAULT_MARQUEE_COLOR, + snapColor = DEFAULT_SNAP_COLOR, }, ref, ) { @@ -1884,6 +1900,7 @@ export const PlanView = forwardRef( y={marqueeRect.y} width={marqueeRect.w} height={marqueeRect.h} + style={{ stroke: marqueeColor, fill: marqueeColor }} pointerEvents="none" /> )} @@ -1965,6 +1982,7 @@ export const PlanView = forwardRef( draft={toolHandlers.draft} toScreen={toScreen} vbPerPx={1 / (meetScale(view, svgRef.current) ?? 1)} + snapColor={snapColor} /> )} @@ -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 && ( - + )} ); @@ -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 ( {(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" && ( - + )} {snap.kind === "midpoint" && ( )} {snap.kind === "intersection" && ( <> - - + + )} {(snap.kind === "center" || snap.kind === "quadrant") && ( - + )} {snap.kind === "onEdge" && ( )} @@ -2095,6 +2134,7 @@ function SnapMarker({ cx={s.x} cy={s.y} r={r} + style={glyphStyle} vectorEffect="non-scaling-stroke" /> )} diff --git a/src/state/viewSlice.ts b/src/state/viewSlice.ts index b7770a8..a85936f 100644 --- a/src/state/viewSlice.ts +++ b/src/state/viewSlice.ts @@ -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; + // 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 { @@ -80,6 +90,8 @@ export function createViewSlice(api: StoreApi): 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 { if (!id) return; set((s) => ({ displayModes: { ...s.displayModes, [id]: mode } })); }, + setMarqueeColor: (hex) => set({ marqueeColor: hex }), + setSnapColor: (hex) => set({ snapColor: hex }), }; } diff --git a/src/styles.css b/src/styles.css index 60e4ca2..62891c1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3,10 +3,69 @@ 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: 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; - /* Flächen */ --bg: #e0dbd4; --panel: #e0dbd4; --panel-2: #d4cfc8; @@ -14,17 +73,13 @@ --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; @@ -33,11 +88,9 @@ --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; @@ -45,61 +98,6 @@ --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 - 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; 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,"); - 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; +} diff --git a/src/theme/accents.ts b/src/theme/accents.ts new file mode 100644 index 0000000..5817f61 --- /dev/null +++ b/src/theme/accents.ts @@ -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); +} diff --git a/src/ui/SettingsDialog.tsx b/src/ui/SettingsDialog.tsx new file mode 100644 index 0000000..121c1e0 --- /dev/null +++ b/src/ui/SettingsDialog.tsx @@ -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 ( +
+
e.stopPropagation()} + > +
+ {t("settings.title")} + +
+ +
+
+
{t("settings.section.display")}
+ + +
+ +
+
{t("settings.section.project")}
+ + commitElevation(e.target.value)} + placeholder="0.0" + /> +
{t("settings.referenceElevation.hint")}
+
+
+ +
+ +
+
+
+ ); +} + +/** 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 ( +
+
+ {label} + +
+
+ {ACCENT_SWATCHES.map((a) => ( +
+
{hint}
+
+ ); +} diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index a3e7e2c..ebd2f86 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -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 ( + } + /> + ); +} + export function TopBar({ project, viewToggleEnabled, @@ -843,7 +906,11 @@ export function TopBar({ combos, resourcesOpen, onToggleResources, + onOpenSettings, layoutMenu, + onImport, + onSaveProject, + onOpenProject, textTarget, onAddText, }: TopBarProps) { @@ -899,10 +966,28 @@ export function TopBar({ return (
- {/* Marke/Logo. */} -
- cad{" "} - {t("brand.tag")} + {/* Marke (DOSSIER-Wortmarke) + Settings/Ressourcen-Icons daneben, + gestapelt wie im Rhino-Plugin (ToolbarApp.jsx: Wortmarke links, + rechts daneben ein schmaler Icon-Stapel). */} +
+
+ + DOSSIER. + + {t("brand.tag")} +
+
+ +
@@ -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). */} -
+ {/* Detailgrad — Massstab lebt nur noch EINMAL im Zoom-Cluster (die + Dropdown hier war ein redundantes Relikt der TopBar-Reorg). */} +
-
@@ -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} />
- {/* Export als eigene kleine Pillen-Reihe (nicht mehr Teil des Zoom-Blocks, - damit der Cluster ruhig bleibt). */} -
- - -
- {/* Darstellungsart (Vectorworks-Stil) — EIN Dropdown, dessen Optionen vom @@ -1177,19 +1227,21 @@ export function TopBar({ - {/* 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.). */}
{layoutMenu} - {project.name} + +
); diff --git a/src/ui/WindowControls.tsx b/src/ui/WindowControls.tsx new file mode 100644 index 0000000..1d4e735 --- /dev/null +++ b/src/ui/WindowControls.tsx @@ -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; + 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 ( +
+ + + +
+ ); +}