opensheetmusicdisplay/opensheetmusicdisplay

View on GitHub
src/OpenSheetMusicDisplay/Cursor.ts

Summary

Maintainability
D
2 days
Test Coverage
import {MusicPartManagerIterator} from "../MusicalScore/MusicParts/MusicPartManagerIterator";
import {MusicPartManager} from "../MusicalScore/MusicParts/MusicPartManager";
import {VoiceEntry} from "../MusicalScore/VoiceData/VoiceEntry";
import {VexFlowStaffEntry} from "../MusicalScore/Graphical/VexFlow/VexFlowStaffEntry";
import {MusicSystem} from "../MusicalScore/Graphical/MusicSystem";
import {OpenSheetMusicDisplay} from "./OpenSheetMusicDisplay";
import {GraphicalMusicSheet} from "../MusicalScore/Graphical/GraphicalMusicSheet";
import {Instrument} from "../MusicalScore/Instrument";
import {Note} from "../MusicalScore/VoiceData/Note";
import {Fraction} from "../Common/DataObjects/Fraction";
import { EngravingRules } from "../MusicalScore/Graphical/EngravingRules";
import { SourceMeasure } from "../MusicalScore/VoiceData/SourceMeasure";
import { StaffLine } from "../MusicalScore/Graphical/StaffLine";
import { GraphicalMeasure } from "../MusicalScore/Graphical/GraphicalMeasure";
import { VexFlowMeasure } from "../MusicalScore/Graphical/VexFlow/VexFlowMeasure";
import { CursorOptions, CursorType } from "./OSMDOptions";
import { BoundingBox } from "../MusicalScore/Graphical/BoundingBox";
import { GraphicalNote } from "../MusicalScore/Graphical/GraphicalNote";

/** A cursor which can iterate through the music sheet. */
export class Cursor {
  constructor(container: HTMLElement, openSheetMusicDisplay: OpenSheetMusicDisplay, cursorOptions: CursorOptions) {
    this.container = container;
    this.openSheetMusicDisplay = openSheetMusicDisplay;
    this.rules = this.openSheetMusicDisplay.EngravingRules;
    this.cursorOptions = cursorOptions;

    // set cursor id
    // TODO add this for the OSMD object as well and refactor this into a util method?
    let id: number = 0;
    this.cursorElementId = "cursorImg-0";
    // find unique cursor id in document
    while (document.getElementById(this.cursorElementId)) {
      id++;
      this.cursorElementId = `cursorImg-${id}`;
    }

    const curs: HTMLElement = document.createElement("img");
    curs.id = this.cursorElementId;
    curs.style.position = "absolute";
    if (this.cursorOptions.follow === true) {
      this.wantedZIndex = "-1";
      curs.style.zIndex = this.wantedZIndex;
    } else {
      this.wantedZIndex = "-2";
      curs.style.zIndex = this.wantedZIndex;
    }
    this.cursorElement = <HTMLImageElement>curs;
    this.container.appendChild(curs);
  }

  public adjustToBackgroundColor(): void {
    let zIndex: string;
    if (!this.rules.PageBackgroundColor) {
          zIndex = this.wantedZIndex;
    } else {
      zIndex = "1";
    }
    this.cursorElement.style.zIndex = zIndex;
  }

  private container: HTMLElement;
  public cursorElement: HTMLImageElement;
  /** a unique id of the cursor's HTMLElement in the document.
   * Should be constant between re-renders and backend changes,
   * but different between different OSMD objects on the same page.
   */
  public cursorElementId: string;
  /** The desired zIndex (layer) of the cursor when no background color is set.
   *  When a background color is set, using a negative zIndex would make the cursor invisible.
   */
  public wantedZIndex: string;
  private openSheetMusicDisplay: OpenSheetMusicDisplay;
  private rules: EngravingRules;
  private manager: MusicPartManager;
  public iterator: MusicPartManagerIterator;
  private graphic: GraphicalMusicSheet;
  public hidden: boolean = true;
  public currentPageNumber: number = 1;
  private cursorOptions: CursorOptions;
  private cursorOptionsRendered: CursorOptions;
  private skipInvisibleNotes: boolean = true;

  /** Initialize the cursor. Necessary before using functions like show() and next(). */
  public init(manager: MusicPartManager, graphic: GraphicalMusicSheet): void {
    this.manager = manager;
    this.graphic = graphic;
    this.reset();
    this.hidden = true;
    this.hide();
  }

