Leichen-Suche: tote Dateien/Funktionen entfernt, revokeMaterial-Bug behoben
ts-prune + cargo check über alle Crates durchsucht, jeder Fund per grep gegengeprüft. Gelöscht: ResourcesPanel.tsx (abgelöstes Alt-Panel), RibbonBar.tsx (Ribbon-Phase-1-Überbleibsel), sowie diverse Funktionen ohne jeden Aufrufer (sampleTerrainColors, docToSvgText/tspanToSvg, isEmpty/deserialize/fromJson, findAccentSwatch, unregisterPanel, removeFromDockLayout, TOOL_ORDER). revokeMaterial (materials/ambientcg.ts) existierte, wurde aber nie aufgerufen — jeder Material-Wechsel/-Upload liess Blob-URLs leaken. Jetzt über revokeMaterialUrls() in ComponentsTab.tsx beim Ersetzen/Löschen/ Bauteil-Löschen verdrahtet. tsc -b / vitest run (891/891) / npm run build grün.
This commit is contained in:
@@ -111,40 +111,6 @@ export function sampleColorsFromImageData(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dekodiert eine Bild-Data-URL und sampelt daraus die Pro-Vertex-Albedo für ein
|
||||
* Gelände-TIN (siehe {@link sampleColorsFromImageData}). Nutzt `createImageBitmap`
|
||||
* + `OffscreenCanvas` (im App-/WebView-Kontext vorhanden; in Node/Tests fehlen sie
|
||||
* — dann `null`, kein Wurf). So bleibt der Render-Pfad synchron: gesampelt wird
|
||||
* EINMAL beim Import, das Ergebnis lebt auf `TerrainMesh.vertexColors`.
|
||||
*/
|
||||
export async function sampleTerrainColors(
|
||||
dataUrl: string,
|
||||
positions: readonly number[],
|
||||
min: { x: number; y: number },
|
||||
max: { x: number; y: number },
|
||||
): Promise<number[] | null> {
|
||||
if (
|
||||
typeof createImageBitmap !== "function" ||
|
||||
typeof OffscreenCanvas !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
const bmp = await createImageBitmap(blob);
|
||||
const canvas = new OffscreenCanvas(bmp.width, bmp.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
|
||||
return sampleColorsFromImageData(img.data, bmp.width, bmp.height, positions, min, max);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Dekodiertes Bild: dichte RGBA8-Bytes + Maße (zeilenweise von oben). */
|
||||
export interface DecodedImage {
|
||||
data: Uint8Array;
|
||||
|
||||
@@ -267,3 +267,26 @@ export async function fetchMaterialMaps(
|
||||
export function revokeMaterial(blobUrls: string[]): void {
|
||||
for (const u of blobUrls) URL.revokeObjectURL(u);
|
||||
}
|
||||
|
||||
const MATERIAL_MAP_KEYS: (keyof ComponentMaterial)[] = [
|
||||
"color",
|
||||
"normal",
|
||||
"roughness",
|
||||
"metalness",
|
||||
"displacement",
|
||||
"ao",
|
||||
];
|
||||
|
||||
/**
|
||||
* Gibt die Blob-URLs eines bereits zugewiesenen `ComponentMaterial` frei (Karten
|
||||
* aus ambientCG-Download ODER lokalem Upload — beide nutzen `createObjectURL`).
|
||||
* Bibliotheks-Assets (`materialFromAsset`) liefern statische Pfade, keine
|
||||
* Blob-URLs, und bleiben daher unangetastet.
|
||||
*/
|
||||
export function revokeMaterialUrls(material: ComponentMaterial | undefined): void {
|
||||
if (!material) return;
|
||||
const blobUrls = MATERIAL_MAP_KEYS.map((k) => material[k]).filter(
|
||||
(u): u is string => typeof u === "string" && u.startsWith("blob:"),
|
||||
);
|
||||
revokeMaterial(blobUrls);
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
// Inhalts-Panel „Ressourcen" — dünner Adapter um den ResourceManager.
|
||||
//
|
||||
// Wiederverwendet die bestehende <ResourceManager>-Komponente (Bauteile /
|
||||
// Schraffuren / Linien) UNVERÄNDERT, rendert sie aber INLINE als Panel-Körper
|
||||
// statt als rechte Schublade. Projekt + Handler kommen aus dem Host-Context.
|
||||
//
|
||||
// ResourceManager ist als Overlay-Schublade gebaut (.res-overlay fixed/inset:0
|
||||
// → .res-drawer mit fester Breite, Rand und Schatten) und erwartet onClose.
|
||||
// Da styles.css und ResourceManager selbst nicht angefasst werden dürfen,
|
||||
// zähmt dieser Adapter die Schubladen-Chrome lokal: ein eingebetteter,
|
||||
// gescopter <style>-Block (kein Eingriff in styles.css) löst das Overlay aus
|
||||
// der Fixpositionierung, lässt die „Schublade" die Panel-Fläche füllen und
|
||||
// blendet den Schließen-Knopf aus (ein Panel schließt man über den Rahmen,
|
||||
// nicht über die Schublade). onClose ist daher ein No-Op.
|
||||
//
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { ResourceManager } from "../ui/ResourceManager";
|
||||
import type { ResourceManagerHandlers } from "../ui/ResourceManager";
|
||||
import { usePanelHost } from "./host";
|
||||
|
||||
/** Inhalt des Panels „Ressourcen". */
|
||||
export function ResourcesPanel() {
|
||||
const host = usePanelHost();
|
||||
|
||||
// Handler aus dem Host auf die Signatur des ResourceManager bündeln.
|
||||
const handlers: ResourceManagerHandlers = {
|
||||
onPatchComponent: host.onPatchComponent,
|
||||
onAddComponent: host.onAddComponent,
|
||||
onDeleteComponent: host.onDeleteComponent,
|
||||
onPatchHatch: host.onPatchHatch,
|
||||
onAddHatch: host.onAddHatch,
|
||||
onDeleteHatch: host.onDeleteHatch,
|
||||
onPatchLineStyle: host.onPatchLineStyle,
|
||||
onAddLineStyle: host.onAddLineStyle,
|
||||
onDeleteLineStyle: host.onDeleteLineStyle,
|
||||
onPatchWallType: host.onPatchWallType,
|
||||
onAddWallType: host.onAddWallType,
|
||||
onDeleteWallType: host.onDeleteWallType,
|
||||
onPatchCeilingType: host.onPatchCeilingType,
|
||||
onPatchRoofType: host.onPatchRoofType,
|
||||
onAddRoofType: host.onAddRoofType,
|
||||
onDeleteRoofType: host.onDeleteRoofType,
|
||||
onAddCeilingType: host.onAddCeilingType,
|
||||
onDeleteCeilingType: host.onDeleteCeilingType,
|
||||
onImportLineStyles: host.onImportLineStyles,
|
||||
onImportHatches: host.onImportHatches,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel-resources-host">
|
||||
{/* Gescopte Override-Regeln: machen aus der Overlay-Schublade einen
|
||||
flächenfüllenden Inline-Körper. Betrifft nur Nachfahren dieses
|
||||
Wrappers — styles.css bleibt unberührt. */}
|
||||
<style>{PANEL_RESOURCES_CSS}</style>
|
||||
<ResourceManager
|
||||
project={host.project}
|
||||
handlers={handlers}
|
||||
onClose={NO_OP}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** onClose-Platzhalter: als Panel gibt es keinen Schubladen-Schließer. */
|
||||
const NO_OP = () => {};
|
||||
|
||||
/**
|
||||
* Lokale Override-Regeln, die die fixe Overlay-Schublade in einen normalen,
|
||||
* im Panel sitzenden Block verwandeln. Klassen-Selektoren sind unter
|
||||
* `.panel-resources-host` verschachtelt, damit nichts außerhalb des Panels
|
||||
* betroffen ist.
|
||||
*/
|
||||
const PANEL_RESOURCES_CSS = `
|
||||
.panel-resources-host {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.panel-resources-host .res-overlay {
|
||||
position: static;
|
||||
inset: auto;
|
||||
z-index: auto;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.panel-resources-host .res-drawer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-left: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* Im Panel ohne Schubladen-Schließer. */
|
||||
.panel-resources-host .res-close {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
@@ -347,20 +347,6 @@ export function removeFloat(layout: LayoutState, panelId: string): LayoutState {
|
||||
return { ...layout, floating };
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt ein Panel aus einem Dock (immutabel). Dünne, benannte Hülle um
|
||||
* `removeFromDock` auf Layout-Ebene; nützlich, um ein Panel ganz zu schließen.
|
||||
*/
|
||||
export function removeFromDockLayout(
|
||||
layout: LayoutState,
|
||||
dock: DockId,
|
||||
panelId: string,
|
||||
): LayoutState {
|
||||
const next = removeFromDock(layout[dock], panelId);
|
||||
if (next === layout[dock]) return layout;
|
||||
return { ...layout, [dock]: next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt das Panel als aktiven Tab innerhalb seiner Gruppe (immutabel). Sucht
|
||||
* die Gruppe im Dock, die das Panel enthält, und macht es dort aktiv. Liegt die
|
||||
|
||||
@@ -42,10 +42,3 @@ export function hasPanel(id: string): boolean {
|
||||
return registry.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt ein Panel aus der Registry. Liefert `true`, wenn etwas entfernt
|
||||
* wurde. Vor allem für Tests und das Entladen von Plugins gedacht.
|
||||
*/
|
||||
export function unregisterPanel(id: string): boolean {
|
||||
return registry.delete(id);
|
||||
}
|
||||
|
||||
+1
-36
@@ -152,7 +152,7 @@ export interface SvgTextOptions {
|
||||
/**
|
||||
* Zerlegt das Dokument in Zeilen/Tspans mit bereits in Benutzereinheiten
|
||||
* umgerechneten Grössen. Der Aufrufer positioniert die Zeilen selbst (Baseline
|
||||
* der Zeile i = `y + Σ lineHeight[0..i-1]`, s. `docToSvgText`).
|
||||
* der Zeile i = `y + Σ lineHeight[0..i-1]`).
|
||||
*/
|
||||
export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] {
|
||||
const basePt = opts.basePt ?? 12;
|
||||
@@ -262,38 +262,3 @@ function runToTspan(run: TextRun, basePt: number, unitPerPt: number, color: stri
|
||||
return tspan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut ein komplettes `<text>`-SVG-Element als String. Jede Zeile ist ein
|
||||
* `<tspan x=… dy=…>`, darin ein `<tspan>` je Run. `text-anchor` folgt der
|
||||
* Absatz-Ausrichtung (left→start, center→middle, right→end).
|
||||
*/
|
||||
export function docToSvgText(doc: RichTextDoc, opts: SvgTextOptions): string {
|
||||
const lines = docToLines(doc, opts);
|
||||
const parts: string[] = [];
|
||||
lines.forEach((line, i) => {
|
||||
const dy = i === 0 ? 0 : line.lineHeight;
|
||||
const anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start";
|
||||
const runSpans = line.tspans.map((t) => tspanToSvg(t)).join("");
|
||||
// Leere Zeile: ein Zero-Width-Space, damit dy dennoch wirkt.
|
||||
const content = runSpans || "​";
|
||||
parts.push(
|
||||
`<tspan x="${round(opts.x)}" dy="${round(dy)}" text-anchor="${anchor}">${content}</tspan>`,
|
||||
);
|
||||
});
|
||||
return `<text x="${round(opts.x)}" y="${round(opts.y)}">${parts.join("")}</text>`;
|
||||
}
|
||||
|
||||
function tspanToSvg(t: SvgTspan): string {
|
||||
const attrs: string[] = [`font-size="${t.fontSize}"`, `fill="${escapeAttr(t.fill)}"`];
|
||||
if (t.fontWeight) attrs.push(`font-weight="${t.fontWeight}"`);
|
||||
if (t.fontStyle) attrs.push(`font-style="${t.fontStyle}"`);
|
||||
if (t.fontFamily) attrs.push(`font-family="${escapeAttr(t.fontFamily)}"`);
|
||||
if (t.textDecoration) attrs.push(`text-decoration="${t.textDecoration}"`);
|
||||
if (t.baselineShift) attrs.push(`dy="${-t.baselineShift}"`);
|
||||
const body = escapeText(t.text);
|
||||
// Bei baseline-shift den dy nach dem Run zurücksetzen, damit die Grundlinie hält.
|
||||
if (t.baselineShift) {
|
||||
return `<tspan ${attrs.join(" ")}>${body}</tspan><tspan dy="${t.baselineShift}">​</tspan>`;
|
||||
}
|
||||
return `<tspan ${attrs.join(" ")}>${body}</tspan>`;
|
||||
}
|
||||
|
||||
@@ -103,11 +103,6 @@ export function plainText(doc: RichTextDoc): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Ist das Dokument (nach Reintext) leer? */
|
||||
export function isEmpty(doc: RichTextDoc): boolean {
|
||||
return plainText(doc).length === 0;
|
||||
}
|
||||
|
||||
/** Gesamtlänge in Zeichen (inkl. Absatzumbrüche als je 1 Zeichen). */
|
||||
export function docLength(doc: RichTextDoc): number {
|
||||
return plainText(doc).length;
|
||||
@@ -376,41 +371,6 @@ export function serialize(doc: RichTextDoc): string {
|
||||
return JSON.stringify({ version: 1, paragraphs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest ein Dokument aus JSON. Robust gegenüber fehlenden Feldern; unbekannter
|
||||
* Input ergibt ein leeres Dokument statt eines Fehlers.
|
||||
*/
|
||||
export function deserialize(json: string): RichTextDoc {
|
||||
try {
|
||||
const raw = JSON.parse(json) as unknown;
|
||||
return fromJson(raw);
|
||||
} catch {
|
||||
return emptyDoc();
|
||||
}
|
||||
}
|
||||
|
||||
/** Wandelt ein bereits geparstes Objekt in ein valides Dokument. */
|
||||
export function fromJson(raw: unknown): RichTextDoc {
|
||||
if (!raw || typeof raw !== "object") return emptyDoc();
|
||||
const obj = raw as { paragraphs?: unknown };
|
||||
if (!Array.isArray(obj.paragraphs)) return emptyDoc();
|
||||
const paragraphs: Paragraph[] = obj.paragraphs.map((p) => {
|
||||
const pp = (p ?? {}) as { runs?: unknown; align?: unknown };
|
||||
const runs: TextRun[] = Array.isArray(pp.runs)
|
||||
? pp.runs.map((r) => {
|
||||
const rr = (r ?? {}) as { text?: unknown; marks?: unknown };
|
||||
const text = typeof rr.text === "string" ? rr.text : "";
|
||||
const marks = normalizeMarks((rr.marks ?? {}) as Marks);
|
||||
return { text, marks };
|
||||
})
|
||||
: [];
|
||||
const para: Paragraph = { runs };
|
||||
if (pp.align === "center" || pp.align === "right") para.align = pp.align;
|
||||
return para;
|
||||
});
|
||||
return normalizeDoc({ version: 1, paragraphs });
|
||||
}
|
||||
|
||||
// ── Style-Presets ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Ein benannter Stil (Marks-Vorlage), z. B. „Titel". */
|
||||
|
||||
@@ -48,7 +48,3 @@ export const DEFAULT_MARQUEE_COLOR = "#CEB188";
|
||||
*/
|
||||
export const DEFAULT_SNAP_COLOR = "#E0BE5A";
|
||||
|
||||
/** Liefert einen Swatch nach `id`, oder `undefined`. */
|
||||
export function findAccentSwatch(id: string): AccentSwatch | undefined {
|
||||
return ACCENT_SWATCHES.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
@@ -621,21 +621,3 @@ export function getTool(id: ToolId): Tool {
|
||||
return TOOLS[id];
|
||||
}
|
||||
|
||||
/** Reihenfolge der Werkzeuge in der Werkzeugleiste. */
|
||||
export const TOOL_ORDER: ToolId[] = [
|
||||
"select",
|
||||
"wall",
|
||||
"ceiling",
|
||||
"window",
|
||||
"door",
|
||||
"stair",
|
||||
"room",
|
||||
"line",
|
||||
"polyline",
|
||||
"rect",
|
||||
"circle",
|
||||
"arc",
|
||||
"text",
|
||||
"textbox",
|
||||
"image",
|
||||
];
|
||||
|
||||
@@ -98,14 +98,14 @@ export interface ResourceManagerHandlers {
|
||||
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
|
||||
/**
|
||||
* Grafische Override-Regeln: ersetzt die GANZE Regelliste (Reihenfolge =
|
||||
* Priorität). Optional, damit bestehende Einbettungen (z. B. ResourcesPanel)
|
||||
* ohne Änderung weiter kompilieren — ohne Handler ist der Tab nur lesend.
|
||||
* Priorität). Optional, damit ältere Einbettungen ohne diesen Handler
|
||||
* weiter kompilieren — ohne Handler ist der Tab nur lesend.
|
||||
*/
|
||||
onSetOverrideRules?: (rules: OverrideRule[]) => void;
|
||||
/**
|
||||
* Bauteil-Typen (Tür/Fenster/Treppe) — Bibliotheks-CRUD, analog Wand-/
|
||||
* Deckentypen. Optional, damit bestehende Einbettungen (z. B. ResourcesPanel)
|
||||
* ohne Änderung weiter kompilieren; ohne Handler bleiben die Tabs verborgen.
|
||||
* Deckentypen. Optional, damit ältere Einbettungen ohne diesen Handler
|
||||
* weiter kompilieren; ohne Handler bleiben die Tabs verborgen.
|
||||
*/
|
||||
onAddDoorType?: () => void;
|
||||
onPatchDoorType?: (id: string, patch: Partial<DoorType>) => void;
|
||||
|
||||
+1
-2
@@ -1141,8 +1141,7 @@ export interface ViewRibbonTabProps {
|
||||
* „Ansichten"-Tab des Ribbons — trägt die aus der TopBar gemergten Mittelcluster:
|
||||
* 2D/3D-Ansichts-Raster (+ Kamera-FOV), Sichtbarkeits-Kombinationen, Detailgrad,
|
||||
* Massstab/Zoom-Cluster und Darstellungsart. EIN Ribbon für alles; Handler/Setter
|
||||
* kommen unverändert von App (gleiche Props wie zuvor an die TopBar). Wird von
|
||||
* `RibbonBar` als Inhalt des „Ansichten"-Tabs gerendert.
|
||||
* kommen unverändert von App (gleiche Props wie zuvor an die TopBar).
|
||||
*/
|
||||
export function ViewRibbonTab({
|
||||
viewToggleEnabled,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { materialFromAsset, DEFAULT_TILE_SIZE_M } from "../../materials/runtime"
|
||||
import {
|
||||
searchMaterials,
|
||||
fetchMaterialMaps,
|
||||
revokeMaterialUrls,
|
||||
AMBIENT_CATEGORIES,
|
||||
type AmbientMaterial,
|
||||
type AmbientResolution,
|
||||
@@ -55,9 +56,13 @@ function MaterialCell({
|
||||
<MaterialPicker
|
||||
material={material}
|
||||
onAssign={(m) => {
|
||||
// Vorheriges Material ersetzt: dessen Blob-URLs (Download/Upload)
|
||||
// sind ab jetzt unerreichbar — sonst leckt jeder Material-Wechsel.
|
||||
revokeMaterialUrls(material);
|
||||
onAssign(m);
|
||||
}}
|
||||
onClear={() => {
|
||||
revokeMaterialUrls(material);
|
||||
onClear();
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
@@ -726,7 +731,10 @@ export function ComponentsTab({
|
||||
component={selected}
|
||||
hatches={project.hatches}
|
||||
onPatch={(patch) => onPatchComponent(selected.id, patch)}
|
||||
onDelete={() => onDeleteComponent(selected.id)}
|
||||
onDelete={() => {
|
||||
revokeMaterialUrls(selected.material);
|
||||
onDeleteComponent(selected.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="res-md-empty">{t("resources.components.empty")}</div>
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert die Gruppen
|
||||
// EINES festen Tabs (kein Tab-Umschalter mehr, v12: Werkzeuge leben im
|
||||
// ToolsPanel). App rendert mehrere `<RibbonBar tab="...">`-Instanzen permanent
|
||||
// nebeneinander (2d/bim/custom) statt einer einzigen, umschaltbaren — jede
|
||||
// zeigt nur die Befehls-Gruppen ohne Panel-Entsprechung.
|
||||
//
|
||||
// Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, wie
|
||||
// im ToolsPanel); Befehls-Elemente über `onRunCommand` (engine.start).
|
||||
//
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { ToolIcon } from "../../panels/ToolsPanel";
|
||||
import { getTool } from "../../tools/tools";
|
||||
import type { ToolId } from "../../tools/types";
|
||||
import { getCommand } from "../../commands/registry";
|
||||
import { Dropdown } from "../Dropdown";
|
||||
import type { DropdownItem } from "../Dropdown";
|
||||
import { RIBBON_TABS, ALL_RIBBON_ITEMS, itemKey, itemFromKey } from "./ribbonItems";
|
||||
import type { RibbonItem, RibbonTabId } from "./ribbonItems";
|
||||
import { CommandIcon } from "./CommandIcon";
|
||||
|
||||
/** Aufgelöste Beschriftung eines Items (Werkzeug bzw. Befehl). */
|
||||
function itemLabel(item: RibbonItem): string {
|
||||
if (item.kind === "tool") return t(getTool(item.id).labelKey);
|
||||
const cmd = getCommand(item.name);
|
||||
return cmd ? t(cmd.labelKey) : item.name;
|
||||
}
|
||||
|
||||
export interface RibbonBarProps {
|
||||
/** Aktiver Tab (State liegt in App, geteilt mit den Reitern in der TopBar). */
|
||||
tab: RibbonTabId;
|
||||
/** Aktives Werkzeug (Highlight der `tool`-Elemente). */
|
||||
activeTool: ToolId;
|
||||
/** Name des laufenden Engine-Befehls (Highlight der `command`-Elemente). */
|
||||
activeCommand: string | null;
|
||||
/** Ob Boden-gebundene (`floorOnly`) Werkzeuge nutzbar sind (aktives Geschoss). */
|
||||
toolsEnabled: boolean;
|
||||
/** Werkzeug aktivieren (gleicher Pfad wie die Sidebar). */
|
||||
onSelectTool: (id: ToolId) => void;
|
||||
/** Engine-Befehl starten (Ändern-Gruppe u. a.). */
|
||||
onRunCommand: (name: string) => void;
|
||||
/** Persistierte Item-Schlüssel der modularen Custom-Bar (Tab „Eigene"). */
|
||||
customItems: string[];
|
||||
/** Ein Item in der Custom-Bar an-/abwählen (Picker im „Eigene"-Tab). */
|
||||
onToggleCustomItem: (key: string) => void;
|
||||
}
|
||||
|
||||
/** Aktivierungs-/Highlight-Kontext, den jeder Button-Typ braucht. */
|
||||
interface ButtonCtx {
|
||||
activeTool: ToolId;
|
||||
activeCommand: string | null;
|
||||
toolsEnabled: boolean;
|
||||
onSelectTool: (id: ToolId) => void;
|
||||
onRunCommand: (name: string) => void;
|
||||
}
|
||||
|
||||
/** Ein einzelner Ribbon-Button (Werkzeug ODER Befehl). */
|
||||
function RibbonButton({ item, ctx }: { item: RibbonItem; ctx: ButtonCtx }) {
|
||||
if (item.kind === "tool") {
|
||||
const tool = getTool(item.id);
|
||||
// Nur Boden-gebundene Werkzeuge sind ohne aktives Geschoss gesperrt; freie
|
||||
// 2D-Werkzeuge (Linie/Kreis/…) bleiben auch auf Zeichnungsebenen nutzbar.
|
||||
const disabled = !!tool.floorOnly && !ctx.toolsEnabled;
|
||||
const active = ctx.activeTool === item.id;
|
||||
const label = t(tool.labelKey);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`ribbon-btn${active ? " active" : ""}`}
|
||||
disabled={disabled}
|
||||
title={disabled ? t("tool.floorOnlyDisabled") : label}
|
||||
onClick={() => ctx.onSelectTool(item.id)}
|
||||
>
|
||||
<ToolIcon id={item.id} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
// Befehls-Element: Label aus der Befehls-Registry ableiten. Für Aktionen ohne
|
||||
// eigenes Engine-Kommando (z. B. „rotate", das über die interaktive
|
||||
// Transformation läuft) auf den i18n-Schlüssel `cmd.<name>.label` zurückfallen.
|
||||
const cmd = getCommand(item.name);
|
||||
const label = cmd ? t(cmd.labelKey) : t(`cmd.${item.name}.label`);
|
||||
const active = ctx.activeCommand === item.name;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`ribbon-btn${active ? " active" : ""}`}
|
||||
title={label}
|
||||
onClick={() => ctx.onRunCommand(item.name)}
|
||||
>
|
||||
<CommandIcon name={item.name} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modulare Custom-Bar (Tab „Eigene") — rendert die vom Nutzer gewählten Items
|
||||
* plus einen „+"-Picker, der ALLE verfügbaren Werkzeuge/Befehle mit Häkchen für
|
||||
* die bereits enthaltenen listet (Klick = an-/abwählen). Gleiche Item-Typen +
|
||||
* gleiche Aktivierung wie die normalen Tabs — kein Sonderweg.
|
||||
*/
|
||||
function CustomBar({
|
||||
customItems,
|
||||
onToggle,
|
||||
ctx,
|
||||
}: {
|
||||
customItems: string[];
|
||||
onToggle: (key: string) => void;
|
||||
ctx: ButtonCtx;
|
||||
}) {
|
||||
const pickerItems: DropdownItem[] = ALL_RIBBON_ITEMS.map((it) => {
|
||||
const key = itemKey(it);
|
||||
return {
|
||||
label: itemLabel(it),
|
||||
active: customItems.includes(key),
|
||||
onSelect: () => onToggle(key),
|
||||
};
|
||||
});
|
||||
const items = customItems
|
||||
.map(itemFromKey)
|
||||
.filter((it): it is RibbonItem => it !== null);
|
||||
return (
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-items">
|
||||
{items.length === 0 && (
|
||||
<div className="ribbon-empty">{t("ribbon.custom.empty")}</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<RibbonButton key={`${itemKey(item)}-${i}`} item={item} ctx={ctx} />
|
||||
))}
|
||||
<Dropdown
|
||||
items={pickerItems}
|
||||
placeholder={t("ribbon.custom.add")}
|
||||
title={t("ribbon.custom.add")}
|
||||
triggerClassName="ribbon-add"
|
||||
/>
|
||||
</div>
|
||||
<div className="ribbon-group-title">{t("ribbon.group.custom")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RibbonBar(props: RibbonBarProps) {
|
||||
const current = RIBBON_TABS.find((tt) => tt.id === props.tab) ?? RIBBON_TABS[0];
|
||||
const ctx: ButtonCtx = {
|
||||
activeTool: props.activeTool,
|
||||
activeCommand: props.activeCommand,
|
||||
toolsEnabled: props.toolsEnabled,
|
||||
onSelectTool: props.onSelectTool,
|
||||
onRunCommand: props.onRunCommand,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ribbon">
|
||||
{/* Der „Eigene"-Tab rendert die modulare Custom-Bar; sonst die
|
||||
datengetriebenen Item-Gruppen. */}
|
||||
<div className="ribbon-body">
|
||||
{current.id === "custom" ? (
|
||||
<CustomBar
|
||||
customItems={props.customItems}
|
||||
onToggle={props.onToggleCustomItem}
|
||||
ctx={ctx}
|
||||
/>
|
||||
) : current.groups.length === 0 ? (
|
||||
<div className="ribbon-empty">{t("ribbon.empty")}</div>
|
||||
) : (
|
||||
current.groups.map((g) => (
|
||||
<div key={g.titleKey} className="ribbon-group">
|
||||
<div className="ribbon-items">
|
||||
{g.items.map((item) => (
|
||||
<RibbonButton
|
||||
key={item.kind === "tool" ? `t:${item.id}` : `c:${item.name}`}
|
||||
item={item}
|
||||
ctx={ctx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="ribbon-group-title">{t(g.titleKey)}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user