238 lines
8.9 KiB
TypeScript
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;
|
|
}
|
|
}
|