  /** Make the cursor visible. */
  public show(): void {
    this.hidden = false;
    //this.resetIterator(); // TODO maybe not here? though setting measure range to draw, rerendering, then handling cursor show is difficult
    this.update();
    this.adjustToBackgroundColor();
  }

  public resetIterator(): void {
    if (!this.openSheetMusicDisplay.Sheet || !this.openSheetMusicDisplay.Sheet.SourceMeasures) { // just a safety measure
      console.log("OSMD.Cursor.resetIterator(): sheet or measures were null/undefined.");
      return;
    }

    // set selection start, so that when there's MinMeasureToDraw set, the cursor starts there right away instead of at measure 1
    const lastSheetMeasureIndex: number = this.openSheetMusicDisplay.Sheet.SourceMeasures.length - 1; // last measure in data model
    let startMeasureIndex: number = this.rules.MinMeasureToDrawIndex;
    startMeasureIndex = Math.min(startMeasureIndex, lastSheetMeasureIndex);
    let endMeasureIndex: number = this.rules.MaxMeasureToDrawIndex;
    endMeasureIndex = Math.min(endMeasureIndex, lastSheetMeasureIndex);

    if (this.openSheetMusicDisplay.Sheet && this.openSheetMusicDisplay.Sheet.SourceMeasures.length > startMeasureIndex) {
      this.openSheetMusicDisplay.Sheet.SelectionStart = this.openSheetMusicDisplay.Sheet.SourceMeasures[startMeasureIndex].AbsoluteTimestamp;
    }
    if (this.openSheetMusicDisplay.Sheet && this.openSheetMusicDisplay.Sheet.SourceMeasures.length > endMeasureIndex) {
      const lastMeasure: SourceMeasure = this.openSheetMusicDisplay.Sheet.SourceMeasures[endMeasureIndex];
      this.openSheetMusicDisplay.Sheet.SelectionEnd = Fraction.plus(lastMeasure.AbsoluteTimestamp, lastMeasure.Duration);
    }

    this.iterator = this.manager.getIterator();
    // remember SkipInvisibleNotes setting, which otherwise gets reset to default value
    this.iterator.SkipInvisibleNotes = this.skipInvisibleNotes;
  }

  private getStaffEntryFromVoiceEntry(voiceEntry: VoiceEntry): VexFlowStaffEntry {
    const measureIndex: number = voiceEntry.ParentSourceStaffEntry.VerticalContainerParent.ParentMeasure.measureListIndex;
    const staffIndex: number = voiceEntry.ParentSourceStaffEntry.ParentStaff.idInMusicSheet;
    return <VexFlowStaffEntry>this.graphic.findGraphicalStaffEntryFromMeasureList(staffIndex, measureIndex, voiceEntry.ParentSourceStaffEntry);
  }

