3d2d4d6321
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung (konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text- Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback, falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform) fuer fluessige Interaktion ohne React-Re-Render je Frame. Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM (Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext- Import, Tauri-Compute-Boundary-PoC).
214 lines
7.4 KiB
TypeScript
214 lines
7.4 KiB
TypeScript
// 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;
|
|
/** Aktuell gewählte Option (hervorgehoben)? */
|
|
active?: boolean;
|
|
/** Tastatur-Kürzel (z. B. „U"), wirkt AUCH bei fokussiertem Eingabefeld. */
|
|
key?: string;
|
|
}
|
|
|
|
/** Ein Zahlenfeld des Tab-Feld-Zyklus (§2.7). */
|
|
export interface CommandLineField {
|
|
id: string;
|
|
labelKey: string;
|
|
/** Gelockter ODER live (maus-folgender) Wert; null = (noch) keiner. */
|
|
value: number | null;
|
|
/** Ist der Wert gelockt (getippt) statt live (folgt der Maus)? */
|
|
locked: boolean;
|
|
/** 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>) => {
|
|
// Options-Kürzel (z. B. U/I/O/P der Kopiermodi): auch bei fokussiertem Feld
|
|
// aktiv. Ein einzelner Buchstabe, der einer Option zugeordnet ist, löst die
|
|
// Option aus statt in die Zahleneingabe zu fallen (Ziffern bleiben Eingabe).
|
|
if (
|
|
active &&
|
|
e.key.length === 1 &&
|
|
!e.ctrlKey &&
|
|
!e.metaKey &&
|
|
!e.altKey
|
|
) {
|
|
const opt = options.find(
|
|
(o) => o.key && o.key.toLowerCase() === e.key.toLowerCase(),
|
|
);
|
|
if (opt) {
|
|
e.preventDefault();
|
|
onOption(opt.id);
|
|
return;
|
|
}
|
|
}
|
|
// 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${o.active ? " active" : ""}`}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
onOption(o.id);
|
|
}}
|
|
>
|
|
{o.key && <span className="cmdline-option-key">{o.key}</span>}
|
|
{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) => {
|
|
// Winkelfeld in Grad, sonst Längen in Metern. Gelockte Werte fest
|
|
// (mehr Nachkommastellen), live (maus-folgende) Werte gerundet.
|
|
const isAngle = f.id === "angle";
|
|
const digits = isAngle ? (f.locked ? 1 : 0) : f.locked ? 3 : 2;
|
|
const unit = isAngle ? "°" : " m";
|
|
const text =
|
|
f.value == null
|
|
? "—"
|
|
: `${Number(f.value.toFixed(digits)).toString()}${unit}`;
|
|
return (
|
|
<span
|
|
key={f.id}
|
|
className={
|
|
`cmdline-field${f.active ? " active" : ""}` +
|
|
(f.locked ? " locked" : f.value != null ? " live" : "")
|
|
}
|
|
title={t(f.labelKey as TranslationKey)}
|
|
>
|
|
<span className="cmdline-field-label">{t(f.labelKey as TranslationKey)}</span>
|
|
<span className="cmdline-field-val">{text}</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>
|
|
);
|
|
},
|
|
);
|