]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/ui/framework/manager.ts
Lexical: Integrated diagram manager, added menu split button
[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 activeModalsByKey: Record<string, EditorFormModal> = {};
15     protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
16     protected decoratorInstancesByNodeKey: Record<string, EditorDecorator> = {};
17     protected context: EditorUiContext|null = null;
18     protected toolbar: EditorContainerUiElement|null = null;
19     protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
20     protected activeContextToolbars: EditorContextToolbar[] = [];
21     protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
22
23     setContext(context: EditorUiContext) {
24         this.context = context;
25         this.setupEventListeners(context);
26         this.setupEditor(context.editor);
27     }
28
29     getContext(): EditorUiContext {
30         if (this.context === null) {
31             throw new Error(`Context attempted to be used without being set`);
32         }
33
34         return this.context;
35     }
36
37     triggerStateUpdateForElement(element: EditorUiElement) {
38         element.updateState({
39             selection: null,
40             editor: this.getContext().editor
41         });
42     }
43
44     registerModal(key: string, modalDefinition: EditorFormModalDefinition) {
45         this.modalDefinitionsByKey[key] = modalDefinition;
46     }
47
48     createModal(key: string): EditorFormModal {
49         const modalDefinition = this.modalDefinitionsByKey[key];
50         if (!modalDefinition) {
51             throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`);
52         }
53
54         const modal = new EditorFormModal(modalDefinition, key);
55         modal.setContext(this.getContext());
56
57         return modal;
58     }
59
60     setModalActive(key: string, modal: EditorFormModal): void {
61         this.activeModalsByKey[key] = modal;
62     }
63
64     setModalInactive(key: string): void {
65         delete this.activeModalsByKey[key];
66     }
67
68     getActiveModal(key: string): EditorFormModal|null {
69         return this.activeModalsByKey[key];
70     }
71
72     registerDecoratorType(type: string, decorator: typeof EditorDecorator) {
73         this.decoratorConstructorsByType[type] = decorator;
74     }
75
76     protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator {
77         if (this.decoratorInstancesByNodeKey[nodeKey]) {
78             return this.decoratorInstancesByNodeKey[nodeKey];
79         }
80
81         const decoratorClass = this.decoratorConstructorsByType[decoratorType];
82         if (!decoratorClass) {
83             throw new Error(`Attempted to use decorator of type [${decoratorType}] but not decorator registered for that type`);
84         }
85
86         // @ts-ignore
87         const decorator = new decoratorClass(nodeKey);
88         this.decoratorInstancesByNodeKey[nodeKey] = decorator;
89         return decorator;
90     }
91
92     getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null {
93         return this.decoratorInstancesByNodeKey[nodeKey] || null;
94     }
95
96     setToolbar(toolbar: EditorContainerUiElement) {
97         if (this.toolbar) {
98             this.toolbar.getDOMElement().remove();
99         }
100
101         this.toolbar = toolbar;
102         toolbar.setContext(this.getContext());
103         this.getContext().containerDOM.prepend(toolbar.getDOMElement());
104     }
105
106     registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) {
107         this.contextToolbarDefinitionsByKey[key] = definition;
108     }
109
110     protected triggerStateUpdate(update: EditorUiStateUpdate): void {
111         const context = this.getContext();
112         context.lastSelection = update.selection;
113         this.toolbar?.updateState(update);
114         this.updateContextToolbars(update);
115         for (const toolbar of this.activeContextToolbars) {
116             toolbar.updateState(update);
117         }
118         this.triggerSelectionChange(update.selection);
119     }
120
121     triggerStateRefresh(): void {
122         this.triggerStateUpdate({
123             editor: this.getContext().editor,
124             selection: this.getContext().lastSelection,
125         });
126     }
127
128     protected triggerSelectionChange(selection: BaseSelection|null): void {
129         if (!selection) {
130             return;
131         }
132
133         for (const handler of this.selectionChangeHandlers) {
134             handler(selection);
135         }
136     }
137
138     onSelectionChange(handler: SelectionChangeHandler): void {
139         this.selectionChangeHandlers.add(handler);
140     }
141
142     offSelectionChange(handler: SelectionChangeHandler): void {
143         this.selectionChangeHandlers.delete(handler);
144     }
145
146     protected updateContextToolbars(update: EditorUiStateUpdate): void {
147         for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
148             const toolbar = this.activeContextToolbars[i];
149             toolbar.destroy();
150             this.activeContextToolbars.splice(i, 1);
151         }
152
153         const node = (update.selection?.getNodes() || [])[0] || null;
154         if (!node) {
155             return;
156         }
157
158         const element = update.editor.getElementByKey(node.getKey());
159         if (!element) {
160             return;
161         }
162
163         const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey);
164         const contentByTarget = new Map<HTMLElement, EditorUiElement[]>();
165         for (const key of toolbarKeys) {
166             const definition = this.contextToolbarDefinitionsByKey[key];
167             const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null;
168             if (matchingElem) {
169                 const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem;
170                 if (!contentByTarget.has(targetEl)) {
171                     contentByTarget.set(targetEl, [])
172                 }
173                 // @ts-ignore
174                 contentByTarget.get(targetEl).push(...definition.content);
175             }
176         }
177
178         for (const [target, contents] of contentByTarget) {
179             const toolbar = new EditorContextToolbar(target, contents);
180             toolbar.setContext(this.getContext());
181             this.activeContextToolbars.push(toolbar);
182
183             this.getContext().containerDOM.append(toolbar.getDOMElement());
184             toolbar.updatePosition();
185         }
186     }
187
188     protected setupEditor(editor: LexicalEditor) {
189         // Update button states on editor selection change
190         editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
191             this.triggerStateUpdate({
192                 editor: editor,
193                 selection: $getSelection(),
194             });
195             return false;
196         }, COMMAND_PRIORITY_LOW);
197
198         // Register our DOM decorate listener with the editor
199         const domDecorateListener: DecoratorListener<EditorDecoratorAdapter> = (decorators: Record<NodeKey, EditorDecoratorAdapter>) => {
200             editor.getEditorState().read(() => {
201                 const keys = Object.keys(decorators);
202                 for (const key of keys) {
203                     const decoratedEl = editor.getElementByKey(key);
204                     if (!decoratedEl) {
205                         continue;
206                     }
207
208                     const adapter = decorators[key];
209                     const decorator = this.getDecorator(adapter.type, key);
210                     decorator.setNode(adapter.getNode());
211                     const decoratorEl = decorator.render(this.getContext(), decoratedEl);
212                     if (decoratorEl) {
213                         decoratedEl.append(decoratorEl);
214                     }
215                 }
216             });
217         }
218         editor.registerDecoratorListener(domDecorateListener);
219     }
220
221     protected setupEventListeners(context: EditorUiContext) {
222         const updateToolbars = (event: Event) => {
223             for (const toolbar of this.activeContextToolbars) {
224                 toolbar.updatePosition();
225             }
226         };
227
228         window.addEventListener('scroll', updateToolbars, {capture: true, passive: true});
229         window.addEventListener('resize', updateToolbars, {passive: true});
230     }
231 }