b9e330ecb7
- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht, unsichtbare Ebenen blenden ihre Führungslinie aus - Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/ Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste (AppMenuBar) neben der Wortmarke - Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert, flackerfrei vor erstem Render gesetzt - SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene - UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access- Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/ Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt - Neues Dachziegel-Material (RoofingTiles013A)
588 lines
22 KiB
TypeScript
588 lines
22 KiB
TypeScript
// Panel-Layout — Standard + Persistenz (localStorage).
|
|
//
|
|
// Verwaltet den LayoutState (welche Panels in welchem Dock, aktiver Tab,
|
|
// Größen). Drei Ebenen:
|
|
// 1. defaultLayout() — der eingebaute Startzustand.
|
|
// 2. loadLayout()/saveLayout() — das zuletzt benutzte Layout (1 Slot).
|
|
// 3. saveNamedLayout()/… und Co. — benannte Layouts (Arbeitsbereiche).
|
|
//
|
|
// Sämtlicher localStorage-Zugriff ist in try/catch gekapselt und prüft, ob
|
|
// `localStorage` überhaupt existiert (SSR/Tests/Privatmodus) — Lesefehler
|
|
// liefern den Standard, Schreibfehler werden still geschluckt.
|
|
//
|
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
|
|
|
import type {
|
|
DockGroup,
|
|
DockId,
|
|
DockState,
|
|
FloatingPanel,
|
|
LayoutState,
|
|
} from "./types";
|
|
|
|
/**
|
|
* Aktuelle Layout-Schema-Version (bei Strukturänderungen erhöhen). v2: rechtes
|
|
* Dock standardmäßig leer, „Ressourcen" nun schwebendes Fenster statt Dock-
|
|
* Panel. v3: schwebende Panels (`floating`). v4: Werkzeug-Palette als Kern-
|
|
* Panel. v5: GESTAPELTE GRUPPEN — ein Dock hält eine Liste vertikaler Gruppen
|
|
* (je eigener Tab-Stapel + aktiver Tab + Höhen-Gewicht) statt eines einzigen
|
|
* Tab-Stapels. v6: Attribute- + Objekt-Info-Palette im Default-Layout
|
|
* (Vectorworks-Bild). v7: Gelände-/Kontext-Palette („site") als weiterer Tab in
|
|
* der rechten unteren Gruppe. Das Hochzählen verwirft alt gespeicherte Layouts
|
|
* sauber auf den neuen Standard (kein Migrationspfad über die Strukturgrenze).
|
|
*/
|
|
// v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) →
|
|
// Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen
|
|
// (Attribute volle linke Höhe statt Werkzeug-Palette oben).
|
|
// v9: Element-Baum ("elements", ROADMAP Paragraf 11) als weiterer Tab in der
|
|
// rechten unteren Gruppe (neben Zeichnungsebenen/Ebenen/Umgebung).
|
|
// v10: Ausschnitte-Panel ("view-snapshots", DOSSIER A2 / ROADMAP Paragraf 2c)
|
|
// als weiterer Tab in der rechten unteren Gruppe.
|
|
// v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer
|
|
// Tab in der rechten unteren Gruppe.
|
|
// v12: Werkzeug-Palette ZURÜCK ins Default-Layout (Nutzer-Entscheid: Werkzeuge
|
|
// gehören in ein andockbares Panel statt ins Ribbon-Band) — wieder oben links,
|
|
// Attribute darunter (Vectorworks-Bild aus v6, vor dem Ribbon-UI-Phase-3-Bump).
|
|
export const LAYOUT_VERSION = 12;
|
|
|
|
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
|
|
const CURRENT_KEY = "cad.layout";
|
|
/** Präfix der localStorage-Schlüssel benannter Layouts: `cad.layouts.<name>`. */
|
|
const NAMED_PREFIX = "cad.layouts.";
|
|
|
|
/**
|
|
* Konventionelle IDs der Kern-Panels für den Standard (entsprechen den IDs in
|
|
* builtinPanels: "drawing-levels", "layers"). Sind diese Panels nicht
|
|
* registriert (Plugin aus, Test), sollte der Host die IDs gegen die Registry
|
|
* filtern (siehe `hasPanel`); das Layout selbst bleibt registry-unabhängig.
|
|
*
|
|
* Das rechte Dock ist standardmäßig LEER: „Ressourcen" ist kein Dock-Panel
|
|
* mehr, sondern ein schwebendes Fenster (ResourceManager), das über die
|
|
* Oberleiste geöffnet wird. Ein leeres Dock wird nicht gerendert, sodass die
|
|
* Mitte (Grundriss/Perspektive) die Breite voll nutzt.
|
|
*/
|
|
/**
|
|
* Standard-Gruppen je Dock-Seite (oben → unten) — das Vectorworks-Bild.
|
|
* Links: Werkzeug-Palette OBEN, Attribute-Palette UNTEN. Rechts: Objekt-Info
|
|
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
|
|
* schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht.
|
|
*/
|
|
// v12: Werkzeug-Palette ("tools") wieder im Default-Layout — oben links, mit
|
|
// der Attribute-Palette darunter (Vectorworks-Bild). Das Ribbon-Band bleibt als
|
|
// Reiter erreichbar, ist aber nicht mehr der primäre Weg zu den Werkzeugen.
|
|
const DEFAULT_LEFT_GROUPS: DockGroup[] = [
|
|
{ tabs: ["tools"], activeTab: "tools", weight: 1 },
|
|
{ tabs: ["attributes"], activeTab: "attributes", weight: 1 },
|
|
];
|
|
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
|
|
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
|
|
{
|
|
tabs: ["drawing-levels", "layers", "site", "elements", "view-snapshots", "layouts"],
|
|
activeTab: "drawing-levels",
|
|
weight: 1.5,
|
|
},
|
|
];
|
|
|
|
/** Standardbreiten der Docks in CSS-Pixeln. */
|
|
const DEFAULT_LEFT_SIZE = 280;
|
|
const DEFAULT_RIGHT_SIZE = 340;
|
|
|
|
/** Höhen-Gewicht einer neu erzeugten Gruppe (Mittel der vorhandenen, sonst 1). */
|
|
function newGroupWeight(groups: DockGroup[]): number {
|
|
if (groups.length === 0) return 1;
|
|
return groups.reduce((s, g) => s + g.weight, 0) / groups.length;
|
|
}
|
|
|
|
/** Tiefe (frische Objekte) Kopie einer Gruppenliste. */
|
|
function cloneGroups(groups: DockGroup[]): DockGroup[] {
|
|
return groups.map((g) => ({ tabs: [...g.tabs], activeTab: g.activeTab, weight: g.weight }));
|
|
}
|
|
|
|
// ── Standard ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Liefert den eingebauten Startzustand: links das Attribute-Panel (volle Höhe),
|
|
* rechts Objektinfo/Raumbilanz oben + Zeichnungsebenen/Ebenen/Umgebung unten.
|
|
* Die Werkzeuge liegen im Ribbon (keine Sidebar mehr). Jeder Aufruf erzeugt
|
|
* frische Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können.
|
|
*/
|
|
export function defaultLayout(): LayoutState {
|
|
return {
|
|
left: { groups: cloneGroups(DEFAULT_LEFT_GROUPS), size: DEFAULT_LEFT_SIZE },
|
|
right: { groups: cloneGroups(DEFAULT_RIGHT_GROUPS), size: DEFAULT_RIGHT_SIZE },
|
|
floating: [],
|
|
version: LAYOUT_VERSION,
|
|
};
|
|
}
|
|
|
|
// ── Mutations-Helfer (rein, immutabel) ──────────────────────────────────────
|
|
//
|
|
// Alle Helfer liefern ein NEUES LayoutState und mutieren die Eingabe nicht.
|
|
// Sie wahren die Invariante „jedes Panel an genau einer Stelle": ein Panel wird
|
|
// zuerst überall entfernt (beide Docks + floating), dann am Ziel eingefügt. Sie
|
|
// kennen die Registry NICHT — Aufrufer reichen gültige Panel-IDs herein.
|
|
|
|
/** Der gegenüberliegende Dock (für Aufräum-Logik). */
|
|
function otherDock(dock: DockId): DockId {
|
|
return dock === "left" ? "right" : "left";
|
|
}
|
|
|
|
/**
|
|
* Entfernt eine Panel-ID aus einer Gruppe (immutabel) oder liefert die Gruppe
|
|
* unverändert zurück, wenn sie das Panel nicht enthält. Wird das Panel der
|
|
* aktive Tab, rückt `activeTab` auf den nächstgelegenen Nachbarn (rechts, sonst
|
|
* links); ist die Gruppe danach leer, wird `activeTab` `null`.
|
|
*/
|
|
function removeFromGroup(group: DockGroup, panelId: string): DockGroup {
|
|
const i = group.tabs.indexOf(panelId);
|
|
if (i < 0) return group;
|
|
const tabs = group.tabs.filter((id) => id !== panelId);
|
|
let activeTab = group.activeTab;
|
|
if (activeTab === panelId) activeTab = tabs[i] ?? tabs[i - 1] ?? null;
|
|
return { ...group, tabs, activeTab };
|
|
}
|
|
|
|
/**
|
|
* Entfernt eine Panel-ID aus allen Gruppen eines Docks und wirft die dadurch
|
|
* leer gewordenen Gruppen weg (immutabel). Dock-Breite bleibt erhalten.
|
|
*/
|
|
function removeFromDock(dock: DockState, panelId: string): DockState {
|
|
const groups = dock.groups
|
|
.map((g) => removeFromGroup(g, panelId))
|
|
.filter((g) => g.tabs.length > 0);
|
|
if (groups.length === dock.groups.length && groups.every((g, i) => g === dock.groups[i])) {
|
|
return dock; // nichts geändert
|
|
}
|
|
return { ...dock, groups };
|
|
}
|
|
|
|
/**
|
|
* Entfernt eine Panel-ID überall (beide Docks + floating). Basis für alle
|
|
* „verschiebe nach …"-Helfer, damit die Eindeutigkeits-Invariante hält.
|
|
*/
|
|
function detach(layout: LayoutState, panelId: string): LayoutState {
|
|
return {
|
|
...layout,
|
|
left: removeFromDock(layout.left, panelId),
|
|
right: removeFromDock(layout.right, panelId),
|
|
floating: layout.floating.filter((f) => f.panelId !== panelId),
|
|
};
|
|
}
|
|
|
|
/** Höchster vergebener z-Index der schwebenden Panels (0, wenn keine). */
|
|
function maxZ(floating: FloatingPanel[]): number {
|
|
return floating.reduce((m, f) => Math.max(m, f.z), 0);
|
|
}
|
|
|
|
/**
|
|
* Verschiebt ein Panel in eine BESTEHENDE Gruppe eines Docks und macht es dort
|
|
* aktiv (Tab-Stapel zusammenlegen). Es wird zuvor überall entfernt. `tabIndex`
|
|
* bestimmt die Einfügeposition in der Gruppe; fehlt er, wird angehängt. Liegt
|
|
* `groupIndex` außerhalb (z. B. weil das Detachen eine Gruppe entfernt hat),
|
|
* wird auf die letzte Gruppe geklemmt; gibt es danach keine Gruppe, wird eine
|
|
* neue angelegt.
|
|
*/
|
|
export function moveToGroup(
|
|
layout: LayoutState,
|
|
panelId: string,
|
|
dock: DockId,
|
|
groupIndex: number,
|
|
tabIndex?: number,
|
|
): LayoutState {
|
|
const detached = detach(layout, panelId);
|
|
const target = detached[dock];
|
|
if (target.groups.length === 0) {
|
|
return moveToNewGroup(detached, panelId, dock, 0);
|
|
}
|
|
const gi = Math.max(0, Math.min(groupIndex, target.groups.length - 1));
|
|
const groups = target.groups.map((g, i) => {
|
|
if (i !== gi) return g;
|
|
const at =
|
|
tabIndex === undefined
|
|
? g.tabs.length
|
|
: Math.max(0, Math.min(tabIndex, g.tabs.length));
|
|
const tabs = [...g.tabs.slice(0, at), panelId, ...g.tabs.slice(at)];
|
|
return { ...g, tabs, activeTab: panelId };
|
|
});
|
|
return { ...detached, [dock]: { ...target, groups } };
|
|
}
|
|
|
|
/**
|
|
* Legt ein Panel als NEUE Gruppe (eigener Stapel) an Position `atIndex` im Dock
|
|
* an (für gestapelte Paletten). Es wird zuvor überall entfernt. `atIndex` wird
|
|
* auf [0, groups.length] geklemmt; das neue Gewicht ist das Mittel der
|
|
* vorhandenen Gruppen, damit der Stapel ausgewogen startet.
|
|
*/
|
|
export function moveToNewGroup(
|
|
layout: LayoutState,
|
|
panelId: string,
|
|
dock: DockId,
|
|
atIndex: number,
|
|
): LayoutState {
|
|
const detached = detach(layout, panelId);
|
|
const target = detached[dock];
|
|
const at = Math.max(0, Math.min(atIndex, target.groups.length));
|
|
const group: DockGroup = {
|
|
tabs: [panelId],
|
|
activeTab: panelId,
|
|
weight: newGroupWeight(target.groups),
|
|
};
|
|
const groups = [...target.groups.slice(0, at), group, ...target.groups.slice(at)];
|
|
return { ...detached, [dock]: { ...target, groups } };
|
|
}
|
|
|
|
/**
|
|
* Rückwärtskompatible Hülle: verschiebt ein Panel in ein Dock, indem es als
|
|
* neue Gruppe ans Ende gehängt wird (das natürliche Ziel beim Andocken an die
|
|
* Rand-Zone oder beim Redock eines schwebenden Fensters).
|
|
*/
|
|
export function moveToDock(
|
|
layout: LayoutState,
|
|
panelId: string,
|
|
dock: DockId,
|
|
): LayoutState {
|
|
return moveToNewGroup(layout, panelId, dock, layout[dock].groups.length);
|
|
}
|
|
|
|
/**
|
|
* Macht ein Panel schwebend (frei positioniert). Es wird zuvor aus jedem Dock
|
|
* entfernt. `rect` setzt Position/Größe; das Panel kommt nach vorne (z über
|
|
* allen anderen). War es bereits schwebend, wird sein Eintrag durch den neuen
|
|
* (mit `rect` und Front-z) ersetzt.
|
|
*/
|
|
export function moveToFloat(
|
|
layout: LayoutState,
|
|
panelId: string,
|
|
rect: { x: number; y: number; w: number; h: number },
|
|
): LayoutState {
|
|
const detached = detach(layout, panelId);
|
|
const z = maxZ(detached.floating) + 1;
|
|
const panel: FloatingPanel = { panelId, ...rect, z };
|
|
return { ...detached, floating: [...detached.floating, panel] };
|
|
}
|
|
|
|
/**
|
|
* Ordnet einen Tab innerhalb EINER Gruppe um (immutabel). Bewegt den Tab an
|
|
* `fromIndex` vor den Tab, der nach dem Entfernen an `toIndex` steht. Indizes
|
|
* außerhalb des Bereichs werden geklemmt; ist nichts zu tun, kommt das Layout
|
|
* unverändert zurück. Der aktive Tab bleibt erhalten (folgt seiner ID).
|
|
*/
|
|
export function reorderInGroup(
|
|
layout: LayoutState,
|
|
dock: DockId,
|
|
groupIndex: number,
|
|
fromIndex: number,
|
|
toIndex: number,
|
|
): LayoutState {
|
|
const d = layout[dock];
|
|
const g = d.groups[groupIndex];
|
|
if (!g) return layout;
|
|
const n = g.tabs.length;
|
|
if (n === 0) return layout;
|
|
const from = Math.max(0, Math.min(fromIndex, n - 1));
|
|
const to = Math.max(0, Math.min(toIndex, n - 1));
|
|
if (from === to) return layout;
|
|
const tabs = [...g.tabs];
|
|
const [moved] = tabs.splice(from, 1);
|
|
tabs.splice(to, 0, moved);
|
|
const groups = d.groups.map((gr, i) => (i === groupIndex ? { ...gr, tabs } : gr));
|
|
return { ...layout, [dock]: { ...d, groups } };
|
|
}
|
|
|
|
/**
|
|
* Verteilt die Höhen-Gewichte der Gruppen eines Docks neu (immutabel) — vom
|
|
* Gruppen-Splitter aufgerufen. `weights` muss so lang sein wie `groups`; zu
|
|
* kleine/0-Werte werden auf ein Minimum angehoben, damit keine Gruppe
|
|
* verschwindet.
|
|
*/
|
|
export function setGroupWeights(
|
|
layout: LayoutState,
|
|
dock: DockId,
|
|
weights: number[],
|
|
): LayoutState {
|
|
const d = layout[dock];
|
|
if (weights.length !== d.groups.length) return layout;
|
|
const groups = d.groups.map((g, i) => ({ ...g, weight: Math.max(0.05, weights[i]) }));
|
|
return { ...layout, [dock]: { ...d, groups } };
|
|
}
|
|
|
|
/**
|
|
* Aktualisiert ein schwebendes Panel (Position/Größe/z) per Teil-Patch. Ein
|
|
* `z`-Wert im Patch wird übernommen wie gegeben (für gezieltes Setzen); zum
|
|
* Nach-vorne-Holen siehe `bringToFront`. Unbekannte ID → Layout unverändert.
|
|
*/
|
|
export function updateFloat(
|
|
layout: LayoutState,
|
|
panelId: string,
|
|
partial: Partial<Omit<FloatingPanel, "panelId">>,
|
|
): LayoutState {
|
|
let changed = false;
|
|
const floating = layout.floating.map((f) => {
|
|
if (f.panelId !== panelId) return f;
|
|
changed = true;
|
|
return { ...f, ...partial };
|
|
});
|
|
return changed ? { ...layout, floating } : layout;
|
|
}
|
|
|
|
/**
|
|
* Holt ein schwebendes Panel nach vorne (höchster z-Index). Ist es bereits
|
|
* vorne (oder unbekannt), kommt das Layout unverändert zurück.
|
|
*/
|
|
export function bringToFront(layout: LayoutState, panelId: string): LayoutState {
|
|
const top = maxZ(layout.floating);
|
|
const current = layout.floating.find((f) => f.panelId === panelId);
|
|
if (!current || current.z === top) return layout;
|
|
return updateFloat(layout, panelId, { z: top + 1 });
|
|
}
|
|
|
|
/**
|
|
* Entfernt ein schwebendes Panel aus `floating` (immutabel). Damit ist das
|
|
* Panel an KEINER Stelle mehr — Aufrufer, die es nicht schließen, sondern
|
|
* andocken wollen, nutzen `moveToDock`.
|
|
*/
|
|
export function removeFloat(layout: LayoutState, panelId: string): LayoutState {
|
|
const floating = layout.floating.filter((f) => f.panelId !== panelId);
|
|
if (floating.length === layout.floating.length) return layout;
|
|
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
|
|
* ID in keiner Gruppe des Docks (oder ist sie schon aktiv), kommt das Layout
|
|
* unverändert zurück.
|
|
*/
|
|
export function setActiveTab(
|
|
layout: LayoutState,
|
|
dock: DockId,
|
|
panelId: string,
|
|
): LayoutState {
|
|
const d = layout[dock];
|
|
const gi = d.groups.findIndex((g) => g.tabs.includes(panelId));
|
|
if (gi < 0 || d.groups[gi].activeTab === panelId) return layout;
|
|
const groups = d.groups.map((g, i) => (i === gi ? { ...g, activeTab: panelId } : g));
|
|
return { ...layout, [dock]: { ...d, groups } };
|
|
}
|
|
|
|
/**
|
|
* Macht ein Panel sichtbar, gleich wo es gerade steckt — NIE destruktiv (schliesst/
|
|
* versteckt nie etwas anderes). Für „Fenster > Panels" (native Menüleiste,
|
|
* appMenu.ts) und künftige ähnliche Einstiege:
|
|
* • schon schwebend → nach vorne holen (bringToFront).
|
|
* • angedockt, inaktiver Tab → als aktiven Tab seiner Gruppe setzen.
|
|
* • nirgends vorhanden → als neues schwebendes Fenster öffnen (Default-Rect).
|
|
*/
|
|
export function revealPanel(layout: LayoutState, panelId: string): LayoutState {
|
|
if (layout.floating.some((f) => f.panelId === panelId)) {
|
|
return bringToFront(layout, panelId);
|
|
}
|
|
for (const dock of ["left", "right"] as DockId[]) {
|
|
if (layout[dock].groups.some((g) => g.tabs.includes(panelId))) {
|
|
return setActiveTab(layout, dock, panelId);
|
|
}
|
|
}
|
|
return moveToFloat(layout, panelId, { x: 120, y: 120, w: 320, h: 420 });
|
|
}
|
|
|
|
// `otherDock` wird (noch) nicht extern gebraucht, ist aber Teil der Aufräum-
|
|
// Semantik; als Helfer exportiert, damit Wiring-Code symmetrisch arbeiten kann.
|
|
export { otherDock };
|
|
|
|
// ── localStorage-Sicherung ──────────────────────────────────────────────────
|
|
|
|
/** Liefert das localStorage-Objekt oder `null`, wenn nicht verfügbar. */
|
|
function storage(): Storage | null {
|
|
try {
|
|
if (typeof localStorage === "undefined") return null;
|
|
return localStorage;
|
|
} catch {
|
|
// Zugriff kann werfen (z. B. blockierte Cookies/Privatmodus).
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Liest und parst einen JSON-Wert; `null` bei Fehlen/ungültig. */
|
|
function readJson<T>(key: string): T | null {
|
|
const ls = storage();
|
|
if (!ls) return null;
|
|
try {
|
|
const raw = ls.getItem(key);
|
|
if (raw === null) return null;
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Schreibt einen JSON-Wert; liefert `false`, wenn das Schreiben scheitert. */
|
|
function writeJson(key: string, value: unknown): boolean {
|
|
const ls = storage();
|
|
if (!ls) return false;
|
|
try {
|
|
ls.setItem(key, JSON.stringify(value));
|
|
return true;
|
|
} catch {
|
|
// Kontingent überschritten oder Zugriff verweigert — still ignorieren.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── Validierung / Migration ─────────────────────────────────────────────────
|
|
|
|
/** Typwächter für eine DockGroup (toleriert Fremddaten aus localStorage). */
|
|
function isDockGroup(v: unknown): v is DockGroup {
|
|
if (typeof v !== "object" || v === null) return false;
|
|
const g = v as Record<string, unknown>;
|
|
return (
|
|
Array.isArray(g.tabs) &&
|
|
g.tabs.every((t) => typeof t === "string") &&
|
|
(g.activeTab === null || typeof g.activeTab === "string") &&
|
|
typeof g.weight === "number"
|
|
);
|
|
}
|
|
|
|
/** Typwächter für einen DockState (toleriert Fremddaten aus localStorage). */
|
|
function isDockState(v: unknown): v is DockState {
|
|
if (typeof v !== "object" || v === null) return false;
|
|
const d = v as Record<string, unknown>;
|
|
return (
|
|
Array.isArray(d.groups) &&
|
|
d.groups.every(isDockGroup) &&
|
|
typeof d.size === "number"
|
|
);
|
|
}
|
|
|
|
/** Typwächter für einen FloatingPanel (toleriert Fremddaten aus localStorage). */
|
|
function isFloatingPanel(v: unknown): v is FloatingPanel {
|
|
if (typeof v !== "object" || v === null) return false;
|
|
const f = v as Record<string, unknown>;
|
|
return (
|
|
typeof f.panelId === "string" &&
|
|
typeof f.x === "number" &&
|
|
typeof f.y === "number" &&
|
|
typeof f.w === "number" &&
|
|
typeof f.h === "number" &&
|
|
typeof f.z === "number"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Prüft eine geladene Struktur und führt sie bei Bedarf auf ein gültiges
|
|
* Layout zurück. Beschädigte Daten ergeben den Standard (konservativ statt
|
|
* Absturz).
|
|
*
|
|
* MIGRATION: Ältere Layouts (Version < aktuell) ohne `floating` werden
|
|
* angehoben, statt verworfen — die Docks bleiben erhalten, `floating` wird zu
|
|
* `[]`. So überlebt das zuletzt benutzte Layout den Umbau auf schwebende
|
|
* Panels. Unbekannte/zukünftige (zu hohe) Versionen ergeben den Standard.
|
|
*/
|
|
function normalizeLayout(v: unknown): LayoutState {
|
|
if (typeof v !== "object" || v === null) return defaultLayout();
|
|
const o = v as Record<string, unknown>;
|
|
// Andere Version (älter ODER neuer) → auf den aktuellen Standard zurückfallen.
|
|
// So erscheinen bei Schema-/Default-Änderungen (z. B. neues Kern-Panel
|
|
// „tools") die neuen Standard-Tabs, statt dass ein altes Layout sie verdeckt.
|
|
if (typeof o.version !== "number" || o.version !== LAYOUT_VERSION) {
|
|
return defaultLayout();
|
|
}
|
|
if (!isDockState(o.left) || !isDockState(o.right)) return defaultLayout();
|
|
// floating fehlt in v2 → leeres Array; sonst nur gültige Einträge übernehmen.
|
|
const floating = Array.isArray(o.floating)
|
|
? o.floating.filter(isFloatingPanel)
|
|
: [];
|
|
return {
|
|
left: o.left,
|
|
right: o.right,
|
|
floating,
|
|
version: LAYOUT_VERSION,
|
|
};
|
|
}
|
|
|
|
// ── Aktuelles Layout (1 Slot) ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Lädt das zuletzt benutzte Layout. Fehlt es, ist es beschädigt oder ist
|
|
* localStorage nicht verfügbar, kommt `defaultLayout()` zurück.
|
|
*/
|
|
export function loadLayout(): LayoutState {
|
|
const raw = readJson<unknown>(CURRENT_KEY);
|
|
if (raw === null) return defaultLayout();
|
|
return normalizeLayout(raw);
|
|
}
|
|
|
|
/** Speichert das aktuelle Layout. Liefert `true` bei Erfolg. */
|
|
export function saveLayout(layout: LayoutState): boolean {
|
|
return writeJson(CURRENT_KEY, layout);
|
|
}
|
|
|
|
// ── Benannte Layouts (Arbeitsbereiche) ──────────────────────────────────────
|
|
|
|
/** Baut den localStorage-Schlüssel für ein benanntes Layout. */
|
|
function namedKey(name: string): string {
|
|
return NAMED_PREFIX + name;
|
|
}
|
|
|
|
/** Speichert ein Layout unter einem Namen. Liefert `true` bei Erfolg. */
|
|
export function saveNamedLayout(name: string, layout: LayoutState): boolean {
|
|
if (!name) return false;
|
|
return writeJson(namedKey(name), layout);
|
|
}
|
|
|
|
/**
|
|
* Lädt ein benanntes Layout. Fehlt es oder ist es beschädigt, kommt `null`
|
|
* zurück (anders als `loadLayout`, damit Aufrufer „nicht gefunden" erkennen).
|
|
*/
|
|
export function loadNamedLayout(name: string): LayoutState | null {
|
|
const raw = readJson<unknown>(namedKey(name));
|
|
if (raw === null) return null;
|
|
return normalizeLayout(raw);
|
|
}
|
|
|
|
/**
|
|
* Listet die Namen aller gespeicherten benannten Layouts (alphabetisch
|
|
* sortiert). Leer, wenn localStorage nicht verfügbar ist.
|
|
*/
|
|
export function listNamedLayouts(): string[] {
|
|
const ls = storage();
|
|
if (!ls) return [];
|
|
const names: string[] = [];
|
|
try {
|
|
for (let i = 0; i < ls.length; i++) {
|
|
const key = ls.key(i);
|
|
if (key && key.startsWith(NAMED_PREFIX)) {
|
|
names.push(key.slice(NAMED_PREFIX.length));
|
|
}
|
|
}
|
|
} catch {
|
|
return [];
|
|
}
|
|
return names.sort();
|
|
}
|
|
|
|
/** Löscht ein benanntes Layout. Liefert `true`, wenn der Zugriff gelang. */
|
|
export function deleteNamedLayout(name: string): boolean {
|
|
const ls = storage();
|
|
if (!ls) return false;
|
|
try {
|
|
ls.removeItem(namedKey(name));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|