Repository: opensheetmusicdisplay/opensheetmusicdisplay
Pull request #972 (2021-02-20 09:08:55)

Feature: Multiple cursors
This feature allows to configure multiple cursors with OSMDOptions.

rvilarl: I leave a note to make the review easier. Basicly the change consist on:
- A new element in the OSMDOptions.ts which is an array of CursorOptions defining the cursors. The parameters are documented in the file. If not defined, one standard cursor is defined in the method SetOptions of OpenSheetMusicDisplay.
- Changes in OpenSheetMusicDisplay.ts to allow multiple cursors. Main difference from an user perspective is that the "cursor" public variable is now "cursors" an array of cursors.
- Changes on the test file to reference cursors[0] instead of cursor.
- Cursor.ts constructor and UpdateStyle accept now an instance of CursorOptions. Code to implement the 4 types of cursors:
     0: Standard highlighting current notes
     1: Thin line left to the current notes
     2: Short thin line on top of stave and left to the current notes
     3: Current compass
     4: Current compass to left of current notes


rvilarl: Some examples of the five different types of cursors , they are not at the same score location

**TYPE 2(grey) & TYPE 1 (green)**
in this example showing metronome location (grey) and left of the next notes to play(green) like in https://michaelecke.com/pianoplay/
![image](https://user-images.githubusercontent.com/22865285/110028627-c5301980-7d33-11eb-9b14-41ec536e64ce.png)


**TYPE 0**
![image](https://user-images.githubusercontent.com/22865285/110027382-2ce56500-7d32-11eb-91ac-40c103d78002.png)

**TYPE 1**
![image](https://user-images.githubusercontent.com/22865285/110027117-e0018e80-7d31-11eb-8e3d-8213316679b2.png)

**TYPE 2**
![image](https://user-images.githubusercontent.com/22865285/110027955-ed6b4880-7d32-11eb-9238-0724d6202a80.png)

**TYPE 3**
![image](https://user-images.githubusercontent.com/22865285/110028246-4cc95880-7d33-11eb-84f6-0fb314f7d848.png)

**TYPE 4**
![image](https://user-images.githubusercontent.com/22865285/110028389-76827f80-7d33-11eb-95c0-cdb9e06ca311.png)

rvilarl: @sschmidTU is there anything that you would like to get changed? Do you still believe that this is a good idea?
sschmidTU: i just didn't get to review it yet, don't worry.
rvilarl: @sschmidTU do you want me to resolve the conflicts?
rvilarl: I did it just in case
conde2: @rvilarl would be cool to add an option to center the cursor, check this issue:

https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/issues/1011


BppleMan: Looking forward to a release containing this PR,I need multiple cursor
sschmidTU: This will probably be merged for the next release.
rvilarl: @sschmidTU I will look tomorrow into that, thanks for the feedback!
rvilarl: I decided to do it right now. Ready!
rvilarl: @sschmidTU i forgot one comment, now ready. Will the new release handle repetitions? 
sschmidTU: > Will the new release handle repetitions?

Probably not yet, cursor repetition handling is part of our playback code, which is in the OSMD audio player, which is currently Github Sponsors-exclusive (early access).

Patch:
diff --git a/src/OpenSheetMusicDisplay/Cursor.ts b/src/OpenSheetMusicDisplay/Cursor.ts
index 164552c0..1b211820 100644
--- a/src/OpenSheetMusicDisplay/Cursor.ts
+++ b/src/OpenSheetMusicDisplay/Cursor.ts
@@ -13,15 +13,19 @@ 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 } from "./OSMDOptions";
+import { BoundingBox } from "../MusicalScore";
 
 /**
  * A cursor which can iterate through the music sheet.
  */
 export class Cursor {
-  constructor(container: HTMLElement, openSheetMusicDisplay: OpenSheetMusicDisplay) {
+  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?
@@ -36,7 +40,11 @@ export class Cursor {
     const curs: HTMLElement = document.createElement("img");
     curs.id = this.cursorElementId;
     curs.style.position = "absolute";
-    curs.style.zIndex = "-1";
+    if (this.cursorOptions.follow === true) {
+      curs.style.zIndex = "-1";
+    } else {
+      curs.style.zIndex = "-2";
+    }
     this.cursorElement = <HTMLImageElement>curs;
     this.container.appendChild(curs);
   }
@@ -55,6 +63,7 @@ export class Cursor {
   private graphic: GraphicalMusicSheet;
   public hidden: boolean = true;
   public currentPageNumber: number = 1;
+  private cursorOptions: CursorOptions;
 
   /** Initialize the cursor. Necessary before using functions like show() and next(). */
   public init(manager: MusicPartManager, graphic: GraphicalMusicSheet): void {
@@ -70,7 +79,7 @@ export class Cursor {
    */
   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.resetIterator(); // TODO maybe not here? though setting measure range to draw, rerendering, then handling cursor show is difficult
     this.update();
   }
 
@@ -162,13 +171,45 @@ export class Cursor {
 
     // This the current HTML Cursor:
     const cursorElement: HTMLImageElement = this.cursorElement;
-    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);
-    const newWidth: number = 3 * 10.0 * this.openSheetMusicDisplay.zoom;
+
+    let newWidth: number = 0;
+    const meassurePositionAndShape: BoundingBox = this.graphic.findGraphicalMeasure(iterator.CurrentMeasureIndex, 0).PositionAndShape;
+    switch (this.cursorOptions.type) {
+      case 1:
+        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 2:
+        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 3:
+        cursorElement.style.top = meassurePositionAndShape.AbsolutePosition.y * 10.0 * this.openSheetMusicDisplay.zoom +"px";
+        cursorElement.style.left = meassurePositionAndShape.AbsolutePosition.x * 10.0 * this.openSheetMusicDisplay.zoom +"px";
+        cursorElement.height = (height * 10.0 * this.openSheetMusicDisplay.zoom);
+        newWidth = meassurePositionAndShape.Size.width * 10 * this.openSheetMusicDisplay.zoom;
+        break;
+      case 4:
+        cursorElement.style.top = meassurePositionAndShape.AbsolutePosition.y * 10.0 * this.openSheetMusicDisplay.zoom +"px";
+        cursorElement.style.left = meassurePositionAndShape.AbsolutePosition.x * 10.0 * this.openSheetMusicDisplay.zoom +"px";
+        cursorElement.height = (height * 10.0 * this.openSheetMusicDisplay.zoom);
+        newWidth = (x-meassurePositionAndShape.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) {
       cursorElement.width = newWidth;
-      this.updateStyle(newWidth);
+      this.updateStyle(newWidth, this.cursorOptions);
     }
     if (this.openSheetMusicDisplay.FollowCursor) {
       if (!this.openSheetMusicDisplay.EngravingRules.RenderSingleHorizontalStaffline) {
@@ -214,9 +255,9 @@ export class Cursor {
     this.update();
   }
 
-  private updateStyle(width: number, color: string = undefined): void {
-    if (!color) {
-      color = this.rules.DefaultColorCursor;
+  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
@@ -224,13 +265,23 @@ export class Cursor {
     c.width = this.cursorElement.width;
     c.height = 1;
     const ctx: CanvasRenderingContext2D = c.getContext("2d");
-    ctx.globalAlpha = 0.5;
+    ctx.globalAlpha = this.cursorOptions.alpha;
     // Generate the gradient
     const gradient: CanvasGradient = ctx.createLinearGradient(0, 0, this.cursorElement.width, 0);
-    gradient.addColorStop(0, "white"); // it was: "transparent"
-    gradient.addColorStop(0.2, color);
-    gradient.addColorStop(0.8, color);
-    gradient.addColorStop(1, "white"); // it was: "transparent"
+    switch (this.cursorOptions.type) {
+      case 1:
+      case 2:
+      case 3:
+      case 4:
+        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);
     // Set the actual image
diff --git a/src/OpenSheetMusicDisplay/OSMDOptions.ts b/src/OpenSheetMusicDisplay/OSMDOptions.ts
index f5b18f20..2eb22aab 100644
--- a/src/OpenSheetMusicDisplay/OSMDOptions.ts
+++ b/src/OpenSheetMusicDisplay/OSMDOptions.ts
@@ -1,6 +1,14 @@
 import { DrawingParametersEnum, ColoringModes } from "../MusicalScore/Graphical/DrawingParameters";
 import { FontStyles } from "../Common/Enums/FontStyles";
 
+export enum CursorType {
+    Standard = 0,
+    ThinLeft = 1,
+    ShortThinTopLeft = 2,
+    CurrentArea = 3,
+    CurrentAreaLeft = 4,
+}
+
 /** Possible options for the OpenSheetMusicDisplay constructor and osmd.setOptions(). None are mandatory.
  *  Note that after using setOptions(), you have to call osmd.render() again to make changes visible.
  *  Example: osmd.setOptions({defaultColorRest: "#AAAAAA", drawSubtitle: false}); osmd.render();
@@ -239,6 +247,10 @@ export interface IOSMDOptions {
      * This works across instruments- If all instruments have subsequent measures with nothing but rests, multirest measures are generated
      */
     autoGenerateMutipleRestMeasuresFromRestMeasures?: boolean;
+    /**
+     * Defines multiple simultaneous cursors. If left undefined the standard cursor will be used.
+     */
+    cursorsOptions?: CursorOptions[];
 }
 
 export enum AlignRestOption {
@@ -293,3 +305,27 @@ export interface AutoBeamOptions {
      */
     groups?: [number[]];
 }
+
+export interface CursorOptions {
+    /**
+     * Type of cursor:
+     * 0: Standard highlighting current notes
+     * 1: Thin line left to the current notes
+     * 2: Short thin line on top of stave and left to the current notes
+     * 3: Current measure
+     * 4: Current measure to left of current notes
+     */
+    type: CursorType;
+    /**
+     * Color to draw the cursor
+     */
+    color: string;
+    /**
+     * If true, this cursor will be followed.
+     */
+    alpha: number;
+    /**
+     * alpha value to be used with color (0.0 transparent, 0.5 medium, 1.0 opaque).
+     */
+    follow: boolean;
+}
diff --git a/src/OpenSheetMusicDisplay/OpenSheetMusicDisplay.ts b/src/OpenSheetMusicDisplay/OpenSheetMusicDisplay.ts
index 9b0d6c04..710fcc5b 100644
--- a/src/OpenSheetMusicDisplay/OpenSheetMusicDisplay.ts
+++ b/src/OpenSheetMusicDisplay/OpenSheetMusicDisplay.ts
@@ -13,7 +13,7 @@ import { MXLHelper } from "../Common/FileIO/Mxl";
 import { AJAX } from "./AJAX";
 import log from "loglevel";
 import { DrawingParametersEnum, DrawingParameters, ColoringModes } from "../MusicalScore/Graphical/DrawingParameters";
-import { IOSMDOptions, OSMDOptions, AutoBeamOptions, BackendType } from "./OSMDOptions";
+import { IOSMDOptions, OSMDOptions, AutoBeamOptions, BackendType, CursorOptions } from "./OSMDOptions";
 import { EngravingRules, PageFormat } from "../MusicalScore/Graphical/EngravingRules";
 import { AbstractExpression } from "../MusicalScore/VoiceData/Expressions/AbstractExpression";
 import { Dictionary } from "typescript-collections";
@@ -62,7 +62,8 @@ export class OpenSheetMusicDisplay {
         this.setOptions(options);
     }
 
-    public cursor: Cursor;
+    private cursorsOptions: CursorOptions[] = [];
+    public cursors: Cursor[] = [];
     public zoom: number = 1.0;
     protected zoomUpdated: boolean = false;
     /** Timeout in milliseconds used in osmd.load(string) when string is a URL. */
@@ -178,8 +179,10 @@ export class OpenSheetMusicDisplay {
     public updateGraphic(): void {
         const calc: MusicSheetCalculator = new VexFlowMusicSheetCalculator(this.rules);
         this.graphic = new GraphicalMusicSheet(this.sheet, calc);
-        if (this.drawingParameters.drawCursors && this.cursor) {
-            this.cursor.init(this.sheet.MusicPartManager, this.graphic);
+        if (this.drawingParameters.drawCursors) {
+            this.cursors.forEach(cursor => {
+                cursor.init(this.sheet.MusicPartManager, this.graphic);
+            });
         }
     }
 
@@ -238,11 +241,13 @@ export class OpenSheetMusicDisplay {
         // Finally, draw
         this.drawer.drawSheet(this.graphic);
 
-        this.enableOrDisableCursor(this.drawingParameters.drawCursors);
+        this.enableOrDisableCursors(this.drawingParameters.drawCursors);
 
-        if (this.drawingParameters.drawCursors && this.cursor) {
+        if (this.drawingParameters.drawCursors) {
             // Update the cursor position
-            this.cursor.update();
+            this.cursors.forEach(cursor => {
+                cursor.update();
+            });
         }
         this.zoomUpdated = false;
         //console.log("[OSMD] render finished");
@@ -570,6 +575,11 @@ export class OpenSheetMusicDisplay {
         if (options.autoGenerateMutipleRestMeasuresFromRestMeasures !== undefined) {
             this.rules.AutoGenerateMutipleRestMeasuresFromRestMeasures = options.autoGenerateMutipleRestMeasuresFromRestMeasures;
         }
+        if (options.cursorsOptions !== undefined) {
+            this.cursorsOptions = options.cursorsOptions;
+        } else {
+            this.cursorsOptions = [{type: 0, color: this.EngravingRules.DefaultColorCursor, alpha: 0.5, follow: options.followCursor}];
+        }
     }
 
     public setColoringMode(options: IOSMDOptions): void {
@@ -651,8 +661,10 @@ export class OpenSheetMusicDisplay {
      * FIXME: Probably unnecessary
      */
     protected reset(): void {
-        if (this.drawingParameters.drawCursors && this.cursor) {
-            this.cursor.hide();
+        if (this.drawingParameters.drawCursors) {
+            this.cursors.forEach(cursor => {
+                cursor.hide();
+            });
         }
         this.sheet = undefined;
         this.graphic = undefined;
@@ -740,41 +752,42 @@ export class OpenSheetMusicDisplay {
     /** Enable or disable (hide) the cursor.
      * @param enable whether to enable (true) or disable (false) the cursor
      */
-    public enableOrDisableCursor(enable: boolean): void {
+    public enableOrDisableCursors(enable: boolean): void {
         this.drawingParameters.drawCursors = enable;
         if (enable) {
-            // save previous cursor state
-            const hidden: boolean = this.cursor?.Hidden;
-            const previousIterator: MusicPartManagerIterator = this.cursor?.Iterator;
-            this.cursor?.hide();
-
-            // check which page/backend to draw the cursor on (the pages may have changed since last cursor)
-            let backendToDrawOn: VexFlowBackend = this.drawer?.Backends[0];
-            if (backendToDrawOn && this.rules.RestoreCursorAfterRerender && this.cursor) {
-                const newPageNumber: number = this.cursor.updateCurrentPage();
-                backendToDrawOn = this.drawer.Backends[newPageNumber - 1];
-            }
-            // create new cursor
-            if (backendToDrawOn && backendToDrawOn.getRenderElement()) {
-                this.cursor = new Cursor(backendToDrawOn.getRenderElement(), this);
-            }
-            if (this.sheet && this.graphic && this.cursor) { // else init is called in load()
-                this.cursor.init(this.sheet.MusicPartManager, this.graphic);
-            }
+            for (let i: number = 0; i < this.cursorsOptions.length; i++){
+                // save previous cursor state
+                const hidden: boolean = this.cursors[i]?.Hidden;
+                const previousIterator: MusicPartManagerIterator = this.cursors[i]?.Iterator;
+                this.cursors[i]?.hide();
+
+                // check which page/backend to draw the cursor on (the pages may have changed since last cursor)
+                let backendToDrawOn: VexFlowBackend = this.drawer?.Backends[0];
+                if (backendToDrawOn && this.rules.RestoreCursorAfterRerender && this.cursors[i]) {
+                    const newPageNumber: number = this.cursors[i].updateCurrentPage();
+                    backendToDrawOn = this.drawer.Backends[newPageNumber - 1];
+                }
+                // create new cursor
+                if (backendToDrawOn && backendToDrawOn.getRenderElement()) {
+                    this.cursors[i] = new Cursor(backendToDrawOn.getRenderElement(), this, this.cursorsOptions[i]);
+                }
+                if (this.sheet && this.graphic && this.cursors[i]) { // else init is called in load()
+                    this.cursors[i].init(this.sheet.MusicPartManager, this.graphic);
+                }
 
-            // restore old cursor state
-            if (this.rules.RestoreCursorAfterRerender) {
-                this.cursor.hidden = hidden;
-                if (previousIterator) {
-                    this.cursor.iterator = previousIterator;
-                    this.cursor.update();
+                // restore old cursor state
+                if (this.rules.RestoreCursorAfterRerender) {
+                    this.cursors[i].hidden = hidden;
+                    if (previousIterator) {
+                        this.cursors[i].iterator = previousIterator;
+                        this.cursors[i].update();
+                    }
                 }
             }
         } else { // disable cursor
-            if (!this.cursor) {
-                return;
-            }
-            this.cursor.hide();
+            this.cursors.forEach(cursor => {
+                cursor.hide();
+            });
             // this.cursor = undefined;
             // TODO cursor should be disabled, not just hidden. otherwise user can just call osmd.cursor.hide().
             // however, this could cause null calls (cursor.next() etc), maybe that needs some solution.


Test patch:
diff --git a/test/Common/OSMD/OSMD_Test.ts b/test/Common/OSMD/OSMD_Test.ts
index e14e2900..bb8a3aaa 100644
--- a/test/Common/OSMD/OSMD_Test.ts
+++ b/test/Common/OSMD/OSMD_Test.ts
@@ -290,12 +290,12 @@ describe("OpenSheetMusicDisplay Main Export", () => {
         it("should move cursor after instrument is hidden", () => {
             osmd.Sheet.Instruments[1].Visible = false;
             osmd.render();
-            osmd.cursor.show();
+            osmd.cursors[0].show();
             for (let i: number = 0; i < 100; i++) {
-                osmd.cursor.next();
+                osmd.cursors[0].next();
             }
             // After 100 steps in the visible score, cursor reached 3rd note from 17, a C
-            chai.expect(osmd.cursor.NotesUnderCursor()[0].halfTone).to.equal(60);
+            chai.expect(osmd.cursors[0].NotesUnderCursor()[0].halfTone).to.equal(60);
         });
     });
     describe("cursor", () => {
@@ -307,7 +307,7 @@ describe("OpenSheetMusicDisplay Main Export", () => {
             opensheetmusicdisplay.load(score).then(
                 (_: {}) => {
                     opensheetmusicdisplay.render();
-                    opensheetmusicdisplay.cursor.show();
+                    opensheetmusicdisplay.cursors[0].show();
                     done();
                 },
                 done
@@ -316,19 +316,19 @@ describe("OpenSheetMusicDisplay Main Export", () => {
 
         describe("get AllVoicesUnderCursor", () => {
             it("retrieves all voices under cursor", () => {
-                const voiceEntries: VoiceEntry[] = opensheetmusicdisplay.cursor.VoicesUnderCursor();
+                const voiceEntries: VoiceEntry[] = opensheetmusicdisplay.cursors[0].VoicesUnderCursor();
                 chai.expect(voiceEntries.length).to.equal(2);
             });
         });
 
         describe("VoicesUnderCursor", () => {
             it("retrieves voices for a specific instrument under cursor", () => {
-                const voiceEntries: VoiceEntry[] = opensheetmusicdisplay.cursor.VoicesUnderCursor();
+                const voiceEntries: VoiceEntry[] = opensheetmusicdisplay.cursors[0].VoicesUnderCursor();
                 chai.expect(voiceEntries.length).to.equal(2);
             });
             it("retrieves all voices under cursor when instrument not specified", () => {
                 const instrument: Instrument = opensheetmusicdisplay.Sheet.Instruments[1];
-                const voiceEntries: VoiceEntry[] = opensheetmusicdisplay.cursor.VoicesUnderCursor(instrument);
+                const voiceEntries: VoiceEntry[] = opensheetmusicdisplay.cursors[0].VoicesUnderCursor(instrument);
                 chai.expect(voiceEntries.length).to.equal(1);
             });
         });
@@ -336,12 +336,12 @@ describe("OpenSheetMusicDisplay Main Export", () => {
         describe("NotesUnderCursor", () => {
             it("gets notes for a specific instrument under cursor", () => {
                 const instrument: Instrument = opensheetmusicdisplay.Sheet.Instruments[0];
-                const notes: Note[] = opensheetmusicdisplay.cursor.NotesUnderCursor(instrument);
+                const notes: Note[] = opensheetmusicdisplay.cursors[0].NotesUnderCursor(instrument);
                 chai.expect(notes.length).to.equal(1);
             });
 
             it("gets all notes under cursor when instrument unspecified", () => {
-                const notes: Note[] = opensheetmusicdisplay.cursor.NotesUnderCursor();
+                const notes: Note[] = opensheetmusicdisplay.cursors[0].NotesUnderCursor();
                 chai.expect(notes.length).to.equal(2);
             });
         });