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

- Marke (DOSSIER-Wortmarke) + Ressourcen-Icon ganz links, wie im
  Rhino-Plugin; Settings-Icon folgt über den neuen Einstellungs-Dialog.
- Massstab/Zoom-Cluster bereinigt: doppelte Massstab-Dropdown entfernt,
  Zoom-Aktionen unter dem Massstab-Dropdown gestapelt (gleiche Breite),
  Ring bei "am Massstab" statt Flächen-Aufhellung.
- PDF/DXF-Export aus der Leiste raus, zusammen mit Speichern/Öffnen/
  Import in einem neuen Datei-Burger-Menü ganz rechts. Speichern/Öffnen
  sind echte JSON-Download/-Upload-Handler fürs ganze Projekt.
- Einstellungs-Fenster (neu): Accent-Palette, Auswahlrahmen-/Snap-Farbe
  editierbar (freier Picker + Presets), Projekt-Referenzhöhe (m ü. M.)
  als Feld.
- LayoutMenu auf die eigene Dropdown-Komponente umgestellt (war noch
  natives <select>).
- Custom Fenstersteuerung (_/□/X) für die randlose Electron-Shell via
  Preload+IPC, eigener Look statt OS-Chrome; Oberleiste als Drag-Region.
- Dark-Theme ist jetzt fester Standard (vorher an OS-Präferenz
  gekoppelt, zeigte je nach Umgebung fälschlich Light) — Light bleibt
  als Opt-in (`[data-theme="light"]`) für einen künftigen Umschalter.
  Alle Oberleisten-Pillen (Dropdown-Trigger, Segmente, Icon-Knöpfe,
  Ansichts-Icons) auf dieselbe dunkle Fläche wie das Kontextmenü
  vereinheitlicht. Panel-Köpfe nicht mehr separat aufgehellt, feste
  Höhe (40px) unabhängig von optionalem Darstellungsmodus-Dropdown.
