]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/ui/framework/manager.ts
685031ff871770333e1b6089e06f34afb5217ca8
[bookstack] / resources / js / wysiwyg / ui / framework / manager.ts
1 import {EditorFormModal, EditorFormModalDefinition} from "./modals";
2 import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
3 import {EditorDecorator, EditorDecoratorAdapter} from "./decorator";
4 import {$getSelection, 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";
8
9 export class EditorUIManager {
10
11     protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
12     protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
13     protected decoratorInstancesByNodeKey: Record<string, EditorDecorator> = {};
14     protected context: EditorUiContext|null = null;
15     protected toolbar: EditorContainerUiElement|null = null;
16     protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
17     protected activeContextToolbars: EditorContextToolbar[] = [];
18
19     setContext(context: EditorUiContext) {
20         this.context = context;
21         this.setupEditor(context.editor);
22     }
23
24     getContext(): EditorUiContext {
25         if (this.context === null) {
26             throw new Error(`Context attempted to be used without being set`);
27         }
28
29         return this.context;
30     }
31
32     triggerStateUpdateForElement(element: EditorUiElement) {
33         element.updateState({
34             selection: null,
35             editor: this.getContext().editor
36         });
37     }
38
39     registerModal(key: string, modalDefinition: EditorFormModalDefinition) {
40         this.modalDefinitionsByKey[key] = modalDefinition;
41     }
42
43     createModal(key: string): EditorFormModal {
44         const modalDefinition = this.modalDefinitionsByKey[key];
45         if (!modalDefinition) {
46             throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`);
47         }
48
49         const modal = new EditorFormModal(modalDefinition);
50         modal.setContext(this.getContext());
51
52         return modal;
53     }
54
55     registerDecoratorType(type: string, decorator: typeof EditorDecorator) {
56         this.decoratorConstructorsByType[type] = decorator;
57     }
58
59     protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator {
60         if (this.decoratorInstancesByNodeKey[nodeKey]) {
61             return this.decoratorInstancesByNodeKey[nodeKey];
62         }
63
64         const decoratorClass = this.decoratorConstructorsByType[decoratorType];
65         if (!decoratorClass) {
66             throw new Error(`Attempted to use decorator of type [${decoratorType}] but not decorator registered for that type`);
67         }
68
69         // @ts-ignore
70         const decorator = new decoratorClass(nodeKey);
71         this.decoratorInstancesByNodeKey[nodeKey] = decorator;
72         return decorator;
73     }
74
75     setToolbar(toolbar: EditorContainerUiElement) {
76         if (this.toolbar) {
77             this.toolbar.getDOMElement().remove();
78         }
79
80         this.toolbar = toolbar;
81         toolbar.setContext(this.getContext());
82         this.getContext().editorDOM.before(toolbar.getDOMElement());
83     }
84
85     registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) {
86         this.contextToolbarDefinitionsByKey[key] = definition;
87     }
88
89     protected triggerStateUpdate(update: EditorUiStateUpdate): void {
90         const context = this.getContext();
91         context.lastSelection = update.selection;
92         this.toolbar?.updateState(update);
93         this.updateContextToolbars(update);
94         for (const toolbar of this.activeContextToolbars) {
95             toolbar.updateState(update);
96         }
97     }
98
99     protected updateContextToolbars(update: EditorUiStateUpdate): void {
100         for (const toolbar of this.activeContextToolbars) {
101             toolbar.empty();
102             toolbar.getDOMElement().remove();
103         }
104
105         const node = (update.selection?.getNodes() || [])[0] || null;
106         if (!node) {
107             return;
108         }
109
110         const element = update.editor.getElementByKey(node.getKey());
111         if (!element) {
112             return;
113         }
114
115         const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey);
116         const contentByTarget = new Map<HTMLElement, EditorUiElement[]>();
117         for (const key of toolbarKeys) {
118             const definition = this.contextToolbarDefinitionsByKey[key];
119             const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null;
120             if (matchingElem) {
121                 const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem;
122                 if (!contentByTarget.has(targetEl)) {
123                     contentByTarget.set(targetEl, [])
124                 }
125                 // @ts-ignore
126                 contentByTarget.get(targetEl).push(...definition.content);
127             }
128         }
129
130         for (const [target, contents] of contentByTarget) {
131             const toolbar = new EditorContextToolbar(contents);
132             toolbar.setContext(this.getContext());
133             this.activeContextToolbars.push(toolbar);
134
135             this.getContext().editorDOM.after(toolbar.getDOMElement());
136             toolbar.attachTo(target);
137         }
138     }
139
140     protected setupEditor(editor: LexicalEditor) {
141         // Update button states on editor selection change
142         editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
143             this.triggerStateUpdate({
144                 editor: editor,
145                 selection: $getSelection(),
146             });
147             return false;
148         }, COMMAND_PRIORITY_LOW);
149
150         // Register our DOM decorate listener with the editor
151         const domDecorateListener: DecoratorListener<EditorDecoratorAdapter> = (decorators: Record<NodeKey, EditorDecoratorAdapter>) => {
152             const keys = Object.keys(decorators);
153             for (const key of keys) {
154                 const decoratedEl = editor.getElementByKey(key);
155                 const adapter = decorators[key];
156                 const decorator = this.getDecorator(adapter.type, key);
157                 decorator.setNode(adapter.getNode());
158                 const decoratorEl = decorator.render(this.getContext());
159                 if (decoratedEl) {
160                     decoratedEl.append(decoratorEl);
161                 }
162             }
163         }
164         editor.registerDecoratorListener(domDecorateListener);
165     }
166 }