]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/ui/framework/manager.ts
Comments: Switched to lexical editor
[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, LexicalEditor} 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 import {DropDownManager} from "./helpers/dropdowns";
10
11 export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
12
13 export class EditorUIManager {
14
15     public dropdowns: DropDownManager = new DropDownManager();
16
17     protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
18     protected activeModalsByKey: Record<string, EditorFormModal> = {};
19     protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
20     protected decoratorInstancesByNodeKey: Record<string, EditorDecorator> = {};
21     protected context: EditorUiContext|null = null;
22     protected toolbar: EditorContainerUiElement|null = null;
23     protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
24     protected activeContextToolbars: EditorContextToolbar[] = [];
25     protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
26     protected domEventAbortController = new AbortController();
27     protected teardownCallbacks: (()=>void)[] = [];
28
29     setContext(context: EditorUiContext) {
30         this.context = context;
31         this.setupEventListeners();
32         this.setupEditor(context.editor);
33     }
34
35     getContext(): EditorUiContext {
36         if (this.context === null) {
37             throw new Error(`Context attempted to be used without being set`);
38         }
39
40         return this.context;
41     }
42
43     triggerStateUpdateForElement(element: EditorUiElement) {
44         element.updateState({
45             selection: null,
46             editor: this.getContext().editor
47         });
48     }
49
50     registerModal(key: string, modalDefinition: EditorFormModalDefinition) {
51         this.modalDefinitionsByKey[key] = modalDefinition;
52     }
53
54     createModal(key: string): EditorFormModal {
55         const modalDefinition = this.modalDefinitionsByKey[key];
56         if (!modalDefinition) {
57             throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`);
58         }
59
60         const modal = new EditorFormModal(modalDefinition, key);
61         modal.setContext(this.getContext());
62
63         return modal;
64     }
65
66     setModalActive(key: string, modal: EditorFormModal): void {
67         this.activeModalsByKey[key] = modal;
68     }
69
70     setModalInactive(key: string): void {
71         delete this.activeModalsByKey[key];
72     }
73
74     getActiveModal(key: string): EditorFormModal|null {
75         return this.activeModalsByKey[key];
76     }
77
78     registerDecoratorType(type: string, decorator: typeof EditorDecorator) {
79         this.decoratorConstructorsByType[type] = decorator;
80     }
81
82     protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator {
83         if (this.decoratorInstancesByNodeKey[nodeKey]) {
84             return this.decoratorInstancesByNodeKey[nodeKey];
85         }
86
87         const decoratorClass = this.decoratorConstructorsByType[decoratorType];
88         if (!decoratorClass) {
89             throw new Error(`Attempted to use decorator of type [${decoratorType}] but not decorator registered for that type`);
90         }
91
92         // @ts-ignore
93         const decorator = new decoratorClass(nodeKey);
94         this.decoratorInstancesByNodeKey[nodeKey] = decorator;
95         return decorator;
96     }
97
98     getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null {
99         return this.decoratorInstancesByNodeKey[nodeKey] || null;
100     }
101
102     setToolbar(toolbar: EditorContainerUiElement) {
103         if (this.toolbar) {
104             this.toolbar.teardown();
105         }
106
107         this.toolbar = toolbar;
108         toolbar.setContext(this.getContext());
109         this.getContext().containerDOM.prepend(toolbar.getDOMElement());
110     }
111
112     registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) {
113         this.contextToolbarDefinitionsByKey[key] = definition;
114     }
115
116     triggerStateUpdate(update: EditorUiStateUpdate): void {
117         setLastSelection(update.editor, update.selection);
118         this.toolbar?.updateState(update);
119         this.updateContextToolbars(update);
120         for (const toolbar of this.activeContextToolbars) {
121             toolbar.updateState(update);
122         }
123         this.triggerSelectionChange(update.selection);
124     }
125
126     triggerStateRefresh(): void {
127         const editor = this.getContext().editor;
128         const update = {
129             editor,
130             selection: getLastSelection(editor),
131         };
132
133         this.triggerStateUpdate(update);
134         this.updateContextToolbars(update);
135     }
136
137     triggerFutureStateRefresh(): void {
138         requestAnimationFrame(() => {
139             this.getContext().editor.getEditorState().read(() => {
140                 this.triggerStateRefresh();
141             });
142         });
143     }
144
145     protected triggerSelectionChange(selection: BaseSelection|null): void {
146         if (!selection) {
147             return;
148         }
149
150         for (const handler of this.selectionChangeHandlers) {
151             handler(selection);
152         }
153     }
154
155     onSelectionChange(handler: SelectionChangeHandler): void {
156         this.selectionChangeHandlers.add(handler);
157     }
158
159     offSelectionChange(handler: SelectionChangeHandler): void {
160         this.selectionChangeHandlers.delete(handler);
161     }
162
163     triggerLayoutUpdate(): void {
164         window.requestAnimationFrame(() => {
165             for (const toolbar of this.activeContextToolbars) {
166                 toolbar.updatePosition();
167             }
168         });
169     }
170
171     getDefaultDirection(): 'rtl' | 'ltr' {
172         return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr';
173     }
174
175     onTeardown(callback: () => void): void {
176         this.teardownCallbacks.push(callback);
177     }
178
179     teardown(): void {
180         this.domEventAbortController.abort('teardown');
181
182         for (const [_, modal] of Object.entries(this.activeModalsByKey)) {
183             modal.teardown();
184         }
185
186         for (const [_, decorator] of Object.entries(this.decoratorInstancesByNodeKey)) {
187             decorator.teardown();
188         }
189
190         if (this.toolbar) {
191             this.toolbar.teardown();
192         }
193
194         for (const toolbar of this.activeContextToolbars) {
195             toolbar.teardown();
196         }
197
198         this.dropdowns.teardown();
199
200         for (const callback of this.teardownCallbacks) {
201             callback();
202         }
203     }
204
205     protected updateContextToolbars(update: EditorUiStateUpdate): void {
206         for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
207             const toolbar = this.activeContextToolbars[i];
208             toolbar.teardown();
209             this.activeContextToolbars.splice(i, 1);
210         }
211
212         const node = (update.selection?.getNodes() || [])[0] || null;
213         if (!node) {
214             return;
215         }
216
217         const element = update.editor.getElementByKey(node.getKey());
218         if (!element) {
219             return;
220         }
221
222         const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey);
223         const contentByTarget = new Map<HTMLElement, EditorUiElement[]>();
224         for (const key of toolbarKeys) {
225             const definition = this.contextToolbarDefinitionsByKey[key];
226             const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null;
227             if (matchingElem) {
228                 const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem;
229                 if (!contentByTarget.has(targetEl)) {
230                     contentByTarget.set(targetEl, [])
231                 }
232                 // @ts-ignore
233                 contentByTarget.get(targetEl).push(...definition.content());
234             }
235         }
236
237         for (const [target, contents] of contentByTarget) {
238             const toolbar = new EditorContextToolbar(target, contents);
239             toolbar.setContext(this.getContext());
240             this.activeContextToolbars.push(toolbar);
241
242             this.getContext().containerDOM.append(toolbar.getDOMElement());
243             toolbar.updatePosition();
244         }
245     }
246
247     protected setupEditor(editor: LexicalEditor) {
248         // Register our DOM decorate listener with the editor
249         const domDecorateListener: DecoratorListener<EditorDecoratorAdapter> = (decorators: Record<NodeKey, EditorDecoratorAdapter>) => {
250             editor.getEditorState().read(() => {
251                 const keys = Object.keys(decorators);
252                 for (const key of keys) {
253                     const decoratedEl = editor.getElementByKey(key);
254                     if (!decoratedEl) {
255                         continue;
256                     }
257
258                     const adapter = decorators[key];
259                     const decorator = this.getDecorator(adapter.type, key);
260                     decorator.setNode(adapter.getNode());
261                     const decoratorEl = decorator.render(this.getContext(), decoratedEl);
262                     if (decoratorEl) {
263                         decoratedEl.append(decoratorEl);
264                     }
265                 }
266             });
267         }
268         editor.registerDecoratorListener(domDecorateListener);
269
270         // Watch for changes to update local state
271         editor.registerUpdateListener(({editorState, prevEditorState}) => {
272             // Watch for selection changes to update the UI on change
273             // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
274             // for all selection changes, so this proved more reliable.
275             const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
276             if (selectionChange) {
277                 editor.update(() => {
278                     const selection = $getSelection();
279                     // console.log('manager::selection', selection);
280                     this.triggerStateUpdate({
281                         editor, selection,
282                     });
283                 });
284             }
285         });
286     }
287
288     protected setupEventListeners() {
289         const layoutUpdate = this.triggerLayoutUpdate.bind(this);
290         window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true, signal: this.domEventAbortController.signal});
291         window.addEventListener('resize', layoutUpdate, {passive: true, signal: this.domEventAbortController.signal});
292     }
293 }