From 5d7fa23825f77c230ce807cfc97ba39f0dd570d8 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 26 Aug 2026 21:11:01 +0200 Subject: [PATCH] task(A1): plugin api v2 types and registry (additive) --- frontend/src/plugins/PluginRegistry.ts | 105 +++++++++++++++++++++++ frontend/src/plugins/index.ts | 11 +++ frontend/src/plugins/types.ts | 110 +++++++++++++++++++++++++ frontend/tests/pluginApiV2.test.ts | 106 ++++++++++++++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 frontend/tests/pluginApiV2.test.ts diff --git a/frontend/src/plugins/PluginRegistry.ts b/frontend/src/plugins/PluginRegistry.ts index 4e9b226..8a9a227 100644 --- a/frontend/src/plugins/PluginRegistry.ts +++ b/frontend/src/plugins/PluginRegistry.ts @@ -9,14 +9,29 @@ import type { ToolExtension, CommandExtension, ImportExportExtension, + // API v2 (Task A1): + PluginV2, + ToolExtensionV2, + PanelExtension, + LibraryProviderExtension, } from './types'; +/** Interner Zustand eines registrierten V2-Plugins. */ +interface PluginStateV2 { + plugin: PluginV2; + enabled: boolean; + loaded: boolean; +} + class PluginRegistryClass { private plugins = new Map(); private states = new Map(); private context: PluginContext | null = null; private listeners = new Set<() => void>(); + // ── API v2 (Task A1) ───────────────────────────────────── + private v2Plugins = new Map(); + /** Set the plugin context (called once on app init) */ setContext(ctx: PluginContext) { this.context = ctx; @@ -164,6 +179,96 @@ class PluginRegistryClass { this.enable(id); } } + for (const [id, state] of this.v2Plugins) { + if (state.plugin.manifest.enabledByDefault && !state.enabled) { + this.enableV2(id); + } + } + } + + // ═══════════ API v2 (Task A1 — additiv) ═══════════ + + /** Registriert ein V2-Plugin. Doppelte IDs werden verworfen (Warnung). */ + registerV2(plugin: PluginV2): void { + const { id } = plugin.manifest; + if (this.v2Plugins.has(id)) { + console.warn(`[PluginRegistry] V2-Plugin '${id}' bereits registriert – verworfen`); + return; + } + this.v2Plugins.set(id, { + plugin, + enabled: false, + loaded: false, + }); + this.notify(); + } + + /** Entfernt ein V2-Plugin (ruft onDestroy wenn geladen). */ + unregisterV2(pluginId: string): void { + const state = this.v2Plugins.get(pluginId); + if (!state) return; + if (state.loaded) state.plugin.onDestroy?.(); + this.v2Plugins.delete(pluginId); + this.notify(); + } + + /** Aktiviert ein V2-Plugin (onInit einmalig, dann onActivate). */ + enableV2(pluginId: string): void { + const state = this.v2Plugins.get(pluginId); + if (!state || state.enabled) return; + state.enabled = true; + if (!state.loaded) { + state.plugin.onInit?.(); + state.loaded = true; + } + state.plugin.onActivate?.(); + this.notify(); + } + + /** Deaktiviert ein V2-Plugin. */ + disableV2(pluginId: string): void { + const state = this.v2Plugins.get(pluginId); + if (!state || !state.enabled) return; + state.enabled = false; + state.plugin.onDeactivate?.(); + this.notify(); + } + + /** Ist dieses V2-Plugin aktiv? */ + isV2Enabled(pluginId: string): boolean { + return this.v2Plugins.get(pluginId)?.enabled ?? false; + } + + /** Alle Tools aus AKTIVEN V2-Plugins (flach über alle Plugins). */ + getToolsV2(): ToolExtensionV2[] { + const tools: ToolExtensionV2[] = []; + for (const state of this.v2Plugins.values()) { + if (state.enabled && state.plugin.tools) tools.push(...state.plugin.tools); + } + return tools; + } + + /** Ein bestimmtes V2-Tool per ID (nur aus aktiven Plugins). */ + getToolV2(toolId: string): ToolExtensionV2 | undefined { + return this.getToolsV2().find((t) => t.manifest.id === toolId); + } + + /** Alle Panels aktiver V2-Plugins. */ + getPanels(): PanelExtension[] { + const panels: PanelExtension[] = []; + for (const state of this.v2Plugins.values()) { + if (state.enabled && state.plugin.panels) panels.push(...state.plugin.panels); + } + return panels; + } + + /** Alle Library-Provider aktiver V2-Plugins. */ + getLibraryProviders(): LibraryProviderExtension[] { + const providers: LibraryProviderExtension[] = []; + for (const state of this.v2Plugins.values()) { + if (state.enabled && state.plugin.libraryProviders) providers.push(...state.plugin.libraryProviders); + } + return providers; } } diff --git a/frontend/src/plugins/index.ts b/frontend/src/plugins/index.ts index e6a419c..31712a2 100644 --- a/frontend/src/plugins/index.ts +++ b/frontend/src/plugins/index.ts @@ -12,6 +12,17 @@ export type { CommandExtension, ImportExportExtension, PropertyField, + // API v2 (Task A1): + PluginV2, + ToolExtensionV2, + ToolManifestPart, + ToolPointerEvent, + ToolContext, + PanelExtension, + LibraryProviderExtension, + LibFolder, + LibBlock, + PluginCategoryV2, } from './types'; // Built-in plugins diff --git a/frontend/src/plugins/types.ts b/frontend/src/plugins/types.ts index d2bed86..a692aa1 100644 --- a/frontend/src/plugins/types.ts +++ b/frontend/src/plugins/types.ts @@ -116,3 +116,113 @@ export interface PluginState { enabled: boolean; loaded: boolean; } + +// ═══════════════════════════════════════════════════════ +// ─── API v2 (Task A1, additiv — siehe ROADMAP TEIL 2.4) ── +// ═══════════════════════════════════════════════════════ +import type { Pt } from '../tools/modification/geometry'; + +/** Manifest-Teil eines V2-Werkzeugs. */ +export interface ToolManifestPart { + id: string; + label: string; + icon: string; + ribbonTab: string; + shortcut?: string; + /** 'basic' = auch im Einfach-Modus sichtbar (Task H1). */ + tags?: Array<'basic' | 'pro'>; +} + +/** Normalisiertes Pointer-Event mit Welt- und Bildschirmkoordinaten. */ +export interface ToolPointerEvent { + world: Pt; + screen: Pt; + button: number; + shift: boolean; + alt: boolean; + ctrl: boolean; +} + +/** Kontext, den der Dispatcher einem aktiven V2-Tool übergibt. */ +export interface ToolContext { + /** Aktueller Optionsstand für dieses Tool (aus toolStore). */ + options: Record; + /** Statuszeilentext setzen. */ + setStatus(msg: string): void; + /** Live-Vorschau setzen (null = Vorschau entfernen). Ab A3 an Renderer angebunden. */ + setPreview?(el: CADElement | null): void; +} + +/** V2-Werkzeug mit eigenen Pointer-Handlern (ersetzt langfristig ToolExtension). */ +export interface ToolExtensionV2 { + manifest: ToolManifestPart & { description?: string }; + /** Optionsleisten-Schema (wiederverwendet PropertyField). */ + optionsSchema: PropertyField[]; + handlers: { + down?(e: ToolPointerEvent, ctx: ToolContext): void; + move?(e: ToolPointerEvent, ctx: ToolContext): void; + up?(e: ToolPointerEvent, ctx: ToolContext): void; + /** true = Tastendruck konsumiert. */ + key?(e: KeyboardEvent, ctx: ToolContext): boolean; + /** ESC / Rechtsklick. */ + cancel?(ctx: ToolContext): void; + }; +} + +/** Eigenes Sidebar-Panel eines Plugins. */ +export interface PanelExtension { + id: string; + title: string; + slot: 'left' | 'right'; + render(): HTMLElement; +} + +/** Ordner einer Bibliothek (LibraryProvider). */ +export interface LibFolder { + id: string; + label: string; + parentId: string | null; +} + +/** Block einer Bibliothek (Metadaten; Payload provider-spezifisch). */ +export interface LibBlock { + id: string; + folderId: string; + name: string; + tags?: string[]; + thumbnail?: string; // dataURL +} + +/** Liefert Kataloge — Kern der Plugin-erweiterbaren Library (Phase F). */ +export interface LibraryProviderExtension { + id: string; + label: string; + listFolders(): Promise; + listBlocks(folderId: string): Promise; + getBlock(id: string): Promise; +} + +/** Erweiterte Manifest-Kategorie inkl. library. */ +export type PluginCategoryV2 = PluginManifest['category'] | 'library'; + +/** Plugin-API v2 (registriert parallel zum alten Plugin-Interface). */ +export interface PluginV2 { + manifest: { + id: string; + name: string; + version: string; + author: string; + description: string; + category: PluginCategoryV2; + enabledByDefault?: boolean; + minAppVersion?: string; + permissions?: Array<'canvas' | 'network' | 'storage'>; + }; + tools?: ToolExtensionV2[]; + panels?: PanelExtension[]; + libraryProviders?: LibraryProviderExtension[]; + onInit?(): void; + onActivate?(): void; + onDeactivate?(): void; + onDestroy?(): void; +} diff --git a/frontend/tests/pluginApiV2.test.ts b/frontend/tests/pluginApiV2.test.ts new file mode 100644 index 0000000..3c92794 --- /dev/null +++ b/frontend/tests/pluginApiV2.test.ts @@ -0,0 +1,106 @@ +/** + * Plugin-API v2 Tests (Task A1). + * Deckt: registerV2 / enableV2 / disableV2 / isV2Enabled, + * getToolsV2 (nur aktive Plugins), getPanels, getLibraryProviders, + * doppelte IDs verworfen, initDefaults mit V2. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { pluginRegistry } from '../src/plugins/PluginRegistry'; +import type { + PluginV2, + ToolExtensionV2, +} from '../src/plugins/types'; + +function makeTool(id: string): ToolExtensionV2 { + return { + manifest: { id, label: id, icon: '□', ribbonTab: 'tools' }, + optionsSchema: [], + handlers: {}, + }; +} + +function makePlugin(id: string, overrides: Partial = {}): PluginV2 { + return { + manifest: { + id, + name: id, + version: '1.0.0', + author: 'test', + description: `Testplugin ${id}`, + category: 'tools', + enabledByDefault: false, + }, + tools: [makeTool(`${id}-tool`)], + ...overrides, + }; +} + +describe('pluginRegistry v2', () => { + // Testisolierung: Singleton-Registry nach jedem Test vollständig räumen + const registered: string[] = []; + function track(plugin: PluginV2): void { + registered.push(plugin.manifest.id); + pluginRegistry.registerV2(plugin); + } + afterEach(() => { + while (registered.length > 0) { + pluginRegistry.unregisterV2(registered.pop()!); + } + }); + + it('registriert und aktiviert ein V2-Plugin; Tools erscheinen in getToolsV2', () => { + track(makePlugin('p1')); + expect(pluginRegistry.getToolsV2().length).toBe(0); // noch deaktiviert + pluginRegistry.enableV2('p1'); + expect(pluginRegistry.isV2Enabled('p1')).toBe(true); + const tools = pluginRegistry.getToolsV2(); + expect(tools.length).toBe(1); + expect(tools[0].manifest.id).toBe('p1-tool'); + expect(pluginRegistry.getToolV2('p1-tool')?.manifest.id).toBe('p1-tool'); + }); + + it('deaktivierte Plugins liefern keine Tools/Panels/Provider', () => { + track( + makePlugin('p2', { + panels: [{ id: 'panel-x', title: 'X', slot: 'left', render: () => document.createElement('div') }], + libraryProviders: [ + { + id: 'lib-x', + label: 'Lib X', + listFolders: async () => [], + listBlocks: async () => [], + getBlock: async () => ({ id: 'b', folderId: '', name: '' }), + }, + ], + }), + ); + pluginRegistry.enableV2('p2'); + expect(pluginRegistry.getPanels().length).toBe(1); + expect(pluginRegistry.getLibraryProviders().length).toBe(1); + pluginRegistry.disableV2('p2'); + expect(pluginRegistry.isV2Enabled('p2')).toBe(false); + expect(pluginRegistry.getPanels().length).toBe(0); + expect(pluginRegistry.getLibraryProviders().length).toBe(0); + expect(pluginRegistry.getToolsV2().length).toBe(0); + }); + + it('verwirft doppelte Plugin-IDs ohne Fehler und behält das erste', () => { + const first = makePlugin('dup'); + first.tools = [makeTool('first-tool')]; + const second = makePlugin('dup'); + second.tools = [makeTool('second-tool')]; + track(first); + pluginRegistry.registerV2(second); // wird verworfen, muss nicht getrackt werden + pluginRegistry.enableV2('dup'); + const ids = pluginRegistry.getToolsV2().map((t) => t.manifest.id); + expect(ids).toEqual(['first-tool']); + }); + + it('initDefaults aktiviert nur enabledByDefault-Plugins', () => { + track({ ...makePlugin('auto-on'), manifest: { ...makePlugin('auto-on').manifest, enabledByDefault: true } }); + track(makePlugin('manual-off')); + pluginRegistry.initDefaults(); + expect(pluginRegistry.isV2Enabled('auto-on')).toBe(true); + expect(pluginRegistry.isV2Enabled('manual-off')).toBe(false); + }); +});