1345 lines
43 KiB
TypeScript
1345 lines
43 KiB
TypeScript
// 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 { RibbonTabs } from "./ribbon/RibbonBar";
|
||
import type { RibbonTabId } from "./ribbon/ribbonItems";
|
||
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;
|
||
|
||
// Ribbon-Tab-Reiter (2D/3D/BIM/Ansichten) sitzen in DIESER Zeile — der Ribbon-
|
||
// Inhalt darunter (RibbonBar). Der aktive Tab liegt in App (geteilter State).
|
||
ribbonTab: RibbonTabId;
|
||
onRibbonTab: (id: RibbonTabId) => void;
|
||
|
||
// Ansichts-/Zoom-/Darstellungs-Steuerung ist in den „Ansichten"-Ribbon-Tab
|
||
// gewandert (siehe ViewRibbonTab). Die TopBar ist nur noch die schmale globale
|
||
// Leiste: Marke/Ressourcen · Tabs · Text-Formatierung · Datei/Export/Einstell.
|
||
|
||
/** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */
|
||
zoomEnabled: boolean;
|
||
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||
onExportPdf: () => void;
|
||
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||
onExportDxf: () => void;
|
||
/** Lädt die Bauteilliste des Projekts als CSV herunter. */
|
||
onExportSchedule: () => void;
|
||
|
||
// 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;
|
||
|
||
// Die Text-/Font-Formatierung (TextGroup) liegt jetzt im „Text"-Ribbon-Tab
|
||
// (App reicht sie via RibbonBar.tabContent), nicht mehr in dieser Leiste.
|
||
}
|
||
|
||
/**
|
||
* 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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Schriftauswahl der Text-Gruppe: 15 kuratierte, lokal gebuendelte
|
||
* Open-Source-Schriften (SIL Open Font License, siehe `src/text/fonts.css`
|
||
* und `public/fonts/<slug>/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`.
|
||
*/
|
||
export 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<string>("Inter");
|
||
const [defSize, setDefSize] = useState<number>(12);
|
||
const [defAlign, setDefAlign] = useState<Align>("left");
|
||
const [defLineHeight, setDefLineHeight] = useState<number>(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 (
|
||
<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"),
|
||
},
|
||
]}
|
||
/>
|
||
<div
|
||
className="tb-lineheight"
|
||
role="group"
|
||
aria-label={t("text.lineHeight")}
|
||
title={t("text.lineHeight.hint")}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="tb-lineheight-btn"
|
||
onClick={() => setLineHeightValue(curLineHeight - 0.1)}
|
||
title={t("text.lineHeight.decrease")}
|
||
aria-label={t("text.lineHeight.decrease")}
|
||
>
|
||
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
|
||
remove
|
||
</span>
|
||
</button>
|
||
<span className="tb-lineheight-value">{curLineHeight.toFixed(1)}×</span>
|
||
<button
|
||
type="button"
|
||
className="tb-lineheight-btn"
|
||
onClick={() => setLineHeightValue(curLineHeight + 0.1)}
|
||
title={t("text.lineHeight.increase")}
|
||
aria-label={t("text.lineHeight.increase")}
|
||
>
|
||
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
|
||
add
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</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,
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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).
|
||
*/
|
||
/** Ein kleiner Icon-Knopf der Quick-Access-Leiste (OCS-Stil). */
|
||
function QaButton({
|
||
icon,
|
||
label,
|
||
onClick,
|
||
disabled,
|
||
active,
|
||
}: {
|
||
icon: string;
|
||
label: string;
|
||
onClick: () => void;
|
||
disabled?: boolean;
|
||
active?: boolean;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`tb-qa-btn${active ? " active" : ""}`}
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
aria-pressed={active}
|
||
aria-label={label}
|
||
title={label}
|
||
>
|
||
<TbIcon name={icon} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** Kleiner Trenner in der Quick-Access-Leiste. */
|
||
function QaSep() {
|
||
return <span className="tb-qa-sep" aria-hidden="true" />;
|
||
}
|
||
|
||
/**
|
||
* Quick-Access-Leiste (OCS-Stil) — alle früheren Datei-Burger-Aktionen plus
|
||
* Ressourcen/Einstellungen als kleine Icon-Knöpfe direkt in der TopBar-Zeile.
|
||
* Ersetzt das Burger-Menü (`AppMenu`). Rein gesteuert über Callbacks (i18n via t).
|
||
*/
|
||
function QuickAccess({
|
||
onOpenProject,
|
||
onSaveProject,
|
||
onImport,
|
||
onExportPdf,
|
||
onExportDxf,
|
||
onExportSchedule,
|
||
onToggleResources,
|
||
onOpenSettings,
|
||
resourcesOpen,
|
||
exportEnabled,
|
||
}: {
|
||
onOpenProject: () => void;
|
||
onSaveProject: () => void;
|
||
onImport: () => void;
|
||
onExportPdf: () => void;
|
||
onExportDxf: () => void;
|
||
onExportSchedule: () => void;
|
||
onToggleResources: () => void;
|
||
onOpenSettings: () => void;
|
||
resourcesOpen: boolean;
|
||
exportEnabled: boolean;
|
||
}) {
|
||
return (
|
||
<div className="tb-qa" role="toolbar" aria-label={t("file.menu")}>
|
||
<QaButton icon="folder_open" label={t("file.open")} onClick={onOpenProject} />
|
||
<QaButton icon="save" label={t("file.save")} onClick={onSaveProject} />
|
||
<QaSep />
|
||
<QaButton icon="download" label={t("file.import")} onClick={onImport} />
|
||
<QaButton
|
||
icon="picture_as_pdf"
|
||
label={t("file.exportPdf")}
|
||
onClick={onExportPdf}
|
||
disabled={!exportEnabled}
|
||
/>
|
||
<QaButton
|
||
icon="output"
|
||
label={t("file.exportDxf")}
|
||
onClick={onExportDxf}
|
||
disabled={!exportEnabled}
|
||
/>
|
||
<QaButton
|
||
icon="table_view"
|
||
label={t("file.exportSchedule")}
|
||
onClick={onExportSchedule}
|
||
/>
|
||
<QaSep />
|
||
<QaButton
|
||
icon="widgets"
|
||
label={t("topbar.resources")}
|
||
onClick={onToggleResources}
|
||
active={resourcesOpen}
|
||
/>
|
||
<QaButton icon="settings" label={t("topbar.settings")} onClick={onOpenSettings} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Props des „Ansichten"-Tabs — die Ansichts-/Zoom-/Darstellungs-Steuerung, die
|
||
* früher in der Mitte der TopBar sass. Eine Teilmenge von {@link TopBarProps}.
|
||
*/
|
||
export interface ViewRibbonTabProps {
|
||
viewToggleEnabled: boolean;
|
||
viewType: ViewType;
|
||
onViewType: (v: ViewType) => void;
|
||
view3d: View3d;
|
||
onView3d: (v: View3d) => void;
|
||
fov: number;
|
||
onFov: (n: number) => void;
|
||
detail: DetailLevel;
|
||
onDetail: (d: DetailLevel) => void;
|
||
detailEnabled: boolean;
|
||
scaleDenominator: number;
|
||
onApplyScale: (n: number) => void;
|
||
liveScale: number | null;
|
||
zoomPercent: number;
|
||
zoomEnabled: boolean;
|
||
onFit: () => void;
|
||
onFitSelection: () => void;
|
||
onZoom100: () => void;
|
||
renderMode: RenderMode;
|
||
onRenderMode: (m: RenderMode) => void;
|
||
renderModeEnabled: boolean;
|
||
planColorMode: PlanColorMode;
|
||
onPlanColorMode: (m: PlanColorMode) => void;
|
||
combos: ComboMenuHandlers;
|
||
/** Text-/Font-Formatierung auf DERSELBEN Leiste (Raumstempel-Ziel; `null` =
|
||
* nur Defaults). Liegt bewusst im „Ansichten"-Tab (Nutzer-Wunsch). */
|
||
textTarget: TextTarget | null;
|
||
}
|
||
|
||
/**
|
||
* „Ansichten"-Tab des Ribbons — trägt die aus der TopBar gemergten Mittelcluster:
|
||
* 2D/3D-Ansichts-Raster (+ Kamera-FOV), Sichtbarkeits-Kombinationen, Detailgrad,
|
||
* Massstab/Zoom-Cluster und Darstellungsart. EIN Ribbon für alles; Handler/Setter
|
||
* kommen unverändert von App (gleiche Props wie zuvor an die TopBar). Wird von
|
||
* `RibbonBar` als Inhalt des „Ansichten"-Tabs gerendert.
|
||
*/
|
||
export function ViewRibbonTab({
|
||
viewToggleEnabled,
|
||
viewType,
|
||
onViewType,
|
||
view3d,
|
||
onView3d,
|
||
fov,
|
||
onFov,
|
||
detail,
|
||
onDetail,
|
||
detailEnabled,
|
||
scaleDenominator,
|
||
onApplyScale,
|
||
liveScale,
|
||
zoomPercent,
|
||
zoomEnabled,
|
||
onFit,
|
||
onFitSelection,
|
||
onZoom100,
|
||
renderMode,
|
||
onRenderMode,
|
||
renderModeEnabled,
|
||
planColorMode,
|
||
onPlanColorMode,
|
||
combos,
|
||
textTarget,
|
||
}: ViewRibbonTabProps) {
|
||
// „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);
|
||
};
|
||
const inPresets = SCALE_PRESETS.includes(scaleDenominator);
|
||
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 (
|
||
<div className="ribbon-views">
|
||
{/* Ansichten als EIN kompaktes Icon-Grid (2D-Grundriss + 3D-Blicke). */}
|
||
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
|
||
<div className="view-grid view-grid-4">
|
||
<button
|
||
className={`view-icon${
|
||
viewToggleEnabled && viewType === "grundriss" ? " active" : ""
|
||
}`}
|
||
onClick={() => onViewType("grundriss")}
|
||
disabled={!viewToggleEnabled}
|
||
title={
|
||
viewToggleEnabled
|
||
? t("topbar.grundriss")
|
||
: t("topbar.viewGroup.disabled")
|
||
}
|
||
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${
|
||
viewToggleEnabled && viewType === "perspektive" && view3d === key
|
||
? " active"
|
||
: ""
|
||
}`}
|
||
onClick={() => {
|
||
onView3d(key);
|
||
if (viewType !== "perspektive") onViewType("perspektive");
|
||
}}
|
||
disabled={!viewToggleEnabled}
|
||
title={
|
||
viewToggleEnabled
|
||
? `${label} — ${t(`view3d.${key}.hint`)}`
|
||
: t("topbar.viewGroup.disabled")
|
||
}
|
||
aria-label={label}
|
||
>
|
||
<ViewIcon name={key} />
|
||
</button>
|
||
))}
|
||
<CameraMenu
|
||
fov={fov}
|
||
onFov={onFov}
|
||
disabled={!viewToggleEnabled || viewType !== "perspektive"}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<Sep />
|
||
|
||
{/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */}
|
||
<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 />
|
||
|
||
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss. */}
|
||
<div className="tb-group" 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>
|
||
</div>
|
||
<Sep />
|
||
|
||
{/* Massstab/Zoom-Cluster (2×2). */}
|
||
<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={ZOOM_CLUSTER_WIDTH}
|
||
options={scaleOptions}
|
||
/>
|
||
<Segment
|
||
ariaLabel={t("topbar.scaleGroup")}
|
||
style={{ width: ZOOM_CLUSTER_WIDTH }}
|
||
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>
|
||
<Sep />
|
||
|
||
{/* Darstellungsart — Grundriss: Farbig/SW; Perspektive: Render-Modus. */}
|
||
<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 />
|
||
|
||
{/* Text-/Font-Formatierung auf DERSELBEN Leiste (Nutzer-Wunsch: Text und
|
||
Ansichten zusammen) — Stil/Schrift/Grösse + B/I/U · L/C/R + Zeilenhöhe,
|
||
formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
|
||
<TextGroup textTarget={textTarget} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function TopBar({
|
||
project,
|
||
ribbonTab,
|
||
onRibbonTab,
|
||
zoomEnabled,
|
||
onExportPdf,
|
||
onExportDxf,
|
||
onExportSchedule,
|
||
resourcesOpen,
|
||
onToggleResources,
|
||
onOpenSettings,
|
||
layoutMenu,
|
||
onImport,
|
||
onSaveProject,
|
||
onOpenProject,
|
||
}: 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);
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<header className="topbar" ref={headerRef}>
|
||
{/* Kleine Wortmarke „dossier" (OCS-Stil, klein/kleingeschrieben) + Quick-
|
||
Access-Leiste: alle Datei-/Export-Aktionen + Ressourcen/Einstellungen
|
||
als kleine Icons direkt in der Zeile (ersetzt das Burger-Menü). */}
|
||
<div className="tb-brandgroup">
|
||
<span className="brand-word">
|
||
dossier<span className="brand-dot">.</span>
|
||
</span>
|
||
</div>
|
||
<QuickAccess
|
||
onOpenProject={onOpenProject}
|
||
onSaveProject={onSaveProject}
|
||
onImport={onImport}
|
||
onExportPdf={onExportPdf}
|
||
onExportDxf={onExportDxf}
|
||
onExportSchedule={onExportSchedule}
|
||
onToggleResources={onToggleResources}
|
||
onOpenSettings={onOpenSettings}
|
||
resourcesOpen={resourcesOpen}
|
||
exportEnabled={zoomEnabled}
|
||
/>
|
||
|
||
<Sep />
|
||
|
||
{/* Ribbon-Tab-Reiter (2D/3D/BIM/Text/Ansichten) — sitzen in dieser Zeile,
|
||
der Ribbon-Inhalt folgt darunter (RibbonBar). So gibt es nur ZWEI
|
||
Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */}
|
||
<RibbonTabs tab={ribbonTab} onTab={onRibbonTab} />
|
||
|
||
{/* Rechte Gruppe: Layout-Menü · Projektname · Fensterknöpfe. Datei-/Export-
|
||
Aktionen liegen jetzt in der Quick-Access-Leiste links (kein Burger). */}
|
||
<div className="topbar-right">
|
||
{layoutMenu}
|
||
<span className="project-name">{project.name}</span>
|
||
<WindowControls />
|
||
</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).
|