task(A1): plugin api v2 types and registry (additive)
This commit is contained in:
@@ -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<string, Plugin>();
|
||||
private states = new Map<string, PluginState>();
|
||||
private context: PluginContext | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
// ── API v2 (Task A1) ─────────────────────────────────────
|
||||
private v2Plugins = new Map<string, PluginStateV2>();
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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