import {EditorFormModal, EditorFormModalDefinition} from "./modals"; import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; import {$getSelection, BaseSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND} from "lexical"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; import {getLastSelection, setLastSelection} from "../../utils/selection"; export type SelectionChangeHandler = (selection: BaseSelection|null) => void; export class EditorUIManager { protected modalDefinitionsByKey: Record = {}; protected activeModalsByKey: Record = {}; protected decoratorConstructorsByType: Record = {}; protected decoratorInstancesByNodeKey: Record = {}; protected context: EditorUiContext|null = null; protected toolbar: EditorContainerUiElement|null = null; protected contextToolbarDefinitionsByKey: Record = {}; protected activeContextToolbars: EditorContextToolbar[] = []; protected selectionChangeHandlers: Set = new Set(); setContext(context: EditorUiContext) { this.context = context; this.setupEventListeners(context); this.setupEditor(context.editor); } getContext(): EditorUiContext { if (this.context === null) { throw new Error(`Context attempted to be used without being set`); } return this.context; } triggerStateUpdateForElement(element: EditorUiElement) { element.updateState({ selection: null, editor: this.getContext().editor }); } registerModal(key: string, modalDefinition: EditorFormModalDefinition) { this.modalDefinitionsByKey[key] = modalDefinition; } createModal(key: string): EditorFormModal { const modalDefinition = this.modalDefinitionsByKey[key]; if (!modalDefinition) { throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`); } const modal = new EditorFormModal(modalDefinition, key); modal.setContext(this.getContext()); return modal; } setModalActive(key: string, modal: EditorFormModal): void { this.activeModalsByKey[key] = modal; } setModalInactive(key: string): void { delete this.activeModalsByKey[key]; } getActiveModal(key: string): EditorFormModal|null { return this.activeModalsByKey[key]; } registerDecoratorType(type: string, decorator: typeof EditorDecorator) { this.decoratorConstructorsByType[type] = decorator; } protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator { if (this.decoratorInstancesByNodeKey[nodeKey]) { return this.decoratorInstancesByNodeKey[nodeKey]; } const decoratorClass = this.decoratorConstructorsByType[decoratorType]; if (!decoratorClass) { throw new Error(`Attempted to use decorator of type [${decoratorType}] but not decorator registered for that type`); } // @ts-ignore const decorator = new decoratorClass(nodeKey); this.decoratorInstancesByNodeKey[nodeKey] = decorator; return decorator; } getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null { return this.decoratorInstancesByNodeKey[nodeKey] || null; } setToolbar(toolbar: EditorContainerUiElement) { if (this.toolbar) { this.toolbar.getDOMElement().remove(); } this.toolbar = toolbar; toolbar.setContext(this.getContext()); this.getContext().containerDOM.prepend(toolbar.getDOMElement()); } registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) { this.contextToolbarDefinitionsByKey[key] = definition; } triggerStateUpdate(update: EditorUiStateUpdate): void { setLastSelection(update.editor, update.selection); this.toolbar?.updateState(update); this.updateContextToolbars(update); for (const toolbar of this.activeContextToolbars) { toolbar.updateState(update); } this.triggerSelectionChange(update.selection); } triggerStateRefresh(): void { const editor = this.getContext().editor; const update = { editor, selection: getLastSelection(editor), }; this.triggerStateUpdate(update); this.updateContextToolbars(update); } triggerFutureStateRefresh(): void { requestAnimationFrame(() => { this.getContext().editor.getEditorState().read(() => { this.triggerStateRefresh(); }); }); } protected triggerSelectionChange(selection: BaseSelection|null): void { if (!selection) { return; } for (const handler of this.selectionChangeHandlers) { handler(selection); } } onSelectionChange(handler: SelectionChangeHandler): void { this.selectionChangeHandlers.add(handler); } offSelectionChange(handler: SelectionChangeHandler): void { this.selectionChangeHandlers.delete(handler); } triggerLayoutUpdate(): void { window.requestAnimationFrame(() => { for (const toolbar of this.activeContextToolbars) { toolbar.updatePosition(); } }); } protected updateContextToolbars(update: EditorUiStateUpdate): void { for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) { const toolbar = this.activeContextToolbars[i]; toolbar.destroy(); this.activeContextToolbars.splice(i, 1); } const node = (update.selection?.getNodes() || [])[0] || null; if (!node) { return; } const element = update.editor.getElementByKey(node.getKey()); if (!element) { return; } const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey); const contentByTarget = new Map(); for (const key of toolbarKeys) { const definition = this.contextToolbarDefinitionsByKey[key]; const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null; if (matchingElem) { const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem; if (!contentByTarget.has(targetEl)) { contentByTarget.set(targetEl, []) } // @ts-ignore contentByTarget.get(targetEl).push(...definition.content); } } for (const [target, contents] of contentByTarget) { const toolbar = new EditorContextToolbar(target, contents); toolbar.setContext(this.getContext()); this.activeContextToolbars.push(toolbar); this.getContext().containerDOM.append(toolbar.getDOMElement()); toolbar.updatePosition(); } } protected setupEditor(editor: LexicalEditor) { // Register our DOM decorate listener with the editor const domDecorateListener: DecoratorListener = (decorators: Record) => { editor.getEditorState().read(() => { const keys = Object.keys(decorators); for (const key of keys) { const decoratedEl = editor.getElementByKey(key); if (!decoratedEl) { continue; } const adapter = decorators[key]; const decorator = this.getDecorator(adapter.type, key); decorator.setNode(adapter.getNode()); const decoratorEl = decorator.render(this.getContext(), decoratedEl); if (decoratorEl) { decoratedEl.append(decoratorEl); } } }); } editor.registerDecoratorListener(domDecorateListener); } protected setupEventListeners(context: EditorUiContext) { const layoutUpdate = this.triggerLayoutUpdate.bind(this); window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true}); window.addEventListener('resize', layoutUpdate, {passive: true}); } }