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";
11 export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
13 export class EditorUIManager {
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();
25 public dropdowns: DropDownManager = new DropDownManager();
27 setContext(context: EditorUiContext) {
28 this.context = context;
29 this.setupEventListeners(context);
30 this.setupEditor(context.editor);
33 getContext(): EditorUiContext {
34 if (this.context === null) {
35 throw new Error(`Context attempted to be used without being set`);
41 triggerStateUpdateForElement(element: EditorUiElement) {
44 editor: this.getContext().editor
48 registerModal(key: string, modalDefinition: EditorFormModalDefinition) {
49 this.modalDefinitionsByKey[key] = modalDefinition;
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`);
58 const modal = new EditorFormModal(modalDefinition, key);
59 modal.setContext(this.getContext());
64 setModalActive(key: string, modal: EditorFormModal): void {
65 this.activeModalsByKey[key] = modal;
68 setModalInactive(key: string): void {
69 delete this.activeModalsByKey[key];
72 getActiveModal(key: string): EditorFormModal|null {
73 return this.activeModalsByKey[key];
76 registerDecoratorType(type: string, decorator: typeof EditorDecorator) {
77 this.decoratorConstructorsByType[type] = decorator;
80 protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator {
81 if (this.decoratorInstancesByNodeKey[nodeKey]) {
82 return this.decoratorInstancesByNodeKey[nodeKey];
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`);
91 const decorator = new decoratorClass(nodeKey);
92 this.decoratorInstancesByNodeKey[nodeKey] = decorator;
96 getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null {
97 return this.decoratorInstancesByNodeKey[nodeKey] || null;
100 setToolbar(toolbar: EditorContainerUiElement) {
102 this.toolbar.getDOMElement().remove();
105 this.toolbar = toolbar;
106 toolbar.setContext(this.getContext());
107 this.getContext().containerDOM.prepend(toolbar.getDOMElement());
110 registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) {
111 this.contextToolbarDefinitionsByKey[key] = definition;
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);
121 this.triggerSelectionChange(update.selection);
124 triggerStateRefresh(): void {
125 const editor = this.getContext().editor;
128 selection: getLastSelection(editor),
131 this.triggerStateUpdate(update);
132 this.updateContextToolbars(update);
135 triggerFutureStateRefresh(): void {
136 requestAnimationFrame(() => {
137 this.getContext().editor.getEditorState().read(() => {
138 this.triggerStateRefresh();
143 protected triggerSelectionChange(selection: BaseSelection|null): void {
148 for (const handler of this.selectionChangeHandlers) {
153 onSelectionChange(handler: SelectionChangeHandler): void {
154 this.selectionChangeHandlers.add(handler);
157 offSelectionChange(handler: SelectionChangeHandler): void {
158 this.selectionChangeHandlers.delete(handler);
161 triggerLayoutUpdate(): void {
162 window.requestAnimationFrame(() => {
163 for (const toolbar of this.activeContextToolbars) {
164 toolbar.updatePosition();
169 getDefaultDirection(): 'rtl' | 'ltr' {
170 return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr';
173 protected updateContextToolbars(update: EditorUiStateUpdate): void {
174 for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
175 const toolbar = this.activeContextToolbars[i];
177 this.activeContextToolbars.splice(i, 1);
180 const node = (update.selection?.getNodes() || [])[0] || null;
185 const element = update.editor.getElementByKey(node.getKey());
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;
196 const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem;
197 if (!contentByTarget.has(targetEl)) {
198 contentByTarget.set(targetEl, [])
201 contentByTarget.get(targetEl).push(...definition.content);
205 for (const [target, contents] of contentByTarget) {
206 const toolbar = new EditorContextToolbar(target, contents);
207 toolbar.setContext(this.getContext());
208 this.activeContextToolbars.push(toolbar);
210 this.getContext().containerDOM.append(toolbar.getDOMElement());
211 toolbar.updatePosition();
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);
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);
231 decoratedEl.append(decoratorEl);
236 editor.registerDecoratorListener(domDecorateListener);
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 // console.log('manager::selection', selection);
248 this.triggerStateUpdate({
256 protected setupEventListeners(context: EditorUiContext) {
257 const layoutUpdate = this.triggerLayoutUpdate.bind(this);
258 window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true});
259 window.addEventListener('resize', layoutUpdate, {passive: true});