- 2D-Zoom-Grenzen deutlich erweitert (20000x/0.005x statt 50x/0.2x).
This commit is contained in:
2026-07-04 05:10:44 +02:00
parent 44942e6976
commit f724c088e0
13 changed files with 1052 additions and 265 deletions
+154 -48
View File
@@ -53,6 +53,7 @@ import { savePlanPdf } from "./export/exportPdf";
import { ExportDxfDialog } from "./ui/ExportDxfDialog";
import type { ExportDxfDecision } from "./ui/ExportDxfDialog";
import { savePlanDxf } from "./export/exportDxf";
import { SettingsDialog } from "./ui/SettingsDialog";
import { PlanView } from "./plan/PlanView";
import type {
PlanViewHandle,
@@ -66,6 +67,8 @@ import { ResourceManager } from "./ui/ResourceManager";
import type { ResourceManagerHandlers } from "./ui/ResourceManager";
import { HatchSwatch } from "./ui/hatchPreview";
import { TopBar } from "./ui/TopBar";
import { Dropdown } from "./ui/Dropdown";
import type { DropdownItem } from "./ui/Dropdown";
import { TextEditorDialog } from "./ui/TextEditorDialog";
import { RoomStampEditor } from "./panels/RoomStampEditor";
import { defaultRoomStamp } from "./model/roomStamp";
@@ -223,6 +226,8 @@ export default function App() {
// Endlosschleife). Actions leben ebenfalls im Zustand und sind referenzstabil.
const project = useStore((s) => s.project);
const setProject = useStore((s) => s.setProject);
const undo = useStore((s) => s.undo);
const redo = useStore((s) => s.redo);
const activeLevelId = useStore((s) => s.activeLevelId);
const setActiveLevelId = useStore((s) => s.setActiveLevelId);
const viewType = useStore((s) => s.viewType);
@@ -246,6 +251,8 @@ export default function App() {
// Modaler PDF-Export-Dialog (Grundriss → Vektor-PDF). true = offen.
const [exportOpen, setExportOpen] = useState(false);
const [exportDxfOpen, setExportDxfOpen] = useState(false);
// Modales Einstellungs-Fenster (Darstellungsfarben + Projekt-Felder).
const [settingsOpen, setSettingsOpen] = useState(false);
// Visuelles Drop-Feedback (dezenter Rahmen), solange eine Datei über die App
// gezogen wird. Zählt dragenter/dragleave aus, um Flackern bei Kind-Elementen
// zu vermeiden.
@@ -268,6 +275,10 @@ export default function App() {
const setReferenceLines = useStore((s) => s.setReferenceLines);
const lineMode = useStore((s) => s.lineMode);
const setLineMode = useStore((s) => s.setLineMode);
const marqueeColor = useStore((s) => s.marqueeColor);
const setMarqueeColor = useStore((s) => s.setMarqueeColor);
const snapColor = useStore((s) => s.snapColor);
const setSnapColor = useStore((s) => s.setSnapColor);
const scaleDenominator = useStore((s) => s.scaleDenominator);
const setScaleDenominator = useStore((s) => s.setScaleDenominator);
// LIVE Papier-Massstab-Nenner aus der View-Transform (PlanView meldet ihn);
@@ -387,6 +398,8 @@ export default function App() {
// Der Klick auf „DXF importieren" triggert .click() dieses Inputs; onChange
// liest die Datei als Text und reicht sie durch parseDxf → addContextObjects.
const dxfInputRef = useRef<HTMLInputElement>(null);
// Verstecktes Datei-Input zum Laden eines Projekts (Datei-Burger „Öffnen").
const projectFileInputRef = useRef<HTMLInputElement>(null);
// Den DXF-Datei-Dialog-Öffner für den „import"-Befehl registrieren (entkoppelt
// über den Modul-Hook). Beim Unmount wieder abmelden.
@@ -791,6 +804,29 @@ export default function App() {
return () => window.removeEventListener("keydown", onKey);
}, []);
// Globaler Undo/Redo-Shortcut: Ctrl+Z / Cmd+Z, Ctrl+Shift+Z / Cmd+Shift+Z
// bzw. Ctrl+Y für redo. Gleicher Eingabe-Guard wie die übrigen globalen
// keydown-Handler (Tab-Zyklus, Zahlen-Shortcuts).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
const key = e.key.toLowerCase();
if (!mod || (key !== "z" && key !== "y")) return;
const el = e.target as HTMLElement | null;
if (
el &&
(el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)
) {
return;
}
e.preventDefault();
if (key === "y" || (key === "z" && e.shiftKey)) redo();
else undo();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [undo, redo]);
// Nummern-Shortcuts für Zeichenwerkzeuge (Vectorworks-Stil): 2 Linie, 4
// Rechteck, 5 Polylinie, 6 Wand, 7 Decke, 8 Fenster, 9 Türe, 0 Raum. Nur auf
// Geschoss-Tabs, nicht beim Tippen in Feldern und nicht mit Modifikator
@@ -1981,6 +2017,41 @@ export default function App() {
r.readAsText(file);
};
// Projekt als Datei speichern/laden (Datei-Burger-Menü, TopBar/AppMenu).
// Reines JSON des ProjectSlice-Zustands (gleiche Blob+Anchor-Download-Technik
// wie savePlanDxf/savePlanPdf); Laden liest die Datei als Text, parst sie und
// ersetzt das Projekt — nur ein grobes Shape-Sanity-Check (walls/layers als
// Array), damit ein fremdes/kaputtes JSON nicht die App zerlegt.
const onSaveProject = () => {
const json = JSON.stringify(project, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${project.name || "projekt"}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const onOpenProject = () => projectFileInputRef.current?.click();
const onProjectFileChosen = (file: File) => {
const r = new FileReader();
r.onload = () => {
try {
const parsed = JSON.parse(String(r.result ?? "")) as Partial<Project>;
if (!Array.isArray(parsed.walls) || !Array.isArray(parsed.layers)) {
console.warn("Projekt-Datei hat kein gültiges Projekt-Format (walls/layers fehlen).");
return;
}
setProject(parsed as Project);
} catch (err) {
console.error("Projekt-Import fehlgeschlagen:", err);
}
};
r.readAsText(file);
};
// Bestätigte Import-Entscheidung ausführen: ggf. neue Zeichnung anlegen, die
// 2D-Konturen als Drawing2D anhängen, optional die Meshes als Kontext
// übernehmen. Reihenfolge: erst Ebene (echte ID), dann Drawings/Kontext.
@@ -2917,6 +2988,18 @@ export default function App() {
e.target.value = "";
}}
/>
{/* Verstecktes Projekt-Datei-Input (Datei-Burger „Öffnen"). */}
<input
ref={projectFileInputRef}
type="file"
accept="application/json,.json"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onProjectFileChosen(f);
e.target.value = "";
}}
/>
<TopBar
project={project}
viewToggleEnabled={activeLevel.kind === "floor"}
@@ -2967,6 +3050,10 @@ export default function App() {
}}
resourcesOpen={resourcesOpen}
onToggleResources={() => setResourcesOpen((v) => !v)}
onOpenSettings={() => setSettingsOpen(true)}
onImport={() => dxfInputRef.current?.click()}
onSaveProject={onSaveProject}
onOpenProject={onOpenProject}
layoutMenu={
<LayoutMenu
layout={layout}
@@ -3008,6 +3095,8 @@ export default function App() {
detail={detail}
referenceLines={referenceLines}
hairline={lineMode === "display"}
marqueeColor={marqueeColor}
snapColor={snapColor}
renderMode={renderMode}
view3d={view3d}
fov={fov}
@@ -3243,6 +3332,18 @@ export default function App() {
/>
)}
{settingsOpen && (
<SettingsDialog
marqueeColor={marqueeColor}
onMarqueeColorChange={setMarqueeColor}
snapColor={snapColor}
onSnapColorChange={setSnapColor}
project={project}
setProject={setProject}
onClose={() => setSettingsOpen(false)}
/>
)}
{/* Text-Editor (schwebendes Fenster): Doppelklick auf einen Raumstempel bzw.
Button in der Objekt-Info öffnet ihn. „Übernehmen" schreibt das
bearbeitete Rich-Text-Dokument über setRoomStampDoc. Die Formatier-
@@ -3908,58 +4009,39 @@ function LayoutMenu({
refresh();
};
const onPick = (value: string) => {
if (value === "__save__") {
onSave();
return;
// Aktions-Menü (analog ComboMenu in TopBar.tsx): „Aktuelles speichern" oben,
// dann je eine Gruppe „Laden" und „Löschen" (Trenner statt <optgroup>, die
// die Dropdown-Komponente nicht kennt).
const items: DropdownItem[] = [];
items.push({ label: t("layout.saveCurrent"), onSelect: onSave });
if (names.length > 0) {
items.push({ divider: true });
for (const n of names) {
items.push({
label: n,
onSelect: () => {
const loaded = loadNamedLayout(n);
if (loaded) onApply(loaded);
},
});
}
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 (
<select
className="layout-menu"
title={t("layout.title")}
aria-label={t("layout.title")}
value=""
onFocus={refresh}
onMouseDown={refresh}
onChange={(e) => {
onPick(e.target.value);
// Auswahl-Anzeige zurücksetzen (Menü ist ein Aktions-Trigger).
e.currentTarget.value = "";
}}
>
<option value="" disabled>
{t("layout.placeholder")}
</option>
<option value="__save__">{t("layout.saveCurrent")}</option>
{names.length > 0 && (
<optgroup label={t("layout.loadGroup")}>
{names.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</optgroup>
)}
{names.length > 0 && (
<optgroup label={t("layout.deleteGroup")}>
{names.map((n) => (
<option key={`del-${n}`} value={`__del__:${n}`}>
{n}
</option>
))}
</optgroup>
)}
</select>
<span onPointerDownCapture={refresh}>
<Dropdown items={items} placeholder={t("layout.placeholder")} title={t("layout.title")} />
</span>
);
}
@@ -3974,6 +4056,8 @@ function Content({
detail,
referenceLines,
hairline,
marqueeColor,
snapColor,
renderMode,
view3d,
fov,
@@ -4029,6 +4113,10 @@ function Content({
detail: DetailLevel;
referenceLines: boolean;
hairline: boolean;
/** Farbe des Auswahlrahmens (Marquee) im Grundriss. */
marqueeColor: string;
/** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */
snapColor: string;
renderMode: RenderMode;
/** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */
view3d: View3d;
@@ -4111,6 +4199,8 @@ function Content({
project={project}
level={level}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
mono={mono}
planViewRef={planViewRef}
onCursor={onCursor}
@@ -4160,6 +4250,8 @@ function Content({
detail={detail}
referenceLines={referenceLines}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
mono={mono}
planViewRef={planViewRef}
onCursor={onCursor}
@@ -4240,6 +4332,8 @@ function Content({
detail={detail}
referenceLines={referenceLines}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
mono={mono}
planViewRef={planViewRef}
onCursor={onCursor}
@@ -4279,6 +4373,8 @@ function LevelPlanView({
detail,
referenceLines,
hairline,
marqueeColor,
snapColor,
mono,
planViewRef,
onCursor,
@@ -4311,6 +4407,8 @@ function LevelPlanView({
detail: DetailLevel;
referenceLines: boolean;
hairline: boolean;
marqueeColor: string;
snapColor: string;
mono: boolean;
planViewRef: React.RefObject<PlanViewHandle>;
onCursor: (model: Vec2 | null) => void;
@@ -4406,6 +4504,8 @@ function LevelPlanView({
grips={grips}
edgeGrips={edgeGrips}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers}
@@ -4429,6 +4529,8 @@ function SectionPlanView({
project,
level,
hairline,
marqueeColor,
snapColor,
mono,
planViewRef,
onCursor,
@@ -4457,6 +4559,8 @@ function SectionPlanView({
project: Project;
level: DrawingLevel;
hairline: boolean;
marqueeColor: string;
snapColor: string;
mono: boolean;
planViewRef: React.RefObject<PlanViewHandle>;
onCursor: (model: Vec2 | null) => void;
@@ -4562,6 +4666,8 @@ function SectionPlanView({
grips={grips}
edgeGrips={edgeGrips}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers}