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