Files
kandev/apps/web/lib/plugins/modal-manager.ts
T

87 lines
2.9 KiB
TypeScript

/**
* Reactive singleton backing `host.openModal(...)` (docs/plans/plugins/PLUGIN-API.md).
*
* Tracks every open plugin modal as a list of `{ instanceId, pluginId, options }`
* and exposes a tiny external-store subscription so `<PluginModalHost/>`
* re-renders whenever a modal opens or closes — same pattern as
* `lib/plugins/registry.ts`'s `pluginRegistry` singleton.
*/
import { useSyncExternalStore } from "react";
import type { PluginModalHandle, PluginModalOptions } from "./types";
export interface OpenPluginModal {
instanceId: string;
pluginId: string;
options: PluginModalOptions;
/** Host-only geometry. Plugins cannot select native workflow layouts through openModal. */
layout?: "task-link";
}
let nextInstanceId = 0;
class PluginModalManager {
private modals: OpenPluginModal[] = [];
private listeners = new Set<() => void>();
private snapshot: OpenPluginModal[] = [];
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getSnapshot = (): OpenPluginModal[] => this.snapshot;
/** Opens a modal owned by `pluginId`; returns a handle to close it. */
openModal(pluginId: string, options: PluginModalOptions): PluginModalHandle {
return this.open(pluginId, options);
}
/** Opens the fixed-width native task-link surface used by host workflow APIs. */
openTaskLinkDialog(pluginId: string, options: PluginModalOptions): PluginModalHandle {
return this.open(pluginId, options, "task-link");
}
private open(
pluginId: string,
options: PluginModalOptions,
layout?: OpenPluginModal["layout"],
): PluginModalHandle {
const instanceId = `plugin-modal-${(nextInstanceId += 1)}`;
this.modals = [...this.modals, { instanceId, pluginId, options, layout }];
this.notify();
return { close: () => this.close(instanceId) };
}
/** Removes every modal owned by `pluginId` (called on plugin disable/unload). */
closeAllForPlugin(pluginId: string): void {
const before = this.modals.length;
this.modals = this.modals.filter((modal) => modal.pluginId !== pluginId);
if (this.modals.length !== before) this.notify();
}
/** Removes a single modal by `instanceId`, e.g. from the host's Dialog `onOpenChange`. */
close(instanceId: string): void {
const before = this.modals.length;
this.modals = this.modals.filter((modal) => modal.instanceId !== instanceId);
if (this.modals.length !== before) this.notify();
}
private notify(): void {
this.snapshot = this.modals;
this.listeners.forEach((listener) => listener());
}
}
export const pluginModalManager = new PluginModalManager();
/** Snapshot hook: re-renders the caller whenever any plugin modal opens/closes. */
export function usePluginModals(): OpenPluginModal[] {
return useSyncExternalStore(
pluginModalManager.subscribe,
pluginModalManager.getSnapshot,
pluginModalManager.getSnapshot,
);
}