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:
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 1–6 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user