]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/ui/framework/manager.ts
Lexical: Added merge cell logic
[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, 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";
8
9 export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
10
11 export class EditorUIManager {
12
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();
21
22     setContext(context: EditorUiContext) {
23         this.context = context;
24         this.setupEventListeners(context);
25         this.setupEditor(context.editor);
26     }
27
28     getContext(): EditorUiContext {
29         if (this.context === null) {
30             throw new Error(`Context attempted to be used without being set`);
31         }
32
33         return this.context;
34     }
35
36     triggerStateUpdateForElement(element: EditorUiElement) {
37         element.updateState({
38             selection: null,
39             editor: this.getContext().editor
40         });
41     }
42
43     registerModal(key: string, modalDefinition: EditorFormModalDefinition) {
44         this.modalDefinitionsByKey[key] = modalDefinition;
45     }
46
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`);
51         }
52
53         const modal = new EditorFormModal(modalDefinition);
54         modal.setContext(this.getContext());
55
56         return modal;
57     }
58
59     registerDecoratorType(type: string, decorator: typeof EditorDecorator) {
60         this.decoratorConstructorsByType[type] = decorator;
61     }
62
63     protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator {
64         if (this.decoratorInstancesByNodeKey[nodeKey]) {
65             return this.decoratorInstancesByNodeKey[nodeKey];
66         }
67
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`);
71         }
72
73         // @ts-ignore
74         const decorator = new decoratorClass(nodeKey);
75         this.decoratorInstancesByNodeKey[nodeKey] = decorator;
76         return decorator;
77     }
78
79     getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null {
80         return this.decoratorInstancesByNodeKey[nodeKey] || null;
81     }
82
83     setToolbar(toolbar: EditorContainerUiElement) {
84         if (this.toolbar) {
85             this.toolbar.getDOMElement().remove();
86         }
87
88         this.toolbar = toolbar;
89         toolbar.setContext(this.getContext());
90         this.getContext().containerDOM.prepend(toolbar.getDOMElement());
91     }
92
93     registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) {
94         this.contextToolbarDefinitionsByKey[key] = definition;
95     }
96
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);
104         }
105         this.triggerSelectionChange(update.selection);
106     }
107
108     triggerStateRefresh(): void {
109         this.triggerStateUpdate({
110             editor: this.getContext().editor,
111             selection: this.getContext().lastSelection,
112         });
113     }
114
115     protected triggerSelectionChange(selection: BaseSelection|null): void {
116         if (!selection) {
117             return;
118         }
119
120         for (const handler of this.selectionChangeHandlers) {
121             handler(selection);
122         }
123     }
124
125     onSelectionChange(handler: SelectionChangeHandler): void {
126         this.selectionChangeHandlers.add(handler);
127     }
128
129     offSelectionChange(handler: SelectionChangeHandler): void {
130         this.selectionChangeHandlers.delete(handler);
131     }
132
133     protected updateContextToolbars(update: EditorUiStateUpdate): void {
134         for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
135             const toolbar = this.activeContextToolbars[i];
136             toolbar.destroy();
137             this.activeContextToolbars.splice(i, 1);
138         }
139
140         const node = (update.selection?.getNodes() || [])[0] || null;
141         if (!node) {
142             return;
143         }
144
145         const element = update.editor.getElementByKey(node.getKey());
146         if (!element) {
147             return;
148         }
149
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;
155             if (matchingElem) {
156                 const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem;
157                 if (!contentByTarget.has(targetEl)) {
158                     contentByTarget.set(targetEl, [])
159                 }
160                 // @ts-ignore
161                 contentByTarget.get(targetEl).push(...definition.content);
162             }
163         }
164
165         for (const [target, contents] of contentByTarget) {
166             const toolbar = new EditorContextToolbar(target, contents);
167             toolbar.setContext(this.getContext());
168             this.activeContextToolbars.push(toolbar);
169
170             this.getContext().containerDOM.append(toolbar.getDOMElement());
171             toolbar.updatePosition();
172         }
173     }
174
175     protected setupEditor(editor: LexicalEditor) {
176         // Update button states on editor selection change
177         editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
178             this.triggerStateUpdate({
179                 editor: editor,
180                 selection: $getSelection(),
181             });
182             return false;
183         }, COMMAND_PRIORITY_LOW);
184
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);
191                     if (!decoratedEl) {
192                         continue;
193                     }
194
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);
199                     if (decoratorEl) {
200                         decoratedEl.append(decoratorEl);
201                     }
202                 }
203             });
204         }
205         editor.registerDecoratorListener(domDecorateListener);
206     }
207
208     protected setupEventListeners(context: EditorUiContext) {
209         const updateToolbars = (event: Event) => {
210             for (const toolbar of this.activeContextToolbars) {
211                 toolbar.updatePosition();
212             }
213         };
214
215         window.addEventListener('scroll', updateToolbars, {capture: true, passive: true});
216         window.addEventListener('resize', updateToolbars, {passive: true});
217     }
218 }