2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung (konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text- Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback, falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform) fuer fluessige Interaktion ohne React-Re-Render je Frame. Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM (Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext- Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
+51
-13
@@ -13,14 +13,20 @@ 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 Wert (oder null = folgt der Maus). */
|
||||
/** 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;
|
||||
}
|
||||
@@ -73,6 +79,25 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -130,12 +155,13 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
className="cmdline-option"
|
||||
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>
|
||||
@@ -144,18 +170,30 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
|
||||
)}
|
||||
{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()}
|
||||
{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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
// Standort-Import-Dialog (modal). Sucht einen Ort/Adresse (geo.admin
|
||||
// SearchServer), lässt einen Radius wählen und importiert Gebäude-Grundrisse
|
||||
// (swisstopo) sowie OSM-Features (Gebäude/Strassen/Wasser/Grün) als Kontext-
|
||||
// Geometrie ins Projekt.
|
||||
//
|
||||
// Muster wie ExportPdfDialog/ImportDialog: abgedunkeltes Overlay, Klick auf den
|
||||
// Hintergrund + Esc schließen. Der eigentliche Netz-Abruf läuft hier (io/*); das
|
||||
// Ergebnis wird über `onImport(objs)` nach oben gereicht (SitePanel legt es via
|
||||
// Host in `project.context` ab). Bezeichner englisch, UI-Text/Kommentare deutsch
|
||||
// (CONVENTIONS.md).
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { geocode, fetchBuildings } from "../io/swissTopo";
|
||||
import type { GeocodeCandidate } from "../io/swissTopo";
|
||||
import { fetchOsm } from "../io/osm";
|
||||
import type { OsmSelection } from "../io/osm";
|
||||
import { featuresToContextObjects } from "../io/geoContext";
|
||||
import type { GeoFeature } from "../io/geoContext";
|
||||
import type { GeoOrigin } from "../io/lv95";
|
||||
import type { ContextObject } from "../model/types";
|
||||
|
||||
export interface ContextImportDialogProps {
|
||||
/** Übernimmt die fertigen Kontext-Objekte (nach erfolgreichem Import). */
|
||||
onImport: (objs: ContextObject[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Sources = {
|
||||
swissBuildings: boolean;
|
||||
osmBuildings: boolean;
|
||||
osmRoads: boolean;
|
||||
osmWater: boolean;
|
||||
osmGreen: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SOURCES: Sources = {
|
||||
swissBuildings: true,
|
||||
osmBuildings: false,
|
||||
osmRoads: true,
|
||||
osmWater: true,
|
||||
osmGreen: true,
|
||||
};
|
||||
|
||||
export function ContextImportDialog({
|
||||
onImport,
|
||||
onClose,
|
||||
}: ContextImportDialogProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [candidates, setCandidates] = useState<GeocodeCandidate[]>([]);
|
||||
const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
|
||||
const [radius, setRadius] = useState(200);
|
||||
const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES);
|
||||
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const searchSeq = useRef(0);
|
||||
|
||||
const runSearch = async () => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
setError(null);
|
||||
setSearching(true);
|
||||
const seq = ++searchSeq.current;
|
||||
try {
|
||||
const res = await geocode(q, 8);
|
||||
if (seq !== searchSeq.current) return; // veraltet
|
||||
setCandidates(res);
|
||||
setSelected(res[0] ?? null);
|
||||
if (res.length === 0) setStatus(t("ctxImport.noResults"));
|
||||
else setStatus(null);
|
||||
} catch {
|
||||
if (seq === searchSeq.current) setError(t("ctxImport.searchError"));
|
||||
} finally {
|
||||
if (seq === searchSeq.current) setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (key: keyof Sources) =>
|
||||
setSources((s) => ({ ...s, [key]: !s[key] }));
|
||||
|
||||
const anySource =
|
||||
sources.swissBuildings ||
|
||||
sources.osmBuildings ||
|
||||
sources.osmRoads ||
|
||||
sources.osmWater ||
|
||||
sources.osmGreen;
|
||||
|
||||
const runImport = async () => {
|
||||
if (!selected || !anySource) return;
|
||||
setError(null);
|
||||
setImporting(true);
|
||||
setStatus(t("ctxImport.working"));
|
||||
try {
|
||||
const center = selected.lv95;
|
||||
let origin: GeoOrigin | undefined;
|
||||
const all: GeoFeature[] = [];
|
||||
|
||||
if (sources.swissBuildings) {
|
||||
const r = await fetchBuildings(center, radius, origin);
|
||||
origin = r.origin;
|
||||
all.push(...r.features);
|
||||
}
|
||||
|
||||
const osmSel: OsmSelection = {
|
||||
buildings: sources.osmBuildings,
|
||||
roads: sources.osmRoads,
|
||||
water: sources.osmWater,
|
||||
green: sources.osmGreen,
|
||||
};
|
||||
if (
|
||||
osmSel.buildings ||
|
||||
osmSel.roads ||
|
||||
osmSel.water ||
|
||||
osmSel.green
|
||||
) {
|
||||
const r = await fetchOsm(center, radius, osmSel, origin);
|
||||
origin = r.origin;
|
||||
all.push(...r.features);
|
||||
}
|
||||
|
||||
if (all.length === 0) {
|
||||
setError(t("ctxImport.empty"));
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const objs = featuresToContextObjects(all, selected.label);
|
||||
onImport(objs);
|
||||
setStatus(
|
||||
t("ctxImport.imported", { count: all.length, sets: objs.length }),
|
||||
);
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t("ctxImport.importError"));
|
||||
setStatus(null);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="imp-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="imp-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("ctxImport.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="imp-head">
|
||||
<span className="imp-head-title">{t("ctxImport.title")}</span>
|
||||
<button
|
||||
className="imp-close"
|
||||
onClick={onClose}
|
||||
aria-label={t("ctxImport.close")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="imp-body">
|
||||
{/* Ortssuche */}
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("ctxImport.location")}</div>
|
||||
<div className="cim-search-row">
|
||||
<input
|
||||
className="cim-input"
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder={t("ctxImport.searchPlaceholder")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void runSearch();
|
||||
}
|
||||
}}
|
||||
aria-label={t("ctxImport.location")}
|
||||
/>
|
||||
<button
|
||||
className="imp-btn"
|
||||
onClick={() => void runSearch()}
|
||||
disabled={searching || !query.trim()}
|
||||
>
|
||||
{searching ? t("ctxImport.searching") : t("ctxImport.search")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<ul className="cim-results">
|
||||
{candidates.map((c, i) => (
|
||||
<li
|
||||
key={`${c.label}-${i}`}
|
||||
className={
|
||||
"cim-result" + (c === selected ? " selected" : "")
|
||||
}
|
||||
onClick={() => setSelected(c)}
|
||||
title={c.label}
|
||||
>
|
||||
{c.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Radius */}
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("ctxImport.radius")}</div>
|
||||
<div className="cim-radius-row">
|
||||
<input
|
||||
className="cim-input cim-input-num"
|
||||
type="number"
|
||||
min={20}
|
||||
max={2000}
|
||||
step={10}
|
||||
value={radius}
|
||||
onChange={(e) =>
|
||||
setRadius(
|
||||
Math.max(20, Math.min(2000, Number(e.target.value) || 0)),
|
||||
)
|
||||
}
|
||||
aria-label={t("ctxImport.radius")}
|
||||
/>
|
||||
<span className="cim-unit">m</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quellen */}
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("ctxImport.sources")}</div>
|
||||
<div className="cim-checks">
|
||||
<label className="cim-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sources.swissBuildings}
|
||||
onChange={() => toggle("swissBuildings")}
|
||||
/>
|
||||
{t("ctxImport.src.swissBuildings")}
|
||||
</label>
|
||||
<label className="cim-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sources.osmBuildings}
|
||||
onChange={() => toggle("osmBuildings")}
|
||||
/>
|
||||
{t("ctxImport.src.osmBuildings")}
|
||||
</label>
|
||||
<label className="cim-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sources.osmRoads}
|
||||
onChange={() => toggle("osmRoads")}
|
||||
/>
|
||||
{t("ctxImport.src.osmRoads")}
|
||||
</label>
|
||||
<label className="cim-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sources.osmWater}
|
||||
onChange={() => toggle("osmWater")}
|
||||
/>
|
||||
{t("ctxImport.src.osmWater")}
|
||||
</label>
|
||||
<label className="cim-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sources.osmGreen}
|
||||
onChange={() => toggle("osmGreen")}
|
||||
/>
|
||||
{t("ctxImport.src.osmGreen")}
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{status && <div className="cim-status">{status}</div>}
|
||||
{error && <div className="imp-warn">{error}</div>}
|
||||
</div>
|
||||
|
||||
<footer className="imp-foot">
|
||||
<button className="imp-btn" onClick={onClose}>
|
||||
{t("ctxImport.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="imp-btn primary"
|
||||
onClick={() => void runImport()}
|
||||
disabled={importing || !selected || !anySource}
|
||||
>
|
||||
{importing ? t("ctxImport.importing") : t("ctxImport.confirm")}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
// bzw. den festen `placeholder`.
|
||||
//
|
||||
// Reine Darstellung + Callbacks; voll gesteuert. Identifier englisch, UI-Text
|
||||
// kommt als Props (i18n) herein (CLAUDE.md).
|
||||
// kommt als Props (i18n) herein (CONVENTIONS.md).
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// DXF-Export-Dialog (modal). Fragt „Schraffuren mitexportieren?" ab und weist auf
|
||||
// die Meter-Einheit hin (DXF ist Modell-Space in echten Metern, 1:1). Muster wie
|
||||
// ExportPdfDialog: abgedunkeltes Overlay, Klick auf den Hintergrund + Esc schliessen.
|
||||
//
|
||||
// Rein gesteuert: hält nur Formular-Zustand und meldet die Entscheidung über
|
||||
// `onExport` nach oben (App führt den Export aus, da es den Plan erzeugt).
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
|
||||
/** Entscheidung, die der Dialog beim Bestätigen liefert. */
|
||||
export interface ExportDxfDecision {
|
||||
includeHatches: boolean;
|
||||
}
|
||||
|
||||
export interface ExportDxfDialogProps {
|
||||
/** Titel/Geschossname (nur Anzeige im Dialogkopf). */
|
||||
planTitle: string;
|
||||
onExport: (decision: ExportDxfDecision) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportDxfDialog({ planTitle, onExport, onClose }: ExportDxfDialogProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const [includeHatches, setIncludeHatches] = useState(true);
|
||||
|
||||
const onConfirm = () => onExport({ includeHatches });
|
||||
|
||||
return (
|
||||
<div className="imp-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="imp-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("exportDxf.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="imp-head">
|
||||
<span className="imp-head-title">{t("exportDxf.title")}</span>
|
||||
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="imp-body">
|
||||
<div className="imp-file">
|
||||
<span className="imp-file-label">{t("export.plan")}</span>
|
||||
<span className="imp-file-name" title={planTitle}>
|
||||
{planTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("exportDxf.hatches")}</div>
|
||||
<Dropdown
|
||||
value={includeHatches ? "yes" : "no"}
|
||||
onChange={(v) => setIncludeHatches(v === "yes")}
|
||||
title={t("exportDxf.hatches")}
|
||||
options={[
|
||||
{ value: "yes", label: t("exportDxf.hatches.yes") },
|
||||
{ value: "no", label: t("exportDxf.hatches.no") },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="imp-note">{t("exportDxf.unitsNote")}</div>
|
||||
</div>
|
||||
|
||||
<footer className="imp-foot">
|
||||
<button className="imp-btn" onClick={onClose}>
|
||||
{t("export.cancel")}
|
||||
</button>
|
||||
<button className="imp-btn primary" onClick={onConfirm}>
|
||||
{t("export.confirm")}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// PDF-Export-Dialog (modal). Fragt Massstab (1:N), Papierformat (A4/A3) und
|
||||
// Ausrichtung (Hoch/Quer) ab und löst den Vektor-PDF-Export aus. Muster wie
|
||||
// ImportDialog: abgedunkeltes Overlay, Klick auf den Hintergrund + Esc schließen.
|
||||
// Themedes Dropdown für die Auswahlfelder.
|
||||
//
|
||||
// Rein gesteuert: hält nur Formular-Zustand und meldet die Entscheidung über
|
||||
// `onExport` nach oben (App führt den eigentlichen Export aus, da es den Plan
|
||||
// erzeugt). Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import { SCALE_PRESETS } from "./TopBar";
|
||||
import type { Orientation, PaperFormat } from "../export/exportPdf";
|
||||
import { pageSizeMm } from "../export/exportPdf";
|
||||
|
||||
/** Entscheidung, die der Dialog beim Bestätigen liefert. */
|
||||
export interface ExportPdfDecision {
|
||||
scaleDenominator: number;
|
||||
paper: PaperFormat;
|
||||
orientation: Orientation;
|
||||
}
|
||||
|
||||
export interface ExportPdfDialogProps {
|
||||
/** Vorbelegung des Massstabs (aktueller Plan-Massstab). */
|
||||
initialScale: number;
|
||||
/** Titel/Geschossname (nur Anzeige im Dialogkopf). */
|
||||
planTitle: string;
|
||||
/**
|
||||
* Massstäbliche Inhalts-Maße des Plans in Metern (Breite/Höhe), für die
|
||||
* „passt aufs Blatt?"-Warnung. Optional.
|
||||
*/
|
||||
contentMeters?: { w: number; h: number };
|
||||
onExport: (decision: ExportPdfDecision) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportPdfDialog({
|
||||
initialScale,
|
||||
planTitle,
|
||||
contentMeters,
|
||||
onExport,
|
||||
onClose,
|
||||
}: ExportPdfDialogProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const [scale, setScale] = useState<number>(initialScale);
|
||||
const [paper, setPaper] = useState<PaperFormat>("a4");
|
||||
const [orientation, setOrientation] = useState<Orientation>("landscape");
|
||||
|
||||
// Passt der Plan im gewählten Massstab aufs Blatt (abzüglich Rand)? Best-effort-
|
||||
// Hinweis; der Export läuft trotzdem (Plan ragt dann über den Blattrand).
|
||||
const fits = (() => {
|
||||
if (!contentMeters) return true;
|
||||
const { w: pageW, h: pageH } = pageSizeMm(paper, orientation);
|
||||
const margin = 12; // Rand + etwas Luft fürs Schriftfeld
|
||||
const k = 1000 / scale; // mm je Meter
|
||||
return (
|
||||
contentMeters.w * k <= pageW - margin * 2 &&
|
||||
contentMeters.h * k <= pageH - margin * 2
|
||||
);
|
||||
})();
|
||||
|
||||
const onConfirm = () => onExport({ scaleDenominator: scale, paper, orientation });
|
||||
|
||||
const scaleOptions = SCALE_PRESETS.map((n) => ({
|
||||
value: String(n),
|
||||
label: `1:${n}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="imp-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="imp-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("export.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="imp-head">
|
||||
<span className="imp-head-title">{t("export.title")}</span>
|
||||
<button
|
||||
className="imp-close"
|
||||
onClick={onClose}
|
||||
aria-label={t("export.close")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="imp-body">
|
||||
<div className="imp-file">
|
||||
<span className="imp-file-label">{t("export.plan")}</span>
|
||||
<span className="imp-file-name" title={planTitle}>
|
||||
{planTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("export.scale")}</div>
|
||||
<Dropdown
|
||||
value={String(scale)}
|
||||
onChange={(v) => setScale(Number(v))}
|
||||
title={t("export.scale")}
|
||||
triggerClassName="tb-dd-mono"
|
||||
options={scaleOptions}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("export.paper")}</div>
|
||||
<Dropdown
|
||||
value={paper}
|
||||
onChange={(v) => setPaper(v as PaperFormat)}
|
||||
title={t("export.paper")}
|
||||
options={[
|
||||
{ value: "a4", label: "A4" },
|
||||
{ value: "a3", label: "A3" },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("export.orientation")}</div>
|
||||
<Dropdown
|
||||
value={orientation}
|
||||
onChange={(v) => setOrientation(v as Orientation)}
|
||||
title={t("export.orientation")}
|
||||
options={[
|
||||
{ value: "portrait", label: t("export.orientation.portrait") },
|
||||
{ value: "landscape", label: t("export.orientation.landscape") },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{!fits && <div className="imp-warn">{t("export.warnFit")}</div>}
|
||||
</div>
|
||||
|
||||
<footer className="imp-foot">
|
||||
<button className="imp-btn" onClick={onClose}>
|
||||
{t("export.cancel")}
|
||||
</button>
|
||||
<button className="imp-btn primary" onClick={onConfirm}>
|
||||
{t("export.confirm")}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
-3
@@ -11,14 +11,14 @@
|
||||
// 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
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.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 type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser";
|
||||
import {
|
||||
contoursToDrawings,
|
||||
countContours,
|
||||
@@ -52,6 +52,21 @@ function kindBadgeKey(kind: DrawingLevelKind): TranslationKey {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert das Entity-Typ-Histogramm der Import-Diagnose zu einer kompakten
|
||||
* Daten-Zeile, z. B. „120 Entities — INSERT×80, ARC×30, LINE×10". Die Entity-
|
||||
* Typ-Codes sind technische DWG-Bezeichner (keine übersetzbare UI-Prosa); nur
|
||||
* die Gesamtzahl ist eine reine Zahl. Bei 0 Entities zeigt sie „0 Entities" —
|
||||
* das deutet auf ein Encoding-/Versions-Problem (nicht auf eine Abdeckungslücke).
|
||||
*/
|
||||
function formatDiagnostics(d: ImportDiagnostics): string {
|
||||
const parts = Object.entries(d.entityCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, n]) => `${type}×${n}`);
|
||||
const head = `${d.total} Entities`;
|
||||
return parts.length > 0 ? `${head} — ${parts.join(", ")}` : head;
|
||||
}
|
||||
|
||||
export interface ImportDialogProps {
|
||||
fileName: string;
|
||||
/**
|
||||
@@ -179,7 +194,19 @@ export function ImportDialog({
|
||||
</li>
|
||||
</ul>
|
||||
{!hasDrawings && !hasMeshes && (
|
||||
<div className="imp-warn">{t("import.summary.empty")}</div>
|
||||
<div className="imp-warn">
|
||||
{t("import.summary.empty")}
|
||||
{/* Diagnose-Histogramm (vom DWG-Parser): macht sichtbar,
|
||||
WORAN es liegt — 0 Entities (Encoding/Version) vs.
|
||||
Abdeckungslücke (z. B. „500 ARC, 200 INSERT"). Entity-Typ-
|
||||
Codes + Zahlen sind technische DWG-Bezeichner (Daten,
|
||||
keine übersetzbare Prosa). */}
|
||||
{parsed?.diagnostics && (
|
||||
<div className="imp-diag">
|
||||
{formatDiagnostics(parsed.diagnostics)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
+475
-3
@@ -16,9 +16,10 @@
|
||||
// Rendering noch Persistenz. Bezeichner englisch, UI-Text deutsch
|
||||
// (CONVENTIONS.md).
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
Component,
|
||||
ComponentMaterial,
|
||||
HatchPattern,
|
||||
HatchStyle,
|
||||
LineStyle,
|
||||
@@ -27,6 +28,15 @@ import type {
|
||||
import { PEN_WEIGHTS } from "../model/types";
|
||||
import { parseLin } from "../io/linParser";
|
||||
import { parsePat } from "../io/patParser";
|
||||
import { MATERIAL_LIBRARY } from "../materials/library";
|
||||
import { materialFromAsset, DEFAULT_TILE_SIZE_M } from "../materials/runtime";
|
||||
import {
|
||||
searchMaterials,
|
||||
fetchMaterialMaps,
|
||||
AMBIENT_CATEGORIES,
|
||||
type AmbientMaterial,
|
||||
type AmbientResolution,
|
||||
} from "../materials/ambientcg";
|
||||
import { EyeIcon } from "./EyeIcon";
|
||||
import { t } from "../i18n";
|
||||
|
||||
@@ -269,7 +279,7 @@ function ResCell({
|
||||
|
||||
// ── Bauteile (Components) ──────────────────────────────────────────────────
|
||||
|
||||
// Spalten: Name | Farbe | Schraffur | Prio | Textur | (löschen).
|
||||
// Spalten: Name | Farbe | Schraffur | Prio | Textur | Material | (löschen).
|
||||
const COMPONENT_COLUMNS: ResColumn[] = [
|
||||
{ titleKey: "resources.col.name" },
|
||||
{ titleKey: "resources.col.color" },
|
||||
@@ -280,10 +290,465 @@ const COMPONENT_COLUMNS: ResColumn[] = [
|
||||
hintKey: "resources.col.prio.hint",
|
||||
},
|
||||
{ titleKey: "resources.col.texture" },
|
||||
{ titleKey: "resources.col.material" },
|
||||
{ titleKey: "" },
|
||||
];
|
||||
const COMPONENT_TEMPLATE =
|
||||
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) 64px minmax(100px, 0.8fr) 36px";
|
||||
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) 64px minmax(90px, 0.7fr) minmax(130px, 0.9fr) 36px";
|
||||
|
||||
/**
|
||||
* Material-Zelle der Bauteil-Tabelle: ein Button, der den aktuellen Material-
|
||||
* Namen zeigt (oder „ohne") und ein Auswahl-Modal öffnet. Die Zuweisung läuft
|
||||
* über `onAssign`/`onClear` (immutabel via onPatchComponent).
|
||||
*/
|
||||
function MaterialCell({
|
||||
material,
|
||||
onAssign,
|
||||
onClear,
|
||||
}: {
|
||||
material: ComponentMaterial | undefined;
|
||||
onAssign: (m: ComponentMaterial) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const label = materialLabel(material);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="res-select res-material-btn"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t("material.assign")}
|
||||
>
|
||||
{material?.color && (
|
||||
<span
|
||||
className="res-material-swatch"
|
||||
style={{ backgroundImage: `url(${material.color})` }}
|
||||
/>
|
||||
)}
|
||||
<span className="res-material-name">{label}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<MaterialPicker
|
||||
material={material}
|
||||
onAssign={(m) => {
|
||||
onAssign(m);
|
||||
}}
|
||||
onClear={() => {
|
||||
onClear();
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Anzeigename eines zugewiesenen Materials (Bibliothek → Name, sonst Upload). */
|
||||
function materialLabel(m: ComponentMaterial | undefined): string {
|
||||
if (!m) return t("material.none");
|
||||
if (m.libraryId) {
|
||||
const asset = MATERIAL_LIBRARY.find((a) => a.id === m.libraryId);
|
||||
if (asset) return asset.name;
|
||||
}
|
||||
return t("material.uploaded");
|
||||
}
|
||||
|
||||
/**
|
||||
* Material-Auswahl-Modal: oben die eingebaute Bibliothek als Vorschau-Grid
|
||||
* (Farb-Karte als Thumbnail) — Klick weist sofort zu; darunter ein Upload-
|
||||
* Bereich (Farbe/Normal/Rauheit/Höhe/AO als Bild-Dateien → ObjectURL) plus die
|
||||
* physische Kachelgröße. „Zuweisen" übernimmt den Upload; „Material entfernen"
|
||||
* setzt das Bauteil auf kein Material zurück.
|
||||
*/
|
||||
function MaterialPicker({
|
||||
material,
|
||||
onAssign,
|
||||
onClear,
|
||||
onClose,
|
||||
}: {
|
||||
material: ComponentMaterial | undefined;
|
||||
onAssign: (m: ComponentMaterial) => void;
|
||||
onClear: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Upload-Zustand: gewählte Karten als ObjectURLs + Kachelgröße. Startwert aus
|
||||
// einem bereits zugewiesenen Upload-Material (damit Größe/Karten sichtbar sind).
|
||||
const [upload, setUpload] = useState<ComponentMaterial>(() =>
|
||||
material && !material.libraryId ? { ...material } : { sizeM: DEFAULT_TILE_SIZE_M },
|
||||
);
|
||||
const [sizeM, setSizeM] = useState<number>(material?.sizeM ?? DEFAULT_TILE_SIZE_M);
|
||||
// Modus des Pickers: Schnellauswahl (lokaler Starter, offline), Live-Browse
|
||||
// der kompletten ambientCG-Bibliothek, oder Upload eigener Karten.
|
||||
const [mode, setMode] = useState<"starter" | "browse" | "upload">("starter");
|
||||
|
||||
const pickFile = (kind: keyof ComponentMaterial) => (file: File | null) => {
|
||||
if (!file) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
setUpload((u) => ({ ...u, [kind]: url }));
|
||||
};
|
||||
|
||||
const uploadFields: { kind: keyof ComponentMaterial; labelKey: string }[] = [
|
||||
{ kind: "color", labelKey: "material.upload.color" },
|
||||
{ kind: "normal", labelKey: "material.upload.normal" },
|
||||
{ kind: "roughness", labelKey: "material.upload.roughness" },
|
||||
{ kind: "displacement", labelKey: "material.upload.displacement" },
|
||||
{ kind: "ao", labelKey: "material.upload.ao" },
|
||||
];
|
||||
|
||||
const applyUpload = () => {
|
||||
if (!upload.color && !upload.normal && !upload.roughness && !upload.ao && !upload.displacement) {
|
||||
return;
|
||||
}
|
||||
onAssign({ ...upload, libraryId: undefined, sizeM });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="res-overlay mat-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="mat-dialog"
|
||||
role="dialog"
|
||||
aria-label={t("material.dialog.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="mat-head">
|
||||
<span className="mat-title">{t("material.dialog.title")}</span>
|
||||
<button className="res-close" onClick={onClose} aria-label={t("material.close")}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="mat-hint">{t("material.dialog.hint")}</div>
|
||||
|
||||
{/* Modus-Umschalter: Schnellauswahl / Bibliothek durchsuchen / Upload. */}
|
||||
<nav className="mat-modes" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === "starter"}
|
||||
className={`mat-mode-btn${mode === "starter" ? " active" : ""}`}
|
||||
onClick={() => setMode("starter")}
|
||||
>
|
||||
{t("material.browse.starter")}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === "browse"}
|
||||
className={`mat-mode-btn${mode === "browse" ? " active" : ""}`}
|
||||
onClick={() => setMode("browse")}
|
||||
>
|
||||
{t("material.browse")}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === "upload"}
|
||||
className={`mat-mode-btn${mode === "upload" ? " active" : ""}`}
|
||||
onClick={() => setMode("upload")}
|
||||
>
|
||||
{t("material.upload")}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Schnellauswahl: lokaler 12er-Starter (offline verfügbar). */}
|
||||
{mode === "starter" && (
|
||||
<div className="mat-grid">
|
||||
{MATERIAL_LIBRARY.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
className={`mat-tile${material?.libraryId === asset.id ? " active" : ""}`}
|
||||
title={asset.name}
|
||||
onClick={() => {
|
||||
onAssign(materialFromAsset(asset, sizeM));
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="mat-thumb"
|
||||
style={{ backgroundImage: `url(${asset.maps.color})` }}
|
||||
/>
|
||||
<span className="mat-tile-name">{asset.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live-Browse der kompletten ambientCG-Bibliothek. */}
|
||||
{mode === "browse" && (
|
||||
<AmbientBrowser
|
||||
sizeM={sizeM}
|
||||
activeId={material?.libraryId}
|
||||
onAssign={(m) => {
|
||||
onAssign(m);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upload eigener Karten. */}
|
||||
{mode === "upload" && (
|
||||
<>
|
||||
<div className="mat-hint">{t("material.upload.hint")}</div>
|
||||
<div className="mat-upload-grid">
|
||||
{uploadFields.map((f) => (
|
||||
<label key={f.kind} className="mat-upload-row">
|
||||
<span className="mat-upload-label">{t(f.labelKey)}</span>
|
||||
<ImagePickButton onFile={pickFile(f.kind)} />
|
||||
{upload[f.kind] && <span className="mat-upload-ok">✓</span>}
|
||||
</label>
|
||||
))}
|
||||
<label className="mat-upload-row">
|
||||
<span className="mat-upload-label">{t("material.size")}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="res-input mono num"
|
||||
value={sizeM}
|
||||
step={0.1}
|
||||
min={0.05}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isNaN(v) && v > 0) setSizeM(v);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mat-actions">
|
||||
<button className="res-add" onClick={applyUpload}>
|
||||
{t("material.upload.apply")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Kachelgröße (m) für Schnellauswahl + Browse, plus Material entfernen. */}
|
||||
{mode !== "upload" && (
|
||||
<div className="mat-actions mat-actions-browse">
|
||||
<label className="mat-size-inline">
|
||||
<span className="mat-upload-label">{t("material.size")}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="res-input mono num"
|
||||
value={sizeM}
|
||||
step={0.1}
|
||||
min={0.05}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isNaN(v) && v > 0) setSizeM(v);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="res-add mat-danger"
|
||||
onClick={() => {
|
||||
onClear();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("material.clear")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-Browse der kompletten ambientCG-Bibliothek: Suchfeld + Kategorie-Filter
|
||||
* + Auflösungswahl über der Trefferliste, Thumbnail-Grid (aus der API, direkt
|
||||
* geladen — CORS `*`) mit Pagination („mehr laden"). Ein Klick lädt die Textur-
|
||||
* Karten des Materials herunter (`fetchMaterialMaps`, über den Proxy), entpackt
|
||||
* sie und weist das entstehende `ComponentMaterial` zu. Lade-/Fehlerzustände
|
||||
* sauber (Spinner, Offline-/CORS-Hinweis).
|
||||
*/
|
||||
function AmbientBrowser({
|
||||
sizeM,
|
||||
activeId,
|
||||
onAssign,
|
||||
}: {
|
||||
sizeM: number;
|
||||
activeId: string | undefined;
|
||||
onAssign: (m: ComponentMaterial) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [committedQuery, setCommittedQuery] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [resolution, setResolution] = useState<AmbientResolution>("1K");
|
||||
const [items, setItems] = useState<AmbientMaterial[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// ID des gerade herunterladenden Materials (Spinner auf der Kachel).
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
const LIMIT = 24;
|
||||
|
||||
// Suche (bei committedQuery/category-Wechsel neu laden — Seite 0).
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
searchMaterials({
|
||||
query: committedQuery,
|
||||
category,
|
||||
limit: LIMIT,
|
||||
offset: 0,
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setOffset(0);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
setError(t("material.browse.error"));
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
console.error("[ambientcg] search failed:", e);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, [committedQuery, category]);
|
||||
|
||||
const loadMore = () => {
|
||||
const nextOffset = offset + LIMIT;
|
||||
setLoading(true);
|
||||
searchMaterials({ query: committedQuery, category, limit: LIMIT, offset: nextOffset })
|
||||
.then((res) => {
|
||||
setItems((prev) => [...prev, ...res.items]);
|
||||
setTotal(res.total);
|
||||
setOffset(nextOffset);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(t("material.browse.error"));
|
||||
console.error("[ambientcg] load more failed:", e);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const pick = async (id: string) => {
|
||||
setDownloadingId(id);
|
||||
setError(null);
|
||||
try {
|
||||
const { material } = await fetchMaterialMaps(id, resolution, sizeM);
|
||||
onAssign(material);
|
||||
} catch (e) {
|
||||
setError(t("material.browse.downloadError"));
|
||||
console.error("[ambientcg] download failed:", e);
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const submitSearch = () => setCommittedQuery(query);
|
||||
|
||||
return (
|
||||
<div className="mat-browse">
|
||||
<div className="mat-hint">{t("material.browse.hint")}</div>
|
||||
<div className="mat-browse-bar">
|
||||
<input
|
||||
type="text"
|
||||
className="res-input mat-search"
|
||||
value={query}
|
||||
placeholder={t("material.browse.searchPlaceholder")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitSearch();
|
||||
}}
|
||||
/>
|
||||
<button className="res-add" onClick={submitSearch}>
|
||||
{t("material.browse.search")}
|
||||
</button>
|
||||
<select
|
||||
className="res-select"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option value="">{t("material.browse.category.all")}</option>
|
||||
{AMBIENT_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="res-select"
|
||||
value={resolution}
|
||||
title={t("material.browse.resolution")}
|
||||
onChange={(e) => setResolution(e.target.value as AmbientResolution)}
|
||||
>
|
||||
<option value="1K">1K</option>
|
||||
<option value="2K">2K</option>
|
||||
<option value="4K">4K</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="mat-browse-error">{error}</div>}
|
||||
|
||||
<div className="mat-grid mat-browse-grid">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
className={`mat-tile${activeId === it.id ? " active" : ""}${
|
||||
downloadingId === it.id ? " loading" : ""
|
||||
}`}
|
||||
title={`${it.name}${it.category ? ` — ${it.category}` : ""}`}
|
||||
disabled={downloadingId !== null}
|
||||
onClick={() => pick(it.id)}
|
||||
>
|
||||
<span
|
||||
className="mat-thumb"
|
||||
style={{ backgroundImage: it.thumbUrl ? `url(${it.thumbUrl})` : undefined }}
|
||||
/>
|
||||
{downloadingId === it.id && <span className="mat-tile-spinner" />}
|
||||
<span className="mat-tile-name">{it.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && items.length === 0 && !error && (
|
||||
<div className="res-empty">{t("material.browse.empty")}</div>
|
||||
)}
|
||||
|
||||
<div className="mat-browse-foot">
|
||||
{loading && <span className="mat-browse-status">{t("material.browse.loading")}</span>}
|
||||
{!loading && items.length > 0 && (
|
||||
<span className="mat-browse-status">
|
||||
{t("material.browse.count", { shown: items.length, total })}
|
||||
</span>
|
||||
)}
|
||||
{!loading && items.length < total && (
|
||||
<button className="res-add" onClick={loadMore}>
|
||||
{t("material.browse.more")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Versteckter Bild-Datei-Picker mit sichtbarem Button. */
|
||||
function ImagePickButton({ onFile }: { onFile: (file: File | null) => void }) {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
return (
|
||||
<>
|
||||
<button className="res-add mat-pick" onClick={() => ref.current?.click()}>
|
||||
{t("material.upload.choose")}
|
||||
</button>
|
||||
<input
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
onFile(e.target.files?.[0] ?? null);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentRow({
|
||||
component,
|
||||
@@ -335,6 +800,13 @@ function ComponentRow({
|
||||
mono
|
||||
/>
|
||||
</ResCell>
|
||||
<ResCell>
|
||||
<MaterialCell
|
||||
material={component.material}
|
||||
onAssign={(material) => onPatch({ material })}
|
||||
onClear={() => onPatch({ material: undefined })}
|
||||
/>
|
||||
</ResCell>
|
||||
<ResCell>
|
||||
<DeleteButton
|
||||
onClick={onDelete}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Schwebendes Dialog-Fenster zum Bearbeiten eines Rich-Text-Dokuments (Raum-
|
||||
// Stempel / Textobjekt). Doppelklick auf einen Stempel öffnet es (nicht den
|
||||
// Footer). Der Body ist der bestehende RichTextEditor (mit eigener Mini-Toolbar,
|
||||
// hier ok, weil eigenes Fenster). „Übernehmen" schreibt das bearbeitete Dokument
|
||||
// zurück, „Abbrechen" verwirft. Bezeichner englisch, UI-Text deutsch.
|
||||
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { RichTextEditor } from "../text/RichTextEditor";
|
||||
import type { RichTextDoc } from "../text/richText";
|
||||
|
||||
export interface TextEditorDialogProps {
|
||||
/** Ausgangsdokument (wird beim Öffnen kopiert; Editor arbeitet lokal). */
|
||||
value: RichTextDoc;
|
||||
/** „Übernehmen" — das bearbeitete Dokument zurückschreiben. */
|
||||
onApply: (doc: RichTextDoc) => void;
|
||||
/** „Abbrechen"/Schliessen — ohne Übernahme. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function TextEditorDialog({ value, onApply, onCancel }: TextEditorDialogProps) {
|
||||
// Lokaler Arbeitszustand — erst „Übernehmen" schreibt zurück (echtes
|
||||
// Abbrechen möglich). Start = übergebenes Dokument.
|
||||
const [draft, setDraft] = useState<RichTextDoc>(value);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="texteditor-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("text.editTitle")}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="texteditor-dialog">
|
||||
<div className="texteditor-head">
|
||||
<span className="texteditor-title">{t("text.editTitle")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="texteditor-x"
|
||||
onClick={onCancel}
|
||||
title={t("text.cancel")}
|
||||
aria-label={t("text.cancel")}
|
||||
>
|
||||
<span className="material-symbols-outlined" aria-hidden="true">
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="texteditor-body">
|
||||
<RichTextEditor value={draft} onChange={setDraft} autoFocus />
|
||||
</div>
|
||||
<div className="texteditor-foot">
|
||||
<button type="button" className="texteditor-btn" onClick={onCancel}>
|
||||
{t("text.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="texteditor-btn texteditor-btn-primary"
|
||||
onClick={() => onApply(draft)}
|
||||
>
|
||||
{t("text.apply")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextEditorDialog;
|
||||
+566
-70
@@ -14,6 +14,21 @@ import { t } from "../i18n";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import type { DropdownItem, DropdownOption } from "./Dropdown";
|
||||
import {
|
||||
applyMark,
|
||||
applyPreset,
|
||||
commonMarkValue,
|
||||
DEFAULT_PRESETS,
|
||||
docLength,
|
||||
isMarkActive,
|
||||
toggleMark,
|
||||
} from "../text/richText";
|
||||
import type {
|
||||
Align,
|
||||
BoolMark,
|
||||
RichTextDoc,
|
||||
TextRange,
|
||||
} from "../text/richText";
|
||||
|
||||
/** Ansichtstyp eines Geschosses (wie in App). */
|
||||
export type ViewType = "grundriss" | "perspektive";
|
||||
@@ -27,9 +42,16 @@ export type DetailLevel = "grob" | "mittel" | "fein";
|
||||
|
||||
/**
|
||||
* Render-/Anzeigemodus der 3D-Ansicht: schattiert, einheitlich weiss (Clay-Look),
|
||||
* Drahtgitter, Verdeckte Kanten (Hidden-Line). Wirkt nur in der Perspektive.
|
||||
* texturiert (echte PBR-Materialien je Bauteil), Drahtgitter, Verdeckte Kanten
|
||||
* (Hidden-Line). Wirkt nur in der Perspektive. Der Slot „section" (Schnitt-
|
||||
* schraffur, Welle B) ist bewusst freigelassen.
|
||||
*/
|
||||
export type RenderMode = "shaded" | "white" | "wireframe" | "hidden";
|
||||
export type RenderMode =
|
||||
| "shaded"
|
||||
| "white"
|
||||
| "textured"
|
||||
| "wireframe"
|
||||
| "hidden";
|
||||
|
||||
/**
|
||||
* Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). „front",
|
||||
@@ -97,6 +119,10 @@ export interface TopBarProps {
|
||||
onFit: () => void;
|
||||
onFitSelection: () => void;
|
||||
onZoom100: () => void;
|
||||
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||||
onExportPdf: () => void;
|
||||
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||||
onExportDxf: () => void;
|
||||
|
||||
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
|
||||
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
|
||||
@@ -131,6 +157,30 @@ export interface TopBarProps {
|
||||
|
||||
/** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */
|
||||
layoutMenu: ReactNode;
|
||||
|
||||
// ── Text-Gruppe (Stempel-/Text-Formatierung) ───────────────────────────────
|
||||
/**
|
||||
* Ziel der Text-Formatierung: `null` = nichts Text-artiges selektiert (die
|
||||
* Controls setzen nur Defaults für neuen Text). Sonst das aktuell selektierte
|
||||
* Rich-Text-Dokument (z. B. Raumstempel) samt optionalem Bereich und einem
|
||||
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
|
||||
*/
|
||||
textTarget: TextTarget | null;
|
||||
/**
|
||||
* Startet das Text-Werkzeug (neues Textobjekt platzieren). `null`, solange das
|
||||
* Werkzeug noch nicht existiert → der „+ Text"-Knopf ist dann deaktiviert.
|
||||
*/
|
||||
onAddText: (() => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschreibt das aktuell formatierbare Rich-Text-Ziel der Oberleiste. `apply`
|
||||
* schreibt das neue Dokument zurück in den App-State (z. B. via setRoomStampDoc).
|
||||
*/
|
||||
export interface TextTarget {
|
||||
doc: RichTextDoc;
|
||||
range: TextRange | null;
|
||||
apply: (doc: RichTextDoc) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,6 +205,19 @@ function Sep() {
|
||||
return <span className="tb-sep" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Material-Symbols-Icon für die Oberleiste (gleiche Icon-Font wie ContextMenu/
|
||||
* MenuIcon). Rein dekorativ — der sprechende Text steckt im `title`/aria-label
|
||||
* des umgebenden Buttons.
|
||||
*/
|
||||
function TbIcon({ name }: { name: string }) {
|
||||
return (
|
||||
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kleines Kombinations-Menü (Vectorworks-Stil) — gespiegelt von `LayoutMenu`:
|
||||
* aktuelle Sichtbarkeit unter einem Namen sichern, eine gespeicherte Kombination
|
||||
@@ -392,6 +455,376 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Eine Zelle einer Segmentpille. */
|
||||
interface SegmentCell {
|
||||
/** Stabiler Schlüssel. */
|
||||
key: string;
|
||||
/** Material-Symbols-Icon-Name. */
|
||||
icon: string;
|
||||
/** Tooltip/aria-label (Klartext). */
|
||||
title: string;
|
||||
/** Aktiver Zustand (Akzentfüllung). */
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmentpille (DOSSIER-Look): ein Aussencontainer (radius 999, overflow hidden)
|
||||
* mit gleich hohen Zellen; interne Trenner über `border-left`. Aktive Zelle in
|
||||
* Akzentfarbe. Genutzt für Zoom-Aktionen, B/I/U und L/C/R. `accent` setzt einen
|
||||
* Akzent-Rand am Container (Auswahl-Bewusstsein der Text-Gruppe).
|
||||
*/
|
||||
function Segment({
|
||||
cells,
|
||||
ariaLabel,
|
||||
accent,
|
||||
style,
|
||||
}: {
|
||||
cells: SegmentCell[];
|
||||
ariaLabel: string;
|
||||
accent?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`tb-seg${accent ? " tb-seg-accent" : ""}`}
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
style={style}
|
||||
>
|
||||
{cells.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
className={`tb-seg-cell${c.active ? " active" : ""}`}
|
||||
onClick={c.onClick}
|
||||
disabled={c.disabled}
|
||||
aria-pressed={c.active}
|
||||
title={c.title}
|
||||
aria-label={c.title}
|
||||
>
|
||||
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
|
||||
{c.icon}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Systemfont-Auswahl der Text-Gruppe. */
|
||||
const TEXT_FONTS: string[] = [
|
||||
"Helvetica",
|
||||
"Arial",
|
||||
"Inter",
|
||||
"Times New Roman",
|
||||
"Georgia",
|
||||
"Courier New",
|
||||
];
|
||||
|
||||
/** Grössen-Presets (pt) der Text-Gruppe. */
|
||||
const TEXT_SIZES: number[] = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48];
|
||||
|
||||
/**
|
||||
* Text-Gruppe der Oberleiste (immer sichtbar, 3×2-Raster). Setzt Defaults für
|
||||
* neuen Text (lokaler State) UND formatiert das aktuelle `textTarget` live. Ist
|
||||
* `textTarget` gesetzt, tragen die Zeile-2-Pillen einen Akzent-Rand und alle
|
||||
* Aktionen wirken auf `textTarget.doc` via `textTarget.apply`.
|
||||
*/
|
||||
function TextGroup({
|
||||
textTarget,
|
||||
onAddText,
|
||||
}: {
|
||||
textTarget: TextTarget | null;
|
||||
onAddText: (() => void) | null;
|
||||
}) {
|
||||
// Defaults für neuen Text, solange nichts Text-artiges selektiert ist. Bei
|
||||
// aktivem Ziel spiegeln die Controls die gemeinsamen Werte des Ziels wider.
|
||||
const [defFont, setDefFont] = useState<string>("Helvetica");
|
||||
const [defSize, setDefSize] = useState<number>(12);
|
||||
const [defAlign, setDefAlign] = useState<Align>("left");
|
||||
const [defBold, setDefBold] = useState(false);
|
||||
const [defItalic, setDefItalic] = useState(false);
|
||||
const [defUnderline, setDefUnderline] = useState(false);
|
||||
|
||||
// Effektiver Bereich am Ziel: expliziter Bereich oder — ohne Bereich — das
|
||||
// ganze Dokument (mind. 1 Zeichen, damit isMarkActive/applyMark greifen).
|
||||
const targetRange = (doc: RichTextDoc, range: TextRange | null): TextRange =>
|
||||
range && range.start !== range.end
|
||||
? range
|
||||
: { start: 0, end: Math.max(1, docLength(doc)) };
|
||||
|
||||
const active = textTarget !== null;
|
||||
|
||||
// Aktueller Anzeige-Zustand: vom Ziel abgeleitet, sonst aus den Defaults.
|
||||
const rng = textTarget ? targetRange(textTarget.doc, textTarget.range) : null;
|
||||
const isBold = textTarget && rng ? isMarkActive(textTarget.doc, rng, "bold") : defBold;
|
||||
const isItalic = textTarget && rng ? isMarkActive(textTarget.doc, rng, "italic") : defItalic;
|
||||
const isUnderline =
|
||||
textTarget && rng ? isMarkActive(textTarget.doc, rng, "underline") : defUnderline;
|
||||
const curFont =
|
||||
textTarget && rng ? commonMarkValue(textTarget.doc, rng, "font") ?? "" : defFont;
|
||||
const curSize =
|
||||
textTarget && rng ? commonMarkValue(textTarget.doc, rng, "sizePt") ?? 0 : defSize;
|
||||
// Ausrichtung: gemeinsamer Wert der berührten Absätze, sonst Default.
|
||||
const curAlign: Align = textTarget
|
||||
? commonAlign(textTarget.doc, rng) ?? "left"
|
||||
: defAlign;
|
||||
|
||||
// ── Aktions-Helfer (wirken auf das Ziel oder setzen Defaults) ──────────────
|
||||
const toggle = (mark: BoolMark, setDefault: (v: boolean) => void, cur: boolean) => {
|
||||
if (textTarget) {
|
||||
const r = targetRange(textTarget.doc, textTarget.range);
|
||||
textTarget.apply(toggleMark(textTarget.doc, r, mark));
|
||||
} else {
|
||||
setDefault(!cur);
|
||||
}
|
||||
};
|
||||
|
||||
const setFontValue = (font: string) => {
|
||||
if (textTarget) {
|
||||
const r = targetRange(textTarget.doc, textTarget.range);
|
||||
textTarget.apply(applyMark(textTarget.doc, r, "font", font));
|
||||
} else {
|
||||
setDefFont(font);
|
||||
}
|
||||
};
|
||||
|
||||
const setSizeValue = (size: number) => {
|
||||
if (!Number.isFinite(size) || size <= 0) return;
|
||||
if (textTarget) {
|
||||
const r = targetRange(textTarget.doc, textTarget.range);
|
||||
textTarget.apply(applyMark(textTarget.doc, r, "sizePt", size));
|
||||
} else {
|
||||
setDefSize(size);
|
||||
}
|
||||
};
|
||||
|
||||
const setAlignValue = (align: Align) => {
|
||||
if (textTarget) {
|
||||
textTarget.apply(applyAlign(textTarget.doc, textTarget.range, align));
|
||||
} else {
|
||||
setDefAlign(align);
|
||||
}
|
||||
};
|
||||
|
||||
const applyPresetValue = (id: string) => {
|
||||
const preset = DEFAULT_PRESETS.find((p) => p.id === id);
|
||||
if (!preset) return;
|
||||
if (textTarget) {
|
||||
const r = textTarget.range ?? { start: 0, end: 0 };
|
||||
textTarget.apply(applyPreset(textTarget.doc, r, preset));
|
||||
} else {
|
||||
// Ohne Ziel: Preset-Defaults übernehmen (Font bleibt, Marks spiegeln).
|
||||
if (preset.marks.sizePt != null) setDefSize(preset.marks.sizePt);
|
||||
setDefBold(!!preset.marks.bold);
|
||||
setDefItalic(!!preset.marks.italic);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Options-Listen ─────────────────────────────────────────────────────────
|
||||
const styleOptions: DropdownOption[] = [
|
||||
{ value: "__none__", label: t("text.style.none") },
|
||||
...DEFAULT_PRESETS.map((p) => ({ value: p.id, label: p.label })),
|
||||
];
|
||||
const fontOptions: DropdownOption[] = TEXT_FONTS.map((f) => ({
|
||||
value: f,
|
||||
label: f,
|
||||
}));
|
||||
const sizeInPresets = TEXT_SIZES.includes(curSize);
|
||||
const sizeOptions: DropdownOption[] = [
|
||||
...(sizeInPresets || curSize <= 0
|
||||
? []
|
||||
: [{ value: "__current__", label: `${curSize} pt` }]),
|
||||
...TEXT_SIZES.map((n) => ({ value: String(n), label: `${n} pt` })),
|
||||
{ value: "__custom__", label: t("text.size.custom") },
|
||||
];
|
||||
|
||||
const onSizeChange = (value: string) => {
|
||||
if (value === "__custom__") {
|
||||
const input = window.prompt(
|
||||
t("text.size.customPrompt"),
|
||||
String(curSize > 0 ? curSize : 12),
|
||||
);
|
||||
const n = input == null ? NaN : Math.round(Number(input.trim()));
|
||||
if (Number.isFinite(n) && n > 0) setSizeValue(n);
|
||||
return;
|
||||
}
|
||||
if (value === "__current__") return;
|
||||
setSizeValue(Number(value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tb-group tb-textgroup" role="group" aria-label={t("text.group")}>
|
||||
{/* Zeile 1: Stil-Preset · Schrift · Grösse. */}
|
||||
<div className="tb-textgroup-row">
|
||||
<Dropdown
|
||||
value="__none__"
|
||||
onChange={applyPresetValue}
|
||||
title={t("text.style")}
|
||||
width={110}
|
||||
options={styleOptions}
|
||||
/>
|
||||
<Dropdown
|
||||
value={curFont || "__none__"}
|
||||
onChange={setFontValue}
|
||||
title={t("text.font")}
|
||||
width={130}
|
||||
options={
|
||||
curFont && !TEXT_FONTS.includes(curFont)
|
||||
? [{ value: curFont, label: curFont }, ...fontOptions]
|
||||
: fontOptions
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
value={
|
||||
curSize <= 0
|
||||
? "__custom__"
|
||||
: sizeInPresets
|
||||
? String(curSize)
|
||||
: "__current__"
|
||||
}
|
||||
onChange={onSizeChange}
|
||||
title={t("text.size")}
|
||||
width={80}
|
||||
triggerClassName="tb-dd-mono"
|
||||
options={sizeOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zeile 2: B/I/U · L/C/R · „+ Text". */}
|
||||
<div className="tb-textgroup-row">
|
||||
<Segment
|
||||
ariaLabel={t("text.group")}
|
||||
accent={active}
|
||||
style={{ width: 110 }}
|
||||
cells={[
|
||||
{
|
||||
key: "bold",
|
||||
icon: "format_bold",
|
||||
title: t("text.bold"),
|
||||
active: !!isBold,
|
||||
onClick: () => toggle("bold", setDefBold, defBold),
|
||||
},
|
||||
{
|
||||
key: "italic",
|
||||
icon: "format_italic",
|
||||
title: t("text.italic"),
|
||||
active: !!isItalic,
|
||||
onClick: () => toggle("italic", setDefItalic, defItalic),
|
||||
},
|
||||
{
|
||||
key: "underline",
|
||||
icon: "format_underlined",
|
||||
title: t("text.underline"),
|
||||
active: !!isUnderline,
|
||||
onClick: () => toggle("underline", setDefUnderline, defUnderline),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Segment
|
||||
ariaLabel={t("text.group")}
|
||||
accent={active}
|
||||
style={{ width: 130 }}
|
||||
cells={[
|
||||
{
|
||||
key: "left",
|
||||
icon: "format_align_left",
|
||||
title: t("text.align.left"),
|
||||
active: curAlign === "left",
|
||||
onClick: () => setAlignValue("left"),
|
||||
},
|
||||
{
|
||||
key: "center",
|
||||
icon: "format_align_center",
|
||||
title: t("text.align.center"),
|
||||
active: curAlign === "center",
|
||||
onClick: () => setAlignValue("center"),
|
||||
},
|
||||
{
|
||||
key: "right",
|
||||
icon: "format_align_right",
|
||||
title: t("text.align.right"),
|
||||
active: curAlign === "right",
|
||||
onClick: () => setAlignValue("right"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="tb-addtext"
|
||||
disabled={onAddText == null}
|
||||
onClick={() => onAddText?.()}
|
||||
title={onAddText ? t("text.add") : t("text.add.hint")}
|
||||
aria-label={t("text.add")}
|
||||
>
|
||||
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
|
||||
add
|
||||
</span>
|
||||
<span className="tb-addtext-label">{t("text.add")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsame Absatz-Ausrichtung der vom Bereich berührten Absätze; `undefined`
|
||||
* bei uneinheitlicher/leerer Auswahl. Ohne Bereich → Ausrichtung des ersten
|
||||
* Absatzes (das ganze Doc als Ziel).
|
||||
*/
|
||||
function commonAlign(doc: RichTextDoc, range: TextRange | null): Align | undefined {
|
||||
const paras = touchedParagraphs(doc, range);
|
||||
if (paras.length === 0) return doc.paragraphs[0]?.align ?? "left";
|
||||
let value: Align | undefined;
|
||||
let first = true;
|
||||
for (const idx of paras) {
|
||||
const a = doc.paragraphs[idx]?.align ?? "left";
|
||||
if (first) {
|
||||
value = a;
|
||||
first = false;
|
||||
} else if (a !== value) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Absatz-Ausrichtung auf alle vom Bereich berührten Absätze. Ohne
|
||||
* Bereich (null) auf ALLE Absätze des Dokuments.
|
||||
*/
|
||||
function applyAlign(doc: RichTextDoc, range: TextRange | null, align: Align): RichTextDoc {
|
||||
const touched = new Set(touchedParagraphs(doc, range));
|
||||
const all = range == null;
|
||||
return {
|
||||
version: 1,
|
||||
paragraphs: doc.paragraphs.map((p, i) =>
|
||||
all || touched.has(i) ? { ...p, align } : p,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Indizes der Absätze, die der Bereich [start,end) berührt. */
|
||||
function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[] {
|
||||
if (range == null || range.start === range.end) {
|
||||
return doc.paragraphs.map((_, i) => i);
|
||||
}
|
||||
const start = Math.min(range.start, range.end);
|
||||
const end = Math.max(range.start, range.end);
|
||||
const out: number[] = [];
|
||||
let cursor = 0;
|
||||
doc.paragraphs.forEach((p, i) => {
|
||||
const len = p.runs.reduce((n, r) => n + r.text.length, 0);
|
||||
const pStart = cursor;
|
||||
const pEnd = cursor + len;
|
||||
if (!(pEnd < start || pStart > end)) out.push(i);
|
||||
cursor += len + 1;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
project,
|
||||
viewToggleEnabled,
|
||||
@@ -412,6 +845,8 @@ export function TopBar({
|
||||
onFit,
|
||||
onFitSelection,
|
||||
onZoom100,
|
||||
onExportPdf,
|
||||
onExportDxf,
|
||||
renderMode,
|
||||
onRenderMode,
|
||||
renderModeEnabled,
|
||||
@@ -427,6 +862,8 @@ export function TopBar({
|
||||
resourcesOpen,
|
||||
onToggleResources,
|
||||
layoutMenu,
|
||||
textTarget,
|
||||
onAddText,
|
||||
}: TopBarProps) {
|
||||
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
|
||||
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
|
||||
@@ -578,8 +1015,10 @@ export function TopBar({
|
||||
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss auf Symbole +
|
||||
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung → dort
|
||||
deaktiviert mit Tooltip statt stiller No-Op. */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
|
||||
<label className="tb-field">
|
||||
{/* Detailgrad + Massstab gestapelt — eine Spalte, platzsparend (wie die
|
||||
Ebenen-/Zeichnungs-Kombis). */}
|
||||
<div className="tb-group tb-stack" role="group" aria-label={t("topbar.detailGroup")}>
|
||||
<label className="tb-field tb-field-row">
|
||||
<span className="tb-label">{t("topbar.detail")}</span>
|
||||
<Dropdown
|
||||
value={detail}
|
||||
@@ -597,16 +1036,7 @@ export function TopBar({
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Massstab „1:N" (echter Papier-Massstab) + Zoom-Buttons. Das Dropdown
|
||||
zeigt den gewählten/angewandten Massstab; rechts steht der LIVE 1:N
|
||||
aus der View-Transform (ändert sich beim Zoomen). Nur aktiv, wenn ein
|
||||
Plan gezeichnet wird (Perspektive zeigt „—"). */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.scaleGroup")}>
|
||||
<label className="tb-field">
|
||||
<label className="tb-field tb-field-row">
|
||||
<span className="tb-label">{t("topbar.scale")}</span>
|
||||
<Dropdown
|
||||
value={inPresets ? String(scaleDenominator) : "__current__"}
|
||||
@@ -617,42 +1047,103 @@ export function TopBar({
|
||||
options={scaleOptions}
|
||||
/>
|
||||
</label>
|
||||
<span className="tb-live-scale" title={t("topbar.scale.live")}>
|
||||
{zoomEnabled && liveScale != null ? `1:${Math.round(liveScale)}` : "—"}
|
||||
</span>
|
||||
<div className="tb-btns">
|
||||
<button
|
||||
className="tb-btn"
|
||||
onClick={onZoom100}
|
||||
disabled={!zoomEnabled}
|
||||
title={t("topbar.zoom100.hint", { n: scaleDenominator })}
|
||||
>
|
||||
{t("topbar.zoom100")}
|
||||
</button>
|
||||
<button
|
||||
className="tb-btn"
|
||||
onClick={onFit}
|
||||
disabled={!zoomEnabled}
|
||||
title={t("topbar.fit.hint")}
|
||||
>
|
||||
{t("topbar.fit")}
|
||||
</button>
|
||||
<button
|
||||
className="tb-btn"
|
||||
onClick={onFitSelection}
|
||||
disabled={!zoomEnabled}
|
||||
title={t("topbar.fitSelection.hint")}
|
||||
>
|
||||
{t("topbar.fitSelection")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="tb-zoom" title={t("topbar.zoom.current")}>
|
||||
{zoomEnabled ? `${Math.round(zoomPercent)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Massstab/Zoom-Cluster (DOSSIER, 2×2). Links über beide Zeilen die
|
||||
kombinierte Stat-Pille (Live-Massstab 1:N oben / Zoom% unten); rechts
|
||||
oben das Massstab-Dropdown, rechts unten die Zoom-Segmentpille. „am
|
||||
Massstab" (Live-Massstab == gewählter Massstab) hebt die Stat-Pille im
|
||||
Akzent hervor. */}
|
||||
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
|
||||
<div
|
||||
className={`tb-stat${
|
||||
zoomEnabled && liveScale != null && Math.round(liveScale) === scaleDenominator
|
||||
? " tb-stat-atscale"
|
||||
: ""
|
||||
}`}
|
||||
title={`${t("topbar.scale.live")} · ${t("topbar.zoom.current")}`}
|
||||
>
|
||||
<span className="tb-stat-scale">
|
||||
{zoomEnabled && liveScale != null ? `1:${Math.round(liveScale)}` : "—"}
|
||||
</span>
|
||||
<span className="tb-stat-zoom">
|
||||
{zoomEnabled ? `${Math.round(zoomPercent)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tb-zoomcluster-right">
|
||||
<Dropdown
|
||||
value={inPresets ? String(scaleDenominator) : "__current__"}
|
||||
disabled={!zoomEnabled}
|
||||
onChange={onScaleChange}
|
||||
title={t("topbar.scale.apply")}
|
||||
triggerClassName="tb-dd-mono"
|
||||
width={140}
|
||||
options={scaleOptions}
|
||||
/>
|
||||
<Segment
|
||||
ariaLabel={t("topbar.scaleGroup")}
|
||||
cells={[
|
||||
{
|
||||
key: "zoom100",
|
||||
icon: "straighten",
|
||||
title: t("topbar.zoom100.hint", { n: scaleDenominator }),
|
||||
onClick: onZoom100,
|
||||
disabled: !zoomEnabled,
|
||||
},
|
||||
{
|
||||
key: "fit",
|
||||
icon: "fit_screen",
|
||||
title: t("topbar.fit.hint"),
|
||||
onClick: onFit,
|
||||
disabled: !zoomEnabled,
|
||||
},
|
||||
{
|
||||
key: "fitSelection",
|
||||
icon: "center_focus_strong",
|
||||
title: t("topbar.fitSelection.hint"),
|
||||
onClick: onFitSelection,
|
||||
disabled: !zoomEnabled,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export als eigene kleine Pillen-Reihe (nicht mehr Teil des Zoom-Blocks,
|
||||
damit der Cluster ruhig bleibt). */}
|
||||
<div className="tb-group tb-btns" role="group" aria-label={t("topbar.exportPdf")}>
|
||||
<button
|
||||
type="button"
|
||||
className="tb-iconbtn"
|
||||
onClick={onExportPdf}
|
||||
disabled={!zoomEnabled}
|
||||
title={t("topbar.exportPdf.hint")}
|
||||
aria-label={t("topbar.exportPdf")}
|
||||
>
|
||||
<TbIcon name="picture_as_pdf" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tb-iconbtn"
|
||||
onClick={onExportDxf}
|
||||
disabled={!zoomEnabled}
|
||||
title={t("topbar.exportDxf.hint")}
|
||||
aria-label={t("topbar.exportDxf")}
|
||||
>
|
||||
<TbIcon name="download" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Text-Gruppe (immer sichtbar) — Stil/Schrift/Grösse + B/I/U · L/C/R +
|
||||
„+ Text". Formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
|
||||
<TextGroup textTarget={textTarget} onAddText={onAddText} />
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Darstellungsart (Vectorworks-Stil) — EIN Dropdown, dessen Optionen vom
|
||||
Ansichtstyp abhängen: Grundriss → Farbig/Schwarz-Weiss (planColorMode);
|
||||
Perspektive → Schattiert/Weiss/Drahtgitter/Kanten (renderMode). Der
|
||||
@@ -669,6 +1160,7 @@ export function TopBar({
|
||||
options={[
|
||||
{ value: "shaded", label: t("display.style.shaded") },
|
||||
{ value: "white", label: t("display.style.white") },
|
||||
{ value: "textured", label: t("display.style.textured") },
|
||||
{ value: "wireframe", label: t("display.style.wireframe") },
|
||||
{ value: "hidden", label: t("display.style.hidden") },
|
||||
]}
|
||||
@@ -693,54 +1185,58 @@ export function TopBar({
|
||||
Achslinien im Grundriss. Nur im Grundriss sinnvoll → sonst deaktiviert. */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.referenceLinesGroup")}>
|
||||
<button
|
||||
className={`tb-toggle${referenceLines ? " active" : ""}`}
|
||||
className={`view-icon${referenceLines ? " active" : ""}`}
|
||||
onClick={() => onReferenceLines(!referenceLines)}
|
||||
disabled={!referenceLinesEnabled}
|
||||
aria-pressed={referenceLines}
|
||||
aria-label={t("topbar.referenceLines")}
|
||||
title={
|
||||
referenceLinesEnabled
|
||||
? t("topbar.referenceLines.hint")
|
||||
? `${t("topbar.referenceLines")} — ${t("topbar.referenceLines.hint")}`
|
||||
: t("topbar.referenceLines.hintDisabled")
|
||||
}
|
||||
>
|
||||
{t("topbar.referenceLines")}
|
||||
<TbIcon name="straighten" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Linien-Darstellung: Display (alle Haarlinien) ↔ Print (echte mm-
|
||||
Strichstärken). Nur im Grundriss sinnvoll. */}
|
||||
Strichstärken). Eine Einfach-Auswahl → als Dropdown (Trigger-Pill +
|
||||
Häkchen-Liste, wie Detailgrad/Massstab), nicht mehr als Segment-Pillen.
|
||||
Optionen tragen ein Icon plus Klartext. Nur im Grundriss sinnvoll. */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.lineModeGroup")}>
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={`view-btn${lineMode === "display" ? " active" : ""}`}
|
||||
onClick={() => onLineMode("display")}
|
||||
<label className="tb-field">
|
||||
<span className="tb-label">{t("topbar.lineModeGroup")}</span>
|
||||
<Dropdown
|
||||
value={lineMode}
|
||||
disabled={!lineModeEnabled}
|
||||
title={t("topbar.lineMode.display.hint")}
|
||||
>
|
||||
{t("topbar.lineMode.display")}
|
||||
</button>
|
||||
<button
|
||||
className={`view-btn${lineMode === "print" ? " active" : ""}`}
|
||||
onClick={() => onLineMode("print")}
|
||||
disabled={!lineModeEnabled}
|
||||
title={t("topbar.lineMode.print.hint")}
|
||||
>
|
||||
{t("topbar.lineMode.print")}
|
||||
</button>
|
||||
</div>
|
||||
onChange={(v) => onLineMode(v as "display" | "print")}
|
||||
title={
|
||||
lineModeEnabled
|
||||
? `${t("topbar.lineMode.display.hint")} · ${t("topbar.lineMode.print.hint")}`
|
||||
: t("topbar.lineModeGroup")
|
||||
}
|
||||
options={[
|
||||
{ value: "display", label: t("topbar.lineMode.display") },
|
||||
{ value: "print", label: t("topbar.lineMode.print") },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Rechte Gruppe: Layout-Menü · Ressourcen · Projektname. */}
|
||||
<div className="topbar-right">
|
||||
{layoutMenu}
|
||||
<button
|
||||
className={`res-trigger${resourcesOpen ? " active" : ""}`}
|
||||
className={`view-icon${resourcesOpen ? " active" : ""}`}
|
||||
onClick={onToggleResources}
|
||||
title={t("topbar.resources.hint")}
|
||||
aria-pressed={resourcesOpen}
|
||||
aria-label={t("topbar.resources")}
|
||||
title={`${t("topbar.resources")} — ${t("topbar.resources.hint")}`}
|
||||
>
|
||||
{t("topbar.resources")}
|
||||
<TbIcon name="widgets" />
|
||||
</button>
|
||||
<span className="project-name">{project.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// 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