@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>
723 lines (624 loc) • 20.8 kB
text/typescript
import { Elements, EventBusEvent, Position, Range, SelectionBoundary } from '@ryusei/code';
import { Component } from '../../classes/Component/Component';
import { CLASS_EMPTY, CLASS_LINE } from '../../constants/classes';
import {
EVENT_SCROLLED,
EVENT_SCROLLER_SCROLL,
EVENT_SELECTED,
EVENT_SELECTING,
EVENT_SELECTION_CHANGE,
EVENT_WINDOW_SCROLL,
} from '../../constants/events';
import {
CHANGED,
CLICKED_RIGHT,
COLLAPSED,
EXTEND,
SELECTED,
SELECTING,
START,
UPDATE,
} from '../../constants/selection-states';
import * as STATES from '../../constants/selection-states';
import {
activeElement,
attr,
closest,
compare,
createRange,
findSelectionBoundary,
format,
getSelection,
hasClass,
isBr,
isGecko,
isHTMLElement,
isIE,
isMobile,
isText,
nextTick,
prevent, rect,
setSelection,
slice,
} from '../../utils';
import { toggleEditable } from '../../utils/dom/toggleEditable/toggleEditable';
import { DELAY_FOR_RESELECTION, ORIGIN } from './constants';
import { State } from './State';
/**
* The class for handing both a native and custom selection.
*
* @since 0.1.0
*/
export class Selection extends Component {
/**
* The collection of selection states.
*
* | State | Description |
* |---|---|
* | `IDLE` | The editor is not active. |
* | `COLLAPSED` | The selection is collapsed. |
* | `START` | The selection will change soon. The native selection has not been updated at this timing. |
* | `CHANGED` | The selection has just changed after the `START` or `EXTEND` state. The native selection has been updated. |
* | `UPDATE` | The selection has been manually updated via `update()`. |
* | `SELECTING` | An user starts selecting texts. |
* | `EXTEND` | The existing selection will be extended soon. |
* | `END` | An user finishes selection. The native selection has not been updated at this timing (in Gecko). |
* | `SELECTED` | The selection which is not collapsed has been settled. |
* | `SELECTED_ALL` | All contents has been selected. |
* | `CLICKED_RIGHT` | The selection is right-clicked. |
*/
readonly STATES = STATES;
/**
* The State instance that manages the selection states.
*
* @readonly
*/
state: State;
/**
* The position where the selection starts.
*
* @readonly
*/
anchor: Position = ORIGIN;
/**
* The position where the selection ends.
*
* @readonly
*/
focus: Position = ORIGIN;
/**
* Keeps the latest scrollTop amount.
*/
private scrollTop: number;
/**
* Initializes the component.
*
* @internal
*
* @param elements - A collection of essential elements.
*/
mount( elements: Elements ): void {
super.mount( elements );
this.state = new State( this.Editor );
this.listen();
}
/**
* Listens to some events.
*/
private listen(): void {
const { editable } = this.elements;
this.bind( document, 'selectionchange', this.onSelectionChange, this );
if ( isIE() ) {
this.bind( editable, 'dblclick', this.onDblClick, this );
} else {
this.bind( editable, 'mousedown', this.onMouseDown, this );
}
this.state.on( 'changed', this.onStateChanged.bind( this ) );
this.on( [ EVENT_SCROLLER_SCROLL, EVENT_WINDOW_SCROLL ], this.onScroll, this );
this.on( EVENT_SCROLLED, this.ensureSelection, this );
}
/**
* Called whenever the selection is changed.
* Be aware that this is fired even when the editor is not focused.
*/
private onSelectionChange(): void {
if ( this.isFocused() ) {
if ( this.is( SELECTING, EXTEND ) ) {
const focus = this.getNativeSelection( true );
if ( focus ) {
this.focus = focus;
this.emit( EVENT_SELECTING );
}
}
this.emit( EVENT_SELECTION_CHANGE );
}
}
/**
* Called when the mouse button is pressed.
* Detects the double-click earlier than the `dblclick` to prevent the native smart selection.
*
* @param e - A MouseEvent object.
*/
private onMouseDown( e: MouseEvent ): void {
if ( e.detail > 1 ) {
this.onDblClick();
prevent( e );
}
}
/**
* Called when the code element is double-clicked.
* If a word is clicked, selects it. Otherwise, selects a clicked node.
*/
private onDblClick(): void {
const range = this.getWordRangeAt( this.anchor );
if ( range ) {
this.set( range.start, range.end );
} else {
const boundary = this.getNativeSelectionBoundary( false );
if ( boundary ) {
const { node } = boundary;
const selection = getSelection();
const range = createRange();
range.selectNode( node );
selection.removeAllRanges();
selection.addRange( range );
const anchor = this.getNativeSelection();
const focus = this.getNativeSelection( true );
if ( anchor && focus ) {
this.set( anchor, focus );
}
}
}
}
/**
* Called whenever the selection state is changed.
*
* - Updating positions at the `START` state is too early
* because the native selection has not been updated yet.
* - Jumps to the focus position just before extending the existing selection by a keyboard
* so that the native selection is able to be updated.
* - The `EVENT_SELECTING` event must be emitted after `EVENT_SELECTED` event
* for listeners to prepare something at the `SELECTING` state.
* - When the state goes into `SELECTED` state, the custom selection may be collapsed,
* e.g. single backward selection -> shift + arrow. To make sure the state becomes `COLLAPSED`,
* sets the native selection.
*
* @param e - An EventBusEvent object.
* @param state - A state number.
* @param prev - A previous state number.
*/
private onStateChanged( e: EventBusEvent, state: number, prev: number ): void {
if ( prev !== UPDATE && prev !== CLICKED_RIGHT ) {
if ( state === COLLAPSED || state === CHANGED || state === SELECTED ) {
this.anchor = this.getNativeSelection() || this.anchor;
this.focus = this.getNativeSelection( true ) || this.focus;
}
if ( prev !== START && state === SELECTED ) {
if ( this.detectSelectAll() ) {
const { lines, lines: { length } } = this;
const lastLineLength = lines[ length - 1 ].text.length;
if ( compare( this.anchor, [ 0, 0 ] ) !== 0
|| compare( this.focus, [ length - 1, lastLineLength ] ) !== 0 )
{
this.selectAll();
return;
}
}
}
}
this.emit( EVENT_SELECTED, this, state, prev );
if ( state === SELECTING ) {
this.emit( EVENT_SELECTING );
}
}
/**
* Called when the window or scroller scrolls.
*/
private onScroll(): void {
const { Input } = this;
const top = window.pageYOffset + this.elements.scroller.scrollTop;
if ( this.isMultiline() && ! Input.disabled && top !== this.scrollTop ) {
this.Input.disabled = true;
this.scrollTop = top;
}
}
/**
* Sets a new selection.
*
* @param anchor - An anchor position.
* @param focus - Optional. A focus position. If omitted, the selection will be collapsed to the anchor.
*/
set( anchor: Position, focus?: Position ): void {
this.setNativeSelection( anchor, focus ) || this.update( anchor, focus );
}
/**
* Returns positions of the current selection.
* If the `normalize` is `true`, the `start` will be always preceding position.
*
* @param normalize - Optional. Whether to normalize the position or not.
*
* @return An object literal with anchor and focus positions.
*/
get( normalize = true ): Range {
const { anchor, focus } = this;
const isBackward = this.isBackward();
return {
start: isBackward && normalize ? focus : anchor,
end : isBackward && normalize ? anchor : focus,
};
}
/**
* Updates the custom selection range without using the native selection.
*
* @param anchor - An anchor position.
* @param focus - Optional. A focus position.
* @param silently - Optional. Whether to change the state or not.
*/
update( anchor: Position, focus?: Position, silently?: boolean ): void {
this.anchor = anchor;
this.focus = focus || anchor;
if ( ! silently ) {
this.state.update( this.isCollapsed() );
}
}
/**
* Selects the current or specified line.
*
* @param row - Optional. A row index where to select.
* @param refresh - Optional. Determines whether to refresh the current selection or not.
* @param backwards - Optional. Determines whether to select a line backwards or not.
*/
selectLine( row = this.focus[ 0 ], refresh = true, backwards?: boolean ): void {
const { lines } = this;
const line = lines[ row ];
if ( line ) {
const start: Position = [ row, 0 ];
const end: Position = row < lines.length - 1 ? [ row + 1, 0 ] : [ row, line.text.length ];
const anchor = backwards ? end : start;
const focus = backwards ? start : end;
if ( refresh ) {
this.set( anchor, focus );
} else {
this.update( anchor, focus, true );
}
}
}
/**
* Selects again the current selection.
*/
reselect(): void {
this.set( this.anchor, this.focus );
}
/**
* Selects the whole code.
*/
selectAll(): void {
const { lines } = this;
const endRow = lines.length - 1;
this.set( [ 0, 0 ], [ endRow, lines[ endRow ].text.length ] );
}
/**
* Holds the current state so that it won't change.
*/
hold(): void {
this.state.hold();
}
/**
* Disables to hold the state so that it will change.
*/
release(): void {
this.state.release();
}
/**
* Converts the selection to a string.
* This returns an empty string when the selection is collapsed.
*
* @return A string representing the current selection.
*/
toString(): string {
const range = this.get();
return this.Code.sliceRange( range.start, range.end );
}
/**
* Returns the DOMRect object of the native selection boundary.
* Note that the boundary node is usually a Text node,
* but sometimes the line or the editable element.
*
* @param focus - Determines whether to get the DOMRect of the focus or anchor node.
*
* @return A DOMRect object if available, or otherwise `null`.
*/
getRect( focus: boolean ): DOMRect | null {
const boundary = this.getNativeSelectionBoundary( focus );
if ( boundary ) {
let { node, offset } = boundary;
while ( isHTMLElement( node ) ) {
node = node.firstChild;
offset = 0;
if ( isBr( node ) ) {
return rect( node );
}
}
if ( node ) {
const range = createRange();
range.setStart( node, offset );
range.collapse( true );
return rect( range );
}
}
return null;
}
/**
* Returns the current location as a string formatted by the i18n definition, such as `'Line: %s, Column: %s'`.
*
* @return A string that describes the current location.
*/
getLocation(): string {
const { focus } = this;
return format( this.i18n.location, focus[ 0 ] + 1, focus[ 1 ] + 1 );
}
/**
* Checks if the selection state is one of the provided states or not.
* This is just an alias of the `state.is()` method.
*
* @example
* ```ts
* // Checks if the state is COLLAPSED or not:
* Selection.is( Selection.STATES.COLLAPSED );
*
* // Checks if the state is START, EXTEND or not:
* Selection.is( Selection.STATES.START, Selection.STATES.EXTEND );
* ```
*
* @param states - A state or states to check.
*
* @return `true` if the current state is one of the provided states, or otherwise `false`.
*/
is( ...states: number[] ): boolean {
return this.state.is( ...states );
}
/**
* Collapses the selection to the anchor or focus position.
*
* @param toFocus - Optional. Collapses the selection to the focus position.
*/
collapse( toFocus?: boolean ): void {
this.set( toFocus ? this.focus : this.anchor );
}
/**
* Checks is the selection is backward or not.
*
* @return `true` if the selection is backward, or otherwise `false`.
*/
isBackward(): boolean {
return compare( this.anchor, this.focus ) > 0;
}
/**
* Checks if the selection is collapsed or not.
*
* @return `true` if the selection is collapsed, or otherwise `false`.
*/
isCollapsed(): boolean {
return compare( this.anchor, this.focus ) === 0;
}
/**
* Checks if more than one line is selected or not.
*
* @return `true` if more than one line is selected or otherwise `false`.
*/
isMultiline(): boolean {
return this.anchor[ 0 ] !== this.focus[ 0 ];
}
/**
* Checks if the provided client position is inside the current selection or not.
*
* @param clientX - The X position that is relative to the client.
* @param clientY - The Y position that is relative to the client.
*
* @return `true` if the position is inside the selection, or otherwise `false`.
*/
isInside( clientX: number, clientY: number ): boolean {
return this.Range.selection.isInside( clientX, clientY );
}
/**
* Destroys the instance.
*
* @internal
*/
destroy(): void {
this.state.destroy();
super.destroy();
}
/**
* Sets a native selection range.
* Be aware that calling `setSelection` emits `selectionchange` only in IE, but does not in others.
*
* @param start - A start position.
* @param end - Optional. An end position. If omitted, the start position is used alternatively.
*
* @return `true` if the selection is successfully changed, or otherwise `undefined`.
*/
private setNativeSelection( start: Position, end = start ): boolean {
const { Chunk } = this;
const isSingle = start[ 0 ] === end[ 0 ];
const startLine = Chunk.getLine( start[ 0 ] ) || Chunk.addPreservedLine( false, start[ 0 ] );
const endLine = isSingle ? startLine : Chunk.getLine( end[ 0 ] ) || Chunk.addPreservedLine( true, end[ 0 ] );
const collapsed = compare( start, end ) === 0;
const anchor = findSelectionBoundary( startLine, start[ 1 ] );
const focus = collapsed ? anchor : findSelectionBoundary( endLine, end[ 1 ] );
if ( anchor && focus ) {
const anchorNode = anchor.node;
const focusNode = focus.node;
anchor.node = isBr( anchorNode ) ? anchorNode.parentNode : anchorNode;
focus.node = isBr( focusNode ) ? focusNode.parentNode : focusNode;
this.hold();
setSelection( anchor, focus );
this.release();
this.state.refresh( collapsed );
}
return true;
}
/**
* Converts the native selection boundary to a position represented as [ row, col ].
* In FF, the selection
*
* @param focus - Optional. Whether to returns a position on the focus boundary or not.
*
* @return A converted position. If the position is not found, always returns [ 0, 0 ].
*/
private getNativeSelection( focus?: boolean ): Position | null {
const line = this.findActiveLine( focus );
const boundary = this.getNativeSelectionBoundary( focus );
if ( line && boundary ) {
const { Chunk } = this;
const range = createRange();
range.setStart( line, 0 );
range.setEnd( boundary.node, boundary.offset );
let row = Chunk.getRow( line );
if ( row < 0 ) {
const anchor = Chunk.getBoundary( false );
const focus = Chunk.getBoundary( true );
if ( anchor.line === line ) {
row = anchor.row;
} else if ( focus.line === line ) {
row = focus.row;
}
}
if ( row > -1 ) {
return [ row, range.toString().length ];
}
}
return null;
}
/**
* Finds a line where the native anchor node belongs.
* If the `focus` is set to `true`, finds a line where the native focus node belongs.
*
* @param focus - Determines whether to find a line that has focus node or not.
*
* @return A line where an anchor or a focus node belongs.
*/
private findActiveLine( focus?: boolean ): HTMLElement | null {
const boundary = this.getNativeSelectionBoundary( focus );
if ( boundary ) {
const { node } = boundary;
const elm = isText( node ) ? node.parentNode : node;
if ( isHTMLElement( elm ) ) {
return closest( elm, `.${ CLASS_LINE }` );
}
}
return null;
}
/**
* Converts the provided position to the range for wrapping the word at the position.
* If the text at the position is not a word, such as `/` or `-`, this returns `null`.
*
* @param row - A row index.
* @param col - A col index.
*
* @return An object that describes the range of the word at the position.
* If the text is not a word, returns `null`.
*/
private getWordRangeAt( [ row, col ]: Position ): Range {
const line = this.lines[ row ];
if ( line ) {
const string = line.text;
const words = string.split( /[^\w]/ );
let index = 0;
for ( let i = 0; i < words.length; i++ ) {
const from = i > 0 ? index + 1 : 0;
const to = from + words[ i ].length;
if ( from <= col && col < to ) {
return { start: [ row, from ], end: [ row, to ] };
}
index = to;
}
}
return null;
}
/**
* Returns a boundary node and offset of the native selection.
* Be aware that the target node must be in the chunk,
* or otherwise this method returns `null`.
* Besides, IE returns a parent node as a boundary node, and child index as a offset
* if the boundary is `<br>`(an empty line).
*
* @param focus - Whether to get the focus boundary or not.
*
* @return An object literal with a node and offset.
*/
private getNativeSelectionBoundary( focus: boolean ): SelectionBoundary {
const { editable } = this.elements;
const selection = getSelection();
const prefix = focus ? 'focus' : 'anchor';
let node = selection[ `${ prefix }Node` ];
let offset = selection[ `${ prefix }Offset` ];
if ( node === editable ) {
node = editable.children[ offset ];
offset = 0;
}
return node ? { node, offset } : null;
}
/**
* Detects selection of all contents in a immediate way, such as the `Select All` iOS context menu.
*
* @return `true` if all contents are selected, or otherwise `false`.
*/
private detectSelectAll(): boolean {
const { lines } = this.elements;
const anchorLine = this.findActiveLine( false );
const focusLine = this.findActiveLine( true );
const elms = slice( lines.children ).filter( elm => ! hasClass( elm, CLASS_EMPTY ) );
return anchorLine === elms[ 0 ]
&& focusLine === elms[ elms.length - 1 ]
&& compare( this.anchor, this.focus )
&& this.anchor[ 1 ] === 0
&& this.focus[ 1 ] === focusLine.textContent.length;
}
/**
* The dirty code to ensure the selection contains the latest nodes.
*/
private ensureSelection(): void {
const { Input } = this;
const { editable } = this.elements;
const selection = getSelection();
if ( ! isMobile() && this.isMultiline() && activeElement() === editable && selection.setBaseAndExtent ) {
const { editable } = this.elements;
const { anchorOffset, focusOffset } = selection;
let { anchorNode, focusNode } = selection;
attr( editable, { 'aria-hidden': true } );
this.hold();
selection.removeAllRanges();
if ( isGecko() ) {
const anchorClone = anchorNode.cloneNode( true );
const focusClone = focusNode.cloneNode( true );
anchorNode.parentNode.replaceChild( anchorClone, anchorNode );
focusNode.parentNode.replaceChild( focusClone, focusNode );
anchorNode = anchorClone;
focusNode = focusClone;
} else {
toggleEditable( editable, false );
}
setTimeout( () => {
selection.setBaseAndExtent( anchorNode, anchorOffset, focusNode, focusOffset );
nextTick( () => {
this.Editor.focus();
Input.disabled = false;
toggleEditable( editable, true );
attr( editable, { 'aria-hidden': null } );
this.release();
} );
}, DELAY_FOR_RESELECTION );
} else {
Input.disabled = false;
}
}
/**
* Checks if the editor is focused or not.
*
* @return `true` if the editor is focused, or otherwise `false`.
*/
private isFocused(): boolean {
return this.Editor && this.Editor.isFocused();
}
}