]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/ui/framework/manager.ts
4ca90a12a1a046157b58c41467761e404809c428
[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         // console.log('selection update', update.selection);
98     }
99
100     protected updateContextToolbars(update: EditorUiStateUpdate): void {
101         for (const toolbar of this.activeContextToolbars) {
102             toolbar.empty();
103             toolbar.getDOMElement().remove();
104         }
105
106         const node = (update.selection?.getNodes() || [])[0] || null;
107         if (!node) {
108             return;
109         }
110
111         const element = update.editor.getElementByKey(node.getKey());
112         if (!element) {
113             return;
114         }
115
116         const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey);
117         const contentByTarget = new Map<HTMLElement, EditorUiElement[]>();
118         for (const key of toolbarKeys) {
119             const definition = this.contextToolbarDefinitionsByKey[key];
120             const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null;
121             if (matchingElem) {
122                 const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem;
123                 if (!contentByTarget.has(targetEl)) {
124                     contentByTarget.set(targetEl, [])
125                 }
126                 // @ts-ignore
127                 contentByTarget.get(targetEl).push(...definition.content);
128             }
129         }
130
131         for (const [target, contents] of contentByTarget) {
132             const toolbar = new EditorContextToolbar(contents);
133             toolbar.setContext(this.getContext());
134             this.activeContextToolbars.push(toolbar);
135
136             this.getContext().editorDOM.after(toolbar.getDOMElement());
137             toolbar.attachTo(target);
138         }
139     }
140
141     protected setupEditor(editor: LexicalEditor) {
142         // Update button states on editor selection change
143         editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
144             this.triggerStateUpdate({
145                 editor: editor,
146                 selection: $getSelection(),
147             });
148             return false;
149         }, COMMAND_PRIORITY_LOW);
150
151         // Register our DOM decorate listener with the editor
152         const domDecorateListener: DecoratorListener<EditorDecoratorAdapter> = (decorators: Record<NodeKey, EditorDecoratorAdapter>) => {
153             const keys = Object.keys(decorators);
154             for (const key of keys) {
155                 const decoratedEl = editor.getElementByKey(key);
156                 const adapter = decorators[key];
157                 const decorator = this.getDecorator(adapter.type, key);
158                 decorator.setNode(adapter.getNode());
159                 const decoratorEl = decorator.render(this.getContext());
160                 if (decoratedEl) {
161                     decoratedEl.append(decoratorEl);
162                 }
163             }
164         }
165         editor.registerDecoratorListener(domDecorateListener);
166     }
167 }