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