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