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;
|
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). */
|
/** Dekodiertes Bild: dichte RGBA8-Bytes + Maße (zeilenweise von oben). */
|
||||||
export interface DecodedImage {
|
export interface DecodedImage {
|
||||||
data: Uint8Array;
|
data: Uint8Array;
|
||||||
|
|||||||
@@ -267,3 +267,26 @@ export async function fetchMaterialMaps(
|
|||||||
export function revokeMaterial(blobUrls: string[]): void {
|
export function revokeMaterial(blobUrls: string[]): void {
|
||||||
for (const u of blobUrls) URL.revokeObjectURL(u);
|
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 };
|
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
|
* 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
|
* 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);
|
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
|
* Zerlegt das Dokument in Zeilen/Tspans mit bereits in Benutzereinheiten
|
||||||
* umgerechneten Grössen. Der Aufrufer positioniert die Zeilen selbst (Baseline
|
* 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[] {
|
export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] {
|
||||||
const basePt = opts.basePt ?? 12;
|
const basePt = opts.basePt ?? 12;
|
||||||
@@ -262,38 +262,3 @@ function runToTspan(run: TextRun, basePt: number, unitPerPt: number, color: stri
|
|||||||
return tspan;
|
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");
|
.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). */
|
/** Gesamtlänge in Zeichen (inkl. Absatzumbrüche als je 1 Zeichen). */
|
||||||
export function docLength(doc: RichTextDoc): number {
|
export function docLength(doc: RichTextDoc): number {
|
||||||
return plainText(doc).length;
|
return plainText(doc).length;
|
||||||
@@ -376,41 +371,6 @@ export function serialize(doc: RichTextDoc): string {
|
|||||||
return JSON.stringify({ version: 1, paragraphs });
|
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 ──────────────────────────────────────────────────────────
|
// ── Style-Presets ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Ein benannter Stil (Marks-Vorlage), z. B. „Titel". */
|
/** Ein benannter Stil (Marks-Vorlage), z. B. „Titel". */
|
||||||
|
|||||||
@@ -48,7 +48,3 @@ export const DEFAULT_MARQUEE_COLOR = "#CEB188";
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_SNAP_COLOR = "#E0BE5A";
|
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];
|
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;
|
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
|
||||||
/**
|
/**
|
||||||
* Grafische Override-Regeln: ersetzt die GANZE Regelliste (Reihenfolge =
|
* Grafische Override-Regeln: ersetzt die GANZE Regelliste (Reihenfolge =
|
||||||
* Priorität). Optional, damit bestehende Einbettungen (z. B. ResourcesPanel)
|
* Priorität). Optional, damit ältere Einbettungen ohne diesen Handler
|
||||||
* ohne Änderung weiter kompilieren — ohne Handler ist der Tab nur lesend.
|
* weiter kompilieren — ohne Handler ist der Tab nur lesend.
|
||||||
*/
|
*/
|
||||||
onSetOverrideRules?: (rules: OverrideRule[]) => void;
|
onSetOverrideRules?: (rules: OverrideRule[]) => void;
|
||||||
/**
|
/**
|
||||||
* Bauteil-Typen (Tür/Fenster/Treppe) — Bibliotheks-CRUD, analog Wand-/
|
* Bauteil-Typen (Tür/Fenster/Treppe) — Bibliotheks-CRUD, analog Wand-/
|
||||||
* Deckentypen. Optional, damit bestehende Einbettungen (z. B. ResourcesPanel)
|
* Deckentypen. Optional, damit ältere Einbettungen ohne diesen Handler
|
||||||
* ohne Änderung weiter kompilieren; ohne Handler bleiben die Tabs verborgen.
|
* weiter kompilieren; ohne Handler bleiben die Tabs verborgen.
|
||||||
*/
|
*/
|
||||||
onAddDoorType?: () => void;
|
onAddDoorType?: () => void;
|
||||||
onPatchDoorType?: (id: string, patch: Partial<DoorType>) => 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:
|
* „Ansichten"-Tab des Ribbons — trägt die aus der TopBar gemergten Mittelcluster:
|
||||||
* 2D/3D-Ansichts-Raster (+ Kamera-FOV), Sichtbarkeits-Kombinationen, Detailgrad,
|
* 2D/3D-Ansichts-Raster (+ Kamera-FOV), Sichtbarkeits-Kombinationen, Detailgrad,
|
||||||
* Massstab/Zoom-Cluster und Darstellungsart. EIN Ribbon für alles; Handler/Setter
|
* 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
|
* kommen unverändert von App (gleiche Props wie zuvor an die TopBar).
|
||||||
* `RibbonBar` als Inhalt des „Ansichten"-Tabs gerendert.
|
|
||||||
*/
|
*/
|
||||||
export function ViewRibbonTab({
|
export function ViewRibbonTab({
|
||||||
viewToggleEnabled,
|
viewToggleEnabled,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { materialFromAsset, DEFAULT_TILE_SIZE_M } from "../../materials/runtime"
|
|||||||
import {
|
import {
|
||||||
searchMaterials,
|
searchMaterials,
|
||||||
fetchMaterialMaps,
|
fetchMaterialMaps,
|
||||||
|
revokeMaterialUrls,
|
||||||
AMBIENT_CATEGORIES,
|
AMBIENT_CATEGORIES,
|
||||||
type AmbientMaterial,
|
type AmbientMaterial,
|
||||||
type AmbientResolution,
|
type AmbientResolution,
|
||||||
@@ -55,9 +56,13 @@ function MaterialCell({
|
|||||||
<MaterialPicker
|
<MaterialPicker
|
||||||
material={material}
|
material={material}
|
||||||
onAssign={(m) => {
|
onAssign={(m) => {
|
||||||
|
// Vorheriges Material ersetzt: dessen Blob-URLs (Download/Upload)
|
||||||
|
// sind ab jetzt unerreichbar — sonst leckt jeder Material-Wechsel.
|
||||||
|
revokeMaterialUrls(material);
|
||||||
onAssign(m);
|
onAssign(m);
|
||||||
}}
|
}}
|
||||||
onClear={() => {
|
onClear={() => {
|
||||||
|
revokeMaterialUrls(material);
|
||||||
onClear();
|
onClear();
|
||||||
}}
|
}}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
@@ -726,7 +731,10 @@ export function ComponentsTab({
|
|||||||
component={selected}
|
component={selected}
|
||||||
hatches={project.hatches}
|
hatches={project.hatches}
|
||||||
onPatch={(patch) => onPatchComponent(selected.id, patch)}
|
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>
|
<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