// Oberleiste (Top-Bar) — MVP nach docs/design/topbar-footer.md.
//
// Gruppen (links → rechts): Marke · Ansichts-Umschalter (Grundriss/Perspektive)
// · Detailgrad (grob/mittel/fein) · Massstab „1:N" + Zoom (Einpassen/Auswahl/
// 100 %) · Render-Modus · Referenzlinien-Toggle · Layout-Menü · Ressourcen.
//
// Die Leiste ist rein gesteuert (controlled): alle Zustände kommen als Props
// aus App, Aktionen melden sich über Callbacks zurück. Bezeichner englisch,
// UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { ReactNode } from "react";
import type { Project } from "../model/types";
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,
commonMarkValue,
DEFAULT_LINE_HEIGHT,
DEFAULT_PRESETS,
docLength,
isMarkActive,
toggleMark,
} from "../text/richText";
import type {
Align,
BoolMark,
RichTextDoc,
TextRange,
} from "../text/richText";
/** Ansichtstyp eines Geschosses (wie in App). */
export type ViewType = "grundriss" | "perspektive";
/**
* Detailgrad der Darstellung (≙ DOSSIER Einfach/Standard/Detail). Wirkt im
* Grundriss auf Tür-/Fenstersymbole + Schraffuren/Linienfeinheit (siehe
* generatePlan.DetailLevel); in der Perspektive noch ohne Wirkung (deferred).
*/
export type DetailLevel = "grob" | "mittel" | "fein";
/**
* Render-/Anzeigemodus der 3D-Ansicht: schattiert, einheitlich weiss (Clay-Look),
* texturiert (echte PBR-Materialien je Bauteil), Drahtgitter, Verdeckte Kanten
* (Hidden-Line). Wirkt nur in der Perspektive. Der Slot „section" (Schnitt-
* schraffur, Welle B) ist bewusst freigelassen.
*/
export type RenderMode =
| "shaded"
| "white"
| "textured"
| "wireframe"
| "hidden";
/**
* Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). „front",
* „top", „side", „iso" springen auf die jeweilige Standardansicht; „perspective"
* ist der freie 3/4-Blick. Wirkt nur in der Perspektive.
*/
export type View3d = "front" | "top" | "side" | "iso" | "perspective";
/**
* Farb-Modus des Grundrisses: „color" zeigt Kategorie-/Element-Farben (heutiges
* Verhalten), „mono" erzeugt einen reinen Schwarz-Weiss-Plan. Wirkt nur im
* Grundriss (orthogonal zum Linien-Modus Display/Print).
*/
export type PlanColorMode = "color" | "mono";
/**
* Massstab-Presets „1:N" (Nenner). `null` steht für „frei" (benutzerdefiniert,
* per Eingabe). Quelle für die Anzeige in Top-Bar und Status-Leiste.
*/
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;
// Ansicht.
/** Ob der Ansichts-Umschalter sinnvoll ist (nur für Geschosse). */
viewToggleEnabled: boolean;
viewType: ViewType;
onViewType: (v: ViewType) => void;
// 3D-Ansicht: kanonischer Blickwinkel + Kamera-Sichtwinkel (FOV). Wirkt nur in
// der Perspektive; ein Preset-Klick wechselt nötigenfalls auch dorthin.
view3d: View3d;
onView3d: (v: View3d) => void;
/** Sichtwinkel der Perspektivkamera in Grad (Kamera-Einstellungen). */
fov: number;
onFov: (n: number) => void;
// Zeichenwerkzeuge (Auswahl/Wand/Linie …), Wandtyp-Wahl und Fang-Menü leben
// jetzt in der Werkzeug-Palette (ToolsPanel), nicht mehr in der Oberleiste.
// Detailgrad.
detail: DetailLevel;
onDetail: (d: DetailLevel) => void;
/** Detailgrad wirkt nur, wenn ein Plan gezeichnet wird (sonst deaktiviert). */
detailEnabled: boolean;
// Massstab + Zoom.
/**
* Gewählter (angewandter) Papier-Massstab-Nenner (1:N) — steuert das Dropdown
* und „100 %". Setzt beim Wählen den Plan-Zoom auf exakt 1:N.
*/
scaleDenominator: number;
/** Einen Papier-Massstab anwenden (Preset/frei): setzt N + Plan-Zoom. */
onApplyScale: (n: number) => void;
/**
* LIVE Papier-Massstab-Nenner aus der aktuellen View-Transform (ändert sich
* beim Wheel-Zoom) oder `null`, wenn keine Plan-Ansicht aktiv ist.
*/
liveScale: number | null;
/** Live-Zoom in Prozent (aus der Plan-View-Transform). */
zoomPercent: number;
/** Zoom-Aktionen — nur wirksam, wenn eine Plan-Ansicht aktiv ist. */
zoomEnabled: boolean;
onFit: () => void;
onFitSelection: () => void;
onZoom100: () => void;
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
onExportPdf: () => void;
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
onExportDxf: () => void;
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
/** Render-Modus nur in der Perspektive sinnvoll (sonst deaktiviert). */
renderModeEnabled: boolean;
// Farb-Modus des Grundrisses (Farbig ↔ Schwarz-Weiss). Nur im Grundriss.
planColorMode: PlanColorMode;
onPlanColorMode: (m: PlanColorMode) => void;
// Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen).
/** Handler-Satz für die beiden Kombinations-Dropdowns. */
combos: ComboMenuHandlers;
// Ressourcen-Fenster.
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
* Controls setzen nur Defaults für neuen Text). Sonst das aktuell selektierte
* Rich-Text-Dokument (z. B. Raumstempel) samt optionalem Bereich und einem
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
*/
textTarget: TextTarget | null;
}
/**
* Beschreibt das aktuell formatierbare Rich-Text-Ziel der Oberleiste. `apply`
* schreibt das neue Dokument zurück in den App-State (z. B. via setRoomStampDoc).
*/
export interface TextTarget {
doc: RichTextDoc;
range: TextRange | null;
apply: (doc: RichTextDoc) => void;
}
/**
* Verdrahtung der beiden Kombinations-Dropdowns (Vectorworks-Stil). App liefert
* die Speicher-/Lade-/Lösch-Logik; die Komponente bleibt rein gesteuert.
*/
export interface ComboMenuHandlers {
// Ebenen-Kombinationen.
listLayerCombos: () => string[];
saveLayerCombo: (name: string) => void;
loadLayerCombo: (name: string) => void;
deleteLayerCombo: (name: string) => void;
// Zeichnungs-Kombinationen.
listDrawingCombos: () => string[];
saveDrawingCombo: (name: string) => void;
loadDrawingCombo: (name: string) => void;
deleteDrawingCombo: (name: string) => void;
}
/** Kleiner vertikaler Trenner zwischen Gruppen. */
function Sep() {
return ;
}
/**
* Material-Symbols-Icon für die Oberleiste (gleiche Icon-Font wie ContextMenu/
* MenuIcon). Rein dekorativ — der sprechende Text steckt im `title`/aria-label
* des umgebenden Buttons.
*/
function TbIcon({ name }: { name: string }) {
return (
{name}
);
}
/**
* Kleines Kombinations-Menü (Vectorworks-Stil) — gespiegelt von `LayoutMenu`:
* aktuelle Sichtbarkeit unter einem Namen sichern, eine gespeicherte Kombination
* wählen (laden) oder löschen. Die Namen werden frisch gelesen, sobald das Menü
* geöffnet wird (onFocus/onMouseDown). Rein gesteuert: alle Aktionen kommen über
* die Callbacks zurück. Wiederverwendbar für Ebenen- UND Zeichnungskombinationen.
*/
function ComboMenu({
title,
savePrompt,
list,
onSave,
onLoad,
onDelete,
}: {
/** Tooltip/aria-label des Dropdowns. */
title: string;
/** Prompt-Text beim Speichern. */
savePrompt: string;
list: () => string[];
onSave: (name: string) => void;
onLoad: (name: string) => void;
onDelete: (name: string) => void;
}) {
// Namen werden frisch gelesen, sobald das Menü gebaut wird (beim Render des
// Triggers reicht nicht — wir lesen bei jedem Öffnen über `refresh`). Da das
// Dropdown gesteuert ist, halten wir die aktuelle Liste im State und
// aktualisieren sie beim Trigger-Klick (siehe `items`-Aufbau unten).
const [names, setNames] = useState([]);
const refresh = () => setNames(list());
const save = () => {
const name = window.prompt(savePrompt);
if (!name) return;
onSave(name.trim());
refresh();
};
// Aktions-Menü (analog ContextMenu-Items): „Aktuelle speichern" oben, dann je
// eine Gruppe „Laden" und „Löschen" (danach Trenner). Das Häkchen entfällt
// hier (kein „Wert"); Löschen ist als danger-Item markiert.
const items: DropdownItem[] = [];
items.push({ label: t("combo.saveCurrent"), onSelect: save });
if (names.length > 0) {
items.push({ divider: true });
for (const n of names) {
items.push({ label: n, onSelect: () => onLoad(n) });
}
items.push({ divider: true });
for (const n of names) {
items.push({
label: `✕ ${n}`,
danger: true,
onSelect: () => {
onDelete(n);
refresh();
},
});
}
}
return (
);
}
/**
* Kamera-Einstellungen als kleines Popover (Vectorworks-Stil): ein FOV-Regler
* für den Sichtwinkel der Perspektivkamera. Öffnet/schliesst per Button; ein
* Klick ausserhalb schliesst es. Rein gesteuert über `fov`/`onFov`.
*/
function CameraMenu({
fov,
onFov,
disabled,
}: {
fov: number;
onFov: (n: number) => void;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
// Popover-Position (fixed), aus dem Button-Rect berechnet — die Oberleiste
// hat overflow:hidden und würde ein absolut positioniertes Popover abschneiden.
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const wrapRef = useRef(null);
const btnRef = useRef(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const toggle = () => {
const next = !open;
if (next && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setPos({ left: r.left, top: r.bottom + 6 });
}
setOpen(next);
};
return (
{open && !disabled && (
)}
);
}
/**
* Parst eine Massstab-Eingabe „1:35" ODER „35" zum Nenner N (gerundet, > 0).
* Liefert null bei ungültiger/leerer Eingabe.
*/
function parseScaleInput(input: string | null): number | null {
if (!input) return null;
// Teil nach einem ':' nehmen, falls vorhanden („1:35" → „35").
const raw = input.includes(":") ? input.split(":").pop() ?? "" : input;
const n = Math.round(Number(raw.trim()));
return Number.isFinite(n) && n > 0 ? n : null;
}
/**
* Kleines Vektor-Icon je 3D-Ansicht (16×16, currentColor). Front/Oben/Seite als
* Quadrat mit Mittellinie (keine/horizontal/vertikal), Perspektive/Isometrie als
* Würfel, Kamera als Kamera-Glyph. Tooltip nennt die Bedeutung im Klartext.
*/
function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
const s = {
width: 16,
height: 16,
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinejoin: "round" as const,
strokeLinecap: "round" as const,
"aria-hidden": true,
};
switch (name) {
case "grundriss":
// Grundriss (2D-Plan): Rechteck mit zwei inneren Wandstummeln + Türlücke.
return (
);
case "front":
return (
);
case "top":
return (
);
case "side":
return (
);
case "perspective":
return (
);
case "iso":
return (
);
case "camera":
return (
);
}
}
/** Eine Zelle einer Segmentpille. */
interface SegmentCell {
/** Stabiler Schlüssel. */
key: string;
/** Material-Symbols-Icon-Name. */
icon: string;
/** Tooltip/aria-label (Klartext). */
title: string;
/** Aktiver Zustand (Akzentfüllung). */
active?: boolean;
onClick: () => void;
disabled?: boolean;
}
/**
* Segmentpille (DOSSIER-Look): ein Aussencontainer (radius 999, overflow hidden)
* mit gleich hohen Zellen; interne Trenner über `border-left`. Aktive Zelle in
* Akzentfarbe. Genutzt für Zoom-Aktionen, B/I/U und L/C/R. `accent` setzt einen
* Akzent-Rand am Container (Auswahl-Bewusstsein der Text-Gruppe).
*/
function Segment({
cells,
ariaLabel,
accent,
style,
}: {
cells: SegmentCell[];
ariaLabel: string;
accent?: boolean;
style?: React.CSSProperties;
}) {
return (
{cells.map((c) => (
))}
);
}
/**
* Schriftauswahl der Text-Gruppe: 15 kuratierte, lokal gebuendelte
* Open-Source-Schriften (SIL Open Font License, siehe `src/text/fonts.css`
* und `public/fonts//OFL.txt`). Bewusst KEINE Systemfonts mehr — die
* Darstellung soll unabhaengig vom Betriebssystem und der PDF-Export
* deterministisch sein. Ein spaeterer Einstellungen-Dialog kann weitere
* Schriften nachladen; das hier ist die Standardliste.
*/
const TEXT_FONTS: string[] = [
"Inter",
"Work Sans",
"IBM Plex Sans",
"IBM Plex Mono",
"Source Sans 3",
"Space Grotesk",
"Manrope",
"Outfit",
"DM Sans",
"Public Sans",
"Karla",
"Rubik",
"Jost",
"Archivo",
"Source Serif 4",
];
/** Grössen-Presets (pt) der Text-Gruppe. */
const TEXT_SIZES: number[] = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48];
/**
* Text-Gruppe der Oberleiste (immer sichtbar, 3×2-Raster). Setzt Defaults für
* neuen Text (lokaler State) UND formatiert das aktuelle `textTarget` live. Ist
* `textTarget` gesetzt, tragen die Zeile-2-Pillen einen Akzent-Rand und alle
* Aktionen wirken auf `textTarget.doc` via `textTarget.apply`.
*/
function TextGroup({
textTarget,
}: {
textTarget: TextTarget | null;
}) {
// Defaults für neuen Text, solange nichts Text-artiges selektiert ist. Bei
// aktivem Ziel spiegeln die Controls die gemeinsamen Werte des Ziels wider.
const [defFont, setDefFont] = useState("Inter");
const [defSize, setDefSize] = useState(12);
const [defAlign, setDefAlign] = useState("left");
const [defLineHeight, setDefLineHeight] = useState(DEFAULT_LINE_HEIGHT);
const [defBold, setDefBold] = useState(false);
const [defItalic, setDefItalic] = useState(false);
const [defUnderline, setDefUnderline] = useState(false);
// Effektiver Bereich am Ziel: expliziter Bereich oder — ohne Bereich — das
// ganze Dokument (mind. 1 Zeichen, damit isMarkActive/applyMark greifen).
const targetRange = (doc: RichTextDoc, range: TextRange | null): TextRange =>
range && range.start !== range.end
? range
: { start: 0, end: Math.max(1, docLength(doc)) };
const active = textTarget !== null;
// Aktueller Anzeige-Zustand: vom Ziel abgeleitet, sonst aus den Defaults.
const rng = textTarget ? targetRange(textTarget.doc, textTarget.range) : null;
const isBold = textTarget && rng ? isMarkActive(textTarget.doc, rng, "bold") : defBold;
const isItalic = textTarget && rng ? isMarkActive(textTarget.doc, rng, "italic") : defItalic;
const isUnderline =
textTarget && rng ? isMarkActive(textTarget.doc, rng, "underline") : defUnderline;
const curFont =
textTarget && rng ? commonMarkValue(textTarget.doc, rng, "font") ?? "" : defFont;
const curSize =
textTarget && rng ? commonMarkValue(textTarget.doc, rng, "sizePt") ?? 0 : defSize;
// Ausrichtung: gemeinsamer Wert der berührten Absätze, sonst Default.
const curAlign: Align = textTarget
? commonAlign(textTarget.doc, rng) ?? "left"
: defAlign;
// Zeilenhöhe: gemeinsamer Wert der berührten Absätze, sonst Default.
const curLineHeight: number = textTarget
? commonLineHeight(textTarget.doc, rng) ?? DEFAULT_LINE_HEIGHT
: defLineHeight;
// ── Aktions-Helfer (wirken auf das Ziel oder setzen Defaults) ──────────────
const toggle = (mark: BoolMark, setDefault: (v: boolean) => void, cur: boolean) => {
if (textTarget) {
const r = targetRange(textTarget.doc, textTarget.range);
textTarget.apply(toggleMark(textTarget.doc, r, mark));
} else {
setDefault(!cur);
}
};
const setFontValue = (font: string) => {
if (textTarget) {
const r = targetRange(textTarget.doc, textTarget.range);
textTarget.apply(applyMark(textTarget.doc, r, "font", font));
} else {
setDefFont(font);
}
};
const setSizeValue = (size: number) => {
if (!Number.isFinite(size) || size <= 0) return;
if (textTarget) {
const r = targetRange(textTarget.doc, textTarget.range);
textTarget.apply(applyMark(textTarget.doc, r, "sizePt", size));
} else {
setDefSize(size);
}
};
const setAlignValue = (align: Align) => {
if (textTarget) {
textTarget.apply(applyAlign(textTarget.doc, textTarget.range, align));
} else {
setDefAlign(align);
}
};
const setLineHeightValue = (lh: number) => {
const clamped = Math.round(Math.min(3, Math.max(0.8, lh)) * 10) / 10;
if (textTarget) {
textTarget.apply(applyLineHeight(textTarget.doc, textTarget.range, clamped));
} else {
setDefLineHeight(clamped);
}
};
const applyPresetValue = (id: string) => {
const preset = DEFAULT_PRESETS.find((p) => p.id === id);
if (!preset) return;
if (textTarget) {
const r = textTarget.range ?? { start: 0, end: 0 };
textTarget.apply(applyPreset(textTarget.doc, r, preset));
} else {
// Ohne Ziel: Preset-Defaults übernehmen (Font bleibt, Marks spiegeln).
if (preset.marks.sizePt != null) setDefSize(preset.marks.sizePt);
setDefBold(!!preset.marks.bold);
setDefItalic(!!preset.marks.italic);
}
};
// ── Options-Listen ─────────────────────────────────────────────────────────
const styleOptions: DropdownOption[] = [
{ value: "__none__", label: t("text.style.none") },
...DEFAULT_PRESETS.map((p) => ({ value: p.id, label: p.label })),
];
const fontOptions: DropdownOption[] = TEXT_FONTS.map((f) => ({
value: f,
label: f,
}));
const sizeInPresets = TEXT_SIZES.includes(curSize);
const sizeOptions: DropdownOption[] = [
...(sizeInPresets || curSize <= 0
? []
: [{ value: "__current__", label: `${curSize} pt` }]),
...TEXT_SIZES.map((n) => ({ value: String(n), label: `${n} pt` })),
{ value: "__custom__", label: t("text.size.custom") },
];
const onSizeChange = (value: string) => {
if (value === "__custom__") {
const input = window.prompt(
t("text.size.customPrompt"),
String(curSize > 0 ? curSize : 12),
);
const n = input == null ? NaN : Math.round(Number(input.trim()));
if (Number.isFinite(n) && n > 0) setSizeValue(n);
return;
}
if (value === "__current__") return;
setSizeValue(Number(value));
};
return (
);
}
/**
* Gemeinsame Absatz-Ausrichtung der vom Bereich berührten Absätze; `undefined`
* bei uneinheitlicher/leerer Auswahl. Ohne Bereich → Ausrichtung des ersten
* Absatzes (das ganze Doc als Ziel).
*/
function commonAlign(doc: RichTextDoc, range: TextRange | null): Align | undefined {
const paras = touchedParagraphs(doc, range);
if (paras.length === 0) return doc.paragraphs[0]?.align ?? "left";
let value: Align | undefined;
let first = true;
for (const idx of paras) {
const a = doc.paragraphs[idx]?.align ?? "left";
if (first) {
value = a;
first = false;
} else if (a !== value) {
return undefined;
}
}
return value;
}
/**
* Setzt die Absatz-Ausrichtung auf alle vom Bereich berührten Absätze. Ohne
* Bereich (null) auf ALLE Absätze des Dokuments.
*/
function applyAlign(doc: RichTextDoc, range: TextRange | null, align: Align): RichTextDoc {
const touched = new Set(touchedParagraphs(doc, range));
const all = range == null;
return {
version: 1,
paragraphs: doc.paragraphs.map((p, i) =>
all || touched.has(i) ? { ...p, align } : p,
),
};
}
/**
* Gemeinsame Zeilenhöhe der vom Bereich berührten Absätze (Vielfaches der
* Basisgrösse); `undefined` bei uneinheitlicher Auswahl.
*/
function commonLineHeight(doc: RichTextDoc, range: TextRange | null): number | undefined {
const paras = touchedParagraphs(doc, range);
if (paras.length === 0) return doc.paragraphs[0]?.lineHeight ?? DEFAULT_LINE_HEIGHT;
let value: number | undefined;
let first = true;
for (const idx of paras) {
const lh = doc.paragraphs[idx]?.lineHeight ?? DEFAULT_LINE_HEIGHT;
if (first) {
value = lh;
first = false;
} else if (lh !== value) {
return undefined;
}
}
return value;
}
/**
* Setzt die Zeilenhöhe auf alle vom Bereich berührten Absätze. Ohne Bereich
* (null) auf ALLE Absätze des Dokuments.
*/
function applyLineHeight(doc: RichTextDoc, range: TextRange | null, lineHeight: number): RichTextDoc {
const touched = new Set(touchedParagraphs(doc, range));
const all = range == null;
return {
version: 1,
paragraphs: doc.paragraphs.map((p, i) =>
all || touched.has(i) ? { ...p, lineHeight } : p,
),
};
}
/** Indizes der Absätze, die der Bereich [start,end) berührt. */
function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[] {
if (range == null || range.start === range.end) {
return doc.paragraphs.map((_, i) => i);
}
const start = Math.min(range.start, range.end);
const end = Math.max(range.start, range.end);
const out: number[] = [];
let cursor = 0;
doc.paragraphs.forEach((p, i) => {
const len = p.runs.reduce((n, r) => n + r.text.length, 0);
const pStart = cursor;
const pEnd = cursor + len;
if (!(pEnd < start || pStart > end)) out.push(i);
cursor += len + 1;
});
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,
viewType,
onViewType,
view3d,
onView3d,
fov,
onFov,
detail,
onDetail,
detailEnabled,
scaleDenominator,
onApplyScale,
liveScale,
zoomPercent,
zoomEnabled,
onFit,
onFitSelection,
onZoom100,
onExportPdf,
onExportDxf,
renderMode,
onRenderMode,
renderModeEnabled,
planColorMode,
onPlanColorMode,
combos,
resourcesOpen,
onToggleResources,
onOpenSettings,
layoutMenu,
onImport,
onSaveProject,
onOpenProject,
textTarget,
}: TopBarProps) {
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
// NUR während des Scrollens ein (Klasse `is-scrolling`, ~900 ms nachlaufend).
const headerRef = useRef(null);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
let timer: ReturnType | undefined;
const onScroll = () => {
el.classList.add("is-scrolling");
if (timer) clearTimeout(timer);
timer = setTimeout(() => el.classList.remove("is-scrolling"), 900);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
if (timer) clearTimeout(timer);
};
}, []);
// „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35".
const onScaleChange = (value: string) => {
if (value === "__custom__") {
const input = window.prompt(
t("topbar.scale.prompt"),
`1:${scaleDenominator}`,
);
const n = parseScaleInput(input);
if (n != null) onApplyScale(n);
return;
}
const n = Number(value);
if (Number.isFinite(n) && n > 0) onApplyScale(n);
};
// Aktueller Nenner steht evtl. nicht in den Presets (frei gesetzt) → dann
// einen Eintrag „1:N" voranstellen, damit das Select den Wert anzeigt.
const inPresets = SCALE_PRESETS.includes(scaleDenominator);
// Optionsliste des Massstab-Dropdowns: ggf. der frei gesetzte aktuelle Wert
// (damit er sichtbar bleibt), dann die Presets, zuletzt „frei …" als
// Sonder-Wert, der über onScaleChange den Prompt öffnet (Funktion unverändert).
const scaleOptions: DropdownOption[] = [
...(inPresets
? []
: [{ value: "__current__", label: `1:${scaleDenominator}` }]),
...SCALE_PRESETS.map((n) => ({ value: String(n), label: `1:${n}` })),
{ value: "__custom__", label: t("topbar.scale.custom") },
];
return (
{/* 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")}
{/* Ansichten als EIN kompaktes Icon-Grid (Vectorworks/DOSSIER-Stil): der
2D-Grundriss UND die 3D-Blicke sind eine durchgängige Icon-Serie —
„Grundriss" schaltet auf den 2D-Plan, die übrigen auf die jeweilige
3D-Ansicht (Perspektive wird dabei automatisch aktiviert). 4 oben /
3 unten. Tooltip nennt jede Ansicht im Klartext. */}
{/* Ansichts-Icons bleiben immer sichtbar; auf Nicht-Geschoss-Ebenen
(Schnitt/Ansicht/2D-Zeichnung) sind sie deaktiviert, weil dort keine
2D/3D-Umschaltung greift — die Serie verschwindet nicht mehr ganz. */}
{/* Sichtbarkeits-Kombinationen (Vectorworks: Ebenen-/Zeichnungs-
kombinationen). Sitzt direkt nach dem Ansichts-Umschalter, weil beides
„Navigations-/Sicht"-Steuerung ist — ruhige Stelle vor dem Detailgrad.
Nur die Dropdowns; eine grafische Überschreibung kommt später. */}
{/* Text-Gruppe (immer sichtbar) — Stil/Schrift/Grösse + B/I/U · L/C/R +
Zeilenhöhe. Formatiert das aktuelle Text-Ziel (Raumstempel) live.
Sitzt bewusst zwischen den Ebenen-Kombis und Detailgrad/Massstab.
„+ Text" ist raus — Text wird ein eigenständiges Zeichenwerkzeug
(HANDOVER-Backlog AUDIT B1), kein TopBar-Knopf mehr. */}
{/* Zeichenwerkzeuge liegen jetzt in der Werkzeug-Palette (ToolsPanel,
dockbar) — nicht mehr in der Oberleiste (Vectorworks-Bild: Werkzeuge
in der Palette). Auch Wandtyp-Wahl + Fang-Menü sind dort. */}
{/* 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 lebt nur noch EINMAL im Zoom-Cluster (die
Dropdown hier war ein redundantes Relikt der TopBar-Reorg). */}
{/* Massstab/Zoom-Cluster (DOSSIER, 2×2). Links über beide Zeilen die
kombinierte Stat-Pille (Live-Massstab 1:N oben / Zoom% unten); rechts
oben das Massstab-Dropdown, rechts unten die Zoom-Segmentpille. „am
Massstab" (Live-Massstab == gewählter Massstab) hebt die Stat-Pille im
Akzent hervor. */}
{/* Darstellungsart (Vectorworks-Stil) — EIN Dropdown, dessen Optionen vom
Ansichtstyp abhängen: Grundriss → Farbig/Schwarz-Weiss (planColorMode);
Perspektive → Schattiert/Weiss/Drahtgitter/Kanten (renderMode). Der
Linien-Modus (Display/Print) bleibt separat. */}
{/* Rechte Gruppe: Layout-Menü · Projektname · Datei-Burger (ganz rechts,
GTK-Stil) — Ressourcen sitzt jetzt bei der Marke (s. o.). */}
{layoutMenu}
{project.name}
);
}
// Hinweis: Die früheren Helfer SNAP_TOGGLES + SnapMenu (Fang-Optionen) und die
// Zeichenwerkzeug-Buttons wurden aus der Oberleiste entfernt; sie leben jetzt
// in der Werkzeug-Palette (src/panels/ToolsPanel.tsx).