import { assertExists } from '@blocksuite/global/utils'; import { ZERO_WIDTH_SPACE } from '../consts.js'; import type { NativePoint } from '../types.js'; import { type BaseTextAttributes, findDocumentOrShadowRoot, isInEmbedElement, } from '../utils/index.js'; import { transformInput } from '../utils/transform-input.js'; import { isMaybeVRangeEqual } from '../utils/v-range.js'; import type { VEditor } from '../virgo.js'; import type { VBeforeinputHookCtx, VCompositionEndHookCtx } from './hook.js'; export class VirgoEventService { private _isComposing = false; private _previousAnchor: NativePoint | null = null; private _previousFocus: NativePoint | null = null; constructor(public readonly editor: VEditor) {} get vRangeProvider() { return this.editor.vRangeProvider; } mount = () => { const rootElement = this.editor.rootElement; if (!this.vRangeProvider) { this.editor.disposables.addFromEvent( document, 'selectionchange', this._onSelectionChange ); } this.editor.disposables.addFromEvent( rootElement, 'beforeinput', this._onBeforeInput ); this.editor.disposables.addFromEvent( rootElement, 'compositionstart', this._onCompositionStart ); this.editor.disposables.addFromEvent( rootElement, 'compositionend', this._onCompositionEnd ); this.editor.disposables.addFromEvent( rootElement, 'keydown', this._onKeyDown ); this.editor.disposables.addFromEvent(rootElement, 'click', this._onClick); }; private _isRangeCompletelyInRoot = () => { const selectionRoot = findDocumentOrShadowRoot(this.editor); const selection = selectionRoot.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); const rootElement = this.editor.rootElement; const rootRange = document.createRange(); rootRange.selectNode(rootElement); if ( range.startContainer.compareDocumentPosition(range.endContainer) & Node.DOCUMENT_POSITION_FOLLOWING ) { return ( rootRange.comparePoint(range.startContainer, range.startOffset) >= 0 && rootRange.comparePoint(range.endContainer, range.endOffset) <= 0 ); } else { return ( rootRange.comparePoint(range.endContainer, range.startOffset) >= 0 && rootRange.comparePoint(range.startContainer, range.endOffset) <= 0 ); } }; private _onSelectionChange = () => { const rootElement = this.editor.rootElement; const previousVRange = this.editor.getVRange(); if (this._isComposing) { return; } const selectionRoot = findDocumentOrShadowRoot(this.editor); const selection = selectionRoot.getSelection(); if (!selection) return; if (selection.rangeCount === 0) { if (previousVRange !== null) { this.editor.setVRange(null, false); } return; } const range = selection.getRangeAt(0); if ( range.startContainer === range.endContainer && range.startContainer.textContent === ZERO_WIDTH_SPACE && range.startOffset === 1 ) { range.setStart(range.startContainer, 0); range.setEnd(range.endContainer, 0); selection.removeAllRanges(); selection.addRange(range); return; } if (!range.intersectsNode(rootElement)) { const isContainerSelected = range.endContainer.contains(rootElement) && Array.from(range.endContainer.childNodes).filter( node => node instanceof HTMLElement ).length === 1 && range.startContainer.contains(rootElement) && Array.from(range.startContainer.childNodes).filter( node => node instanceof HTMLElement ).length === 1; if (isContainerSelected) { this.editor.focusEnd(); return; } else { if (previousVRange !== null) { this.editor.setVRange(null, false); } return; } } this._previousAnchor = [range.startContainer, range.startOffset]; this._previousFocus = [range.endContainer, range.endOffset]; const vRange = this.editor.toVRange(selection.getRangeAt(0)); if (!isMaybeVRangeEqual(previousVRange, vRange)) { this.editor.setVRange(vRange, false); } // avoid infinite syncVRange if ( ((range.startContainer.nodeType !== Node.TEXT_NODE || range.endContainer.nodeType !== Node.TEXT_NODE) && range.startContainer !== this._previousAnchor[0] && range.endContainer !== this._previousFocus[0] && range.startOffset !== this._previousAnchor[1] && range.endOffset !== this._previousFocus[1]) || range.startContainer.nodeType === Node.COMMENT_NODE || range.endContainer.nodeType === Node.COMMENT_NODE ) { this.editor.syncVRange(); } }; private _onCompositionStart = () => { this._isComposing = true; // embeds is not editable and it will break IME const embeds = this.editor.rootElement.querySelectorAll( '[data-virgo-embed="true"]' ); embeds.forEach(embed => { embed.removeAttribute('contenteditable'); }); }; private _onCompositionEnd = async (event: CompositionEvent) => { this._isComposing = false; this.editor.rerenderWholeEditor(); await this.editor.waitForUpdate(); if (this.editor.isReadonly || !this._isRangeCompletelyInRoot()) return; const vRange = this.editor.getVRange(); if (!vRange) return; let ctx: VCompositionEndHookCtx | null = { vEditor: this.editor, raw: event, vRange, data: event.data, attributes: {} as TextAttributes, }; const hook = this.editor.hooks.compositionEnd; if (hook) { ctx = hook(ctx); } if (!ctx) return; const { vRange: newVRange, data: newData } = ctx; if (newVRange.index >= 0) { const selection = window.getSelection(); if (selection && selection.rangeCount !== 0) { const range = selection.getRangeAt(0); const container = range.startContainer; // https://github.com/w3c/input-events/issues/137 // IME will directly modify the DOM and is difficult to hijack and cancel. // We need to delete this part of the content and restore the selection. if (container instanceof Text) { if (container.parentElement?.dataset.virgoText !== 'true') { container.remove(); } else { const [text] = this.editor.getTextPoint(newVRange.index); const vText = text.parentElement?.closest('v-text'); if (vText) { if (vText.str !== text.textContent) { text.textContent = vText.str; } } else { const forgedVText = text.parentElement?.closest( '[data-virgo-text="true"]' ); if (forgedVText instanceof HTMLElement) { if (forgedVText.dataset.virgoTextValue) { if (forgedVText.dataset.virgoTextValue !== text.textContent) { text.textContent = forgedVText.dataset.virgoTextValue; } } else { throw new Error( 'We detect a forged v-text node but it has no data-virgo-text-value attribute.' ); } } } } const newRange = this.editor.toDomRange(newVRange); if (newRange) { assertExists(newRange); selection.removeAllRanges(); selection.addRange(newRange); } } } if (newData && newData.length > 0) { this.editor.insertText(newVRange, newData, ctx.attributes); this.editor.setVRange( { index: newVRange.index + newData.length, length: 0, }, false ); } } }; private _onBeforeInput = (event: InputEvent) => { event.preventDefault(); if ( this.editor.isReadonly || this._isComposing || !this._isRangeCompletelyInRoot() ) return; if (!this.editor.getVRange()) return; const targetRanges = event.getTargetRanges(); if (targetRanges.length > 0) { const staticRange = targetRanges[0]; const range = document.createRange(); range.setStart(staticRange.startContainer, staticRange.startOffset); range.setEnd(staticRange.endContainer, staticRange.endOffset); const vRange = this.editor.toVRange(range); if (!isMaybeVRangeEqual(this.editor.getVRange(), vRange)) { this.editor.setVRange(vRange, false); } } const vRange = this.editor.getVRange(); if (!vRange) return; let ctx: VBeforeinputHookCtx | null = { vEditor: this.editor, raw: event, vRange, data: event.data, attributes: {} as TextAttributes, }; const hook = this.editor.hooks.beforeinput; if (hook) { ctx = hook(ctx); } if (!ctx) return; const { raw: newEvent, data, vRange: newVRange } = ctx; transformInput( newEvent.inputType, data, ctx.attributes, newVRange, this.editor as VEditor ); }; private _onKeyDown = (event: KeyboardEvent) => { if ( !event.shiftKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight') ) { const vRange = this.editor.getVRange(); if (!vRange || vRange.length !== 0) return; const prevent = () => { event.preventDefault(); event.stopPropagation(); }; const deltas = this.editor.getDeltasByVRange(vRange); if (deltas.length === 2) { if (event.key === 'ArrowLeft' && this.editor.isEmbed(deltas[0][0])) { prevent(); this.editor.setVRange({ index: vRange.index - 1, length: 1, }); } else if ( event.key === 'ArrowRight' && this.editor.isEmbed(deltas[1][0]) ) { prevent(); this.editor.setVRange({ index: vRange.index, length: 1, }); } } else if (deltas.length === 1) { const delta = deltas[0][0]; if (this.editor.isEmbed(delta)) { if (event.key === 'ArrowLeft' && vRange.index - 1 >= 0) { prevent(); this.editor.setVRange({ index: vRange.index - 1, length: 1, }); } else if ( event.key === 'ArrowRight' && vRange.index + 1 <= this.editor.yTextLength ) { prevent(); this.editor.setVRange({ index: vRange.index, length: 1, }); } } } } }; private _onClick = (event: MouseEvent) => { // select embed element when click on it if (event.target instanceof Node && isInEmbedElement(event.target)) { const selectionRoot = findDocumentOrShadowRoot(this.editor); const selection = selectionRoot.getSelection(); if (!selection) return; if (event.target instanceof HTMLElement) { const vElement = event.target.closest('v-element'); if (vElement) { selection.selectAllChildren(vElement); } } else { const vElement = event.target.parentElement?.closest('v-element'); if (vElement) { selection.selectAllChildren(vElement); } } } }; }