Files
DOSSIER-STANDALONE/src/ui/TopBar.tsx
T
karim 5fe7221f6c Fang-Optionen und Display/Print-Umschalter in die Footerbar
Der Linien-Modus (Display/Print) wandert aus der Oberleiste und die
Fang-Optionen aus der Werkzeug-Palette in die Statusleiste unten:

- StatusBar: segmentierter Display/Print-Umschalter (Stil wie der
  Renderer-Umschalter) plus Fang-Pill mit nach oben öffnendem Popover
  (Master-Schalter + Endpunkt/Mittelpunkt/Schnittpunkt/Kante/Raster/
  Ortho + Rastermass).
- TopBar: Linien-Modus-Gruppe entfernt.
- ToolsPanel: Fang-Abschnitt entfernt.
- App reicht lineMode/snap an die StatusBar durch.
2026-07-03 20:08:03 +02:00

1217 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 {
applyMark,
applyPreset,
commonMarkValue,
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];
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;
// Referenzlinien (Wandachsen).
referenceLines: boolean;
onReferenceLines: (on: boolean) => void;
/** Referenzlinien nur im Grundriss sinnvoll (sonst deaktiviert). */
referenceLinesEnabled: boolean;
// Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen).
/** Handler-Satz für die beiden Kombinations-Dropdowns. */
combos: ComboMenuHandlers;
// Ressourcen-Fenster.
resourcesOpen: boolean;
onToggleResources: () => void;
/** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */
layoutMenu: ReactNode;
// ── 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;
/**
* Startet das Text-Werkzeug (neues Textobjekt platzieren). `null`, solange das
* Werkzeug noch nicht existiert → der „+ Text"-Knopf ist dann deaktiviert.
*/
onAddText: (() => void) | 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 <span className="tb-sep" aria-hidden="true" />;
}
/**
* 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 (
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
{name}
</span>
);
}
/**
* 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<string[]>([]);
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 (
<span onPointerDownCapture={refresh}>
<Dropdown
items={items}
placeholder={t("combo.placeholder")}
title={title}
/>
</span>
);
}
/**
* 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<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(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 (
<div className="tb-popwrap" ref={wrapRef}>
<button
ref={btnRef}
className={`view-icon${open ? " active" : ""}`}
disabled={disabled}
onClick={toggle}
title={`${t("view3d.camera")} — ${t("view3d.camera.hint")}`}
aria-label={t("view3d.camera")}
aria-haspopup="true"
aria-expanded={open}
>
<ViewIcon name="camera" />
</button>
{open && !disabled && (
<div
className="tb-popover"
role="dialog"
aria-label={t("view3d.camera")}
style={{ left: pos.left, top: pos.top }}
>
<label className="tb-poprow">
<span className="tb-label">
{t("view3d.fov")}: {Math.round(fov)}°
</span>
<input
type="range"
min={20}
max={90}
step={1}
value={fov}
onChange={(e) => onFov(Number(e.target.value))}
title={t("view3d.fov.hint")}
/>
</label>
</div>
)}
</div>
);
}
/**
* 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 (
<svg {...s}>
<rect x="2.5" y="2.5" width="11" height="11" />
<path d="M2.5 9.5 L6 9.5 M9 9.5 L13.5 9.5 M9 9.5 L9 13.5" />
</svg>
);
case "front":
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
</svg>
);
case "top":
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="3.5" y1="8" x2="12.5" y2="8" />
</svg>
);
case "side":
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="8" y1="3.5" x2="8" y2="12.5" />
</svg>
);
case "perspective":
return (
<svg {...s}>
<rect x="2.5" y="6" width="7" height="7" />
<path d="M9.5 6 L13.5 3 M9.5 13 L13.5 10 M2.5 6 L6.5 3 L13.5 3 L13.5 10" />
</svg>
);
case "iso":
return (
<svg {...s}>
<path d="M8 2 L14 5.5 L14 10.5 L8 14 L2 10.5 L2 5.5 Z M8 8 L8 14 M8 8 L14 5.5 M8 8 L2 5.5" />
</svg>
);
case "camera":
return (
<svg {...s}>
<rect x="2.5" y="5.5" width="11" height="7.5" rx="1" />
<path d="M6 5.5 L7 3.5 L9 3.5 L10 5.5" />
<circle cx="8" cy="9.2" r="2.1" />
</svg>
);
}
}
/** 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 (
<div
className={`tb-seg${accent ? " tb-seg-accent" : ""}`}
role="group"
aria-label={ariaLabel}
style={style}
>
{cells.map((c) => (
<button
key={c.key}
type="button"
className={`tb-seg-cell${c.active ? " active" : ""}`}
onClick={c.onClick}
disabled={c.disabled}
aria-pressed={c.active}
title={c.title}
aria-label={c.title}
>
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
{c.icon}
</span>
</button>
))}
</div>
);
}
/** Systemfont-Auswahl der Text-Gruppe. */
const TEXT_FONTS: string[] = [
"Helvetica",
"Arial",
"Inter",
"Times New Roman",
"Georgia",
"Courier New",
];
/** 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,
onAddText,
}: {
textTarget: TextTarget | null;
onAddText: (() => void) | 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<string>("Helvetica");
const [defSize, setDefSize] = useState<number>(12);
const [defAlign, setDefAlign] = useState<Align>("left");
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;
// ── 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 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 (
<div className="tb-group tb-textgroup" role="group" aria-label={t("text.group")}>
{/* Zeile 1: Stil-Preset · Schrift · Grösse. */}
<div className="tb-textgroup-row">
<Dropdown
value="__none__"
onChange={applyPresetValue}
title={t("text.style")}
width={110}
options={styleOptions}
/>
<Dropdown
value={curFont || "__none__"}
onChange={setFontValue}
title={t("text.font")}
width={130}
options={
curFont && !TEXT_FONTS.includes(curFont)
? [{ value: curFont, label: curFont }, ...fontOptions]
: fontOptions
}
/>
<Dropdown
value={
curSize <= 0
? "__custom__"
: sizeInPresets
? String(curSize)
: "__current__"
}
onChange={onSizeChange}
title={t("text.size")}
width={80}
triggerClassName="tb-dd-mono"
options={sizeOptions}
/>
</div>
{/* Zeile 2: B/I/U · L/C/R · „+ Text". */}
<div className="tb-textgroup-row">
<Segment
ariaLabel={t("text.group")}
accent={active}
style={{ width: 110 }}
cells={[
{
key: "bold",
icon: "format_bold",
title: t("text.bold"),
active: !!isBold,
onClick: () => toggle("bold", setDefBold, defBold),
},
{
key: "italic",
icon: "format_italic",
title: t("text.italic"),
active: !!isItalic,
onClick: () => toggle("italic", setDefItalic, defItalic),
},
{
key: "underline",
icon: "format_underlined",
title: t("text.underline"),
active: !!isUnderline,
onClick: () => toggle("underline", setDefUnderline, defUnderline),
},
]}
/>
<Segment
ariaLabel={t("text.group")}
accent={active}
style={{ width: 130 }}
cells={[
{
key: "left",
icon: "format_align_left",
title: t("text.align.left"),
active: curAlign === "left",
onClick: () => setAlignValue("left"),
},
{
key: "center",
icon: "format_align_center",
title: t("text.align.center"),
active: curAlign === "center",
onClick: () => setAlignValue("center"),
},
{
key: "right",
icon: "format_align_right",
title: t("text.align.right"),
active: curAlign === "right",
onClick: () => setAlignValue("right"),
},
]}
/>
<button
type="button"
className="tb-addtext"
disabled={onAddText == null}
onClick={() => onAddText?.()}
title={onAddText ? t("text.add") : t("text.add.hint")}
aria-label={t("text.add")}
>
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
add
</span>
<span className="tb-addtext-label">{t("text.add")}</span>
</button>
</div>
</div>
);
}
/**
* 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,
),
};
}
/** 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;
}
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,
referenceLines,
onReferenceLines,
referenceLinesEnabled,
combos,
resourcesOpen,
onToggleResources,
layoutMenu,
textTarget,
onAddText,
}: 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<HTMLElement | null>(null);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
let timer: ReturnType<typeof setTimeout> | 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 (
<header className="topbar" ref={headerRef}>
{/* Marke/Logo. */}
<div className="brand">
<span className="logo">▦</span> cad{" "}
<span className="tag">{t("brand.tag")}</span>
</div>
<Sep />
{/* 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. */}
{viewToggleEnabled && (
<>
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
<div className="view-grid view-grid-4">
<button
className={`view-icon${viewType === "grundriss" ? " active" : ""}`}
onClick={() => onViewType("grundriss")}
title={t("topbar.grundriss")}
aria-label={t("topbar.grundriss")}
>
<ViewIcon name="grundriss" />
</button>
{(
[
["top", t("view3d.top")],
["iso", t("view3d.iso")],
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
] as [View3d, string][]
).map(([key, label]) => (
<button
key={key}
className={`view-icon${
viewType === "perspektive" && view3d === key ? " active" : ""
}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
title={`${label} — ${t(`view3d.${key}.hint`)}`}
aria-label={label}
>
<ViewIcon name={key} />
</button>
))}
<CameraMenu
fov={fov}
onFov={onFov}
disabled={viewType !== "perspektive"}
/>
</div>
</div>
<Sep />
</>
)}
{/* 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. */}
<div className="tb-group tb-stack" role="group" aria-label={t("combo.layers.title")}>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("combo.layers")}</span>
<ComboMenu
title={t("combo.layers.title")}
savePrompt={t("combo.layers.savePrompt")}
list={combos.listLayerCombos}
onSave={combos.saveLayerCombo}
onLoad={combos.loadLayerCombo}
onDelete={combos.deleteLayerCombo}
/>
</label>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("combo.drawings")}</span>
<ComboMenu
title={t("combo.drawings.title")}
savePrompt={t("combo.drawings.savePrompt")}
list={combos.listDrawingCombos}
onSave={combos.saveDrawingCombo}
onLoad={combos.loadDrawingCombo}
onDelete={combos.deleteDrawingCombo}
/>
</label>
</div>
<Sep />
{/* 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 gestapelt — eine Spalte, platzsparend (wie die
Ebenen-/Zeichnungs-Kombis). */}
<div className="tb-group tb-stack" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.detail")}</span>
<Dropdown
value={detail}
disabled={!detailEnabled}
onChange={(v) => onDetail(v as DetailLevel)}
title={
detailEnabled
? t("topbar.detail.hint")
: t("topbar.detail.hintDisabled")
}
options={[
{ value: "grob", label: t("topbar.detail.grob") },
{ value: "mittel", label: t("topbar.detail.mittel") },
{ value: "fein", label: t("topbar.detail.fein") },
]}
/>
</label>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.scale")}</span>
<Dropdown
value={inPresets ? String(scaleDenominator) : "__current__"}
disabled={!zoomEnabled}
onChange={onScaleChange}
title={t("topbar.scale.apply")}
triggerClassName="tb-dd-mono"
options={scaleOptions}
/>
</label>
</div>
<Sep />
{/* 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. */}
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
<div
className={`tb-stat${
zoomEnabled && liveScale != null && Math.round(liveScale) === scaleDenominator
? " tb-stat-atscale"
: ""
}`}
title={`${t("topbar.scale.live")} · ${t("topbar.zoom.current")}`}
>
<span className="tb-stat-scale">
{zoomEnabled && liveScale != null ? `1:${Math.round(liveScale)}` : "—"}
</span>
<span className="tb-stat-zoom">
{zoomEnabled ? `${Math.round(zoomPercent)}%` : "—"}
</span>
</div>
<div className="tb-zoomcluster-right">
<Dropdown
value={inPresets ? String(scaleDenominator) : "__current__"}
disabled={!zoomEnabled}
onChange={onScaleChange}
title={t("topbar.scale.apply")}
triggerClassName="tb-dd-mono"
width={140}
options={scaleOptions}
/>
<Segment
ariaLabel={t("topbar.scaleGroup")}
cells={[
{
key: "zoom100",
icon: "straighten",
title: t("topbar.zoom100.hint", { n: scaleDenominator }),
onClick: onZoom100,
disabled: !zoomEnabled,
},
{
key: "fit",
icon: "fit_screen",
title: t("topbar.fit.hint"),
onClick: onFit,
disabled: !zoomEnabled,
},
{
key: "fitSelection",
icon: "center_focus_strong",
title: t("topbar.fitSelection.hint"),
onClick: onFitSelection,
disabled: !zoomEnabled,
},
]}
/>
</div>
</div>
{/* Export als eigene kleine Pillen-Reihe (nicht mehr Teil des Zoom-Blocks,
damit der Cluster ruhig bleibt). */}
<div className="tb-group tb-btns" role="group" aria-label={t("topbar.exportPdf")}>
<button
type="button"
className="tb-iconbtn"
onClick={onExportPdf}
disabled={!zoomEnabled}
title={t("topbar.exportPdf.hint")}
aria-label={t("topbar.exportPdf")}
>
<TbIcon name="picture_as_pdf" />
</button>
<button
type="button"
className="tb-iconbtn"
onClick={onExportDxf}
disabled={!zoomEnabled}
title={t("topbar.exportDxf.hint")}
aria-label={t("topbar.exportDxf")}
>
<TbIcon name="download" />
</button>
</div>
<Sep />
{/* Text-Gruppe (immer sichtbar) — Stil/Schrift/Grösse + B/I/U · L/C/R +
„+ Text". Formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
<TextGroup textTarget={textTarget} onAddText={onAddText} />
<Sep />
{/* 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. */}
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
<label className="tb-field">
<span className="tb-label">{t("display.style")}</span>
{viewType === "perspektive" ? (
<Dropdown
value={renderMode}
disabled={!renderModeEnabled}
onChange={(v) => onRenderMode(v as RenderMode)}
title={t("display.style.hint")}
options={[
{ value: "shaded", label: t("display.style.shaded") },
{ value: "white", label: t("display.style.white") },
{ value: "textured", label: t("display.style.textured") },
{ value: "wireframe", label: t("display.style.wireframe") },
{ value: "hidden", label: t("display.style.hidden") },
]}
/>
) : (
<Dropdown
value={planColorMode}
onChange={(v) => onPlanColorMode(v as PlanColorMode)}
title={t("display.style.hint")}
options={[
{ value: "color", label: t("display.style.color") },
{ value: "mono", label: t("display.style.mono") },
]}
/>
)}
</label>
</div>
<Sep />
{/* Referenzlinien (Wandachsen) — zeigt/versteckt die gestrichelten
Achslinien im Grundriss. Nur im Grundriss sinnvoll → sonst deaktiviert. */}
<div className="tb-group" role="group" aria-label={t("topbar.referenceLinesGroup")}>
<button
className={`view-icon${referenceLines ? " active" : ""}`}
onClick={() => onReferenceLines(!referenceLines)}
disabled={!referenceLinesEnabled}
aria-pressed={referenceLines}
aria-label={t("topbar.referenceLines")}
title={
referenceLinesEnabled
? `${t("topbar.referenceLines")} — ${t("topbar.referenceLines.hint")}`
: t("topbar.referenceLines.hintDisabled")
}
>
<TbIcon name="straighten" />
</button>
</div>
<Sep />
{/* Rechte Gruppe: Layout-Menü · Ressourcen · Projektname. */}
<div className="topbar-right">
{layoutMenu}
<button
className={`view-icon${resourcesOpen ? " active" : ""}`}
onClick={onToggleResources}
aria-pressed={resourcesOpen}
aria-label={t("topbar.resources")}
title={`${t("topbar.resources")} — ${t("topbar.resources.hint")}`}
>
<TbIcon name="widgets" />
</button>
<span className="project-name">{project.name}</span>
</div>
</header>
);
}
// 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).