import { Injectable, Signal, computed, signal } from '@angular/core'; import type { PluginApiActionContribution, PluginApiChannelSectionContribution, PluginApiDomMountRequest, PluginApiEmbedRendererContribution, PluginApiPageContribution, PluginApiPanelContribution, PluginApiSettingsPageContribution, PluginApiUiContributionMap, TojuPluginDisposable } from '../../domain/models/plugin-api.models'; type ContributionKind = keyof PluginApiUiContributionMap; export interface PluginUiContributionRecord { contribution: TContribution; contributionKey: string; id: string; pluginId: string; } export interface PluginUiConflictDiagnostic { contributionId: string; kind: ContributionKind; pluginIds: string[]; } interface PluginDomMountRecord { element: HTMLElement; id: string; pluginId: string; } @Injectable({ providedIn: 'root' }) export class PluginUiRegistryService { readonly appPages = this.createContributionSignal('appPages'); readonly appPageRecords = this.createContributionRecordSignal('appPages'); readonly channelSections = this.createContributionSignal('channelSections'); readonly channelSectionRecords = this.createContributionRecordSignal('channelSections'); readonly composerActions = this.createContributionSignal('composerActions'); readonly composerActionRecords = this.createContributionRecordSignal('composerActions'); readonly embeds = this.createContributionSignal('embeds'); readonly embedRecords = this.createContributionRecordSignal('embeds'); readonly profileActions = this.createContributionSignal('profileActions'); readonly profileActionRecords = this.createContributionRecordSignal('profileActions'); readonly settingsPages = this.createContributionSignal('settingsPages'); readonly settingsPageRecords = this.createContributionRecordSignal('settingsPages'); readonly sidePanels = this.createContributionSignal('sidePanels'); readonly sidePanelRecords = this.createContributionRecordSignal('sidePanels'); readonly toolbarActions = this.createContributionSignal('toolbarActions'); readonly toolbarActionRecords = this.createContributionRecordSignal('toolbarActions'); readonly conflicts = computed(() => this.collectConflicts()); private readonly domMounts = new Map(); private readonly contributionsSignal = signal<{ appPages: PluginUiContributionRecord[]; channelSections: PluginUiContributionRecord[]; composerActions: PluginUiContributionRecord[]; embeds: PluginUiContributionRecord[]; profileActions: PluginUiContributionRecord[]; settingsPages: PluginUiContributionRecord[]; sidePanels: PluginUiContributionRecord[]; toolbarActions: PluginUiContributionRecord[]; }>({ appPages: [], channelSections: [], composerActions: [], embeds: [], profileActions: [], settingsPages: [], sidePanels: [], toolbarActions: [] }); registerAppPage(pluginId: string, id: string, contribution: PluginApiPageContribution): TojuPluginDisposable { return this.register('appPages', pluginId, id, contribution); } registerChannelSection(pluginId: string, id: string, contribution: PluginApiChannelSectionContribution): TojuPluginDisposable { return this.register('channelSections', pluginId, id, contribution); } registerComposerAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable { return this.register('composerActions', pluginId, id, contribution); } registerEmbedRenderer(pluginId: string, id: string, contribution: PluginApiEmbedRendererContribution): TojuPluginDisposable { return this.register('embeds', pluginId, id, contribution); } mountElement(pluginId: string, id: string, request: PluginApiDomMountRequest): TojuPluginDisposable { const mountId = `${pluginId}:${id}`; const target = this.resolveMountTarget(request.target); if (!target) { throw new Error(`Plugin mount target not found: ${typeof request.target === 'string' ? request.target : request.target.tagName}`); } this.unmountElement(mountId); request.element.dataset['pluginOwner'] = pluginId; request.element.dataset['pluginMountId'] = mountId; target.insertAdjacentElement(request.position ?? 'beforeend', request.element); this.domMounts.set(mountId, { element: request.element, id: mountId, pluginId }); return { dispose: () => this.unmountElement(mountId) }; } registerProfileAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable { return this.register('profileActions', pluginId, id, contribution); } registerSettingsPage(pluginId: string, id: string, contribution: PluginApiSettingsPageContribution): TojuPluginDisposable { return this.register('settingsPages', pluginId, id, contribution); } registerSidePanel(pluginId: string, id: string, contribution: PluginApiPanelContribution): TojuPluginDisposable { return this.register('sidePanels', pluginId, id, contribution); } registerToolbarAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable { return this.register('toolbarActions', pluginId, id, contribution); } unregisterPlugin(pluginId: string): void { for (const mount of this.domMounts.values()) { if (mount.pluginId === pluginId) { this.unmountElement(mount.id); } } this.contributionsSignal.update((current) => ({ appPages: current.appPages.filter((entry) => entry.pluginId !== pluginId), channelSections: current.channelSections.filter((entry) => entry.pluginId !== pluginId), composerActions: current.composerActions.filter((entry) => entry.pluginId !== pluginId), embeds: current.embeds.filter((entry) => entry.pluginId !== pluginId), profileActions: current.profileActions.filter((entry) => entry.pluginId !== pluginId), settingsPages: current.settingsPages.filter((entry) => entry.pluginId !== pluginId), sidePanels: current.sidePanels.filter((entry) => entry.pluginId !== pluginId), toolbarActions: current.toolbarActions.filter((entry) => entry.pluginId !== pluginId) })); } private register( kind: TKind, pluginId: string, id: string, contribution: PluginApiUiContributionMap[TKind][number] ): TojuPluginDisposable { const contributionId = `${pluginId}:${id}`; this.contributionsSignal.update((current) => ({ ...current, [kind]: [ ...current[kind].filter((entry) => entry.id !== contributionId), { contribution, contributionKey: id, id: contributionId, pluginId } ] })); return { dispose: () => this.unregister(kind, contributionId) }; } private unregister(kind: ContributionKind, contributionId: string): void { this.contributionsSignal.update((current) => ({ ...current, [kind]: current[kind].filter((entry) => entry.id !== contributionId) })); } private resolveMountTarget(target: Element | string): Element | null { return typeof target === 'string' ? document.querySelector(target) : target; } private unmountElement(mountId: string): void { const mount = this.domMounts.get(mountId); if (!mount) { return; } mount.element.remove(); this.domMounts.delete(mountId); } private createContributionSignal(kind: TKind): Signal { return computed(() => this.contributionsSignal()[kind].map((entry) => entry.contribution) as PluginApiUiContributionMap[TKind]); } private createContributionRecordSignal( kind: TKind ): Signal[]> { return computed(() => this.contributionsSignal()[kind] as PluginUiContributionRecord[]); } private collectConflicts(): PluginUiConflictDiagnostic[] { const conflicts: PluginUiConflictDiagnostic[] = []; for (const kind of Object.keys(this.contributionsSignal()) as ContributionKind[]) { const byKey = new Map>(); for (const entry of this.contributionsSignal()[kind]) { const pluginIds = byKey.get(entry.contributionKey) ?? new Set(); pluginIds.add(entry.pluginId); byKey.set(entry.contributionKey, pluginIds); } for (const [contributionId, pluginIds] of byKey.entries()) { if (pluginIds.size > 1) { conflicts.push({ contributionId, kind, pluginIds: Array.from(pluginIds).sort() }); } } } return conflicts; } }