@stringsync/vexml
Version:
MusicXML to Vexflow
167 lines (166 loc) • 6.03 kB
JavaScript
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 };
},
};
}
}