UNPKG

@ryusei/code

Version:

<div align="center"> <a href="https://code.ryuseijs.com"> <img alt="RyuseiCode" src="https://code.ryuseijs.com/images/svg/logo.svg" width="70"> </a>

252 lines (222 loc) 6.78 kB
import { Elements } from '@ryusei/code'; import { EVENT_BLUR, EVENT_FOCUS } from '../../constants/events'; import { ARROW_KEYS } from '../../constants/keys'; import { CHANGED, CLICKED_RIGHT, COLLAPSED, END, EXTEND, IDLE, SELECTED, SELECTING, START, UPDATE, } from '../../constants/selection-states'; import { Editor } from '../../core/Editor/Editor'; import { State as Base } from '../../event/State'; import { activeElement, getSelection, includes, isPrevented, normalizeKey, off, on } from '../../utils'; import { Selection } from './Selection'; /** * The class for observing the selection states. * * @since 0.1.0 */ export class State extends Base<number> { /** * Holds collection of elements. */ private readonly elements: Elements; /** * Holds the Editor instance. */ private readonly Editor: Editor; /** * Holds the Selection instance. */ private readonly Selection: Selection; /** * The WeakMap key for identifying event handlers(just uses a new empty object). */ private readonly key = {}; /** * Describes what device makes the selection change. */ device: 'pointer' | 'keyboard'; /** * The State constructor. * * @param Editor - An Editor instance. */ constructor( Editor: Editor ) { super( IDLE ); this.Editor = Editor; this.elements = Editor.elements; this.Selection = Editor.Components.Selection; this.listen(); } /** * Listens to some events. * Note that the `mouseup` event of `window` needs to be listened to instead of the editable element, * because users may release the mouse outside of it. */ private listen(): void { const { editable } = this.elements; const { event } = this.Editor; const { key } = this; const onKeydown = this.onKeydown.bind( this ); on( document, 'selectionchange', this.onSelectionChange.bind( this ), key ); on( window, 'pointerup', this.onSelectionEnd.bind( this ), key ); on( editable, 'pointerdown', this.onSelectionStart.bind( this ), key ); on( editable, 'keydown', onKeydown, key ); on( editable, 'keyup', this.onKeyup.bind( this ), key ); event.on( EVENT_FOCUS, this.onFocus.bind( this ) ); event.on( EVENT_BLUR, this.onBlur.bind( this ) ); } /** * Called when the editor is focused. */ private onFocus(): void { if ( this.is( IDLE ) ) { this.set( COLLAPSED ); } } /** * Called when the editor is blurred. * Needs to check the Components existence because this may be called after destruction. */ private onBlur(): void { if ( this.Editor.Components ) { if ( ! this.isFocused() ) { this.set( IDLE ); } } } /** * Called whenever the selection of the document is changed. * - Only handles the change made by the editable element. * - Detects the selection change that made by the start action, such as `pointerdown` and * makes the state go into the `CHANGED` state. * - If the selection changes after `CHANGED`, which means user selects texts and the range is not collapsed, * makes the state go into the `SELECTING` state. * - In FF, the event is sometimes fired after `pointerdown`. * - In iOS, the event is fired after `pointerup`. */ private onSelectionChange(): void { if ( activeElement() !== this.Editor.elements.editable ) { return; } if ( this.is( START, EXTEND ) ) { this.set( CHANGED ); } else if ( this.is( CHANGED ) ) { this.set( SELECTING ); } else if ( this.is( COLLAPSED, SELECTED ) ) { if ( getSelection().isCollapsed ) { this.set( CHANGED ); this.set( COLLAPSED ); } else { this.set( SELECTING ); this.set( SELECTED ); } } } /** * Called when the pointer becomes active or when arrow keys are pressed. * If a shift key is pressed, * that means the existing selection is being updated instead that a new one is created. * * @param e - An event object. */ private onSelectionStart( e: PointerEvent | KeyboardEvent ): void { if ( isPrevented( e ) ) { return; } this.device = e instanceof PointerEvent ? 'pointer' : 'keyboard'; const { Selection } = this; if ( e instanceof PointerEvent ) { if ( e.button === 2 && Selection.isInside( e.clientX, e.clientY ) ) { this.set( CLICKED_RIGHT ); return; } } this.set( e.shiftKey ? EXTEND : START ); } /** * Called when the `pointerup` or `keyup` event is triggered on the window object. * Note that the state goes into `SELECTED` when the previous state is `EXTEND` * even if the native selection is collapsed, * because an anchor node may disappear after scrolling. * The selection is correctly handled by the Selection class. */ private onSelectionEnd(): void { if ( this.device && ! this.is( IDLE ) ) { this.device = null; if ( ! this.is( CLICKED_RIGHT ) ) { if ( this.is( EXTEND ) ) { this.set( SELECTED ); } else { this.set( END ); this.set( getSelection().isCollapsed ? COLLAPSED : SELECTED ); } } } } /** * Called when any key is pressed. * * @param e - A KeyboardEvent object. */ private onKeydown( e: KeyboardEvent ): void { if ( includes( ARROW_KEYS, normalizeKey( e.key ) ) ) { this.onSelectionStart( e ); } } /** * Called when any key is released. * * @param e - A KeyboardEvent object. */ private onKeyup( e: KeyboardEvent ): void { if ( includes( ARROW_KEYS, normalizeKey( e.key ) ) ) { this.onSelectionEnd(); } } /** * Checks if the editor or the context menu has focus or not. * * @return `true` if they have focus or otherwise `false`. */ private isFocused(): boolean { return this.elements.editor.contains( activeElement() ) || this.Editor.Components.ContextMenu.isFocused(); } /** * Should be called when the custom selection is manually updated. * * @param collapsed - Indicates whether the new selection is collapsed or not. */ update( collapsed: boolean ): void { if ( ! this.is( START, EXTEND ) ) { this.set( UPDATE ); this.set( collapsed ? COLLAPSED : SELECTED ); } } /** * Attempts to refresh the selection state. * * @param collapsed - Indicates whether the new selection is collapsed or not. */ refresh( collapsed: boolean ): void { if ( ! this.is( START, EXTEND ) ) { this.set( START ); this.set( CHANGED ); this.set( collapsed ? COLLAPSED : SELECTED ); } } /** * Destroys the instance. */ destroy(): void { this.event.destroy(); off( null, '', this.key ); } }