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:
2026-08-20 19:51:52 +02:00
parent cbdc3d5d76
commit 003fde4320
13 changed files with 38 additions and 448 deletions
-34
View File
@@ -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;
+23
View File
@@ -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);
}
-100
View File
@@ -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;
}
`;
-14
View File
@@ -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
-7
View File
@@ -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
View File
@@ -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 || "&#8203;";
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}">&#8203;</tspan>`;
}
return `<tspan ${attrs.join(" ")}>${body}</tspan>`;
}
-40
View File
@@ -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". */
-4
View File
@@ -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);
}
-18
View File
@@ -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",
];
+4 -4
View File
@@ -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
View File
@@ -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,
+9 -1
View File
@@ -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>
-188
View File
@@ -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>
);
}