  /** Moves the cursor to the current position of the iterator (visually), e.g. after next(). */
  public update(): void {
    if (this.hidden || this.hidden === undefined || this.hidden === null) {
      return;
    }
    this.updateCurrentPage(); // attach cursor to new page DOM if necessary

    // this.graphic?.Cursors?.length = 0;
    const iterator: MusicPartManagerIterator = this.iterator;
    // TODO when measure draw range (drawUpToMeasureNumber) was changed, next/update can fail to move cursor. but of course it can be reset before.

    let voiceEntries: VoiceEntry[] = iterator.CurrentVisibleVoiceEntries();
    let currentMeasureIndex: number = iterator.CurrentMeasureIndex;
    let x: number = 0, y: number = 0, height: number = 0;
    let musicSystem: MusicSystem;
    if (voiceEntries.length === 0 && !iterator.FrontReached && !iterator.EndReached) {
      // e.g. when the note at the current position is in an instrument that's now invisible, and there's no other note at this position, vertically
      iterator.moveToPrevious();
      voiceEntries = iterator.CurrentVisibleVoiceEntries();
      iterator.moveToNext();
      // after this, the else condition below should trigger, positioning the cursor at the left-most note. See #1312
    }
    if (iterator.FrontReached && voiceEntries.length === 0) {
      // show beginning of first measure (of stafflines, to create a visual difference to the first note position)
      //   this position is technically before the sheet/first note - e.g. cursor.Iterator.CurrentTimestamp.RealValue = -1
      iterator.moveToNext();
      voiceEntries = iterator.CurrentVisibleVoiceEntries();
      const firstVisibleMeasure: GraphicalMeasure = this.findVisibleGraphicalMeasure(iterator.CurrentMeasureIndex);
      x = firstVisibleMeasure.PositionAndShape.AbsolutePosition.x;
      musicSystem = firstVisibleMeasure.ParentMusicSystem;
      iterator.moveToPrevious();
    } else if (iterator.EndReached || !iterator.CurrentVoiceEntries || voiceEntries.length === 0) {
      // show end of last measure (of stafflines, to create a visual difference to the first note position)
      //   this position is technically after the sheet/last note - e.g. cursor.Iterator.CurrentTimestamp.RealValue = 99999
      iterator.moveToPrevious();
    voiceEntries = iterator.CurrentVisibleVoiceEntries();
      currentMeasureIndex = iterator.CurrentMeasureIndex;
      const lastVisibleMeasure: GraphicalMeasure = this.findVisibleGraphicalMeasure(iterator.CurrentMeasureIndex);
      x = lastVisibleMeasure.PositionAndShape.AbsolutePosition.x + lastVisibleMeasure.PositionAndShape.Size.width;
      musicSystem = lastVisibleMeasure.ParentMusicSystem;
      iterator.moveToNext();
    } else if (iterator.CurrentMeasure.isReducedToMultiRest) {
      // multiple measure rests aren't used when one
      const multiRestGMeasure: GraphicalMeasure = this.findVisibleGraphicalMeasure(iterator.CurrentMeasureIndex);
      const totalRestMeasures: number = multiRestGMeasure.parentSourceMeasure.multipleRestMeasures;
      const currentRestMeasureNumber: number = iterator.CurrentMeasure.multipleRestMeasureNumber;
      const progressRatio: number = currentRestMeasureNumber / (totalRestMeasures + 1);
      const effectiveWidth: number = multiRestGMeasure.PositionAndShape.Size.width - (multiRestGMeasure as VexFlowMeasure).beginInstructionsWidth;
      x = multiRestGMeasure.PositionAndShape.AbsolutePosition.x + (multiRestGMeasure as VexFlowMeasure).beginInstructionsWidth + progressRatio * effectiveWidth;

      musicSystem = multiRestGMeasure.ParentMusicSystem;
    } else {
      // get all staff entries inside the current voice entry
      const gseArr: VexFlowStaffEntry[] = voiceEntries.map(ve => this.getStaffEntryFromVoiceEntry(ve));
      // sort them by x position and take the leftmost entry
      const gse: VexFlowStaffEntry =
            gseArr.sort((a, b) => a?.PositionAndShape?.AbsolutePosition?.x <= b?.PositionAndShape?.AbsolutePosition?.x ? -1 : 1 )[0];
      if (gse) {
        x = gse.PositionAndShape.AbsolutePosition.x;
        musicSystem = gse.parentMeasure.ParentMusicSystem;
      }

      // debug: change color of notes under cursor (needs re-render)
      // for (const gve of gse.graphicalVoiceEntries) {
      //   for (const note of gve.notes) {
      //     note.sourceNote.NoteheadColor = "#0000FF";
      //   }
      // }
    }
    if (!musicSystem?.StaffLines[0]) {
      return;
    }

    // y is common for both multirest and non-multirest, given the MusicSystem
    //   note: StaffLines[0] is guaranteed to exist in this.findVisibleGraphicalMeasure
    y = musicSystem.PositionAndShape.AbsolutePosition.y + musicSystem.StaffLines[0].PositionAndShape.RelativePosition.y;
    let endY: number = musicSystem.PositionAndShape.AbsolutePosition.y;
    const bottomStaffline: StaffLine = musicSystem.StaffLines[musicSystem.StaffLines.length - 1];
    if (bottomStaffline) { // can be undefined if drawFromMeasureNumber changed after cursor was shown
      endY += bottomStaffline.PositionAndShape.RelativePosition.y + bottomStaffline.StaffHeight;
    }
    height = endY - y;

    // Update the graphical cursor
    const visibleMeasure: GraphicalMeasure = this.findVisibleGraphicalMeasure(currentMeasureIndex);
    if (!visibleMeasure) {
      return;
    }
    const measurePositionAndShape: BoundingBox = visibleMeasure.PositionAndShape;
    this.updateWidthAndStyle(measurePositionAndShape, x, y, height);

    if (this.openSheetMusicDisplay.FollowCursor && this.cursorOptions.follow) {
      if (!this.openSheetMusicDisplay.EngravingRules.RenderSingleHorizontalStaffline) {
        const diff: number = this.cursorElement.getBoundingClientRect().top;
        this.cursorElement.scrollIntoView({behavior: diff < 1000 ? "smooth" : "auto", block: "center"});
      } else {
        this.cursorElement.scrollIntoView({behavior: "smooth", inline: "center"});
      }
    }
    // Show cursor
    // // Old cursor: this.graphic.Cursors.push(cursor);
    this.cursorElement.style.display = "";
  }

