Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge

Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
This commit is contained in:
2026-06-30 20:52:27 +02:00
commit ca859c4aa4
157 changed files with 37921 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
// Command-Line-UI (docs/design/rhino-command-system.md §3.2) — sitzt ÜBER der
// Statusleiste. Drei Rollen gleichzeitig (§2.1): Prompt-Text (links), klickbare
// Inline-Optionen, Texteingabe; darüber ein Autocomplete-Dropdown.
//
// Rein gesteuert: alle Aktionen laufen über Callbacks an die Engine. Tab (global
// in App) fokussiert das Eingabefeld. Bezeichner englisch, sichtbarer Text via t().
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
export interface CommandLineOption {
id: string;
labelKey: string;
value?: string;
}
/** Ein Zahlenfeld des Tab-Feld-Zyklus (§2.7). */
export interface CommandLineField {
id: string;
labelKey: string;
/** Gelockter Wert (oder null = folgt der Maus). */
value: number | null;
/** Aktuelles Tab-Ziel? */
active: boolean;
}
export interface CommandLineProps {
/** Läuft gerade ein Befehl? (steuert den Ruhetext.) */
active: boolean;
/** Aktueller Prompt-Key (i18n) oder null im Ruhezustand. */
promptKey: string | null;
/** Inline-Optionen des aktuellen Schritts. */
options: CommandLineOption[];
/** Geordnete Zahlenfelder des aktuellen Schritts (Tab-Feld-Zyklus). */
fields: CommandLineField[];
/** Enter mit dem aktuellen Eingabetext (Befehl/Koordinate/Zahl). */
onSubmit: (text: string) => void;
/** Klick auf eine Inline-Option. */
onOption: (id: string) => void;
/** Tab im Feld-Modus: nächstes Feld aktivieren. */
onCycleField: () => void;
/** Esc: laufenden Befehl abbrechen. */
onCancel: () => void;
/** Autocomplete-Kandidaten für ein Präfix (nur im Ruhezustand sinnvoll). */
suggest: (prefix: string) => string[];
}
export interface CommandLineHandle {
/** Fokussiert (und öffnet) das Eingabefeld — vom globalen Tab-Handler genutzt. */
focus: () => void;
}
export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
function CommandLine(
{ active, promptKey, options, fields, onSubmit, onOption, onCycleField, onCancel, suggest },
ref,
) {
const [text, setText] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
}));
// Autocomplete nur im Ruhezustand (Befehlsname tippen), nicht in einem Schritt.
const suggestions = !active && text.trim() !== "" ? suggest(text).slice(0, 6) : [];
const hasFields = fields.length > 0;
const submit = (value: string) => {
onSubmit(value);
setText("");
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Tasten NICHT zum globalen App-Handler durchreichen (eigener Eingabefokus).
if (e.key === "Enter") {
e.preventDefault();
submit(text);
} else if (e.key === "Escape") {
e.preventDefault();
if (active) onCancel();
setText("");
inputRef.current?.blur();
} else if (e.key === "Tab") {
if (hasFields) {
// Feld-Modus (§2.7): Tab zyklt die Felder. Steht eine getippte Zahl im
// Feld, wird sie zuerst ins aktive Feld gelockt (das rückt selbst vor),
// sonst nur zum nächsten Feld weiterschalten.
e.preventDefault();
if (text.trim() !== "") submit(text);
else onCycleField();
} else if (suggestions.length > 0) {
// Ruhezustand: Tab akzeptiert den ersten Autocomplete-Vorschlag.
e.preventDefault();
setText(suggestions[0]);
}
}
};
const promptText = promptKey
? t(promptKey as TranslationKey)
: t("cmd.idle.prompt");
return (
<div className="cmdline" role="group" aria-label={t("cmd.aria")}>
{suggestions.length > 0 && (
<ul className="cmdline-suggest" role="listbox">
{suggestions.map((s) => (
<li
key={s}
role="option"
aria-selected="false"
className="cmdline-suggest-item"
onMouseDown={(e) => {
// mousedown (vor blur), damit der Klick nicht den Fokus verliert.
e.preventDefault();
submit(s);
}}
>
{s}
</li>
))}
</ul>
)}
<span className="cmdline-prompt">{promptText}</span>
{options.length > 0 && (
<span className="cmdline-options">
{options.map((o) => (
<button
key={o.id}
type="button"
className="cmdline-option"
onMouseDown={(e) => {
e.preventDefault();
onOption(o.id);
}}
>
{t(o.labelKey as TranslationKey)}
{o.value != null && <span className="cmdline-option-val">={o.value}</span>}
</button>
))}
</span>
)}
{hasFields && (
<span className="cmdline-fields">
{fields.map((f) => (
<span
key={f.id}
className={`cmdline-field${f.active ? " active" : ""}`}
title={t(f.labelKey as TranslationKey)}
>
<span className="cmdline-field-label">{t(f.labelKey as TranslationKey)}</span>
<span className="cmdline-field-val">
{f.value == null ? "—" : Number(f.value.toFixed(3)).toString()}
</span>
</span>
))}
</span>
)}
<input
ref={inputRef}
className="cmdline-input"
type="text"
spellCheck={false}
autoComplete="off"
value={text}
placeholder={active ? "" : t("cmd.idle.placeholder")}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
/>
</div>
);
},
);
+164
View File
@@ -0,0 +1,164 @@
// Generisches, wiederverwendbares Kontextmenü — 1:1 nach DOSSIER
// (docs/design/context-menu.md, Quelle src/components/ContextMenu.jsx).
//
// Reine Darstellung + Callbacks: der Aufrufer hält die offene Position (x/y in
// Viewport-Pixeln), die Item-Liste und reagiert über `onClose`. Das Menü selbst
// kümmert sich nur um Positionierung (Fixed + Rand-Clamp), das Schließen
// (Außenklick · Escape · erneuter Rechtsklick · Scroll · nach Item-Klick) und
// die Darstellung der Items (Icon, Shortcut, danger, divider, disabled).
//
// Gerendert über ein Portal in <body>, damit es über allem liegt und nicht vom
// overflow/transform eines Panels abgeschnitten wird (z-index ~300, unter
// Modals). Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
/**
* Ein Eintrag im Kontextmenü. Ein reiner Trenner setzt nur `divider: true`
* (alle anderen Felder werden dann ignoriert). Sonst ist `label` Pflicht und
* `onClick` die ausgelöste Aktion.
*/
export interface ContextMenuItem {
/** Sichtbarer Text (deutsch). Bei `divider` ungenutzt. */
label?: string;
/** Optionales Icon (z. B. Material-Symbol-Name oder eigener Knoten). */
icon?: ReactNode;
/** Aktion beim Klick. Wird nur ausgeführt, wenn nicht `disabled`. */
onClick?: () => void;
/** Ausgegraut, nicht klickbar. */
disabled?: boolean;
/** Rote Schrift (destruktive Aktion, z. B. Löschen). */
danger?: boolean;
/** Optionales Tastenkürzel rechtsbündig (nur Anzeige). */
shortcut?: string;
/**
* Optionaler Tooltip (title) — vor allem, um bei `disabled` den Grund zu
* nennen (z. B. „keine Auswahl"). Reine Anzeige.
*/
title?: string;
/** Reiner Trenner (1px Linie); ignoriert alle anderen Felder. */
divider?: boolean;
}
export interface ContextMenuProps {
/** Ankerposition in Viewport-Pixeln (clientX). */
x: number;
/** Ankerposition in Viewport-Pixeln (clientY). */
y: number;
/** Die Einträge in Anzeigereihenfolge. */
items: ContextMenuItem[];
/** Schließen-Callback (Außenklick · Esc · Scroll · nach Item-Klick). */
onClose: () => void;
/** Optionaler Titel oben (caps, gedämpft) — z. B. Ebenenname/-code. */
title?: string;
}
/** Rand-Abstand zum Viewport beim Clampen (px). */
const EDGE_PAD = 4;
/**
* Material-Symbols-Icon (DOSSIER nutzt die Icon-Font). Fällt auf reinen Text
* zurück; ist die Font nicht geladen, bleibt der Slot via min-width leer
* ausgerichtet, sodass die Labels trotzdem fluchten.
*/
export function MenuIcon({ name }: { name: string }) {
return (
<span className="material-symbols-outlined ctx-icon" aria-hidden="true">
{name}
</span>
);
}
export function ContextMenu({ x, y, items, onClose, title }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
// Tatsächliche Position nach dem Clampen an die Viewport-Ränder. Bis das Menü
// gemessen ist, am Anker zeigen (kein Sprung — useLayoutEffect korrigiert vor
// dem Paint).
const [pos, setPos] = useState({ x, y });
// Nach dem Mount messen und so verschieben, dass das Menü vollständig im
// Viewport bleibt (rechter/unterer Rand → nach links/oben klappen).
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let nx = x;
let ny = y;
if (nx + rect.width > vw - EDGE_PAD) nx = vw - rect.width - EDGE_PAD;
if (ny + rect.height > vh - EDGE_PAD) ny = vh - rect.height - EDGE_PAD;
if (nx < EDGE_PAD) nx = EDGE_PAD;
if (ny < EDGE_PAD) ny = EDGE_PAD;
setPos({ x: nx, y: ny });
}, [x, y, items]);
// Schließen bei: Klick/Rechtsklick außerhalb · Escape · Scroll · Resize.
// pointerdown (capture) fängt auch den Beginn eines erneuten Rechtsklicks.
useEffect(() => {
const onPointerDown = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onScrollOrResize = () => onClose();
// capture: vor anderen Handlern; passive scroll reicht (wir reagieren nur).
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
return () => {
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
};
}, [onClose]);
const onItemClick = (item: ContextMenuItem) => {
if (item.disabled || item.divider) return;
item.onClick?.();
onClose(); // Auto-Close nach Item-Klick.
};
const menu = (
<div
ref={ref}
className="ctx-menu"
role="menu"
style={{ left: pos.x, top: pos.y }}
// Eigenen Rechtsklick abfangen, damit kein Browser-Menü erscheint.
onContextMenu={(e) => e.preventDefault()}
>
{title && <div className="ctx-title">{title}</div>}
{items.map((item, i) =>
item.divider ? (
<div key={`div-${i}`} className="ctx-divider" role="separator" />
) : (
<button
key={`item-${i}`}
type="button"
role="menuitem"
className={`ctx-item${item.danger ? " danger" : ""}`}
disabled={item.disabled}
title={item.title}
onClick={() => onItemClick(item)}
>
{item.icon !== undefined && (
<span className="ctx-item-icon">{item.icon}</span>
)}
<span className="ctx-item-label">{item.label}</span>
{item.shortcut && (
<span className="ctx-item-shortcut">{item.shortcut}</span>
)}
</button>
),
)}
</div>
);
return createPortal(menu, document.body);
}
+330
View File
@@ -0,0 +1,330 @@
// Eigene, themebare Dropdown-Komponente im Stil des Kontextmenüs (ContextMenu).
//
// Ersetzt die nativen <select>-Dropdowns der Oberleiste, deren geöffnete
// Optionsliste vom Betriebssystem gerendert (blau, eckig, nicht themebar) wird.
// Das Popover hier ist dasselbe dunkle, abgerundete Popover wie das
// Kontextmenü: gleiche Tokens (Schatten, Radius, Akzent-Hover), Tastatur
// (↑/↓/Enter/Esc), Klick-außerhalb schließt, aktive Option markiert.
//
// Zwei Verwendungsarten:
// • Wert-Auswahl wie <select>: `options` + `value` + `onChange` (Häkchen an
// der aktiven Option).
// • Aktions-Menü wie ein Trigger-Dropdown: `items` (mit Trennern/danger/
// disabled, analog ContextMenu-Items). Der Trigger zeigt dann `value`-Label
// bzw. den festen `placeholder`.
//
// Reine Darstellung + Callbacks; voll gesteuert. Identifier englisch, UI-Text
// kommt als Props (i18n) herein (CLAUDE.md).
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
/** Eine wählbare Option (Wert-Auswahl-Modus, analog <option>). */
export interface DropdownOption {
value: string;
label: string;
/** Ausgegraut, nicht wählbar. */
disabled?: boolean;
}
/**
* Ein Eintrag im Aktions-Modus (analog ContextMenuItem). Ein reiner Trenner
* setzt nur `divider: true`. Sonst ist `label` Pflicht und `onSelect` die
* Aktion.
*/
export interface DropdownItem {
/** Sichtbarer Text. Bei `divider` ungenutzt. */
label?: string;
/** Aktion bei Auswahl. Nur ausgeführt, wenn nicht `disabled`. */
onSelect?: () => void;
/** Ausgegraut, nicht klickbar. */
disabled?: boolean;
/** Rote Schrift (destruktive Aktion). */
danger?: boolean;
/** Als aktiv markiert (Häkchen). */
active?: boolean;
/** Reiner Trenner (1px Linie); ignoriert alle anderen Felder. */
divider?: boolean;
}
export interface DropdownProps {
/**
* Wert-Auswahl-Modus: aktueller Wert + Optionsliste. Genau einer von
* `options`/`items` wird angegeben.
*/
options?: DropdownOption[];
/** Aktueller Wert (Wert-Auswahl-Modus) — bestimmt Trigger-Label + Häkchen. */
value?: string;
/** Wertwechsel-Callback (Wert-Auswahl-Modus). */
onChange?: (value: string) => void;
/** Aktions-Modus: Liste aus Einträgen/Trennern (statt `options`). */
items?: DropdownItem[];
/**
* Festes Trigger-Label (Aktions-Modus, da es keinen „Wert" gibt). Im
* Wert-Auswahl-Modus ungenutzt — dort zeigt der Trigger das Options-Label.
*/
placeholder?: string;
/** Tooltip/aria-label des Triggers. */
title?: string;
/** Trigger ausgegraut, nicht klickbar. */
disabled?: boolean;
/** Mindestbreite des Triggers (px) — analog <select>-Breiten. */
width?: number;
/** Optionale Zusatzklasse am Trigger (z. B. monospace beim Massstab). */
triggerClassName?: string;
/** Optionaler Inhalt rechts im Trigger statt des Chevrons (selten). */
triggerSuffix?: ReactNode;
}
/** Rand-Abstand zum Viewport beim Clampen (px). */
const EDGE_PAD = 4;
/** Kleines Chevron (Aufklapp-Pfeil) im Trigger. */
function Chevron() {
return (
<svg
className="tb-dd-chevron"
width="9"
height="6"
viewBox="0 0 10 6"
aria-hidden="true"
>
<path d="M0 0l5 6 5-6z" fill="currentColor" />
</svg>
);
}
/** Häkchen vor der aktiven Option. */
function Check() {
return (
<svg
className="tb-dd-check"
width="12"
height="12"
viewBox="0 0 12 12"
aria-hidden="true"
>
<path
d="M2.5 6.2 L4.7 8.6 L9.5 3"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function Dropdown({
options,
value,
onChange,
items,
placeholder,
title,
disabled = false,
width,
triggerClassName,
triggerSuffix,
}: DropdownProps) {
const [open, setOpen] = useState(false);
// Popover-Position (fixed), aus dem Trigger-Rect berechnet — die Oberleiste
// hat overflow:hidden und würde ein absolut positioniertes Popover
// abschneiden (gleiche Technik wie CameraMenu/ContextMenu).
const [pos, setPos] = useState({ left: 0, top: 0, minWidth: 0 });
// Tastatur-Fokus (Index in der gerenderten Zeilenliste).
const [activeIdx, setActiveIdx] = useState(-1);
const btnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
// Einheitliche Zeilenliste: im Wert-Modus aus `options`, im Aktions-Modus aus
// `items`. So teilen beide Modi dieselbe Render-/Tastatur-Logik.
const rows: DropdownItem[] = items
? items
: (options ?? []).map((o) => ({
label: o.label,
disabled: o.disabled,
active: o.value === value,
onSelect: () => onChange?.(o.value),
}));
// Trigger-Label: Wert-Modus → Label der aktiven Option; sonst `placeholder`.
const current = options?.find((o) => o.value === value);
const triggerLabel = current?.label ?? placeholder ?? "";
// Index der ersten auswählbaren Zeile (für Tastatur-Start).
const firstSelectable = rows.findIndex((r) => !r.divider && !r.disabled);
const place = () => {
const r = btnRef.current?.getBoundingClientRect();
if (!r) return;
setPos({ left: r.left, top: r.bottom + 4, minWidth: r.width });
};
const openMenu = () => {
if (disabled) return;
place();
// Tastatur-Fokus auf die aktive bzw. erste auswählbare Zeile setzen.
const activeRow = rows.findIndex((r) => r.active && !r.disabled);
setActiveIdx(activeRow >= 0 ? activeRow : firstSelectable);
setOpen(true);
};
const close = () => {
setOpen(false);
setActiveIdx(-1);
};
const selectRow = (row: DropdownItem) => {
if (row.disabled || row.divider) return;
row.onSelect?.();
close();
};
// Nach dem Öffnen: Position vor dem Paint an die Viewport-Ränder clampen.
useLayoutEffect(() => {
if (!open) return;
const el = menuRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
setPos((p) => {
let left = p.left;
let top = p.top;
if (left + rect.width > vw - EDGE_PAD) left = vw - rect.width - EDGE_PAD;
if (top + rect.height > vh - EDGE_PAD) {
// Nach oben klappen, wenn unten kein Platz ist.
const r = btnRef.current?.getBoundingClientRect();
top = r ? r.top - rect.height - 4 : vh - rect.height - EDGE_PAD;
}
if (left < EDGE_PAD) left = EDGE_PAD;
if (top < EDGE_PAD) top = EDGE_PAD;
return { ...p, left, top };
});
}, [open]);
// Schließen bei: Außenklick · Scroll · Resize. (Esc + Pfeile laufen über den
// Keydown-Handler unten, der nur bei offenem Menü aktiv ist.)
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
const tgt = e.target as Node;
if (
menuRef.current?.contains(tgt) ||
btnRef.current?.contains(tgt)
) {
return;
}
close();
};
const onScrollOrResize = () => close();
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
return () => {
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
};
}, [open]);
// Tastatur: ↑/↓ bewegt den Fokus über auswählbare Zeilen (Trenner/disabled
// überspringen), Enter wählt, Esc schließt.
const step = (dir: 1 | -1) => {
if (rows.length === 0) return;
let i = activeIdx;
for (let n = 0; n < rows.length; n++) {
i = (i + dir + rows.length) % rows.length;
const row = rows[i];
if (row && !row.divider && !row.disabled) {
setActiveIdx(i);
return;
}
}
};
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
if (!open) {
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
openMenu();
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
step(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
step(-1);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const row = rows[activeIdx];
if (row) selectRow(row);
}
};
const menu = open ? (
<div
ref={menuRef}
className="ctx-menu tb-dd-menu"
role="listbox"
style={{ left: pos.left, top: pos.top, minWidth: pos.minWidth }}
onContextMenu={(e) => e.preventDefault()}
>
{rows.map((row, i) =>
row.divider ? (
<div key={`div-${i}`} className="ctx-divider" role="separator" />
) : (
<button
key={`row-${i}`}
type="button"
role="option"
aria-selected={!!row.active}
className={`ctx-item tb-dd-item${row.danger ? " danger" : ""}${
i === activeIdx ? " is-active" : ""
}`}
disabled={row.disabled}
onMouseEnter={() => !row.disabled && setActiveIdx(i)}
onClick={() => selectRow(row)}
>
<span className="tb-dd-check-slot">{row.active && <Check />}</span>
<span className="ctx-item-label">{row.label}</span>
</button>
),
)}
</div>
) : null;
return (
<>
<button
ref={btnRef}
type="button"
className={`tb-dd-trigger${open ? " active" : ""}${
triggerClassName ? ` ${triggerClassName}` : ""
}`}
style={width ? { minWidth: width } : undefined}
disabled={disabled}
title={title}
aria-label={title}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => (open ? close() : openMenu())}
onKeyDown={onTriggerKeyDown}
>
<span className="tb-dd-trigger-label">{triggerLabel}</span>
{triggerSuffix ?? <Chevron />}
</button>
{menu && createPortal(menu, document.body)}
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { t } from "../i18n";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
/** Sicherheitsnetz: fängt Render-Fehler ab, statt die Seite weiß zu lassen. */
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error(t("error.console"), error, info);
}
render(): ReactNode {
if (this.state.error) {
return (
<div className="error-screen">
<h1>{t("error.title")}</h1>
<p>{t("error.body")}</p>
<pre>{this.state.error.message}</pre>
<button onClick={() => location.reload()}>{t("error.reload")}</button>
</div>
);
}
return this.props.children;
}
}
+39
View File
@@ -0,0 +1,39 @@
// Inline-SVG-Augensymbol für Sichtbarkeits-Schalter im Navigator.
// Nutzt currentColor → erbt die Textfarbe der Zeile (aktiv/gedimmt/grau).
// Offen = sichtbar, geschlossen/durchgestrichen = ausgeblendet.
/** Augensymbol; `open` steuert offen (sichtbar) vs. durchgestrichen. */
export function EyeIcon({ open }: { open: boolean }) {
return (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{open ? (
<>
{/* Augenkontur + Pupille. */}
<path d="M1.5 12S5 5 12 5s10.5 7 10.5 7-3.5 7-10.5 7S1.5 12 1.5 12Z" />
<circle cx={12} cy={12} r={3} />
</>
) : (
<>
{/* Augenkontur, gedimmt, plus diagonaler Strich. */}
<path
d="M1.5 12S5 5 12 5s10.5 7 10.5 7-3.5 7-10.5 7S1.5 12 1.5 12Z"
opacity={0.45}
/>
<circle cx={12} cy={12} r={3} opacity={0.45} />
<line x1={3} y1={3} x2={21} y2={21} />
</>
)}
</svg>
);
}
+333
View File
@@ -0,0 +1,333 @@
// Import-Dialog (modal) für DXF/DWG. Öffnet sich beim Import (Button ODER
// Datei-Drop) und fragt, WIE mit der Datei umzugehen ist:
// • Ziel-Zeichnungsebene: bestehende wählen ODER neue „Zeichnung" anlegen.
// • Ebenen-Handling: DXF-Layer als Kategorien (categoryCode) übernehmen vs.
// alles auf die aktive Kategorie.
// • Mesh-Behandlung: vorhandene Meshes als Kontext-Geometrie (3D) importieren.
//
// Muster wie ResourceManager: abgedunkeltes Overlay, Klick auf den Hintergrund
// und Esc schließen. Diese Komponente HÄLT nur Formular-Zustand und liefert beim
// Bestätigen eine strukturierte Entscheidung (`ImportDecision`) nach oben; die
// eigentlichen Store-Mutationen (Ebene anlegen, Drawings anhängen, Kontext)
// führt App in der richtigen Reihenfolge aus.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CLAUDE.md). Alle sichtbaren
// Texte über t(...).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types";
import type { DxfImportResult } from "../io/dxfParser";
import {
contoursToDrawings,
countContours,
distinctLayers,
} from "../io/dxfToDrawings";
import type { CategoryMode } from "../io/dxfToDrawings";
/** Strukturierte Import-Entscheidung, die der Dialog beim Bestätigen liefert. */
export interface ImportDecision {
/** Ziel: bestehende Ebene (id) ODER neue Zeichnung (name). */
target: { kind: "existing"; levelId: string } | { kind: "new"; name: string };
/** Wie DXF-Layer auf Kategorien abgebildet werden. */
categoryMode: CategoryMode;
/** Ob die Meshes als Kontext-Geometrie übernommen werden. */
importMeshes: boolean;
/** Die geparsten Konturen-Sätze (für die spätere 2D-Konvertierung). */
parsed: DxfImportResult;
}
/** i18n-Badge-Key je Zeichnungsebenen-Art. */
function kindBadgeKey(kind: DrawingLevelKind): TranslationKey {
switch (kind) {
case "floor":
return "import.badge.floor";
case "section":
return "import.badge.section";
case "elevation":
return "import.badge.elevation";
case "drawing":
return "import.badge.drawing";
}
}
export interface ImportDialogProps {
fileName: string;
/**
* true, wenn eine DWG-Datei NICHT gelesen werden konnte (LibreDWG-WASM schlug
* fehl) → ODA-Hinweis als Fallback. Bei erfolgreichem DWG-Parsing ist das false
* und `parsed` enthält das Ergebnis (gleicher Fluss wie DXF).
*/
isDwg: boolean;
/** true, solange eine DWG-Datei noch geparst wird (WASM lädt/läuft). */
loading?: boolean;
/** Geparstes Ergebnis (null während Laden / bei DWG-Parse-Fehler). */
parsed: DxfImportResult | null;
project: Project;
activeCategoryCode: string;
onImport: (decision: ImportDecision) => void;
onClose: () => void;
}
export function ImportDialog({
fileName,
isDwg,
loading = false,
parsed,
project,
activeCategoryCode,
onImport,
onClose,
}: ImportDialogProps) {
// Esc schließt den Dialog (wie ResourceManager).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const contourSets = parsed?.contours ?? [];
const meshes: ImportedMesh[] = parsed?.meshes ?? [];
const contourCount = countContours(contourSets);
const layers = distinctLayers(contourSets);
const hasDrawings = contourCount > 0;
const hasMeshes = meshes.length > 0;
// ── Formular-Zustand ──────────────────────────────────────────────────────
const firstLevelId = project.drawingLevels[0]?.id ?? "";
const [targetKind, setTargetKind] = useState<"existing" | "new">("new");
const [existingLevelId, setExistingLevelId] = useState<string>(firstLevelId);
// Default-Name der neuen Zeichnung aus dem Dateinamen (ohne Endung).
const [newName, setNewName] = useState<string>(() =>
fileName.replace(/\.[^.]+$/, "") || "",
);
const [categoryMode, setCategoryMode] = useState<CategoryMode>(
layers.length > 0 ? "byLayer" : "active",
);
const [importMeshes, setImportMeshes] = useState<boolean>(true);
const canImport =
!isDwg && !loading && (hasDrawings || (hasMeshes && importMeshes));
const onConfirm = () => {
if (!parsed || !canImport) return;
const target =
targetKind === "existing"
? ({ kind: "existing", levelId: existingLevelId } as const)
: ({ kind: "new", name: newName } as const);
onImport({ target, categoryMode, importMeshes, parsed });
};
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("import.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("import.title")}</span>
<button
className="imp-close"
onClick={onClose}
aria-label={t("import.close")}
>
×
</button>
</header>
<div className="imp-body">
{/* Datei-Zusammenfassung. */}
<div className="imp-file">
<span className="imp-file-label">{t("import.file")}</span>
<span className="imp-file-name" title={fileName}>
{fileName}
</span>
</div>
{loading ? (
// DWG wird gelesen (WASM lädt/parst) → Ladehinweis, nur Abbrechen.
<section className="imp-section">
<div className="imp-section-title">{t("import.dwg.heading")}</div>
<div className="imp-dwg-hint">{t("import.dwg.loading")}</div>
</section>
) : isDwg ? (
// DWG konnte nicht gelesen werden → ODA-Fallback-Hinweis.
<section className="imp-section">
<div className="imp-section-title">{t("import.dwg.heading")}</div>
<div className="imp-dwg-hint">{t("import.dwg.hint")}</div>
</section>
) : (
<>
{/* Inhalt der Datei. */}
<section className="imp-section">
<div className="imp-section-title">
{t("import.summary.heading")}
</div>
<ul className="imp-summary">
<li>{t("import.summary.contours", { n: contourCount })}</li>
<li>{t("import.summary.meshes", { n: meshes.length })}</li>
<li>
{layers.length > 0
? t("import.summary.layers", { layers: layers.join(", ") })
: t("import.summary.noLayers")}
</li>
</ul>
{!hasDrawings && !hasMeshes && (
<div className="imp-warn">{t("import.summary.empty")}</div>
)}
</section>
{/* Ziel-Zeichnungsebene. */}
{hasDrawings && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.target.heading")}
</div>
<label className="imp-radio">
<input
type="radio"
name="imp-target"
checked={targetKind === "existing"}
onChange={() => setTargetKind("existing")}
/>
<span>{t("import.target.existing")}</span>
</label>
{targetKind === "existing" && (
<select
className="imp-select"
value={existingLevelId}
onChange={(e) => setExistingLevelId(e.target.value)}
aria-label={t("import.target.existing")}
>
{project.drawingLevels.map((z) => (
<option key={z.id} value={z.id}>
{z.name} · {t(kindBadgeKey(z.kind))}
</option>
))}
</select>
)}
<label className="imp-radio">
<input
type="radio"
name="imp-target"
checked={targetKind === "new"}
onChange={() => setTargetKind("new")}
/>
<span>{t("import.target.new")}</span>
</label>
{targetKind === "new" && (
<input
type="text"
className="imp-input"
value={newName}
placeholder={t("import.target.newNamePlaceholder")}
onChange={(e) => setNewName(e.target.value)}
aria-label={t("import.target.newNamePlaceholder")}
/>
)}
</section>
)}
{/* Ebenen-Handling (DXF-Layer → Kategorien). */}
{hasDrawings && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.layers.heading")}
</div>
<label className="imp-radio">
<input
type="radio"
name="imp-cat"
checked={categoryMode === "byLayer"}
disabled={layers.length === 0}
onChange={() => setCategoryMode("byLayer")}
/>
<span>{t("import.layers.asCategories")}</span>
</label>
<label className="imp-radio">
<input
type="radio"
name="imp-cat"
checked={categoryMode === "active"}
onChange={() => setCategoryMode("active")}
/>
<span>
{t("import.layers.toActive", { code: activeCategoryCode })}
</span>
</label>
</section>
)}
{/* Mesh-Behandlung. */}
{hasMeshes && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.mesh.heading")}
</div>
<label className="imp-check">
<input
type="checkbox"
checked={importMeshes}
onChange={(e) => setImportMeshes(e.target.checked)}
/>
<span>{t("import.mesh.asContext")}</span>
</label>
<div className="imp-note">{t("import.mesh.note")}</div>
</section>
)}
</>
)}
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("import.cancel")}
</button>
<button
className="imp-btn primary"
onClick={onConfirm}
disabled={!canImport}
>
{t("import.confirm")}
</button>
</footer>
</div>
</div>
);
}
/**
* Baut aus einer Import-Entscheidung die fertigen Drawing2D-Elemente für die
* Ziel-Ebene `levelId`. `byLayer` mappt jeden DXF-Layer-Namen deterministisch
* auf einen Kategorie-Code: existiert eine Kategorie mit passendem Namen, wird
* deren Code genutzt; sonst fällt der Layer auf den aktiven Code zurück.
*
* (Die App ruft diese Funktion, NACHDEM sie ggf. die neue Ebene angelegt hat —
* sie braucht die echte Ziel-`levelId`.)
*/
export function buildDrawings(
decision: ImportDecision,
levelId: string,
project: Project,
activeCategoryCode: string,
) {
// Layer-Name → Kategorie-Code: bevorzugt eine Kategorie mit demselben Namen
// (case-insensitiv), sonst der aktive Code. Identifier bleiben so stabil.
const byName = new Map<string, string>();
for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code);
const layerToCode = (layerName: string): string =>
byName.get(layerName.toLowerCase()) ?? activeCategoryCode;
return contoursToDrawings(
decision.parsed.contours,
levelId,
decision.categoryMode,
activeCategoryCode,
layerToCode,
);
}
+840
View File
@@ -0,0 +1,840 @@
// Ressourcen-Manager — verwaltete Stil-Bibliotheken nach DOSSIER-Vorbild
// (Vectorworks-Stil). Drei Sektionen in einer rechten Schublade (Drawer):
// • Bauteile (Components) — Poché-/3D-Farbe, Verschneidungs-Rang, Schraffur.
// • Schraffuren (Hatches) — Muster/Maßstab/Winkel/Farbe + Linienstil.
// • Linien (Line Styles) — Strichstärke/Farbe/Strichmuster.
//
// Darstellung als saubere Tabelle (CONVENTIONS.md): pro Tab EINE sticky Kopfzeile
// mit Spaltentiteln, darunter kompakte Datenzeilen mit Inline-Edit pro Zelle —
// keine wiederholten Feld-Beschriftungen je Zeile. Umgesetzt als CSS-Grid, das
// Kopf- und Datenzeilen dieselben Spalten teilen (`grid-template-columns` je
// Tab), damit die Spalten exakt fluchten.
//
// Reine Darstellung + Callbacks: alle Mutationen laufen immutabel über die
// vom App gereichten Handler (setProject). Plan & 3D leiten aus demselben
// Projekt ab und aktualisieren dadurch live — diese Komponente kennt weder
// Rendering noch Persistenz. Bezeichner englisch, UI-Text deutsch
// (CONVENTIONS.md).
import { useRef, useState } from "react";
import type {
Component,
HatchPattern,
HatchStyle,
LineStyle,
Project,
} from "../model/types";
import { PEN_WEIGHTS } from "../model/types";
import { parseLin } from "../io/linParser";
import { parsePat } from "../io/patParser";
import { EyeIcon } from "./EyeIcon";
import { t } from "../i18n";
// ── Auswahl-Optionen (UI-Beschriftung übersetzt, Werte englisch) ───────────
/** Schraffur-Muster mit i18n-Key für die Beschriftung. */
const PATTERN_OPTIONS: { value: HatchPattern; labelKey: string }[] = [
{ value: "none", labelKey: "pattern.none" },
{ value: "solid", labelKey: "pattern.solid" },
{ value: "insulation", labelKey: "pattern.insulation" },
{ value: "diagonal", labelKey: "pattern.diagonal" },
{ value: "crosshatch", labelKey: "pattern.crosshatch" },
];
/**
* Benannte Strichmuster (mm). `null` = durchgezogen. Werte als Schlüssel
* serialisiert, damit das Dropdown den aktuellen Stil wiedererkennt.
*/
const DASH_OPTIONS: { key: string; labelKey: string; dash: number[] | null }[] = [
{ key: "solid", labelKey: "dash.solid", dash: null },
{ key: "dashed", labelKey: "dash.dashed", dash: [6, 4] },
{ key: "dotted", labelKey: "dash.dotted", dash: [1, 3] },
];
/** Findet den Dash-Schlüssel, der zu einem Strichmuster passt (sonst custom). */
function dashKey(dash: number[] | null): string {
const hit = DASH_OPTIONS.find(
(o) =>
(o.dash === null && dash === null) ||
(o.dash !== null &&
dash !== null &&
o.dash.length === dash.length &&
o.dash.every((v, i) => v === dash[i])),
);
return hit ? hit.key : "custom";
}
// ── Wiederverwendbare Feld-Bausteine (DOSSIER-Look) ────────────────────────
// Alle Steuerelemente füllen ihre Tabellenzelle (width:100%) und werden über
// die Spaltenbreite des Grids dimensioniert; Zahlenfelder sind rechtsbündig.
/** Kompaktes Textfeld (Pill) für Namen/Platzhalter. */
function TextField({
value,
onChange,
placeholder,
mono = false,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
mono?: boolean;
}) {
return (
<input
type="text"
className={`res-input${mono ? " mono" : ""}`}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
);
}
/** Zahlenfeld (Pill, rechtsbündig, monospace) mit Live-Commit. */
function NumberField({
value,
onChange,
step = 1,
min,
max,
list,
}: {
value: number;
onChange: (v: number) => void;
step?: number;
min?: number;
max?: number;
/** Optionale <datalist>-ID mit Vorschlagswerten (z. B. Stiftstärken). */
list?: string;
}) {
return (
<input
type="number"
className="res-input mono num"
value={Number.isFinite(value) ? value : 0}
step={step}
min={min}
max={max}
list={list}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v)) onChange(v);
}}
/>
);
}
/**
* Farb-Swatch mit nativem Color-Picker. Das <label> umschließt einen
* sichtbaren Swatch und den unsichtbaren color-input — Klick öffnet den
* Picker, der Swatch zeigt die Farbe live (nach DOSSIER ColorBar).
*/
function ColorField({
color,
onChange,
}: {
color: string;
onChange: (v: string) => void;
}) {
return (
<label className="res-color" title={color}>
<span className="res-color-swatch" style={{ background: color }} />
<input
type="color"
value={color}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
/** Pill-Dropdown (erbt das globale select-Styling). */
function SelectField<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
}) {
return (
<select
className="res-select"
value={value}
onChange={(e) => onChange(e.target.value as T)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
/** Runder Löschen-Knopf (Mülleimer-Icon) in der letzten Spalte. */
function DeleteButton({ onClick, title }: { onClick: () => void; title: string }) {
return (
<button className="res-delete" title={title} onClick={onClick}>
<svg
width={15}
height={15}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" />
<path d="M10 11v6M14 11v6" />
</svg>
</button>
);
}
// ── Tabellen-Gerüst ────────────────────────────────────────────────────────
/** Eine Spaltendefinition: Titel-Key + optionale rechtsbündige Ausrichtung. */
interface ResColumn {
/** i18n-Key des Spaltentitels (leerer String = titellose Spalte). */
titleKey: string;
/** Zahlen-Spalten rechtsbündig ausrichten (Titel + Zellen). */
align?: "right";
/** Optionaler i18n-Key für den Tooltip auf dem Spaltentitel. */
hintKey?: string;
}
/**
* Tabelle als CSS-Grid: eine sticky Kopfzeile mit Spaltentiteln, darunter eine
* Datenzeile je Item. Kopf und Zeilen erben `gridTemplateColumns` vom äußeren
* Grid, damit die Spalten exakt fluchten. Datenzeilen werden als Kinder
* übergeben (jede Zeile = `ResRow`).
*/
function ResTable({
columns,
template,
children,
}: {
columns: ResColumn[];
template: string;
children: React.ReactNode;
}) {
return (
<div className="res-table" style={{ gridTemplateColumns: template }}>
<div className="res-thead">
{columns.map((c, i) => (
<div
key={i}
className={`res-th${c.align === "right" ? " num" : ""}`}
title={c.hintKey ? t(c.hintKey) : undefined}
>
{c.titleKey ? t(c.titleKey) : ""}
</div>
))}
</div>
{children}
</div>
);
}
/** Eine Datenzeile der Tabelle (Zellen als Kinder, eine je Spalte). */
function ResRow({ children }: { children: React.ReactNode }) {
return <div className="res-tr">{children}</div>;
}
/** Eine Tabellenzelle; optional rechtsbündig (für Zahlen). */
function ResCell({
children,
align,
emphasis = false,
}: {
children: React.ReactNode;
align?: "right";
emphasis?: boolean;
}) {
return (
<div
className={`res-td${align === "right" ? " num" : ""}${
emphasis ? " emphasis" : ""
}`}
>
{children}
</div>
);
}
// ── Bauteile (Components) ──────────────────────────────────────────────────
// Spalten: Name | Farbe | Schraffur | Prio | Textur | (löschen).
const COMPONENT_COLUMNS: ResColumn[] = [
{ titleKey: "resources.col.name" },
{ titleKey: "resources.col.color" },
{ titleKey: "resources.col.hatch" },
{
titleKey: "resources.col.prio",
align: "right",
hintKey: "resources.col.prio.hint",
},
{ titleKey: "resources.col.texture" },
{ titleKey: "" },
];
const COMPONENT_TEMPLATE =
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) 64px minmax(100px, 0.8fr) 36px";
function ComponentRow({
component,
hatches,
onPatch,
onDelete,
}: {
component: Component;
hatches: HatchStyle[];
onPatch: (patch: Partial<Component>) => void;
onDelete: () => void;
}) {
const hatchOptions = hatches.map((h) => ({ value: h.id, label: h.name }));
return (
<ResRow>
<ResCell>
<TextField
value={component.name}
onChange={(name) => onPatch({ name })}
placeholder={t("resources.components.placeholder")}
/>
</ResCell>
<ResCell>
<ColorField
color={component.color}
onChange={(color) => onPatch({ color })}
/>
</ResCell>
<ResCell>
<SelectField
value={component.hatchId}
onChange={(hatchId) => onPatch({ hatchId })}
options={hatchOptions}
/>
</ResCell>
<ResCell align="right" emphasis>
<NumberField
value={component.joinPriority}
onChange={(joinPriority) => onPatch({ joinPriority })}
step={10}
min={0}
/>
</ResCell>
<ResCell>
<TextField
value={component.texture3d ?? ""}
onChange={(v) => onPatch({ texture3d: v || undefined })}
placeholder={t("resources.texture.empty")}
mono
/>
</ResCell>
<ResCell>
<DeleteButton
onClick={onDelete}
title={t("resources.delete.component", { name: component.name })}
/>
</ResCell>
</ResRow>
);
}
function ComponentsTab({
project,
onPatchComponent,
onAddComponent,
onDeleteComponent,
}: {
project: Project;
onPatchComponent: (id: string, patch: Partial<Component>) => void;
onAddComponent: () => void;
onDeleteComponent: (id: string) => void;
}) {
return (
<div className="res-tab">
<div className="res-hint">{t("resources.components.hint")}</div>
<ResTable columns={COMPONENT_COLUMNS} template={COMPONENT_TEMPLATE}>
{project.components.map((c) => (
<ComponentRow
key={c.id}
component={c}
hatches={project.hatches}
onPatch={(patch) => onPatchComponent(c.id, patch)}
onDelete={() => onDeleteComponent(c.id)}
/>
))}
{project.components.length === 0 && (
<EmptyState text={t("resources.components.empty")} />
)}
</ResTable>
<AddButton label={t("resources.add")} onClick={onAddComponent} />
</div>
);
}
// ── Schraffuren (Hatches) ──────────────────────────────────────────────────
// Spalten: Name | Muster | Maßstab | Winkel | Farbe | Linienstil | (löschen).
const HATCH_COLUMNS: ResColumn[] = [
{ titleKey: "resources.col.name" },
{ titleKey: "resources.col.pattern" },
{ titleKey: "resources.col.scale", align: "right" },
{ titleKey: "resources.col.angle", align: "right" },
{ titleKey: "resources.col.color" },
{ titleKey: "resources.col.lineStyle" },
{ titleKey: "" },
];
const HATCH_TEMPLATE =
"minmax(110px, 1fr) minmax(120px, 0.9fr) 64px 60px auto minmax(120px, 0.9fr) 36px";
function HatchRow({
hatch,
lineStyles,
onPatch,
onDelete,
}: {
hatch: HatchStyle;
lineStyles: LineStyle[];
onPatch: (patch: Partial<HatchStyle>) => void;
onDelete: () => void;
}) {
// Sentinel-Eintrag „— ohne —", da lineStyleId optional ist.
const NONE = "__none__";
const lineOptions = [
{ value: NONE, label: t("resources.lineStyle.none") },
...lineStyles.map((l) => ({ value: l.id, label: l.name })),
];
return (
<ResRow>
<ResCell>
<TextField
value={hatch.name}
onChange={(name) => onPatch({ name })}
placeholder={t("resources.hatches.placeholder")}
/>
</ResCell>
<ResCell>
<SelectField
value={hatch.pattern}
onChange={(pattern) => onPatch({ pattern })}
options={PATTERN_OPTIONS.map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
/>
</ResCell>
<ResCell align="right">
<NumberField
value={hatch.scale}
onChange={(scale) => onPatch({ scale })}
step={0.1}
min={0.01}
/>
</ResCell>
<ResCell align="right">
<NumberField
value={hatch.angle}
onChange={(angle) => onPatch({ angle })}
step={5}
/>
</ResCell>
<ResCell>
<ColorField color={hatch.color} onChange={(color) => onPatch({ color })} />
</ResCell>
<ResCell>
<SelectField
value={hatch.lineStyleId ?? NONE}
onChange={(v) => onPatch({ lineStyleId: v === NONE ? undefined : v })}
options={lineOptions}
/>
</ResCell>
<ResCell>
<DeleteButton
onClick={onDelete}
title={t("resources.delete.hatch", { name: hatch.name })}
/>
</ResCell>
</ResRow>
);
}
function HatchesTab({
project,
onPatchHatch,
onAddHatch,
onDeleteHatch,
onImportHatches,
}: {
project: Project;
onPatchHatch: (id: string, patch: Partial<HatchStyle>) => void;
onAddHatch: () => void;
onDeleteHatch: (id: string) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
}) {
return (
<div className="res-tab">
<div className="res-hint">{t("resources.hatches.hint")}</div>
<ResTable columns={HATCH_COLUMNS} template={HATCH_TEMPLATE}>
{project.hatches.map((h) => (
<HatchRow
key={h.id}
hatch={h}
lineStyles={project.lineStyles}
onPatch={(patch) => onPatchHatch(h.id, patch)}
onDelete={() => onDeleteHatch(h.id)}
/>
))}
{project.hatches.length === 0 && (
<EmptyState text={t("resources.hatches.empty")} />
)}
</ResTable>
<div className="res-add-row">
<button className="res-add" onClick={onAddHatch}>
<span className="res-add-plus">+</span> {t("resources.add")}
</button>
<FileImportButton
accept=".pat,text/plain"
label={t("resources.import.pat")}
onText={(text) => onImportHatches(patToHatches(text))}
/>
</div>
</div>
);
}
// ── Linien (Line Styles) ───────────────────────────────────────────────────
/** Kleine Vorschau eines Linienstils (Stärke + Strichmuster) als SVG. */
function LinePreview({ style }: { style: LineStyle }) {
// Stärke (mm) optisch skaliert auf 16 px für die Vorschau.
const px = Math.max(1, Math.min(6, style.weight * 6));
const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined;
return (
<svg className="res-line-preview" viewBox="0 0 120 12" preserveAspectRatio="none">
<line
x1={4}
y1={6}
x2={116}
y2={6}
stroke={style.color}
strokeWidth={px}
strokeDasharray={dasharray}
strokeLinecap="butt"
/>
</svg>
);
}
// Spalten: Name | Stärke (mm) | Farbe | Strich | Vorschau | (löschen).
const LINE_COLUMNS: ResColumn[] = [
{ titleKey: "resources.col.name" },
{ titleKey: "resources.col.weight", align: "right" },
{ titleKey: "resources.col.color" },
{ titleKey: "resources.col.stroke" },
{ titleKey: "resources.col.preview" },
{ titleKey: "" },
];
const LINE_TEMPLATE =
"minmax(110px, 1fr) 80px auto minmax(120px, 0.9fr) minmax(90px, 1fr) 36px";
function LineStyleRow({
style,
onPatch,
onDelete,
}: {
style: LineStyle;
onPatch: (patch: Partial<LineStyle>) => void;
onDelete: () => void;
}) {
return (
<ResRow>
<ResCell>
<TextField
value={style.name}
onChange={(name) => onPatch({ name })}
placeholder={t("resources.lines.placeholder")}
/>
</ResCell>
<ResCell align="right">
<NumberField
value={style.weight}
onChange={(weight) => onPatch({ weight })}
step={0.05}
min={0.01}
max={2}
list="pen-weights"
/>
</ResCell>
<ResCell>
<ColorField color={style.color} onChange={(color) => onPatch({ color })} />
</ResCell>
<ResCell>
<SelectField
value={dashKey(style.dash)}
onChange={(key) => {
const opt = DASH_OPTIONS.find((o) => o.key === key);
if (opt) onPatch({ dash: opt.dash });
}}
options={DASH_OPTIONS.map((o) => ({ value: o.key, label: t(o.labelKey) }))}
/>
</ResCell>
<ResCell>
<LinePreview style={style} />
</ResCell>
<ResCell>
<DeleteButton
onClick={onDelete}
title={t("resources.delete.lineStyle", { name: style.name })}
/>
</ResCell>
</ResRow>
);
}
function LinesTab({
project,
onPatchLineStyle,
onAddLineStyle,
onDeleteLineStyle,
onImportLineStyles,
}: {
project: Project;
onPatchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
onAddLineStyle: () => void;
onDeleteLineStyle: (id: string) => void;
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
}) {
return (
<div className="res-tab">
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */}
<datalist id="pen-weights">
{PEN_WEIGHTS.map((w) => (
<option key={w} value={w} />
))}
</datalist>
<div className="res-hint">{t("resources.lines.hint")}</div>
<ResTable columns={LINE_COLUMNS} template={LINE_TEMPLATE}>
{project.lineStyles.map((l) => (
<LineStyleRow
key={l.id}
style={l}
onPatch={(patch) => onPatchLineStyle(l.id, patch)}
onDelete={() => onDeleteLineStyle(l.id)}
/>
))}
{project.lineStyles.length === 0 && (
<EmptyState text={t("resources.lines.empty")} />
)}
</ResTable>
<div className="res-add-row">
<button className="res-add" onClick={onAddLineStyle}>
<span className="res-add-plus">+</span> {t("resources.add")}
</button>
<FileImportButton
accept=".lin,text/plain"
label={t("resources.import.lin")}
onText={(text) => onImportLineStyles(linToStyles(text))}
/>
</div>
</div>
);
}
/**
* Datei-Import-Schaltfläche (verstecktes <input type=file>). Liest die gewählte
* Datei als Text und reicht ihn nach oben. Für .lin/.pat-Import.
*/
function FileImportButton({
accept,
label,
onText,
}: {
accept: string;
label: string;
onText: (text: string) => void;
}) {
const ref = useRef<HTMLInputElement | null>(null);
return (
<>
<button className="res-add" onClick={() => ref.current?.click()}>
{label}
</button>
<input
ref={ref}
type="file"
accept={accept}
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) {
const r = new FileReader();
r.onload = () => onText(String(r.result ?? ""));
r.readAsText(f);
}
e.target.value = "";
}}
/>
</>
);
}
/** .lin-Text → id-lose Linienstile (schwarze Haarlinie 0.18 als Default). */
function linToStyles(text: string): Omit<LineStyle, "id">[] {
return parseLin(text).map((p) => ({
name: p.name,
weight: 0.18,
color: "#1a1a1a",
dash: p.dash,
}));
}
/**
* .pat-Text → id-lose Schraffuren. Unser Modell kennt nur feste Muster; daher
* approximieren wir: ≥2 Linienfamilien → Kreuzschraffur, sonst Diagonal, mit dem
* Winkel der ersten Familie. (Vollwertiges custom-Pattern später.)
*/
function patToHatches(text: string): Omit<HatchStyle, "id">[] {
return parsePat(text).map((p) => ({
name: p.name,
pattern: (p.families.length >= 2 ? "crosshatch" : "diagonal") as HatchPattern,
scale: 1,
angle: p.families[0]?.angle ?? 45,
color: "#1a1a1a",
lineStyleId: "hatch-line",
}));
}
// ── Gemeinsame kleine Bausteine ────────────────────────────────────────────
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<div className="res-add-row">
<button className="res-add" onClick={onClick}>
<span className="res-add-plus">+</span> {label}
</button>
</div>
);
}
function EmptyState({ text }: { text: string }) {
return <div className="res-empty">{text}</div>;
}
// ── Manager-Schublade ──────────────────────────────────────────────────────
type TabId = "components" | "hatches" | "lines";
const TABS: { id: TabId; labelKey: string }[] = [
{ id: "components", labelKey: "resources.tab.components" },
{ id: "hatches", labelKey: "resources.tab.hatches" },
{ id: "lines", labelKey: "resources.tab.lines" },
];
/** Bündel der immutablen Mutationen (von App über setProject bereitgestellt). */
export interface ResourceManagerHandlers {
onPatchComponent: (id: string, patch: Partial<Component>) => void;
onAddComponent: () => void;
onDeleteComponent: (id: string) => void;
onPatchHatch: (id: string, patch: Partial<HatchStyle>) => void;
onAddHatch: () => void;
onDeleteHatch: (id: string) => void;
onPatchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
onAddLineStyle: () => void;
onDeleteLineStyle: (id: string) => void;
/** Import: fügt fertige (id-lose) Linienstile/Schraffuren hinzu (.lin/.pat). */
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
}
/**
* Rechte Schublade mit drei Tabs (Bauteile / Schraffuren / Linien). Wird über
* den „Ressourcen"-Eintrag der Oberleiste geöffnet und mutiert das Projekt
* live über die gereichten Handler.
*/
export function ResourceManager({
project,
onClose,
handlers,
}: {
project: Project;
onClose: () => void;
handlers: ResourceManagerHandlers;
}) {
const [tab, setTab] = useState<TabId>("components");
return (
<div className="res-overlay" onClick={onClose}>
<aside
className="res-drawer"
role="dialog"
aria-label={t("resources.label")}
onClick={(e) => e.stopPropagation()}
>
<header className="res-head">
<div className="res-head-title">
<span className="res-head-icon">
<EyeIcon open />
</span>
{t("resources.title")}
</div>
<button
className="res-close"
onClick={onClose}
aria-label={t("resources.close")}
>
×
</button>
</header>
<nav className="res-tabs" role="tablist">
{TABS.map((tabDef) => (
<button
key={tabDef.id}
role="tab"
aria-selected={tab === tabDef.id}
className={`res-tab-btn${tab === tabDef.id ? " active" : ""}`}
onClick={() => setTab(tabDef.id)}
>
{t(tabDef.labelKey)}
</button>
))}
</nav>
<div className="res-body">
{tab === "components" && (
<ComponentsTab
project={project}
onPatchComponent={handlers.onPatchComponent}
onAddComponent={handlers.onAddComponent}
onDeleteComponent={handlers.onDeleteComponent}
/>
)}
{tab === "hatches" && (
<HatchesTab
project={project}
onPatchHatch={handlers.onPatchHatch}
onAddHatch={handlers.onAddHatch}
onDeleteHatch={handlers.onDeleteHatch}
onImportHatches={handlers.onImportHatches}
/>
)}
{tab === "lines" && (
<LinesTab
project={project}
onPatchLineStyle={handlers.onPatchLineStyle}
onAddLineStyle={handlers.onAddLineStyle}
onDeleteLineStyle={handlers.onDeleteLineStyle}
onImportLineStyles={handlers.onImportLineStyles}
/>
)}
</div>
</aside>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
// Footer / Status-Leiste (unten, ~22 px) — MVP nach docs/design/topbar-footer.md.
//
// Layout: links der Werkzeug-Hinweis (kontextueller Text des aktiven
// Werkzeugs), rechts ein Cluster aus abgeleiteten View-/Modell-Werten:
// Cursor X/Y (Meter, „—" wenn nicht über dem Plan) · Einheit „m" ·
// Massstab 1:N · Zoom % · aktives Geschoss · aktive Ebene.
//
// Rein gesteuert (controlled): alle Werte kommen aus App (Modell + View-State),
// keine eigene Logik. Bezeichner englisch, UI-Text/Kommentare deutsch.
import type { Vec2 } from "../model/types";
import { t } from "../i18n";
export interface StatusBarProps {
/** Kontextueller Werkzeug-Hinweis links (z. B. „Auswahl"). */
hint: string;
/** Cursor in Modell-Metern oder `null` (nicht über dem Plan). */
cursor: Vec2 | null;
/** Einheit aus dem Projekt (vorerst fix „m"). */
unit: string;
/**
* LIVE Papier-Massstab-Nenner (1:N) aus der View-Transform oder `null`
* (keine Plan-Ansicht aktiv → „—").
*/
scaleDenominator: number | null;
/** Live-Zoom in Prozent oder `null` (keine zoombare Ansicht aktiv). */
zoomPercent: number | null;
/** Name des aktiven Geschosses / der aktiven Zeichnungsebene. */
activeFloor: string;
/** Name der aktiven Ebene (Kategorie) oder „—". */
activeLayer: string;
}
/** Ein Wert-Feld im rechten Cluster: kleine Beschriftung + Wert. */
function Field({ label, value }: { label: string; value: string }) {
return (
<span className="sb-field">
<span className="sb-key">{label}</span>
<span className="sb-val">{value}</span>
</span>
);
}
/** Vertikaler Trenner im Cluster. */
function Sep() {
return <span className="sb-sep" aria-hidden="true" />;
}
/** Eine Meterzahl für die Statusleiste (3 Nachkommastellen, ohne Einheit). */
const fmt = (n: number): string => n.toFixed(3);
export function StatusBar({
hint,
cursor,
unit,
scaleDenominator,
zoomPercent,
activeFloor,
activeLayer,
}: StatusBarProps) {
const coords = cursor
? `${fmt(cursor.x)}, ${fmt(cursor.y)}`
: "—";
return (
<footer className="statusbar">
{/* Links: Werkzeug-Hinweis. */}
<span className="sb-hint">{hint}</span>
{/* Rechts: abgeleiteter Wert-Cluster. */}
<div className="sb-cluster">
<Field label={t("status.xy")} value={coords} />
<Sep />
<Field label={t("status.unit")} value={unit} />
<Sep />
<Field
label={t("status.scale")}
value={scaleDenominator == null ? "—" : `1:${Math.round(scaleDenominator)}`}
/>
<Sep />
<Field
label={t("status.zoom")}
value={zoomPercent == null ? "—" : `${Math.round(zoomPercent)}%`}
/>
<Sep />
<Field label={t("status.floor")} value={activeFloor} />
<Sep />
<Field label={t("status.layer")} value={activeLayer} />
</div>
</footer>
);
}
+753
View File
@@ -0,0 +1,753 @@
// 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";
/** 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),
* Drahtgitter, Verdeckte Kanten (Hidden-Line). Wirkt nur in der Perspektive.
*/
export type RenderMode = "shaded" | "white" | "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;
// 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;
// Linien-Darstellung: Display (Haarlinien) ↔ Print (echte mm-Strichstärken).
lineMode: "display" | "print";
onLineMode: (m: "display" | "print") => void;
/** Nur im Grundriss sinnvoll. */
lineModeEnabled: 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;
}
/**
* 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" />;
}
/**
* 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>
);
}
}
export function TopBar({
project,
viewToggleEnabled,
viewType,
onViewType,
view3d,
onView3d,
fov,
onFov,
detail,
onDetail,
detailEnabled,
scaleDenominator,
onApplyScale,
liveScale,
zoomPercent,
zoomEnabled,
onFit,
onFitSelection,
onZoom100,
renderMode,
onRenderMode,
renderModeEnabled,
planColorMode,
onPlanColorMode,
referenceLines,
onReferenceLines,
referenceLinesEnabled,
lineMode,
onLineMode,
lineModeEnabled,
combos,
resourcesOpen,
onToggleResources,
layoutMenu,
}: 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. */}
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field">
<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 „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">
<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>
<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 />
{/* 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: "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={`tb-toggle${referenceLines ? " active" : ""}`}
onClick={() => onReferenceLines(!referenceLines)}
disabled={!referenceLinesEnabled}
aria-pressed={referenceLines}
title={
referenceLinesEnabled
? t("topbar.referenceLines.hint")
: t("topbar.referenceLines.hintDisabled")
}
>
{t("topbar.referenceLines")}
</button>
</div>
<Sep />
{/* Linien-Darstellung: Display (alle Haarlinien) ↔ Print (echte mm-
Strichstärken). 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")}
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>
</div>
{/* Rechte Gruppe: Layout-Menü · Ressourcen · Projektname. */}
<div className="topbar-right">
{layoutMenu}
<button
className={`res-trigger${resourcesOpen ? " active" : ""}`}
onClick={onToggleResources}
title={t("topbar.resources.hint")}
>
{t("topbar.resources")}
</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).
+65
View File
@@ -0,0 +1,65 @@
// Modusleiste der aktiven Transformation (Bewegen/Spiegeln/Drehen), im Stil von
// Vectorworks. Zeigt die Operation, die Vervielfältigungs-Modi U/I/O/P und einen
// kontextuellen Klick-Hinweis. Tasten U/I/O/P schalten die Modi (in App); ein
// Klick auf die Schaltflächen tut dasselbe. Rein gesteuert (controlled).
import { t } from "../i18n";
import type { CopyMode, TransformOp } from "../tools/transform";
const MODES: { key: string; mode: CopyMode; labelKey: string }[] = [
{ key: "U", mode: "move", labelKey: "transform.mode.move" },
{ key: "I", mode: "copy", labelKey: "transform.mode.copy" },
{ key: "O", mode: "array", labelKey: "transform.mode.array" },
{ key: "P", mode: "distribute", labelKey: "transform.mode.distribute" },
];
/** Kontextueller Hinweis je Operation + Anzahl bereits gesetzter Punkte. */
function hintKey(op: TransformOp, pointsLen: number): string {
return `transform.hint.${op}.${Math.min(pointsLen, op === "rotate" ? 2 : 1)}`;
}
export function TransformBar({
op,
copyMode,
count,
pointsLen,
onMode,
onCancel,
}: {
op: TransformOp;
copyMode: CopyMode;
count: number;
pointsLen: number;
onMode: (mode: CopyMode) => void;
onCancel: () => void;
}) {
return (
<div className="transform-bar" role="group" aria-label={t("transform.bar")}>
<span className="tf-op">{t(`transform.${op}`)}</span>
<span className="tf-sep" />
<div className="tf-modes">
{MODES.map((m) => (
<button
key={m.mode}
className={`tf-mode${copyMode === m.mode ? " active" : ""}`}
onClick={() => onMode(m.mode)}
title={t(m.labelKey)}
>
<span className="tf-key">{m.key}</span>
<span className="tf-mode-label">
{t(m.labelKey)}
{(m.mode === "array" || m.mode === "distribute") && copyMode === m.mode
? ` ×${count}`
: ""}
</span>
</button>
))}
</div>
<span className="tf-sep" />
<span className="tf-hint">{t(hintKey(op, pointsLen))}</span>
<button className="tf-cancel" onClick={onCancel} title={t("transform.cancel")}>
</button>
</div>
);
}