@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>
521 lines (452 loc) • 13 kB
text/typescript
import { Elements, InputState, Position, TokenInfo } from '@ryusei/code';
import { Component } from '../../classes/Component/Component';
import { LINE_BREAK } from '../../constants/characters';
import {
EVENT_CHANGE,
EVENT_CHANGED,
EVENT_COMPOSITION_END,
EVENT_COMPOSITION_START,
EVENT_COMPOSITION_UPDATE,
EVENT_FOCUS_LINE_CHANGED,
EVENT_INPUT,
EVENT_KEYDOWN, EVENT_MOUNTED,
EVENT_NEWLINE,
} from '../../constants/events';
import { ARROW_KEYS } from '../../constants/keys';
import {
assign,
getSelection,
isIE,
isPrevented,
isUndefined,
nextTick,
normalizeKey,
prevent,
text,
} from '../../utils';
/**
* The input type for composition.
*
* @since 0.1.0
*/
const COMPOSITION_INPUT_TYPE = 'composition';
/**
* The class for handling the user input.
*
* @since 0.1.0
*/
export class Input extends Component {
/**
* The `TokenInfo` object saved when any key is pressed.
*
* @readonly
*/
info: TokenInfo | null;
/**
* Indicates whether the input is in composition session or not.
*
* @readonly
*/
composing: boolean;
/**
* Keeps the latest focus line.
*
* @readonly
*/
line: Element;
/**
* Keeps the latest focus row index.
*
* @readonly
*/
row: number;
/**
* Holds the current state.
*/
private state: InputState;
/**
* Indicates whether the input is currently disabled or not.
*/
private _disabled: boolean;
/**
* Initialized the component.
*
* @internal
*
* @param elements - A collection of essential editor elements.
*/
mount( elements: Elements ): void {
super.mount( elements );
this.listen();
}
/**
* Listen to some events.
*/
private listen(): void {
const { editable } = this.elements;
this.bind( editable, 'keydown', this.onKeydown, this );
this.bind( editable, isIE() ? 'textinput' : 'input', this.onInput, this );
this.bind( editable, 'compositionstart', this.onCompositionStart, this );
this.bind( editable, 'compositionupdate', this.onCompositionUpdate, this );
this.bind( editable, 'compositionend', this.onCompositionEnd, this );
this.on( EVENT_MOUNTED, () => {
this.line = this.Chunk.elms[ 0 ];
this.row = 0;
} );
this.on( EVENT_FOCUS_LINE_CHANGED, ( e, line, row ) => {
this.line = line;
this.row = row;
} );
}
/**
* Called when the composition starts.
* Needs to emit the `change` event at this timing to save the start position.
* Note that some browsers do not support a CompositionEvent object.
*
* @param e - A CompositionEvent object or a regular Event object.
*/
private onCompositionStart( e: CompositionEvent ): void {
if ( this.disabled ) {
const { Editor, Selection } = this;
const range = Selection.get( false );
getSelection().removeAllRanges();
Editor.blur();
nextTick( () => {
Editor.focus();
Selection.set( range.start, range.end );
} );
return;
}
this.composing = true;
this.set( COMPOSITION_INPUT_TYPE );
this.emit( EVENT_COMPOSITION_START, e );
this.emit( EVENT_CHANGE, COMPOSITION_INPUT_TYPE );
}
/**
* Called whenever the composing content is updated.
*
* @param e - A CompositionEvent object or a regular Event object.
*/
private onCompositionUpdate( e: CompositionEvent ): void {
nextTick( () => {
this.Selection.update( this.getCaretPosition() );
this.emit( EVENT_COMPOSITION_UPDATE, e );
} );
}
/**
* Called when the composition ends.
*
* @param e - A CompositionEvent object or a regular Event object.
*/
private onCompositionEnd( e: CompositionEvent ): void {
this.composing = false;
this.apply();
this.emit( EVENT_COMPOSITION_END, e );
}
/**
* Called whenever any key is pressed.
*
* @param e - A KeyboardEvent object.
*/
private onKeydown( e: KeyboardEvent ): void {
const key = normalizeKey( e.key );
const { altKey, shiftKey } = e;
if ( this.disabled && ! ARROW_KEYS.includes( key ) ) {
return prevent( e, true );
}
this.emit( EVENT_KEYDOWN, e );
if ( isPrevented( e ) ) {
return;
}
this.info = this.lines.getInfoAt( this.getCaretPosition() );
this.set( 'input', { key } );
if ( key === 'Enter' ) {
this.handleEnter( e );
return;
}
if ( this.Selection.isCollapsed() ) {
if ( key === 'Delete' && ! shiftKey && ! altKey ) {
this.handleDelete( e );
} else if ( key === 'Backspace' && ! altKey ) {
this.handleBackspace( e );
}
}
}
/**
* Called whenever any input is received.
* Need to wait for the `compositionend` before calling `apply()`.
*/
private onInput( e: InputEvent ): void {
this.emit( EVENT_INPUT, this, e );
if ( ! this.composing ) {
this.apply();
}
}
/**
* Handles the Enter key.
*
* @param e - A KeyboardEvent object.
*/
private handleEnter( e: KeyboardEvent ): void {
if ( ! this.composing ) {
this.set( 'newline', {
key : 'Enter',
value : this.before + LINE_BREAK + this.after,
position: [ this.row + 1, 0 ],
} );
this.emit( EVENT_NEWLINE, this );
this.apply();
prevent( e );
}
}
/**
* Handles the delete key.
*
* @param e - A KeyboardEvent object.
*/
private handleDelete( e: KeyboardEvent ): void {
const { row, lines } = this;
if ( this.col === this.value.length && row < lines.length - 1 ) {
this.apply( {
type : 'deleteNext',
key : 'Delete',
value : this.value + lines[ row + 1 ].text,
startRow: row,
endRow : row + 1,
} );
prevent( e );
}
}
/**
* Handles the backspace key.
*/
private handleBackspace( e: KeyboardEvent ): void {
const { row, col } = this;
if ( col === 0 ) {
if ( row > 0 ) {
const prev = this.lines[ row - 1 ].text;
this.apply( {
type : 'removePrev',
key : 'Backspace',
value : prev + this.value,
startRow: row - 1,
position: [ row - 1, prev.length ],
} );
}
prevent( e );
}
}
/**
* Appends a line break if the provided row is not the end of the document.
*
* @param value - A value where the line break will be appended.
* @param row - Optional. A row index.
*
* @return The value with the line break, or the provided value itself.
*/
private appendLineBreak( value: string, row = this.row ): string {
return value + ( row < this.lines.length - 1 ? LINE_BREAK : '' );
}
/**
* Settles the final value to apply.
*
* @param value - A value to settle.
* @param endRow - An end row index.
*/
private settleValue( value: string, endRow: number ): string {
const { state } = this;
if ( state ) {
if ( state.insertion ) {
value = this.before + state.insertion + this.after;
} else if ( ! isUndefined( state.value ) ) {
value = state.value;
}
}
return this.appendLineBreak( value, endRow );
}
/**
* Settles the final position to apply.
*
* @param position - A position to settle.
*/
private settlePosition( position: Position ): Position {
const { state } = this;
if ( state ) {
if ( state.position ) {
return state.position;
}
position[ 1 ] += state.offset || 0;
}
return position;
}
/**
* Returns the current caret position.
*
* @return A position of the caret.
*/
private getCaretPosition(): Position {
return [ this.row, this.col ];
}
/**
* Sets the input state.
* If the state with the provided type exists, new props will be assigned to it.
* The props object accepts following values:
*
* | State | Description |
* |---|---|
* | `key?` | The key that makes the input. |
* | `startRow?` | The start row index to replace lines with the current value from. |
* | `endRow?` | The end row index to replace lines with the current value to. |
* | `value?` | The value to replace lines with. If omitted, the current value will be used. |
* | `insertion?` | Specifies the value to insert at the caret position instead of setting the value. |
* | `offset?` | The number of offset cols after the state is applied. |
* | `position?` | Explicitly specifies the position after the state is applied. The `offset` will be ignored. |
*
* @param type - The type of the state.
* @param props - Optional. An object with state values.
*/
set( type: string, props: Omit<InputState, 'type'> = {} ): void {
const { state } = this;
if ( state && state.type === type ) {
this.state = assign( state, props );
} else {
this.state = assign( { type }, props );
}
}
/**
* Returns the current state object if available.
*
* @return The current state object if available, or `null` if not.
*/
get(): InputState | null {
return this.state;
}
/**
* Applies the state to the editor and clears it.
*
* @example
* ```ts
* const ryuseiCode = new RyuseiCode();
* ryuseiCode.apply( 'textarea' );
*
* ryuseiCode.on( 'focus', () => {
* const { Input } = ryuseiCode.Editor.Components;
*
* setTimeout( () => {
* Input.apply( {
* insertion: 'foo',
* offset: 3,
* } );
* }, 1000 );
* } );
* ```
*
* @param state - Optional. A new state to apply.
*/
apply( state?: InputState ): void {
this.state = state || this.state;
if ( ! this.state || ! this.line ) {
return;
}
const { Selection, row } = this;
const { type, startRow = row, endRow = row } = this.state;
const position = this.getCaretPosition();
if ( type !== COMPOSITION_INPUT_TYPE ) {
this.emit( EVENT_CHANGE, type );
}
this.Code.replaceLines( startRow, endRow, this.settleValue( this.value, endRow ) );
this.Sync.sync( startRow, endRow, endRow );
Selection.set( this.settlePosition( position ) );
this.emit( EVENT_CHANGED, type );
this.state = null;
this.info = null;
}
/**
* Returns a character at the current caret position or specified col index.
*
* @param col - Optional. A col index of the desired character.
*
* @return A character at the specified position.
*/
char( col = this.col ): string {
return this.value.charAt( col );
}
/**
* Returns the value of the current line without the tailing line break.
*
* @return A text of the current line.
*/
get value(): string {
return text( this.line ) || '';
}
/**
* Sets a new value to the current line.
* In most cases, it's better to use `apply()` to edit the line instead
* because this does not syncs the change to the editor.
*
* @param value - A new value to set.
*/
set value( value: string ) {
text( this.line, value );
}
/**
* Returns the string of the current line before the caret position.
*
* @return The string before the caret.
*/
get before(): string {
return this.value.slice( 0, this.col );
}
/**
* Returns the string of the current line after the caret position.
*
* @return The string after the caret.
*/
get after(): string {
return this.value.slice( this.col );
}
/**
* Returns the length of the current line.
*
* @return The length of the current line.
*/
get length(): number {
return this.value.length;
}
/**
* Returns the current col index.
*
* @return The col index of the caret.
*/
get col(): number {
const { line } = this;
const selection = getSelection();
if ( line && selection.rangeCount > 0 ) {
const range = selection.getRangeAt( 0 ).cloneRange();
range.setStart( line, 0 );
return range.toString().length;
}
return 0;
}
/**
* Returns `true` if the input is disabled.
*
* @internal
*
* @return `true` if the input is disabled.
*/
get disabled(): boolean {
return this._disabled;
}
/**
* Makes the input disabled.
* All keys are ignored while it is disabled.
*
* @internal
*
* @param disabled - Determines whether to disable or enable the input.
*/
set disabled( disabled: boolean ) {
this._disabled = disabled;
}
}