  private findVisibleGraphicalMeasure(measureIndex: number): GraphicalMeasure {
    for (let i: number = 0; i < this.graphic.NumberOfStaves; i++) {
      const measure: GraphicalMeasure = this.graphic.findGraphicalMeasure(this.iterator.CurrentMeasureIndex, i);
      if (measure?.ParentStaff.ParentInstrument.Visible) {
        return measure;
      }
    }
  }

  public updateWidthAndStyle(measurePositionAndShape: BoundingBox, x: number, y: number, height: number): void {
    const cursorElement: HTMLImageElement = this.cursorElement;
    let newWidth: number = 0;
    switch (this.cursorOptions.type) {
      case CursorType.ThinLeft:
        cursorElement.style.top = (y * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.style.left = ((x - 1.5) * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.height = (height * 10.0 * this.openSheetMusicDisplay.zoom);
        newWidth = 5 * this.openSheetMusicDisplay.zoom;
        break;
      case CursorType.ShortThinTopLeft:
        cursorElement.style.top = ((y-2.5) * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.style.left = (x * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.height = (1.5 * 10.0 * this.openSheetMusicDisplay.zoom);
        newWidth = 5 * this.openSheetMusicDisplay.zoom;
        break;
      case CursorType.CurrentArea:
        cursorElement.style.top = measurePositionAndShape.AbsolutePosition.y * 10.0 * this.openSheetMusicDisplay.zoom +"px";
        cursorElement.style.left = measurePositionAndShape.AbsolutePosition.x * 10.0 * this.openSheetMusicDisplay.zoom +"px";
        cursorElement.height = (height * 10.0 * this.openSheetMusicDisplay.zoom);
        newWidth = measurePositionAndShape.Size.width * 10 * this.openSheetMusicDisplay.zoom;
        break;
      case CursorType.CurrentAreaLeft:
        cursorElement.style.top = measurePositionAndShape.AbsolutePosition.y * 10.0 * this.openSheetMusicDisplay.zoom +"px";
        cursorElement.style.left = measurePositionAndShape.AbsolutePosition.x * 10.0 * this.openSheetMusicDisplay.zoom +"px";
        cursorElement.height = (height * 10.0 * this.openSheetMusicDisplay.zoom);
        newWidth = (x-measurePositionAndShape.AbsolutePosition.x) * 10 * this.openSheetMusicDisplay.zoom;
        break;
        default:
        cursorElement.style.top = (y * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.style.left = ((x - 1.5) * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.height = (height * 10.0 * this.openSheetMusicDisplay.zoom);
        newWidth = 3 * 10.0 * this.openSheetMusicDisplay.zoom;
        break;
    }

    // if (newWidth !== cursorElement.width) { // this `if` is unnecessary and prevents updating color
    cursorElement.width = newWidth;
    if (this.cursorOptionsRendered !== this.cursorOptions) {
      this.updateStyle(newWidth, this.cursorOptions);
      // only update style (creating new cursor element) if options changed.
      //   For width, it seems to be enough to update cursorElement.width, see osmd#1519
    }
  }

  /** Hide the cursor. */
  public hide(): void {
    // Hide the actual cursor element
    this.cursorElement.style.display = "none";
    //this.graphic.Cursors.length = 0;
    // Forcing the sheet to re-render is not necessary anymore
    //if (!this.hidden) {
    //    this.openSheetMusicDisplay.render();
    //}
    this.hidden = true;
  }

  /** Go to previous entry / note / vertical position. */
   public previous(): void {
    this.iterator.moveToPreviousVisibleVoiceEntry(false);
    this.update();
  }

  /** Go to next entry / note / vertical position. */
  public next(): void {
    this.iterator.moveToNextVisibleVoiceEntry(false); // moveToNext() would not skip notes in hidden (visible = false) parts
    this.update();
  }

  /** reset cursor to start position (start of sheet or osmd.Sheet.SelectionStart if set). */
  public reset(): void {
    this.resetIterator();
    //this.iterator.moveToNext();
    this.update();
  }

  /** updates cursor style (visually), e.g. cursor.cursorOptions.type or .color. */
  private updateStyle(width: number, cursorOptions: CursorOptions = undefined): void {
    if (cursorOptions !== undefined) {
      this.cursorOptions = cursorOptions;
    }
    // Create a dummy canvas to generate the gradient for the cursor
    // FIXME This approach needs to be improved
    const c: HTMLCanvasElement = document.createElement("canvas");
    c.width = this.cursorElement.width;
    c.height = 1;
    const ctx: CanvasRenderingContext2D = c.getContext("2d");
    ctx.globalAlpha = this.cursorOptions.alpha;
    // Generate the gradient
    const gradient: CanvasGradient = ctx.createLinearGradient(0, 0, this.cursorElement.width, 0);
    switch (this.cursorOptions.type) {
      case CursorType.ThinLeft:
      case CursorType.ShortThinTopLeft:
      case CursorType.CurrentArea:
      case CursorType.CurrentAreaLeft:
        gradient.addColorStop(1, this.cursorOptions.color);
        break;
      default:
        gradient.addColorStop(0, "white"); // it was: "transparent"
        gradient.addColorStop(0.2, this.cursorOptions.color);
        gradient.addColorStop(0.8, this.cursorOptions.color);
        gradient.addColorStop(1, "white"); // it was: "transparent"
      break;
    }
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, width, 1);
    this.cursorOptionsRendered = {...this.cursorOptions}; // clone, otherwise !== doesn't work
    // Set the actual image
    this.cursorElement.src = c.toDataURL("image/png");
  }

  public get Iterator(): MusicPartManagerIterator {
    return this.iterator;
  }

  public get Hidden(): boolean {
    return this.hidden;
  }

  /** returns voices under the current Cursor position. Without instrument argument, all voices are returned. */
  public VoicesUnderCursor(instrument?: Instrument): VoiceEntry[] {
    return this.iterator.CurrentVisibleVoiceEntries(instrument);
  }

  public NotesUnderCursor(instrument?: Instrument): Note[] {
    const voiceEntries: VoiceEntry[]  = this.VoicesUnderCursor(instrument);
    const notes: Note[] = [];
    voiceEntries.forEach(voiceEntry => {
      notes.push.apply(notes, voiceEntry.Notes);
    });
    return notes;
  }

  public GNotesUnderCursor(instrument?: Instrument): GraphicalNote[] {
    const voiceEntries: VoiceEntry[]  = this.VoicesUnderCursor(instrument);
    const notes: GraphicalNote[] = [];
    voiceEntries.forEach(voiceEntry => {
      notes.push(...voiceEntry.Notes.map(note => this.rules.GNote(note)));
    });
    return notes;
  }

  /** Check if there was a change in current page, and attach cursor element to the corresponding HTMLElement (div).
   *  This is only necessary if using PageFormat (multiple pages).
   */
  public updateCurrentPage(): number {
    let timestamp: Fraction = this.iterator.currentTimeStamp;
    if (timestamp.RealValue < 0) {
      timestamp = new Fraction(0, 0);
    }
    for (const page of this.graphic.MusicPages) {
      const lastSystemTimestamp: Fraction = page.MusicSystems.last().GetSystemsLastTimeStamp();
      if (lastSystemTimestamp.gt(timestamp)) {
        // gt: the last timestamp of the last system is equal to the first of the next page,
        //   so we do need to use gt, not gte here.
        const newPageNumber: number = page.PageNumber;
        if (newPageNumber !== this.currentPageNumber) {
          this.container.removeChild(this.cursorElement);
          this.container = document.getElementById("osmdCanvasPage" + newPageNumber);
          this.container.appendChild(this.cursorElement);
          // TODO maybe store this.pageCurrentlyAttachedTo, though right now it isn't necessary
          // alternative to remove/append:
          // this.openSheetMusicDisplay.enableOrDisableCursor(true);
        }
        return this.currentPageNumber = newPageNumber;
      }
    }
    return 1;
  }

  public get SkipInvisibleNotes(): boolean {
    return this.skipInvisibleNotes;
  }

  public set SkipInvisibleNotes(value: boolean) {
    this.skipInvisibleNotes = value;
    this.iterator.SkipInvisibleNotes = value;
  }

  public get CursorOptions(): CursorOptions {
    return this.cursorOptions;
  }

  public set CursorOptions(value: CursorOptions) {
    this.cursorOptions = value;
  }

  /** Hides and removes the cursor element, deletes object variables. */
  public Dispose(): void {
    this.hide();
    this.container.removeChild(this.cursorElement);
    this.rules = undefined;
    this.openSheetMusicDisplay = undefined;
    this.cursorOptions = undefined;
  }
}