import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { DisposableGroup } from '@blocksuite/global/utils'; import { LifeCycleWatcher } from '../extension/index.js'; import { KeymapIdentifier } from '../identifier.js'; import { type BlockComponent, EditorHost } from '../view/index.js'; import { type UIEventHandler, UIEventState, UIEventStateContext, } from './base.js'; import { ClipboardControl } from './control/clipboard.js'; import { KeyboardControl } from './control/keyboard.js'; import { PointerControl } from './control/pointer.js'; import { RangeControl } from './control/range.js'; import { EventScopeSourceType, EventSourceState } from './state/source.js'; import { toLowerCase } from './utils.js'; const bypassEventNames = [ 'beforeInput', 'blur', 'focus', 'contextMenu', 'wheel', ] as const; const eventNames = [ 'click', 'doubleClick', 'tripleClick', 'pointerDown', 'pointerMove', 'pointerUp', 'pointerOut', 'dragStart', 'dragMove', 'dragEnd', 'pinch', 'pan', 'keyDown', 'keyUp', 'selectionChange', 'compositionStart', 'compositionUpdate', 'compositionEnd', 'cut', 'copy', 'paste', 'nativeDragStart', 'nativeDragMove', 'nativeDragEnd', 'nativeDrop', ...bypassEventNames, ] as const; export type EventName = (typeof eventNames)[number]; export type EventOptions = { flavour?: string; blockId?: string; }; export type EventHandlerRunner = { fn: UIEventHandler; flavour?: string; blockId?: string; }; export class UIEventDispatcher extends LifeCycleWatcher { private static _activeDispatcher: UIEventDispatcher | null = null; static override readonly key = 'UIEventDispatcher'; private _active = false; private readonly _clipboardControl: ClipboardControl; private _handlersMap = Object.fromEntries( eventNames.map((name): [EventName, Array] => [name, []]) ) as Record>; private readonly _keyboardControl: KeyboardControl; private readonly _pointerControl: PointerControl; private readonly _rangeControl: RangeControl; bindHotkey = (...args: Parameters) => this._keyboardControl.bindHotkey(...args); disposables = new DisposableGroup(); private get _currentSelections() { return this.std.selection.value; } get active() { return this._active; } get host() { return this.std.host; } constructor(std: BlockSuite.Std) { super(std); this._pointerControl = new PointerControl(this); this._keyboardControl = new KeyboardControl(this); this._rangeControl = new RangeControl(this); this._clipboardControl = new ClipboardControl(this); this.disposables.add(this._pointerControl); } private _bindEvents() { bypassEventNames.forEach(eventName => { this.disposables.addFromEvent( this.host, toLowerCase(eventName), event => { this.run( eventName, UIEventStateContext.from( new UIEventState(event), new EventSourceState({ event, sourceType: EventScopeSourceType.Selection, }) ) ); }, eventName === 'wheel' ? { passive: false, } : undefined ); }); this._pointerControl.listen(); this._keyboardControl.listen(); this._rangeControl.listen(); this._clipboardControl.listen(); let _dragging = false; this.disposables.addFromEvent(this.host, 'pointerdown', () => { _dragging = true; this._setActive(true); }); this.disposables.addFromEvent(this.host, 'pointerup', () => { _dragging = false; }); this.disposables.addFromEvent(this.host, 'click', () => { this._setActive(true); }); this.disposables.addFromEvent(this.host, 'focusin', () => { this._setActive(true); }); this.disposables.addFromEvent(this.host, 'focusout', e => { if (e.relatedTarget && !this.host.contains(e.relatedTarget as Node)) { this._setActive(false); } }); this.disposables.addFromEvent(this.host, 'blur', () => { if (_dragging) { return; } this._setActive(false); }); this.disposables.addFromEvent(this.host, 'dragover', () => { this._setActive(true); }); this.disposables.addFromEvent(this.host, 'dragend', () => { this._setActive(false); }); this.disposables.addFromEvent(this.host, 'drop', () => { this._setActive(true); }); this.disposables.addFromEvent(this.host, 'pointerenter', () => { if (this._isActiveElementOutsideHost()) { return; } this._setActive(true); }); this.disposables.addFromEvent(this.host, 'pointerleave', () => { if ( (document.activeElement && this.host.contains(document.activeElement)) || _dragging ) { return; } this._setActive(false); }); } private _buildEventScopeBySelection(name: EventName) { const handlers = this._handlersMap[name]; if (!handlers) return; const selections = this._currentSelections; const ids = selections.map(selection => selection.blockId); return this.buildEventScope(name, ids); } private _buildEventScopeByTarget(name: EventName, target: Node) { const handlers = this._handlersMap[name]; if (!handlers) return; // TODO(mirone/#6534): find a better way to get block element from a node const el = target instanceof Element ? target : target.parentElement; const block = el?.closest('[data-block-id]'); const blockId = block?.blockId; if (!blockId) { return this._buildEventScopeBySelection(name); } return this.buildEventScope(name, [blockId]); } private _getDeepActiveElement(): Element | null { let active = document.activeElement; while (active && active.shadowRoot && active.shadowRoot.activeElement) { active = active.shadowRoot.activeElement; } return active; } private _getEventScope(name: EventName, state: EventSourceState) { const handlers = this._handlersMap[name]; if (!handlers) return; let output: EventHandlerRunner[] | undefined; switch (state.sourceType) { case EventScopeSourceType.Selection: { output = this._buildEventScopeBySelection(name); break; } case EventScopeSourceType.Target: { output = this._buildEventScopeByTarget( name, state.event.target as Node ); break; } default: { throw new BlockSuiteError( ErrorCode.EventDispatcherError, `Unknown event scope source: ${state.sourceType}` ); } } return output; } private _isActiveElementOutsideHost(): boolean { const activeElement = this._getDeepActiveElement(); return ( activeElement !== null && this._isEditableElementActive(activeElement) && !this.host.contains(activeElement) ); } private _isEditableElementActive(element: Element | null): boolean { if (!element) return false; return ( element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || (element instanceof EditorHost && !element.doc.readonly) || (element as HTMLElement).isContentEditable ); } private _setActive(active: boolean) { if (active) { if (UIEventDispatcher._activeDispatcher !== this) { if (UIEventDispatcher._activeDispatcher) { UIEventDispatcher._activeDispatcher._active = false; } UIEventDispatcher._activeDispatcher = this; } this._active = true; } else { if (UIEventDispatcher._activeDispatcher === this) { UIEventDispatcher._activeDispatcher = null; } this._active = false; } } add(name: EventName, handler: UIEventHandler, options?: EventOptions) { const runner: EventHandlerRunner = { fn: handler, flavour: options?.flavour, blockId: options?.blockId, }; this._handlersMap[name].unshift(runner); return () => { if (this._handlersMap[name].includes(runner)) { this._handlersMap[name] = this._handlersMap[name].filter( x => x !== runner ); } }; } buildEventScope( name: EventName, blocks: string[] ): EventHandlerRunner[] | undefined { const handlers = this._handlersMap[name]; if (!handlers) return; const globalEvents = handlers.filter( handler => handler.flavour === undefined && handler.blockId === undefined ); let blockIds: string[] = blocks; const events: EventHandlerRunner[] = []; const flavourSeen: Record = {}; while (blockIds.length > 0) { const idHandlers = handlers.filter( handler => handler.blockId && blockIds.includes(handler.blockId) ); const flavourHandlers = blockIds .map(blockId => this.std.doc.getBlock(blockId)?.flavour) .filter((flavour): flavour is string => { if (!flavour) return false; if (flavourSeen[flavour]) return false; flavourSeen[flavour] = true; return true; }) .flatMap(flavour => { return handlers.filter(handler => handler.flavour === flavour); }); events.push(...idHandlers, ...flavourHandlers); blockIds = blockIds .map(blockId => { const parent = this.std.doc.getParent(blockId); return parent?.id; }) .filter((id): id is string => !!id); } return events.concat(globalEvents); } override mounted() { if (this.disposables.disposed) { this.disposables = new DisposableGroup(); } this._bindEvents(); const std = this.std; this.std.provider .getAll(KeymapIdentifier) .forEach(({ getter, options }) => { this.bindHotkey(getter(std), options); }); } run( name: EventName, context: UIEventStateContext, runners?: EventHandlerRunner[] ) { if (!this.active) return; const sourceState = context.get('sourceState'); if (!runners) { runners = this._getEventScope(name, sourceState); if (!runners) { return; } } for (const runner of runners) { const { fn } = runner; const result = fn(context); if (result) { context.get('defaultState').event.stopPropagation(); return; } } } override unmounted() { this.disposables.dispose(); } }