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";
10 export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
12 export class EditorUIManager {
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();
24 setContext(context: EditorUiContext) {
25 this.context = context;
26 this.setupEventListeners(context);
27 this.setupEditor(context.editor);
30 getContext(): EditorUiContext {
31 if (this.context === null) {
32 throw new Error(`Context attempted to be used without being set`);
38 triggerStateUpdateForElement(element: EditorUiElement) {
41 editor: this.getContext().editor
45 registerModal(key: string, modalDefinition: EditorFormModalDefinition) {
46 this.modalDefinitionsByKey[key] = modalDefinition;
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`);
55 const modal = new EditorFormModal(modalDefinition, key);
56 modal.setContext(this.getContext());
61 setModalActive(key: string, modal: EditorFormModal): void {
62 this.activeModalsByKey[key] = modal;
65 setModalInactive(key: string): void {
66 delete this.activeModalsByKey[key];
69 getActiveModal(key: string): EditorFormModal|null {
70 return this.activeModalsByKey[key];
73 registerDecoratorType(type: string, decorator: typeof EditorDecorator) {
74 this.decoratorConstructorsByType[type] = decorator;
77 protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator {
78 if (this.decoratorInstancesByNodeKey[nodeKey]) {
79 return this.decoratorInstancesByNodeKey[nodeKey];
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`);
88 const decorator = new decoratorClass(nodeKey);
89 this.decoratorInstancesByNodeKey[nodeKey] = decorator;
93 getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null {
94 return this.decoratorInstancesByNodeKey[nodeKey] || null;
97 setToolbar(toolbar: EditorContainerUiElement) {
99 this.toolbar.getDOMElement().remove();
102 this.toolbar = toolbar;
103 toolbar.setContext(this.getContext());
104 this.getContext().containerDOM.prepend(toolbar.getDOMElement());
107 registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) {
108 this.contextToolbarDefinitionsByKey[key] = definition;
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);
118 this.triggerSelectionChange(update.selection);
121 triggerStateRefresh(): void {
122 const editor = this.getContext().editor;
123 this.triggerStateUpdate({
125 selection: getLastSelection(editor),
129 protected triggerSelectionChange(selection: BaseSelection|null): void {
134 for (const handler of this.selectionChangeHandlers) {
139 onSelectionChange(handler: SelectionChangeHandler): void {
140 this.selectionChangeHandlers.add(handler);
143 offSelectionChange(handler: SelectionChangeHandler): void {
144 this.selectionChangeHandlers.delete(handler);
147 triggerLayoutUpdate(): void {
148 window.requestAnimationFrame(() => {
149 for (const toolbar of this.activeContextToolbars) {
150 toolbar.updatePosition();
155 protected updateContextToolbars(update: EditorUiStateUpdate): void {
156 for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
157 const toolbar = this.activeContextToolbars[i];
159 this.activeContextToolbars.splice(i, 1);
162 const node = (update.selection?.getNodes() || [])[0] || null;
167 const element = update.editor.getElementByKey(node.getKey());
172 const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey);
173 const contentByTarget = new Map<HTMLElement, EditorUiElement[]>();
174 for (const key of toolbarKeys) {
175 const definition = this.contextToolbarDefinitionsByKey[key];
176 const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null;
178 const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem;
179 if (!contentByTarget.has(targetEl)) {
180 contentByTarget.set(targetEl, [])
183 contentByTarget.get(targetEl).push(...definition.content);
187 for (const [target, contents] of contentByTarget) {
188 const toolbar = new EditorContextToolbar(target, contents);
189 toolbar.setContext(this.getContext());
190 this.activeContextToolbars.push(toolbar);
192 this.getContext().containerDOM.append(toolbar.getDOMElement());
193 toolbar.updatePosition();
197 protected setupEditor(editor: LexicalEditor) {
198 // Update button states on editor selection change
199 editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
200 this.triggerStateUpdate({
202 selection: $getSelection(),
205 }, COMMAND_PRIORITY_LOW);
207 // Register our DOM decorate listener with the editor
208 const domDecorateListener: DecoratorListener<EditorDecoratorAdapter> = (decorators: Record<NodeKey, EditorDecoratorAdapter>) => {
209 editor.getEditorState().read(() => {
210 const keys = Object.keys(decorators);
211 for (const key of keys) {
212 const decoratedEl = editor.getElementByKey(key);
217 const adapter = decorators[key];
218 const decorator = this.getDecorator(adapter.type, key);
219 decorator.setNode(adapter.getNode());
220 const decoratorEl = decorator.render(this.getContext(), decoratedEl);
222 decoratedEl.append(decoratorEl);
227 editor.registerDecoratorListener(domDecorateListener);
230 protected setupEventListeners(context: EditorUiContext) {
231 const layoutUpdate = this.triggerLayoutUpdate.bind(this);
232 window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true});
233 window.addEventListener('resize', layoutUpdate, {passive: true});