UNPKG

@stringsync/vexml

Version:

MusicXML to Vexflow

167 lines (166 loc) 6.03 kB
import * as events from '../events'; import * as util from '../util'; import { Rect, Point } from '../spatial'; import { Scroller } from './scroller'; import { FastCursorFrameLocator } from './fastcursorframelocator'; import { BSearchCursorFrameLocator } from './bsearchcursorframelocator'; import { Duration } from './duration'; import { LazyCursorStateHintProvider } from './lazycursorstatehintprovider'; import { EmptyCursorFrame } from './emptycursorframe'; import { HintDescriber } from './hintdescriber'; // NOTE: At 2px and below, there is some antialiasing issues on higher resolutions. The cursor will appear to "pulse" as // it moves. This will happen even when rounding the position. const CURSOR_WIDTH_PX = 3; export class Cursor { path; locator; scroller; elementDescriber; topic = new events.Topic(); index = 0; alpha = 0; // interpolation factor, ranging from 0 to 1 previousFrame = new EmptyCursorFrame(); constructor(path, locator, scroller, elementDescriber) { this.path = path; this.locator = locator; this.scroller = scroller; this.elementDescriber = elementDescriber; } static create(path, scrollContainer, elementDescriber) { const bSearchLocator = new BSearchCursorFrameLocator(path); const fastLocator = new FastCursorFrameLocator(path, bSearchLocator); const scroller = new Scroller(scrollContainer); return new Cursor(path, fastLocator, scroller, elementDescriber); } iterable() { // Clone the cursor to avoid modifying the index of this instance. const cursor = new Cursor(this.path, this.locator, this.scroller, this.elementDescriber); return new CursorIterator(cursor); } getCurrentState() { const index = this.index; const hasNext = index < this.path.getFrames().length - 1; const hasPrevious = index > 0; const frame = this.getCurrentFrame(); const rect = this.getCursorRect(frame, this.alpha); const hintDescriber = new HintDescriber(this.elementDescriber); const hints = new LazyCursorStateHintProvider(frame, this.previousFrame, hintDescriber); return { index, hasNext, hasPrevious, frame, rect, hints, }; } next() { if (this.index === this.path.getFrames().length - 1) { this.update(this.index, { alpha: 1 }); } else { this.update(this.index + 1, { alpha: 0 }); } } previous() { this.update(this.index - 1, { alpha: 0 }); } goTo(index) { this.update(index, { alpha: 0 }); } /** Snaps to the closest sequence entry step. */ snap(timeMs) { const time = this.normalize(timeMs); const index = this.locator.locate(time); util.assertNotNull(index, 'Cursor frame locator failed to find a frame.'); this.update(index, { alpha: 0 }); } /** Seeks to the exact position, interpolating as needed. */ seek(timestampMs) { const time = this.normalize(timestampMs); const index = this.locator.locate(time); util.assertNotNull(index, 'Cursor frame locator failed to find a frame.'); const entry = this.path.getFrames().at(index); util.assertDefined(entry); const left = entry.tRange.start; const right = entry.tRange.end; const alpha = (time.ms - left.ms) / (right.ms - left.ms); this.update(index, { alpha }); } isFullyVisible() { const cursorRect = this.getCurrentState().rect; return this.scroller.isFullyVisible(cursorRect); } scrollIntoView(behavior = 'auto') { const scrollPoint = this.getScrollPoint(); this.scroller.scrollTo(scrollPoint, behavior); } addEventListener(name, listener, opts) { const id = this.topic.subscribe(name, listener); if (opts?.emitBootstrapEvent) { listener(this.getCurrentState()); } return id; } removeEventListener(...ids) { for (const id of ids) { this.topic.unsubscribe(id); } } removeAllEventListeners() { this.topic.unsubscribeAll(); } getCurrentFrame() { return this.path.getFrames().at(this.index) ?? new EmptyCursorFrame(); } getScrollPoint() { const cursorRect = this.getCurrentState().rect; const x = cursorRect.center().x; const y = cursorRect.y; return new Point(x, y); } normalize(timeMs) { const ms = util.clamp(0, this.getDuration().ms, timeMs); return Duration.ms(ms); } getDuration() { return this.path.getFrames().at(-1)?.tRange.end ?? Duration.zero(); } getCursorRect(frame, alpha) { const x = frame.xRange.lerp(alpha); const y = frame.yRange.start; const w = CURSOR_WIDTH_PX; const h = frame.yRange.getSize(); return new Rect(x, y, w, h); } update(index, { alpha }) { index = util.clamp(0, this.path.getFrames().length - 1, index); alpha = util.clamp(0, 1, alpha); // Round to 3 decimal places to avoid overloading the event system with redundant updates. alpha = Math.round(alpha * 1000) / 1000; if (index !== this.index || alpha !== this.alpha) { this.previousFrame = this.getCurrentFrame(); this.index = index; this.alpha = alpha; this.topic.publish('change', this.getCurrentState()); } } } class CursorIterator { cursor; constructor(cursor) { this.cursor = cursor; } [Symbol.iterator]() { return { next: () => { const state = this.cursor.getCurrentState(); const done = !state.hasNext; if (!done) { this.cursor.next(); } return { value: state, done }; }, }; } }