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