2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand

Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent cfe5249440
commit 3d2d4d6321
184 changed files with 29421 additions and 669 deletions
+566 -70
View File
@@ -14,6 +14,21 @@ 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";
@@ -27,9 +42,16 @@ export type DetailLevel = "grob" | "mittel" | "fein";
/**
* Render-/Anzeigemodus der 3D-Ansicht: schattiert, einheitlich weiss (Clay-Look),
* Drahtgitter, Verdeckte Kanten (Hidden-Line). Wirkt nur in der Perspektive.
* 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" | "wireframe" | "hidden";
export type RenderMode =
| "shaded"
| "white"
| "textured"
| "wireframe"
| "hidden";
/**
* Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). „front",
@@ -97,6 +119,10 @@ export interface TopBarProps {
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.
@@ -131,6 +157,30 @@ export interface TopBarProps {
/** 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;
}
/**
@@ -155,6 +205,19 @@ 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
@@ -392,6 +455,376 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
}
}
/** 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,
@@ -412,6 +845,8 @@ export function TopBar({
onFit,
onFitSelection,
onZoom100,
onExportPdf,
onExportDxf,
renderMode,
onRenderMode,
renderModeEnabled,
@@ -427,6 +862,8 @@ export function TopBar({
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
@@ -578,8 +1015,10 @@ export function TopBar({
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss auf Symbole +
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung → dort
deaktiviert mit Tooltip statt stiller No-Op. */}
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field">
{/* 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}
@@ -597,16 +1036,7 @@ export function TopBar({
]}
/>
</label>
</div>
<Sep />
{/* Massstab „1:N" (echter Papier-Massstab) + Zoom-Buttons. Das Dropdown
zeigt den gewählten/angewandten Massstab; rechts steht der LIVE 1:N
aus der View-Transform (ändert sich beim Zoomen). Nur aktiv, wenn ein
Plan gezeichnet wird (Perspektive zeigt „—"). */}
<div className="tb-group" role="group" aria-label={t("topbar.scaleGroup")}>
<label className="tb-field">
<label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.scale")}</span>
<Dropdown
value={inPresets ? String(scaleDenominator) : "__current__"}
@@ -617,42 +1047,103 @@ export function TopBar({
options={scaleOptions}
/>
</label>
<span className="tb-live-scale" title={t("topbar.scale.live")}>
{zoomEnabled && liveScale != null ? `1:${Math.round(liveScale)}` : "—"}
</span>
<div className="tb-btns">
<button
className="tb-btn"
onClick={onZoom100}
disabled={!zoomEnabled}
title={t("topbar.zoom100.hint", { n: scaleDenominator })}
>
{t("topbar.zoom100")}
</button>
<button
className="tb-btn"
onClick={onFit}
disabled={!zoomEnabled}
title={t("topbar.fit.hint")}
>
{t("topbar.fit")}
</button>
<button
className="tb-btn"
onClick={onFitSelection}
disabled={!zoomEnabled}
title={t("topbar.fitSelection.hint")}
>
{t("topbar.fitSelection")}
</button>
</div>
<span className="tb-zoom" title={t("topbar.zoom.current")}>
{zoomEnabled ? `${Math.round(zoomPercent)}%` : "—"}
</span>
</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
@@ -669,6 +1160,7 @@ export function TopBar({
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") },
]}
@@ -693,54 +1185,58 @@ export function TopBar({
Achslinien im Grundriss. Nur im Grundriss sinnvoll → sonst deaktiviert. */}
<div className="tb-group" role="group" aria-label={t("topbar.referenceLinesGroup")}>
<button
className={`tb-toggle${referenceLines ? " active" : ""}`}
className={`view-icon${referenceLines ? " active" : ""}`}
onClick={() => onReferenceLines(!referenceLines)}
disabled={!referenceLinesEnabled}
aria-pressed={referenceLines}
aria-label={t("topbar.referenceLines")}
title={
referenceLinesEnabled
? t("topbar.referenceLines.hint")
? `${t("topbar.referenceLines")}${t("topbar.referenceLines.hint")}`
: t("topbar.referenceLines.hintDisabled")
}
>
{t("topbar.referenceLines")}
<TbIcon name="straighten" />
</button>
</div>
<Sep />
{/* Linien-Darstellung: Display (alle Haarlinien) ↔ Print (echte mm-
Strichstärken). Nur im Grundriss sinnvoll. */}
Strichstärken). Eine Einfach-Auswahl → als Dropdown (Trigger-Pill +
Häkchen-Liste, wie Detailgrad/Massstab), nicht mehr als Segment-Pillen.
Optionen tragen ein Icon plus Klartext. Nur im Grundriss sinnvoll. */}
<div className="tb-group" role="group" aria-label={t("topbar.lineModeGroup")}>
<div className="view-toggle">
<button
className={`view-btn${lineMode === "display" ? " active" : ""}`}
onClick={() => onLineMode("display")}
<label className="tb-field">
<span className="tb-label">{t("topbar.lineModeGroup")}</span>
<Dropdown
value={lineMode}
disabled={!lineModeEnabled}
title={t("topbar.lineMode.display.hint")}
>
{t("topbar.lineMode.display")}
</button>
<button
className={`view-btn${lineMode === "print" ? " active" : ""}`}
onClick={() => onLineMode("print")}
disabled={!lineModeEnabled}
title={t("topbar.lineMode.print.hint")}
>
{t("topbar.lineMode.print")}
</button>
</div>
onChange={(v) => onLineMode(v as "display" | "print")}
title={
lineModeEnabled
? `${t("topbar.lineMode.display.hint")} · ${t("topbar.lineMode.print.hint")}`
: t("topbar.lineModeGroup")
}
options={[
{ value: "display", label: t("topbar.lineMode.display") },
{ value: "print", label: t("topbar.lineMode.print") },
]}
/>
</label>
</div>
{/* Rechte Gruppe: Layout-Menü · Ressourcen · Projektname. */}
<div className="topbar-right">
{layoutMenu}
<button
className={`res-trigger${resourcesOpen ? " active" : ""}`}
className={`view-icon${resourcesOpen ? " active" : ""}`}
onClick={onToggleResources}
title={t("topbar.resources.hint")}
aria-pressed={resourcesOpen}
aria-label={t("topbar.resources")}
title={`${t("topbar.resources")}${t("topbar.resources.hint")}`}
>
{t("topbar.resources")}
<TbIcon name="widgets" />
</button>
<span className="project-name">{project.name}</span>
</div>