1 import {EditorFormModal, EditorFormModalDefinition} from "./modals";
2 import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
3 import {EditorDecorator, EditorDecoratorAdapter} from "./decorator";
4 import {$getSelection, BaseSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND} from "lexical";
5 import {DecoratorListener} from "lexical/LexicalEditor";
6 import type {NodeKey} from "lexical/LexicalNode";
7 import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
9 export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
11 export class EditorUIManager {
13 protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
14 protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
15 protected decoratorInstancesByNodeKey: Record<string, EditorDecorator> = {};
16 protected context: EditorUiContext|null = null;
17 protected toolbar: EditorContainerUiElement|null = null;
18 protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
19 protected activeContextToolbars: EditorContextToolbar[] = [];
20 protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
22 setContext(context: EditorUiContext) {
23 this.context = context;
24 this.setupEventListeners(context);
25 this.setupEditor(context.editor);
28 getContext(): EditorUiContext {
29 if (this.context === null) {
30 throw new Error(`Context attempted to be used without being set`);
36 triggerStateUpdateForElement(element: EditorUiElement) {
39 editor: this.getContext().editor
43 registerModal(key: string, modalDefinition: EditorFormModalDefinition) {
44 this.modalDefinitionsByKey[key] = modalDefinition;
47 createModal(key: string): EditorFormModal {
48 const modalDefinition = this.modalDefinitionsByKey[key];
49 if (!modalDefinition) {
50 throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`);
53 const modal = new EditorFormModal(modalDefinition);
54 modal.setContext(this.getContext());
59 registerDecoratorType(type: string, decorator: typeof EditorDecorator) {
60 this.decoratorConstructorsByType[type] = decorator;
63 protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator {
64 if (this.decoratorInstancesByNodeKey[nodeKey]) {
65 return this.decoratorInstancesByNodeKey[nodeKey];
68 const decoratorClass = this.decoratorConstructorsByType[decoratorType];
69 if (!decoratorClass) {
70 throw new Error(`Attempted to use decorator of type [${decoratorType}] but not decorator registered for that type`);
74 const decorator = new decoratorClass(nodeKey);
75 this.decoratorInstancesByNodeKey[nodeKey] = decorator;
79 getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null {
80 return this.decoratorInstancesByNodeKey[nodeKey] || null;
83 setToolbar(toolbar: EditorContainerUiElement) {
85 this.toolbar.getDOMElement().remove();
88 this.toolbar = toolbar;
89 toolbar.setContext(this.getContext());
90 this.getContext().containerDOM.prepend(toolbar.getDOMElement());
93 registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) {
94 this.contextToolbarDefinitionsByKey[key] = definition;
97 protected triggerStateUpdate(update: EditorUiStateUpdate): void {
98 const context = this.getContext();
99 context.lastSelection = update.selection;
100 this.toolbar?.updateState(update);
101 this.updateContextToolbars(update);
102 for (const toolbar of this.activeContextToolbars) {
103 toolbar.updateState(update);
105 this.triggerSelectionChange(update.selection);
108 triggerStateRefresh(): void {
109 this.triggerStateUpdate({
110 editor: this.getContext().editor,
111 selection: this.getContext().lastSelection,
115 protected triggerSelectionChange(selection: BaseSelection|null): void {
120 for (const handler of this.selectionChangeHandlers) {
125 onSelectionChange(handler: SelectionChangeHandler): void {
126 this.selectionChangeHandlers.add(handler);
129 offSelectionChange(handler: SelectionChangeHandler): void {
130 this.selectionChangeHandlers.delete(handler);
133 protected updateContextToolbars(update: EditorUiStateUpdate): void {
134 for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
135 const toolbar = this.activeContextToolbars[i];
137 this.activeContextToolbars.splice(i, 1);
140 const node = (update.selection?.getNodes() || [])[0] || null;
145 const element = update.editor.getElementByKey(node.getKey());
150 const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey);
151 const contentByTarget = new Map<HTMLElement, EditorUiElement[]>();
152 for (const key of toolbarKeys) {
153 const definition = this.contextToolbarDefinitionsByKey[key];
154 const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null;
156 const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem;
157 if (!contentByTarget.has(targetEl)) {
158 contentByTarget.set(targetEl, [])
161 contentByTarget.get(targetEl).push(...definition.content);
165 for (const [target, contents] of contentByTarget) {
166 const toolbar = new EditorContextToolbar(target, contents);
167 toolbar.setContext(this.getContext());
168 this.activeContextToolbars.push(toolbar);
170 this.getContext().containerDOM.append(toolbar.getDOMElement());
171 toolbar.updatePosition();
175 protected setupEditor(editor: LexicalEditor) {
176 // Update button states on editor selection change
177 editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
178 this.triggerStateUpdate({
180 selection: $getSelection(),
183 }, COMMAND_PRIORITY_LOW);
185 // Register our DOM decorate listener with the editor
186 const domDecorateListener: DecoratorListener<EditorDecoratorAdapter> = (decorators: Record<NodeKey, EditorDecoratorAdapter>) => {
187 editor.getEditorState().read(() => {
188 const keys = Object.keys(decorators);
189 for (const key of keys) {
190 const decoratedEl = editor.getElementByKey(key);
195 const adapter = decorators[key];
196 const decorator = this.getDecorator(adapter.type, key);
197 decorator.setNode(adapter.getNode());
198 const decoratorEl = decorator.render(this.getContext(), decoratedEl);
200 decoratedEl.append(decoratorEl);
205 editor.registerDecoratorListener(domDecorateListener);
208 protected setupEventListeners(context: EditorUiContext) {
209 const updateToolbars = (event: Event) => {
210 for (const toolbar of this.activeContextToolbars) {
211 toolbar.updatePosition();
215 window.addEventListener('scroll', updateToolbars, {capture: true, passive: true});
216 window.addEventListener('resize', updateToolbars, {passive: true});