import { isDeepEqual } from "@noya-app/noya-utils"; import type { CSSProperties } from "react"; import type { BaseContextProperties } from "./contextProperties"; export type BaseMode = { type: PropertyKey }; export type BaseEventHandlerMap = Record void>; export type Plugin< Mode extends BaseMode, Data extends object, ContextProperties extends object, EventHandlerMap extends BaseEventHandlerMap, > = { name: string; data: Data; handlers?: (contextProperties: ContextProperties) => Partial; finalizers?: ( contextProperties: ContextProperties ) => Partial; getCursor?: ({ data, mode, }: { data: unknown; mode: unknown; }) => CSSProperties["cursor"] | undefined; // Used only for type inference __mode?: Mode; }; export type PluginData

= P extends Plugin ? D : never; export type PluginMode

= P extends Plugin ? M : never; export type PluginContextProperties

= P extends Plugin ? H : never; export type PluginEventHandlerMap

= P extends Plugin ? E : never; export type PluginSystemMode

= P extends PluginSystem ? M : never; export type PluginSystemData

= P extends PluginSystem ? D : never; export type PluginSystemContextProperties

= P extends PluginSystem ? H : never; export type PluginSystemEventHandlerMap

= P extends PluginSystem ? E : never; export type AnyPlugin = Plugin; export type AnyPluginSystem = PluginSystem< BaseMode, object, any, BaseEventHandlerMap >; /** * Ensure that any switch statement over a mode type handles the default case. * This helps make sure the system is resilient to new modes being added. */ export const UnhandledSymbol = Symbol("need_to_handle_default_case"); export type NoneMode = { type: "none" } | { type: typeof UnhandledSymbol }; export type HandlerMap< ContextProperties extends object, EventHandlerMap extends BaseEventHandlerMap, > = { name: string; handlers: (contextProperties: ContextProperties) => Partial; finalizers: ( contextProperties: ContextProperties ) => Partial; }; export type PluginSystemOptions = { debugFilter?: boolean | string | RegExp; debugInfoFromContext?: (context: ContextProperties) => string; }; export type GetCursorUnknown = ({ data, mode, }: { data: unknown; mode: unknown; }) => CSSProperties["cursor"] | undefined; export class PluginSystem< Mode extends BaseMode, Data extends object, ContextProperties extends object, EventHandlerMap extends BaseEventHandlerMap, > { static create() { return new PluginSystem({ mode: { type: "none" }, data: {}, handlerMaps: [], options: {}, getCursor: () => undefined, }); } mode: Mode; data: Data; handlerMaps: HandlerMap[]; options: PluginSystemOptions; getCursor: GetCursorUnknown; constructor({ mode, data, handlerMaps, options, getCursor, }: { mode: Mode; data: Data; handlerMaps: HandlerMap[]; options: PluginSystemOptions; getCursor: GetCursorUnknown; }) { this.mode = mode; this.data = data; this.handlerMaps = handlerMaps; this.options = options; this.getCursor = getCursor; } get contextProperties(): ContextProperties { throw new Error("Handler context is only used for type inference"); } debugLog(context: ContextProperties, ...args: any[]) { const debugFilter = this.options.debugFilter; if (!debugFilter) return; const stringArgs = args.flatMap((arg) => { if (typeof arg === "string") return [arg]; return []; }); if ( stringArgs.some((arg) => { if (debugFilter instanceof RegExp) { return debugFilter.test(arg); } if (typeof debugFilter === "string") { return arg.includes(debugFilter); } return !!debugFilter; }) ) { if (this.options.debugInfoFromContext) { console.info(this.options.debugInfoFromContext(context), ...args); } else { console.info(...args); } } } addMode() { return new PluginSystem( { mode: this.mode, data: this.data, handlerMaps: this.handlerMaps, options: this.options, getCursor: this.getCursor, } ); } addData(data: D) { Object.entries(data).forEach(([k, v]) => { const existingValue = (this.data as Record)[k]; if (k in this.data && !isDeepEqual(v, existingValue)) { throw new Error( `Attempted to overwrite existing data key "${k}" with a different value` ); } }); return new PluginSystem( { mode: this.mode, data: { ...this.data, ...data }, handlerMaps: this.handlerMaps, options: this.options, getCursor: this.getCursor, } ); } addContextProperties() { return new PluginSystem( { mode: this.mode, data: this.data, handlerMaps: this.handlerMaps, options: this.options, getCursor: this.getCursor, } ); } addEventHandlers( handlerMap?: HandlerMap ) { type HandlerType = HandlerMap; if (!handlerMap) { return new PluginSystem< Mode, Data, ContextProperties, EventHandlerMap & E >({ mode: this.mode, data: this.data, handlerMaps: this.handlerMaps as HandlerType[], options: this.options, getCursor: this.getCursor, }); } return new PluginSystem( { mode: this.mode, data: this.data, handlerMaps: [...this.handlerMaps, handlerMap] as HandlerType[], options: this.options, getCursor: this.getCursor, } ); } addPlugin< M extends BaseMode, D extends object, H extends object, E extends BaseEventHandlerMap, >(plugin: Plugin) { // TODO: ideally, these should be updated to avoid requiring explicit generic // parameters as well return this.addMode() .addData(plugin.data) .addContextProperties() .addEventHandlers({ name: plugin.name, handlers: plugin.handlers || (() => ({})), finalizers: plugin.finalizers || (() => ({})), }) .addGetCursor(plugin.getCursor || (() => undefined)); } addPlugins( ...plugins: Plugins ): PluginsToSystem; addPlugins(...plugins: AnyPlugin[]) { return plugins.reduce( (system, plugin) => system.addPlugin(plugin), this ); } addOptions(options: PluginSystemOptions) { return new PluginSystem({ mode: this.mode, data: this.data, handlerMaps: this.handlerMaps, options, getCursor: this.getCursor, }); } addGetCursor(getCursor: GetCursorUnknown) { const handleGetCursor = ({ data, mode }: { data: Data; mode: Mode }) => { const cursor = getCursor({ data, mode }); if (cursor) return cursor; return this.getCursor({ data, mode }); }; return new PluginSystem({ mode: this.mode, data: this.data, handlerMaps: this.handlerMaps, options: this.options, getCursor: handleGetCursor as GetCursorUnknown, }); } callEventHandler( context: ContextProperties, key: K, ...args: Parameters ) { for (const { name, handlers } of this.handlerMaps.toReversed()) { const handlersWithContext = handlers(context); if (key in handlersWithContext) { const eventArg = args.length > 0 ? args[0] : undefined; if ( eventArg && "defaultPrevented" in eventArg && eventArg.defaultPrevented ) { break; } this.debugLog(context, `[${key.toString()}]`, name); handlersWithContext[key]?.(...args); if ( eventArg && "defaultPrevented" in eventArg && eventArg.defaultPrevented ) { this.debugLog(context, `[${key.toString()}]`, name, "**handled**"); break; } } } for (const { name, finalizers } of this.handlerMaps) { const finalizersWithContext = finalizers(context); if (key in finalizersWithContext) { this.debugLog(context, `[${key.toString()}]`, name, "finalizer"); finalizersWithContext[key]?.(...args); } } } getAllEventNames(context: ContextProperties): (keyof EventHandlerMap)[] { const allKeys = this.handlerMaps.flatMap(({ handlers }) => Object.keys(handlers(context)) ); return Array.from(new Set(allKeys)); } getHandlers(context: ContextProperties): Partial { const handlers = this.handlerMaps.map(({ handlers }) => handlers(context)); const allKeys = handlers.flatMap((handlerMap) => Object.keys(handlerMap)); let result: Partial = {}; for (const key of allKeys) { (result[key] as any) = (...args: any[]) => { (this.callEventHandler as any)(context, key, ...args); }; } return result; } clone() { return new PluginSystem({ mode: this.mode, data: this.data, handlerMaps: this.handlerMaps, options: this.options, getCursor: this.getCursor, }); } } export type ModeHandlerMap = { [K in M["type"]]?: (mode: Extract) => void; }; export function modeSwitch( mode: Extract, handlers: ModeHandlerMap ) { // The `as any` issue isn't detected in VSCode but is when building. return handlers[mode.type]?.(mode as any); } export type PluginsToSystem = PluginsToSystemRecurse< // identity for a union is never never, {}, {}, {}, Plugins >; type PluginsToSystemRecurse< Mode extends BaseMode, Data extends object, UniqueContextProperties extends object, EventHandlerMap extends BaseEventHandlerMap, Plugins extends readonly AnyPlugin[], > = Plugins extends readonly [ Plugin, ...infer Tail extends AnyPlugin[], ] ? PluginsToSystemRecurse< Mode | M, Data & D, // don't include context shared across all plugins UniqueContextProperties & Omit>, EventHandlerMap & E, Tail > : PluginSystem< Mode, Data, BaseContextProperties & UniqueContextProperties, EventHandlerMap >;