import {EditorFormModal, EditorFormModalDefinition} from "./modals";
-import {EditorUiContext} from "./core";
-import {EditorDecorator} from "./decorator";
+import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
+import {EditorDecorator, EditorDecoratorAdapter} from "./decorator";
+import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
+import {DecoratorListener} from "lexical/LexicalEditor";
+import type {NodeKey} from "lexical/LexicalNode";
+import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
+import {getLastSelection, setLastSelection} from "../../utils/selection";
+import {DropDownManager} from "./helpers/dropdowns";
+export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
export class EditorUIManager {
protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
+ protected activeModalsByKey: Record<string, EditorFormModal> = {};
protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
protected decoratorInstancesByNodeKey: Record<string, EditorDecorator> = {};
protected context: EditorUiContext|null = null;
+ protected toolbar: EditorContainerUiElement|null = null;
+ protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
+ protected activeContextToolbars: EditorContextToolbar[] = [];
+ protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
+
+ public dropdowns: DropDownManager = new DropDownManager();
setContext(context: EditorUiContext) {
this.context = context;
+ this.setupEventListeners(context);
+ this.setupEditor(context.editor);
}
getContext(): EditorUiContext {
return this.context;
}
+ triggerStateUpdateForElement(element: EditorUiElement) {
+ element.updateState({
+ selection: null,
+ editor: this.getContext().editor
+ });
+ }
+
registerModal(key: string, modalDefinition: EditorFormModalDefinition) {
this.modalDefinitionsByKey[key] = modalDefinition;
}
throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`);
}
- const modal = new EditorFormModal(modalDefinition);
+ const modal = new EditorFormModal(modalDefinition, key);
modal.setContext(this.getContext());
return modal;
}
+ setModalActive(key: string, modal: EditorFormModal): void {
+ this.activeModalsByKey[key] = modal;
+ }
+
+ setModalInactive(key: string): void {
+ delete this.activeModalsByKey[key];
+ }
+
+ getActiveModal(key: string): EditorFormModal|null {
+ return this.activeModalsByKey[key];
+ }
+
registerDecoratorType(type: string, decorator: typeof EditorDecorator) {
this.decoratorConstructorsByType[type] = decorator;
}
- getDecorator(decoratorType: string, nodeKey: string): EditorDecorator {
+ protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator {
if (this.decoratorInstancesByNodeKey[nodeKey]) {
return this.decoratorInstancesByNodeKey[nodeKey];
}
this.decoratorInstancesByNodeKey[nodeKey] = decorator;
return decorator;
}
+
+ getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null {
+ return this.decoratorInstancesByNodeKey[nodeKey] || null;
+ }
+
+ setToolbar(toolbar: EditorContainerUiElement) {
+ if (this.toolbar) {
+ this.toolbar.getDOMElement().remove();
+ }
+
+ this.toolbar = toolbar;
+ toolbar.setContext(this.getContext());
+ this.getContext().containerDOM.prepend(toolbar.getDOMElement());
+ }
+
+ registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) {
+ this.contextToolbarDefinitionsByKey[key] = definition;
+ }
+
+ triggerStateUpdate(update: EditorUiStateUpdate): void {
+ setLastSelection(update.editor, update.selection);
+ this.toolbar?.updateState(update);
+ this.updateContextToolbars(update);
+ for (const toolbar of this.activeContextToolbars) {
+ toolbar.updateState(update);
+ }
+ this.triggerSelectionChange(update.selection);
+ }
+
+ triggerStateRefresh(): void {
+ const editor = this.getContext().editor;
+ const update = {
+ editor,
+ selection: getLastSelection(editor),
+ };
+
+ this.triggerStateUpdate(update);
+ this.updateContextToolbars(update);
+ }
+
+ triggerFutureStateRefresh(): void {
+ requestAnimationFrame(() => {
+ this.getContext().editor.getEditorState().read(() => {
+ this.triggerStateRefresh();
+ });
+ });
+ }
+
+ protected triggerSelectionChange(selection: BaseSelection|null): void {
+ if (!selection) {
+ return;
+ }
+
+ for (const handler of this.selectionChangeHandlers) {
+ handler(selection);
+ }
+ }
+
+ onSelectionChange(handler: SelectionChangeHandler): void {
+ this.selectionChangeHandlers.add(handler);
+ }
+
+ offSelectionChange(handler: SelectionChangeHandler): void {
+ this.selectionChangeHandlers.delete(handler);
+ }
+
+ triggerLayoutUpdate(): void {
+ window.requestAnimationFrame(() => {
+ for (const toolbar of this.activeContextToolbars) {
+ toolbar.updatePosition();
+ }
+ });
+ }
+
+ getDefaultDirection(): 'rtl' | 'ltr' {
+ return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr';
+ }
+
+ protected updateContextToolbars(update: EditorUiStateUpdate): void {
+ for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
+ const toolbar = this.activeContextToolbars[i];
+ toolbar.destroy();
+ this.activeContextToolbars.splice(i, 1);
+ }
+
+ const node = (update.selection?.getNodes() || [])[0] || null;
+ if (!node) {
+ return;
+ }
+
+ const element = update.editor.getElementByKey(node.getKey());
+ if (!element) {
+ return;
+ }
+
+ const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey);
+ const contentByTarget = new Map<HTMLElement, EditorUiElement[]>();
+ for (const key of toolbarKeys) {
+ const definition = this.contextToolbarDefinitionsByKey[key];
+ const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null;
+ if (matchingElem) {
+ const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem;
+ if (!contentByTarget.has(targetEl)) {
+ contentByTarget.set(targetEl, [])
+ }
+ // @ts-ignore
+ contentByTarget.get(targetEl).push(...definition.content);
+ }
+ }
+
+ for (const [target, contents] of contentByTarget) {
+ const toolbar = new EditorContextToolbar(target, contents);
+ toolbar.setContext(this.getContext());
+ this.activeContextToolbars.push(toolbar);
+
+ this.getContext().containerDOM.append(toolbar.getDOMElement());
+ toolbar.updatePosition();
+ }
+ }
+
+ protected setupEditor(editor: LexicalEditor) {
+ // Register our DOM decorate listener with the editor
+ const domDecorateListener: DecoratorListener<EditorDecoratorAdapter> = (decorators: Record<NodeKey, EditorDecoratorAdapter>) => {
+ editor.getEditorState().read(() => {
+ const keys = Object.keys(decorators);
+ for (const key of keys) {
+ const decoratedEl = editor.getElementByKey(key);
+ if (!decoratedEl) {
+ continue;
+ }
+
+ const adapter = decorators[key];
+ const decorator = this.getDecorator(adapter.type, key);
+ decorator.setNode(adapter.getNode());
+ const decoratorEl = decorator.render(this.getContext(), decoratedEl);
+ if (decoratorEl) {
+ decoratedEl.append(decoratorEl);
+ }
+ }
+ });
+ }
+ editor.registerDecoratorListener(domDecorateListener);
+
+ // Watch for changes to update local state
+ editor.registerUpdateListener(({editorState, prevEditorState}) => {
+ // Watch for selection changes to update the UI on change
+ // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
+ // for all selection changes, so this proved more reliable.
+ const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
+ if (selectionChange) {
+ editor.update(() => {
+ const selection = $getSelection();
+ // console.log('manager::selection', selection);
+ this.triggerStateUpdate({
+ editor, selection,
+ });
+ });
+ }
+ });
+ }
+
+ protected setupEventListeners(context: EditorUiContext) {
+ const layoutUpdate = this.triggerLayoutUpdate.bind(this);
+ window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true});
+ window.addEventListener('resize', layoutUpdate, {passive: true});
+ }
}
\ No newline at end of file