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 public dropdowns: DropDownManager = new DropDownManager();
17 protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
18 protected activeModalsByKey: Record<string, EditorFormModal> = {};
19 protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
20 protected decoratorInstancesByNodeKey: Record<string, EditorDecorator> = {};
21 protected context: EditorUiContext|null = null;
22 protected toolbar: EditorContainerUiElement|null = null;
23 protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
24 protected activeContextToolbars: EditorContextToolbar[] = [];
25 protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
26 protected domEventAbortController = new AbortController();
27 protected teardownCallbacks: (()=>void)[] = [];
29 setContext(context: EditorUiContext) {
30 this.context = context;
31 this.setupEventListeners();
32 this.setupEditor(context.editor);
35 getContext(): EditorUiContext {
36 if (this.context === null) {
37 throw new Error(`Context attempted to be used without being set`);
43 triggerStateUpdateForElement(element: EditorUiElement) {
46 editor: this.getContext().editor
50 registerModal(key: string, modalDefinition: EditorFormModalDefinition) {
51 this.modalDefinitionsByKey[key] = modalDefinition;
54 createModal(key: string): EditorFormModal {
55 const modalDefinition = this.modalDefinitionsByKey[key];
56 if (!modalDefinition) {
57 throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`);
60 const modal = new EditorFormModal(modalDefinition, key);
61 modal.setContext(this.getContext());
66 setModalActive(key: string, modal: EditorFormModal): void {
67 this.activeModalsByKey[key] = modal;
70 setModalInactive(key: string): void {
71 delete this.activeModalsByKey[key];
74 getActiveModal(key: string): EditorFormModal|null {
75 return this.activeModalsByKey[key];
78 registerDecoratorType(type: string, decorator: typeof EditorDecorator) {
79 this.decoratorConstructorsByType[type] = decorator;
82 protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator {
83 if (this.decoratorInstancesByNodeKey[nodeKey]) {
84 return this.decoratorInstancesByNodeKey[nodeKey];
87 const decoratorClass = this.decoratorConstructorsByType[decoratorType];
88 if (!decoratorClass) {
89 throw new Error(`Attempted to use decorator of type [${decoratorType}] but not decorator registered for that type`);
93 const decorator = new decoratorClass(nodeKey);
94 this.decoratorInstancesByNodeKey[nodeKey] = decorator;
98 getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null {
99 return this.decoratorInstancesByNodeKey[nodeKey] || null;
102 setToolbar(toolbar: EditorContainerUiElement) {
104 this.toolbar.teardown();
107 this.toolbar = toolbar;
108 toolbar.setContext(this.getContext());
109 this.getContext().containerDOM.prepend(toolbar.getDOMElement());
112 registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) {
113 this.contextToolbarDefinitionsByKey[key] = definition;
116 triggerStateUpdate(update: EditorUiStateUpdate): void {
117 setLastSelection(update.editor, update.selection);
118 this.toolbar?.updateState(update);
119 this.updateContextToolbars(update);
120 for (const toolbar of this.activeContextToolbars) {
121 toolbar.updateState(update);
123 this.triggerSelectionChange(update.selection);
126 triggerStateRefresh(): void {
127 const editor = this.getContext().editor;
130 selection: getLastSelection(editor),
133 this.triggerStateUpdate(update);
134 this.updateContextToolbars(update);
137 triggerFutureStateRefresh(): void {
138 requestAnimationFrame(() => {
139 this.getContext().editor.getEditorState().read(() => {
140 this.triggerStateRefresh();
145 protected triggerSelectionChange(selection: BaseSelection|null): void {
150 for (const handler of this.selectionChangeHandlers) {
155 onSelectionChange(handler: SelectionChangeHandler): void {
156 this.selectionChangeHandlers.add(handler);
159 offSelectionChange(handler: SelectionChangeHandler): void {
160 this.selectionChangeHandlers.delete(handler);
163 triggerLayoutUpdate(): void {
164 window.requestAnimationFrame(() => {
165 for (const toolbar of this.activeContextToolbars) {
166 toolbar.updatePosition();
171 getDefaultDirection(): 'rtl' | 'ltr' {
172 return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr';
175 onTeardown(callback: () => void): void {
176 this.teardownCallbacks.push(callback);
180 this.domEventAbortController.abort('teardown');
182 for (const [_, modal] of Object.entries(this.activeModalsByKey)) {
186 for (const [_, decorator] of Object.entries(this.decoratorInstancesByNodeKey)) {
187 decorator.teardown();
191 this.toolbar.teardown();
194 for (const toolbar of this.activeContextToolbars) {
198 this.dropdowns.teardown();
200 for (const callback of this.teardownCallbacks) {
205 protected updateContextToolbars(update: EditorUiStateUpdate): void {
206 for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
207 const toolbar = this.activeContextToolbars[i];
209 this.activeContextToolbars.splice(i, 1);
212 const node = (update.selection?.getNodes() || [])[0] || null;
217 const element = update.editor.getElementByKey(node.getKey());
222 const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey);
223 const contentByTarget = new Map<HTMLElement, EditorUiElement[]>();
224 for (const key of toolbarKeys) {
225 const definition = this.contextToolbarDefinitionsByKey[key];
226 const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null;
228 const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem;
229 if (!contentByTarget.has(targetEl)) {
230 contentByTarget.set(targetEl, [])
233 contentByTarget.get(targetEl).push(...definition.content());
237 for (const [target, contents] of contentByTarget) {
238 const toolbar = new EditorContextToolbar(target, contents);
239 toolbar.setContext(this.getContext());
240 this.activeContextToolbars.push(toolbar);
242 this.getContext().containerDOM.append(toolbar.getDOMElement());
243 toolbar.updatePosition();
247 protected setupEditor(editor: LexicalEditor) {
248 // Register our DOM decorate listener with the editor
249 const domDecorateListener: DecoratorListener<EditorDecoratorAdapter> = (decorators: Record<NodeKey, EditorDecoratorAdapter>) => {
250 editor.getEditorState().read(() => {
251 const keys = Object.keys(decorators);
252 for (const key of keys) {
253 const decoratedEl = editor.getElementByKey(key);
258 const adapter = decorators[key];
259 const decorator = this.getDecorator(adapter.type, key);
260 decorator.setNode(adapter.getNode());
261 const decoratorEl = decorator.render(this.getContext(), decoratedEl);
263 decoratedEl.append(decoratorEl);
268 editor.registerDecoratorListener(domDecorateListener);
270 // Watch for changes to update local state
271 editor.registerUpdateListener(({editorState, prevEditorState}) => {
272 // Watch for selection changes to update the UI on change
273 // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
274 // for all selection changes, so this proved more reliable.
275 const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
276 if (selectionChange) {
277 editor.update(() => {
278 const selection = $getSelection();
279 // console.log('manager::selection', selection);
280 this.triggerStateUpdate({
288 protected setupEventListeners() {
289 const layoutUpdate = this.triggerLayoutUpdate.bind(this);
290 window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true, signal: this.domEventAbortController.signal});
291 window.addEventListener('resize', layoutUpdate, {passive: true, signal: this.domEventAbortController.signal});