@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>
501 lines (430 loc) • 13 kB
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Complete</title>
<link href="../../../../dist/css/themes/ryuseicode-ryusei.min.css" rel="stylesheet">
</head>
<body style="margin: 1em;">
<pre id="code">
import { Components, Elements, Extensions, Language, Options } from '/code';
import { AnyFunction } from '/light/dist/types/types';
import * as CoreComponents from '../../components';
import { ATTRIBUTES_EDITABLE_AREA } from '../../constants/attributes';
import {
CLASS_BACKGROUND,
CLASS_BODY,
CLASS_CONTAINER,
CLASS_EDITOR,
CLASS_EMPTY,
CLASS_FOCUSED,
CLASS_INITIALIZED,
CLASS_LINES,
CLASS_OVERLAY,
CLASS_READONLY,
CLASS_RENDERED,
CLASS_SCROLLER,
CLASS_TEXTAREA,
CLASS_VIEW,
} from '../../constants/classes';
import {
EVENT_BLUR,
EVENT_CHANGED,
EVENT_DESTROYED,
EVENT_FOCUS,
EVENT_MOUNT,
EVENT_MOUNTED,
EVENT_READONLY,
} from '../../constants/events';
import { EventBus } from '../../event/EventBus';
import {
activeElement,
addClass,
assert,
assign,
create,
debounce,
div,
escapeHtml,
focus,
forOwn,
hasClass,
isFunction,
isHTMLElement,
isString,
isUndefined,
on,
query,
remove,
removeClass,
styles,
text,
toggleClass,
unit,
} from '../../utils';
import { Renderer } from '../Renderer/Renderer';
/**
* The debounce duration for evaluating `focusout` of the editor.
*
* @since 0.1.0
*/
const FOCUSOUT_DEBOUNCE_DURATION = 10;
/**
* The core class for the editor.
*
* @since 0.1.0
*/
export class Editor {
/**
* Holds collection of editor elements.
*/
elements: Elements;
/**
* Holds Component instances.
*/
Components: Partial<Components> = {};
/**
* Holds Extension instances.
*/
protected Extensions: Partial<Extensions> = {};
/**
* Holds options.
*/
readonly options: Options;
/**
* Holds the EventBus instance.
*/
readonly event: EventBus;
/**
* Holds the source element.
*/
protected source: HTMLElement;
/**
* Holds the root element.
*/
protected root: HTMLElement;
/**
* Holds the language object.
*/
readonly language: Language;
/**
* Indicates whether the editor is readonly or not.
*/
protected _readOnly: boolean;
/**
* The Editor constructor.
*
* @param language - A Language object.
* @param options - Options.
* @param extensions - An object with additional components.
*/
constructor( language: Language, options: Options, extensions: Partial<Extensions> = {} ) {
this.language = language;
this.options = options;
this.event = new EventBus();
forOwn( CoreComponents, ( Component, name ) => {
this.Components[ name ] = new Component( this );
} );
forOwn( extensions, ( Extension, name ) => {
const value = this.options[ name.charAt( 0 ).toLowerCase() + name.slice( 1 ) ];
if ( isUndefined( value ) || value ) {
this.Extensions[ name ] = new Extension( this );
}
} );
}
/**
* Initializes the editor and components.
*/
protected mount(): void {
const { options, event, elements } = this;
this.listen();
event.emit( EVENT_MOUNT, elements );
forOwn( this.Components, Component => {
Component.mount( elements );
} );
forOwn( this.Extensions, Extension => {
Extension.mount( elements );
} );
event.emit( EVENT_MOUNTED, elements );
this.readOnly = options.readOnly;
if ( options.autofocus ) {
this.focus();
}
}
/**
* Collects essential elements that constitute the code editor.
*/
protected collect(): void {
const { root } = this;
const editor = query<HTMLDivElement>( root, `.${ CLASS_EDITOR }` );
const lines = query<HTMLDivElement>( root, `.${ CLASS_LINES }` );
lines.contentEditable = 'true';
this.elements = Object.freeze( {
root,
editor,
lines,
editable : lines,
view : query<HTMLDivElement>( root, `.${ CLASS_VIEW }` ),
body : query<HTMLDivElement>( root, `.${ CLASS_BODY }` ),
scroller : query<HTMLDivElement>( root, `.${ CLASS_SCROLLER }` ),
container : query<HTMLDivElement>( root, `.${ CLASS_CONTAINER }` ),
input : create( 'textarea', assign( { class: CLASS_TEXTAREA }, ATTRIBUTES_EDITABLE_AREA ), editor ),
overlay : div( CLASS_OVERLAY, root ),
background: div( { class: CLASS_BACKGROUND, 'aria-hidden': true }, editor ),
} );
}
/**
* Listens to some events.
*/
protected listen(): void {
const { elements, elements: { root }, event } = this;
const isFocused = this.isFocused.bind( this );
let type: string;
this.bind( root, 'pointerdown', () => {
type = 'pointer';
} );
this.bind( elements.editor, 'click', () => {
if ( ! isFocused() ) {
this.focus( true );
}
} );
this.bind( root, 'focusin', () => {
if ( isFocused() && ! hasClass( root, CLASS_FOCUSED ) ) {
addClass( root, CLASS_FOCUSED );
event.emit( EVENT_FOCUS, type );
}
} );
this.bind( root, 'focusout', debounce( () => {
if ( ! isFocused() && hasClass( root, CLASS_FOCUSED ) ) {
removeClass( root, CLASS_FOCUSED );
event.emit( EVENT_BLUR );
type = '';
}
}, FOCUSOUT_DEBOUNCE_DURATION ) );
event.on( [ EVENT_MOUNTED, EVENT_CHANGED ], () => {
toggleClass( elements.root, CLASS_EMPTY, ! this.value );
} );
}
/**
* Listens to native events.
*
* @param elm - A document, a window or an element.
* @param events - An event name or names.
* @param callback - A callback function.
*/
protected bind( elm: Document | Window | Element, events: string, callback: ( e: Event ) => void ): void {
on( elm, events, callback, this );
}
/**
* Applies the editor to the target element.
*
* @param target - A selector to find the target element, or a target element itself.
* @param code - Optional. The code to overwrite the content of the target element.
*/
apply( target: string | Element, code?: string ): void {
const elm = isString( target ) ? query( document, target ) : target;
if ( isHTMLElement( elm ) ) {
this.source = elm;
if ( hasClass( elm, CLASS_RENDERED ) ) {
this.root = elm;
const pre = query( elm, 'pre' );
this.Components.Code.init( text( pre ) || '' );
remove( pre )
} else {
elm.insertAdjacentHTML( 'afterend', this.html( isUndefined( code ) ? text( elm ) : code, false ) );
styles( elm, { display: 'none' } );
this.root = elm.nextElementSibling as HTMLElement;
}
addClass( this.root, CLASS_INITIALIZED );
this.collect();
this.mount();
} else {
assert( false, `${ target } is invalid.` );
}
}
/**
* Returns HTML of the editor.
* This may not contain all lines because IE can not render tons of HTML tags at the same time.
* The number of lines can be specified by options.
*
* @param code - A code string.
* @param source - Whether to embed the source code into the editor or not.
*
* @return The HTML of the editor.
*/
html( code: string, source?: boolean ): string {
const { Code } = this.Components;
Code.init( code );
return new Renderer( Code, this.event, this.options ).html( source );
}
/**
* Saves the content to the source element.
*/
save(): void {
const { source, value } = this;
if ( source instanceof HTMLTextAreaElement ) {
source.value = value;
} else {
text( source, escapeHtml( value ) );
}
}
/**
* Focuses to the editable area.
*
* @param reselect - Determines whether to reselect the last position or not.
*/
focus( reselect?: boolean ): void {
if ( reselect ) {
this.Components.Selection.reselect();
} else {
focus( this.elements.editable );
}
}
/**
* Removes the focus from the editor.
*/
blur(): void {
const elm = activeElement();
if ( this.isFocused() && isHTMLElement( elm ) ) {
elm.blur();
}
}
/**
* Attempts to invoke the method of the specified extension.
*
* @param name - A name of the extension.
* @param method - A method name to invoke.
* @param args - Optional. Arguments for the method.
*
* @return The return value of the method.
*/
invoke<K extends keyof Extensions, P extends keyof Extensions[ K ], V extends Extensions[ K ][ P ]>(
name: K,
method: P,
...args: V extends AnyFunction ? Parameters<V> : any[]
): V extends AnyFunction ? ReturnType<V> : void {
const extension = this.Extensions[ name ];
if ( extension && isFunction( extension[ method ] ) ) {
return extension[ method ]( ...args );
}
}
/**
* Returns the extension of the specified name.
*
* @param name - A name of an extension.
*
* @return An extension if found, or otherwise `undefined`.
*/
require<K extends keyof Extensions>( name: K ): Extensions[ K ] | undefined {
return this.Extensions[ name ];
}
/**
* Checks if the editor is focused or not.
*
* @return `true` if the editor is focused, or otherwise `false`.
*/
isFocused(): boolean {
return this.root.contains( activeElement() );
}
/**
* Destroys the editor.
*/
destroy(): void {
const { event } = this;
this.save();
forOwn( assign( this.Components, this.Extensions ), Component => {
Component.destroy();
} );
delete this.Components;
delete this.Extensions;
styles( this.source, { display: '' } );
remove( this.elements.root );
event.emit( EVENT_DESTROYED );
event.destroy();
}
/**
* Sets a new value to the editor.
*
* @param value - A value to set.
*/
set value( value: string ) {
const { Components, Components: { Code } } = this;
Code.value = value;
Components.View.jump( 0 );
Components.Sync.sync( 0, Code.size - 1 );
Components.Selection.set( [ 0, 0 ] );
this.event.emit( 'refresh' );
}
/**
* Returns the current value of the editor.
*
* @return The current value.
*/
get value(): string {
return this.Components.Code.value;
}
/**
* Sets the width of the root element.
*
* @param width - Width to set as pixel or CSS styles.
*/
set width( width: number | string ) {
styles( this.root, { width: unit( width ) } );
this.Components.View.emitResize();
}
/**
* Returns the width of the editor in pixel.
*
* @return The width of the editor.
*/
get width(): number {
return this.root.clientWidth;
}
/**
* Sets the height of the root element.
*
* @param height - Height to set as pixel or CSS styles.
*/
set height( height: number | string ) {
styles( this.root, { height: unit( height ) } );
this.Components.View.emitResize();
}
/**
* Returns the height of the editor in pixel.
*
* @return The height of the editor.
*/
get height(): number {
return this.root.clientHeight;
}
/**
* Makes the editor mutable or immutable.
*
* @param readOnly - Whether to make the editor immutable or mutable.
*/
set readOnly( readOnly: boolean ) {
const { elements } = this;
const { editable } = elements;
editable.contentEditable = readOnly ? 'false' : 'true';
this.Components.Input.readOnly = readOnly;
this._readOnly = readOnly;
toggleClass( elements.root, CLASS_READONLY, readOnly );
this.event.emit( EVENT_READONLY, readOnly );
}
/**
* Indicates whether the editor is disabled or not.
*
* @return - `true` if the input is read-only or `false` if not.
*/
get readOnly(): boolean {
return this._readOnly;
}
}</pre>
<script src="../../../../dist/js/ryuseicode-complete.min.js"></script>
<script>
const ryuseiCode = new RyuseiCode( { autoFocus: true, language: 'typescript', maxHeight: '60em' } );
ryuseiCode.apply( '#code' );
</script>
</body>
</html>