feat: plugins v1
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user