@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>
198 lines (173 loc) • 5.73 kB
text/typescript
import { Elements, Range as PositionRange, RangeData } from '@ryusei/code';
import { Component } from '../../classes/Component/Component';
import { CLASS_MARKERS } from '../../constants/classes';
import { EVENT_CHUNK_MOVED, EVENT_FONT_LOADED, EVENT_RESIZE, EVENT_SCROLLED } from '../../constants/events';
import { between, compare, div, forOwn, text, throttle } from '../../utils';
import { Marker } from './Marker';
import { SelectionMarker } from './SelectionMarker';
/**
* The throttle duration for calling the `observe` method while scrolling.
*
* @since 0.1.0
*/
export const OBSERVE_THROTTLE_DURATION = 200;
/**
* Limits the number of ranges to register.
*
* @since 0.1.0
*/
export const MAX_RANGES = 10000;
/**
* The class for highlighting the selection and arbitrary ranges.
*
* @since 0.1.0
*/
export class Range extends Component {
/**
* Holds the SelectionMarker instance.
*
* @readonly
*/
selection: SelectionMarker;
/**
* Stores ranges with categorizing them into arbitrary groups.
*/
readonly ranges: Record<string, RangeData[]>= {};
/**
* Stores wrapper elements of markers.
*/
readonly groups: Record<string, HTMLDivElement> = {};
/**
* Initializes the component.
*
* @internal
*
* @param elements - A collection of editor elements.
*/
mount( elements: Elements ): void {
super.mount( elements );
this.selection = new SelectionMarker( this.Editor, elements );
const observe = this.observe.bind( this, false );
this.on( EVENT_CHUNK_MOVED, throttle( observe, OBSERVE_THROTTLE_DURATION ) );
this.on( EVENT_SCROLLED, observe );
this.on( [ EVENT_FONT_LOADED, EVENT_RESIZE ], this.observe.bind( this, true ) );
}
/**
* Observes ranges and draw/hide them.
*
* @param refresh - Optional. If `true`, redraws markers without their caches.
*/
private observe( refresh?: boolean ): void {
if ( this.Editor ) {
forOwn( this.ranges, ( ranges, group ) => {
if ( this.groups[ group ] ) {
this.draw( group, refresh );
}
} );
}
}
/**
* Draws visible markers.
*
* @param group - A group to draw.
* @param refresh - Optional. If `true`, redraws markers without their caches.
*/
private draw( group: string, refresh?: boolean ): void {
const ranges = this.ranges[ group ];
let html = '';
ranges.forEach( data => {
const { range } = data;
if ( this.isVisible( range ) ) {
html += data.marker.html( range.start, range.end, ! refresh );
}
} );
this.groups[ group ].innerHTML = html;
}
/**
* Checks if the range should be drawn or not.
* This returns `true` when the range boundary is inside the viewport, or the range contains it.
*
* @param range - A range to check.
*
* @return `true` if the range should be drawn or otherwise `false`.
*/
private isVisible( range: PositionRange ): boolean {
const { Chunk } = this;
const [ startRow ] = range.start;
const [ endRow ] = range.end;
return Chunk.includes( startRow ) || Chunk.includes( endRow ) || between( Chunk.start, startRow, endRow );
}
/**
* Registers ranges to the group and draw them as markers.
* They will remain until they are explicitly cleared by the `clear()` method.
* If `concat` is `true`, sequential ranges will be concatenated as a single range.
*
* @example
* ```ts
* const ryuseiCode = new RyuseiCode();
* ryuseiCode.apply( 'textarea' );
*
* const { Range } = ryuseiCode.Editor.Components;
*
* Range.register( 'my-ranges', [
* { start: [ 0, 0 ], end: [ 0, 5 ] },
* { start: [ 1, 0 ], end: [ 1, 3 ] },
* ] );
*
* // Clear ranges after 2 seconds.
* setTimeout( () => {
* Range.clear( 'my-ranges' );
* }, 2000 );
* ```
*
* @param group - A group name.
* @param ranges - A range or ranges to draw.
* @param concat - Optional. Determines whether to concat sequential ranges into the single one or not.
* @param constructor - Optional. Specifies the Marker constructor.
*/
register( group: string, ranges: PositionRange[], concat = true, constructor: typeof Marker = Marker ): void {
const { ranges: info } = this;
let lastRange: PositionRange;
info[ group ] = info[ group ] || [];
ranges = ranges.slice( 0, MAX_RANGES );
for ( let i = 0; i < ranges.length; i++ ) {
const range = ranges[ i ];
if ( concat && lastRange && compare( lastRange.end, range.start ) === 0 ) {
lastRange.end = range.end;
} else {
lastRange = { start: range.start, end: range.end };
info[ group ].push( { range: lastRange, marker: new constructor( this.Editor, this.elements ) } );
}
}
if ( ! this.groups[ group ] ) {
const classes = [ CLASS_MARKERS, `${ CLASS_MARKERS }--${ group }` ];
this.groups[ group ] = div( classes, this.elements.background );
}
this.observe();
}
/**
* Clears ranges and rendered markers that belong to the specified group.
* If the group name is omitted, this method clears all ranges.
*
* @param group - Optional. A group name to clear.
*/
clear( group?: string ): void {
if ( group ) {
const ranges = this.ranges[ group ];
if ( ranges ) {
text( this.groups[ group ], '' );
this.clearRanges( group );
}
} else {
forOwn( this.ranges, ( markers, key ) => { this.clear( key ) } );
}
}
/**
* Clears ranges in the specified group, but rendered markers will remain.
*
* @param group - A group name to clear.
*/
clearRanges( group: string ): void {
this.ranges[ group ] = [];
}
}