]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/ui/framework/manager.ts
Lexical: Added table column cut/copy/paste support
[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     protected updateContextToolbars(update: EditorUiStateUpdate): void {
148         for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
149             const toolbar = this.activeContextToolbars[i];
150             toolbar.destroy();
151             this.activeContextToolbars.splice(i, 1);
152         }
153
154         const node = (update.selection?.getNodes() || [])[0] || null;
155         if (!node) {
156             return;
157         }
158
159         const element = update.editor.getElementByKey(node.getKey());
160         if (!element) {
161             return;
162         }
163
164         const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey);
165         const contentByTarget = new Map<HTMLElement, EditorUiElement[]>();
166         for (const key of toolbarKeys) {
167             const definition = this.contextToolbarDefinitionsByKey[key];
168             const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null;
169             if (matchingElem) {
170                 const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem;
171                 if (!contentByTarget.has(targetEl)) {
172                     contentByTarget.set(targetEl, [])
173                 }
174                 // @ts-ignore
175                 contentByTarget.get(targetEl).push(...definition.content);
176             }
177         }
178
179         for (const [target, contents] of contentByTarget) {
180             const toolbar = new EditorContextToolbar(target, contents);
181             toolbar.setContext(this.getContext());
182             this.activeContextToolbars.push(toolbar);
183
184             this.getContext().containerDOM.append(toolbar.getDOMElement());
185             toolbar.updatePosition();
186         }
187     }
188
189     protected setupEditor(editor: LexicalEditor) {
190         // Update button states on editor selection change
191         editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
192             this.triggerStateUpdate({
193                 editor: editor,
194                 selection: $getSelection(),
195             });
196             return false;
197         }, COMMAND_PRIORITY_LOW);
198
199         // Register our DOM decorate listener with the editor
200         const domDecorateListener: DecoratorListener<EditorDecoratorAdapter> = (decorators: Record<NodeKey, EditorDecoratorAdapter>) => {
201             editor.getEditorState().read(() => {
202                 const keys = Object.keys(decorators);
203                 for (const key of keys) {
204                     const decoratedEl = editor.getElementByKey(key);
205                     if (!decoratedEl) {
206                         continue;
207                     }
208
209                     const adapter = decorators[key];
210                     const decorator = this.getDecorator(adapter.type, key);
211                     decorator.setNode(adapter.getNode());
212                     const decoratorEl = decorator.render(this.getContext(), decoratedEl);
213                     if (decoratorEl) {
214                         decoratedEl.append(decoratorEl);
215                     }
216                 }
217             });
218         }
219         editor.registerDecoratorListener(domDecorateListener);
220     }
221
222     protected setupEventListeners(context: EditorUiContext) {
223         const updateToolbars = (event: Event) => {
224             for (const toolbar of this.activeContextToolbars) {
225                 toolbar.updatePosition();
226             }
227         };
228
229         window.addEventListener('scroll', updateToolbars, {capture: true, passive: true});
230         window.addEventListener('resize', updateToolbars, {passive: true});
231     }
232 }