From 9d6e86d4c0748b85fa920cff3fa24bd087946c00 Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 17:00:09 +0200 Subject: [PATCH] Ribbon-UI Phase 1: datengetriebene Tab-Leiste (2D/3D/BIM/Ansichten), additiv unter TopBar --- src/App.tsx | 22 ++++++ src/i18n/de.ts | 9 +++ src/i18n/en.ts | 9 +++ src/panels/ToolsPanel.tsx | 2 +- src/styles.css | 132 +++++++++++++++++++++++++++++++++ src/ui/ribbon/CommandIcon.tsx | 77 +++++++++++++++++++ src/ui/ribbon/RibbonBar.tsx | 134 ++++++++++++++++++++++++++++++++++ src/ui/ribbon/ribbonItems.ts | 104 ++++++++++++++++++++++++++ 8 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 src/ui/ribbon/CommandIcon.tsx create mode 100644 src/ui/ribbon/RibbonBar.tsx create mode 100644 src/ui/ribbon/ribbonItems.ts diff --git a/src/App.tsx b/src/App.tsx index c8bcb8a..31ee012 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -70,6 +70,7 @@ import { ResourceManager } from "./ui/ResourceManager"; import type { ResourceManagerHandlers } from "./ui/ResourceManager"; import { HatchSwatch } from "./ui/hatchPreview"; import { TopBar } from "./ui/TopBar"; +import { RibbonBar } from "./ui/ribbon/RibbonBar"; import { Dropdown } from "./ui/Dropdown"; import type { DropdownItem } from "./ui/Dropdown"; import { TextEditorDialog } from "./ui/TextEditorDialog"; @@ -810,6 +811,16 @@ export default function App() { setActiveTool(id); }; + // Ribbon-Befehl (Ändern-Gruppe u. a.): startet einen Engine-Befehl OHNE + // eigenes Werkzeug (kein ToolId). Läuft über DIESELBE Engine wie das Tippen des + // Befehls; das Werkzeug-Highlight fällt auf „Auswahl" zurück, das Aktiv- + // Highlight des Ribbon-Befehls spiegelt der laufende Befehl (engineView). + const onRunCommand = (name: string) => { + engine.start(name); + setActiveTool("select"); + requestAnimationFrame(() => commandLineRef.current?.focus()); + }; + // 3D-Viewport als Erstellungs-Fläche: der Viewport projiziert den Cursor auf // die Geschoss-Arbeitsebene und liefert einen rohen Modell-`Vec2`; wir füttern // ihn in DIESELBE ansichts-agnostische CommandEngine wie PlanView (move/pick). @@ -3244,6 +3255,17 @@ export default function App() { textTarget={textTarget} /> + {/* Ribbon-Oberleiste (2D · 3D · BIM · Ansichten) — additiv unter der TopBar. + Teilt sich denselben Aktivierungs-Pfad wie die Werkzeug-Sidebar; die + Sidebar bleibt vorerst erhalten (Plan-Doc Phase 3 entfernt sie). */} + +
{/* Linkes Dock: gestapelte Gruppen (Werkzeuge oben, Zeichnungsebenen/ Ebenen unten). Jede Gruppe baut über buildHost ihren eigenen diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 94cad1c..a57b977 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -120,6 +120,15 @@ export const de = { "tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius", "tool.arc": "Bogen", "tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)", + // Ribbon-Oberleiste (Tabs + Gruppen) + "ribbon.tab.2d": "2D", + "ribbon.tab.3d": "3D", + "ribbon.tab.bim": "BIM", + "ribbon.tab.views": "Ansichten", + "ribbon.group.draw": "Zeichnen", + "ribbon.group.modify": "Ändern", + "ribbon.group.components": "Bauteile", + "ribbon.empty": "Noch keine Werkzeuge in diesem Tab", "tool.select.hint": "Auswahl — Elemente klicken/aufziehen", "tool.window.hint": "Fenster: Wand wählen, dann Position setzen", "tool.door.hint": "Tür: Wand wählen, dann Position setzen", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c4b08ed..58e2540 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -119,6 +119,15 @@ export const en: Record = { "tool.circle.hint": "Circle: set center, then radius", "tool.arc": "Arc", "tool.arc.hint": "Arc: center, start point, then end point (CCW)", + // Ribbon top bar (tabs + groups) + "ribbon.tab.2d": "2D", + "ribbon.tab.3d": "3D", + "ribbon.tab.bim": "BIM", + "ribbon.tab.views": "Views", + "ribbon.group.draw": "Draw", + "ribbon.group.modify": "Modify", + "ribbon.group.components": "Components", + "ribbon.empty": "No tools in this tab yet", "tool.select.hint": "Select — click/drag elements", "tool.window.hint": "Window: pick a wall, then set the position", "tool.door.hint": "Door: pick a wall, then set the position", diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx index aa38c46..4dc87b7 100644 --- a/src/panels/ToolsPanel.tsx +++ b/src/panels/ToolsPanel.tsx @@ -19,7 +19,7 @@ import { Dropdown } from "../ui/Dropdown"; // Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug // (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der // Zeile übernimmt. -function ToolIcon({ id }: { id: ToolId }) { +export function ToolIcon({ id }: { id: ToolId }) { const common = { width: 16, height: 16, diff --git a/src/styles.css b/src/styles.css index 870c963..067c69d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5340,3 +5340,135 @@ body { background: #e5484d; color: #fff; } + +/* ── Ribbon-Oberleiste (2D · 3D · BIM · Ansichten) ───────────────────────── + Datengetriebene Tab-Leiste unter der TopBar (RibbonBar). Kompakte Band-Höhe: + Tab-Reiter oben, darunter die Werkzeug-/Befehls-Gruppen des aktiven Tabs. */ +.ribbon { + display: flex; + flex-direction: column; + background: var(--panel); + border-bottom: 1px solid var(--line); + position: relative; + z-index: 1; + flex: 0 0 auto; +} +.ribbon-tabs { + display: flex; + align-items: flex-end; + gap: 2px; + padding: 0 10px; + border-bottom: 1px solid var(--line); +} +.ribbon-tab { + appearance: none; + border: none; + background: transparent; + color: var(--muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 7px 12px 6px; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.12s, border-color 0.12s; +} +.ribbon-tab:hover { + color: var(--label); +} +.ribbon-tab.active { + color: var(--ink); + border-bottom-color: var(--accent-border, var(--accent)); +} +.ribbon-body { + display: flex; + align-items: stretch; + gap: 0; + padding: 4px 8px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: transparent transparent; + min-height: 56px; +} +.ribbon-body::-webkit-scrollbar { + height: 6px; +} +.ribbon-body::-webkit-scrollbar-thumb { + background: transparent; +} +.ribbon-body:hover::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} +/* Eine Gruppe: Buttons oben, kleiner Gruppentitel darunter; rechts ein dezenter + Trenner (wie OCS-Ribbon-Gruppen). */ +.ribbon-group { + display: flex; + flex-direction: column; + padding: 0 8px; + border-right: 1px solid var(--line); +} +.ribbon-group:last-child { + border-right: none; +} +.ribbon-items { + display: flex; + align-items: flex-start; + gap: 2px; + flex: 1; +} +.ribbon-group-title { + text-align: center; + color: var(--muted); + font-size: 9px; + letter-spacing: 0.05em; + text-transform: uppercase; + padding-top: 2px; +} +/* Ein Ribbon-Button: Icon oben, Label darunter (kompakt, wie eine Palette). */ +.ribbon-btn { + appearance: none; + border: 1px solid transparent; + background: transparent; + color: var(--label); + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + width: 52px; + padding: 4px 2px 3px; + border-radius: 5px; + cursor: pointer; + font-size: 10px; + line-height: 1.15; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.ribbon-btn span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ribbon-btn:hover:not(:disabled) { + background: var(--panel-2); + border-color: var(--line); +} +.ribbon-btn.active { + background: var(--accent-dim, var(--panel-2)); + border-color: var(--accent-border, var(--accent)); + color: var(--ink); +} +.ribbon-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.ribbon-empty { + display: flex; + align-items: center; + color: var(--muted); + font-size: 11px; + font-style: italic; + padding: 0 8px; +} diff --git a/src/ui/ribbon/CommandIcon.tsx b/src/ui/ribbon/CommandIcon.tsx new file mode 100644 index 0000000..c85c03b --- /dev/null +++ b/src/ui/ribbon/CommandIcon.tsx @@ -0,0 +1,77 @@ +// Kleine Inline-SVG-Glyphen für Befehls-Ribbon-Elemente (Ändern-Gruppe u. a.), +// die keine ToolId haben und daher nicht über `ToolIcon` abgedeckt sind. Gleicher +// Stil wie `ToolIcon` (~16×16, stroke=currentColor, fill=none), damit das Icon +// die Textfarbe der Zeile übernimmt. Unbekannte Namen → kein Icon (null). + +const common = { + width: 16, + height: 16, + viewBox: "0 0 16 16", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.4, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, +}; + +export function CommandIcon({ name }: { name: string }) { + switch (name) { + case "move": + // Vier-Wege-Verschiebepfeil. + return ( + + + + + + + + + ); + case "copy": + // Zwei überlappende Rechtecke (Duplikat). + return ( + + + + + ); + case "mirror": + // Spiegelachse (gestrichelt) mit zwei gespiegelten Dreiecken. + return ( + + + + + + ); + case "offset": + // Zwei parallele Winkel (Ursprung + Versatz). + return ( + + + + + ); + case "trim": + // Schere (Stutzen). + return ( + + + + + + + ); + case "join": + // Zwei Segmente, die sich an einer Ecke treffen (Verbinden). + return ( + + + + + ); + default: + return null; + } +} diff --git a/src/ui/ribbon/RibbonBar.tsx b/src/ui/ribbon/RibbonBar.tsx new file mode 100644 index 0000000..1f4a4b3 --- /dev/null +++ b/src/ui/ribbon/RibbonBar.tsx @@ -0,0 +1,134 @@ +// Ribbon-Oberleiste (docs/design/ribbon-ui-plan.md, Phase 1) — datengetriebene +// Tab-Leiste unter der TopBar. Zeigt je Tab (2D · 3D · BIM · Ansichten) die +// gruppierten Werkzeuge/Befehle aus `RIBBON_TABS`. ADDITIV: die Werkzeug-Sidebar +// (ToolsPanel) bleibt vorerst funktionsfähig — beide teilen sich denselben +// Aktivierungs-Pfad (onSelectTool / engine.start), es gibt keine Duplikat-Logik. +// +// Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, +// wie in der Sidebar); Befehls-Elemente über `onRunCommand` (engine.start). Das +// Aktiv-Highlight spiegelt für Werkzeuge `activeTool`, für Befehle den laufenden +// Befehl (`activeCommand`). +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { useState } from "react"; +import { t } from "../../i18n"; +import { ToolIcon } from "../../panels/ToolsPanel"; +import { getTool } from "../../tools/tools"; +import type { ToolId } from "../../tools/types"; +import { getCommand } from "../../commands/registry"; +import { RIBBON_TABS } from "./ribbonItems"; +import type { RibbonItem, RibbonTabId } from "./ribbonItems"; +import { CommandIcon } from "./CommandIcon"; + +export interface RibbonBarProps { + /** Aktives Werkzeug (Highlight der `tool`-Elemente). */ + activeTool: ToolId; + /** Name des laufenden Engine-Befehls (Highlight der `command`-Elemente). */ + activeCommand: string | null; + /** Ob Boden-gebundene (`floorOnly`) Werkzeuge nutzbar sind (aktives Geschoss). */ + toolsEnabled: boolean; + /** Werkzeug aktivieren (gleicher Pfad wie die Sidebar). */ + onSelectTool: (id: ToolId) => void; + /** Engine-Befehl starten (Ändern-Gruppe u. a.). */ + onRunCommand: (name: string) => void; +} + +/** Ein einzelner Ribbon-Button (Werkzeug ODER Befehl). */ +function RibbonButton({ + item, + activeTool, + activeCommand, + toolsEnabled, + onSelectTool, + onRunCommand, +}: { + item: RibbonItem; +} & Omit & { activeCommand: string | null }) { + if (item.kind === "tool") { + const tool = getTool(item.id); + // Nur Boden-gebundene Werkzeuge sind ohne aktives Geschoss gesperrt; freie + // 2D-Werkzeuge (Linie/Kreis/…) bleiben auch auf Zeichnungsebenen nutzbar. + const disabled = !!tool.floorOnly && !toolsEnabled; + const active = activeTool === item.id; + const label = t(tool.labelKey); + return ( + + ); + } + // Befehls-Element: Label aus der Befehls-Registry ableiten. + const cmd = getCommand(item.name); + const label = cmd ? t(cmd.labelKey) : item.name; + const active = activeCommand === item.name; + return ( + + ); +} + +export function RibbonBar(props: RibbonBarProps) { + const [tab, setTab] = useState("2d"); + const current = RIBBON_TABS.find((tt) => tt.id === tab) ?? RIBBON_TABS[0]; + + return ( +
+ {/* Tab-Reiter */} +
+ {RIBBON_TABS.map((tt) => ( + + ))} +
+ + {/* Gruppen des aktiven Tabs */} +
+ {current.groups.length === 0 ? ( +
{t("ribbon.empty")}
+ ) : ( + current.groups.map((g) => ( +
+
+ {g.items.map((item) => ( + + ))} +
+
{t(g.titleKey)}
+
+ )) + )} +
+
+ ); +} diff --git a/src/ui/ribbon/ribbonItems.ts b/src/ui/ribbon/ribbonItems.ts new file mode 100644 index 0000000..f210230 --- /dev/null +++ b/src/ui/ribbon/ribbonItems.ts @@ -0,0 +1,104 @@ +// Ribbon-Registry (docs/design/ribbon-ui-plan.md §Architektur) — EINE +// datengetriebene Beschreibung aller Ribbon-Elemente. Die Ribbon-Tabs UND eine +// spätere modulare Custom-Bar (Phase 4) sind bloß zwei Ansichten über dieselbe +// Registry, ohne Sonderweg pro Element. +// +// Ein `RibbonItem` ist entweder ein Werkzeug (`tool` → onSelectTool, wie die +// Werkzeug-Sidebar) oder ein Engine-Befehl (`command` → engine.start). Beide +// münden in exakt DENSELBEN Pfad wie Klick in der alten Sidebar bzw. das Tippen +// des Befehlsnamens — keine Duplikate. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Sichtbare +// Texte kommen als i18n-Schlüssel und werden erst in der Komponente über t(...) +// aufgelöst. + +import type { ToolId } from "../../tools/types"; + +/** Ein aktivierbares Ribbon-Element. Bei `command` wird die Beschriftung aus der + * Befehls-Registry (`getCommand(name).labelKey`) abgeleitet — kein doppeltes + * Pflegen von Labels. */ +export type RibbonItem = + | { kind: "tool"; id: ToolId } + | { kind: "command"; name: string }; + +/** Eine benannte Gruppe von Elementen innerhalb eines Tabs. */ +export interface RibbonGroup { + titleKey: string; + items: RibbonItem[]; +} + +/** Kennung eines Ribbon-Tabs (fest, für Tab-State + i18n). */ +export type RibbonTabId = "2d" | "3d" | "bim" | "views"; + +/** Ein Ribbon-Tab: Beschriftung + gruppierte Elemente. */ +export interface RibbonTab { + id: RibbonTabId; + labelKey: string; + groups: RibbonGroup[]; +} + +const t = (id: ToolId): RibbonItem => ({ kind: "tool", id }); +const c = (name: string): RibbonItem => ({ kind: "command", name }); + +/** + * Die Ribbon-Tabs. Phase 1 füllt 2D (Zeichnen + Ändern) und BIM (Bauteile) mit + * den vorhandenen Werkzeugen/Befehlen; 3D und Ansichten bleiben vorerst leer + * (Platzhalter, in einer späteren Phase gefüllt — siehe Plan-Doc). + */ +export const RIBBON_TABS: RibbonTab[] = [ + { + id: "2d", + labelKey: "ribbon.tab.2d", + groups: [ + { + titleKey: "ribbon.group.draw", + items: [ + t("select"), + t("line"), + t("polyline"), + t("rect"), + t("circle"), + t("arc"), + ], + }, + { + titleKey: "ribbon.group.modify", + items: [ + c("move"), + c("copy"), + c("mirror"), + c("offset"), + c("trim"), + c("join"), + ], + }, + ], + }, + { + id: "bim", + labelKey: "ribbon.tab.bim", + groups: [ + { + titleKey: "ribbon.group.components", + items: [ + t("wall"), + t("window"), + t("door"), + t("stair"), + t("ceiling"), + t("room"), + ], + }, + ], + }, + { + id: "3d", + labelKey: "ribbon.tab.3d", + groups: [], + }, + { + id: "views", + labelKey: "ribbon.tab.views", + groups: [], + }, +];