Files
Toju/toju-app/src/app/domains/plugins/application/services/plugin-ui-registry.service.ts
2026-04-29 01:14:14 +02:00

238 lines
8.9 KiB
TypeScript

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<TContribution> {
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<string, PluginDomMountRecord>();
private readonly contributionsSignal = signal<{
appPages: PluginUiContributionRecord<PluginApiPageContribution>[];
channelSections: PluginUiContributionRecord<PluginApiChannelSectionContribution>[];
composerActions: PluginUiContributionRecord<PluginApiActionContribution>[];
embeds: PluginUiContributionRecord<PluginApiEmbedRendererContribution>[];
profileActions: PluginUiContributionRecord<PluginApiActionContribution>[];
settingsPages: PluginUiContributionRecord<PluginApiSettingsPageContribution>[];
sidePanels: PluginUiContributionRecord<PluginApiPanelContribution>[];
toolbarActions: PluginUiContributionRecord<PluginApiActionContribution>[];
}>({
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<TKind extends ContributionKind>(
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<TKind extends ContributionKind>(kind: TKind): Signal<PluginApiUiContributionMap[TKind]> {
return computed(() => this.contributionsSignal()[kind].map((entry) => entry.contribution) as PluginApiUiContributionMap[TKind]);
}
private createContributionRecordSignal<TKind extends ContributionKind>(
kind: TKind
): Signal<PluginUiContributionRecord<PluginApiUiContributionMap[TKind][number]>[]> {
return computed(() => this.contributionsSignal()[kind] as PluginUiContributionRecord<PluginApiUiContributionMap[TKind][number]>[]);
}
private collectConflicts(): PluginUiConflictDiagnostic[] {
const conflicts: PluginUiConflictDiagnostic[] = [];
for (const kind of Object.keys(this.contributionsSignal()) as ContributionKind[]) {
const byKey = new Map<string, Set<string>>();
for (const entry of this.contributionsSignal()[kind]) {
const pluginIds = byKey.get(entry.contributionKey) ?? new Set<string>();
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;
}
}