task(A1): plugin api v2 types and registry (additive)
This commit is contained in:
@@ -9,14 +9,29 @@ import type {
|
|||||||
ToolExtension,
|
ToolExtension,
|
||||||
CommandExtension,
|
CommandExtension,
|
||||||
ImportExportExtension,
|
ImportExportExtension,
|
||||||
|
// API v2 (Task A1):
|
||||||
|
PluginV2,
|
||||||
|
ToolExtensionV2,
|
||||||
|
PanelExtension,
|
||||||
|
LibraryProviderExtension,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
/** Interner Zustand eines registrierten V2-Plugins. */
|
||||||
|
interface PluginStateV2 {
|
||||||
|
plugin: PluginV2;
|
||||||
|
enabled: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
class PluginRegistryClass {
|
class PluginRegistryClass {
|
||||||
private plugins = new Map<string, Plugin>();
|
private plugins = new Map<string, Plugin>();
|
||||||
private states = new Map<string, PluginState>();
|
private states = new Map<string, PluginState>();
|
||||||
private context: PluginContext | null = null;
|
private context: PluginContext | null = null;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
// ── API v2 (Task A1) ─────────────────────────────────────
|
||||||
|
private v2Plugins = new Map<string, PluginStateV2>();
|
||||||
|
|
||||||
/** Set the plugin context (called once on app init) */
|
/** Set the plugin context (called once on app init) */
|
||||||
setContext(ctx: PluginContext) {
|
setContext(ctx: PluginContext) {
|
||||||
this.context = ctx;
|
this.context = ctx;
|
||||||
@@ -164,6 +179,96 @@ class PluginRegistryClass {
|
|||||||
this.enable(id);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ export type {
|
|||||||
CommandExtension,
|
CommandExtension,
|
||||||
ImportExportExtension,
|
ImportExportExtension,
|
||||||
PropertyField,
|
PropertyField,
|
||||||
|
// API v2 (Task A1):
|
||||||
|
PluginV2,
|
||||||
|
ToolExtensionV2,
|
||||||
|
ToolManifestPart,
|
||||||
|
ToolPointerEvent,
|
||||||
|
ToolContext,
|
||||||
|
PanelExtension,
|
||||||
|
LibraryProviderExtension,
|
||||||
|
LibFolder,
|
||||||
|
LibBlock,
|
||||||
|
PluginCategoryV2,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// Built-in plugins
|
// Built-in plugins
|
||||||
|
|||||||
@@ -116,3 +116,113 @@ export interface PluginState {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
loaded: 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<string, unknown>;
|
||||||
|
/** 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<LibFolder[]>;
|
||||||
|
listBlocks(folderId: string): Promise<LibBlock[]>;
|
||||||
|
getBlock(id: string): Promise<LibBlock>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user