Cursor.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. import {MusicPartManagerIterator} from "../MusicalScore/MusicParts/MusicPartManagerIterator";
  2. import {MusicPartManager} from "../MusicalScore/MusicParts/MusicPartManager";
  3. import {VoiceEntry} from "../MusicalScore/VoiceData/VoiceEntry";
  4. import {VexFlowStaffEntry} from "../MusicalScore/Graphical/VexFlow/VexFlowStaffEntry";
  5. import {MusicSystem} from "../MusicalScore/Graphical/MusicSystem";
  6. import {OpenSheetMusicDisplay} from "./OpenSheetMusicDisplay";
  7. import {GraphicalMusicSheet} from "../MusicalScore/Graphical/GraphicalMusicSheet";
  8. import {Instrument} from "../MusicalScore/Instrument";
  9. import {Note} from "../MusicalScore/VoiceData/Note";
  10. import {Fraction} from "../Common/DataObjects/Fraction";
  11. import { EngravingRules } from "../MusicalScore/Graphical/EngravingRules";
  12. import { SourceMeasure } from "../MusicalScore/VoiceData/SourceMeasure";
  13. import { StaffLine } from "../MusicalScore/Graphical/StaffLine";
  14. import { GraphicalMeasure } from "../MusicalScore/Graphical/GraphicalMeasure";
  15. import { VexFlowMeasure } from "../MusicalScore/Graphical/VexFlow/VexFlowMeasure";
  16. import { CursorOptions } from "./OSMDOptions";
  17. import { BoundingBox } from "../MusicalScore/Graphical/BoundingBox";
  18. import { GraphicalNote } from "../MusicalScore/Graphical/GraphicalNote";
  19. import { GraphicalStaffEntry } from "../MusicalScore/Graphical/GraphicalStaffEntry";
  20. import { IPlaybackListener } from "../Common/Interfaces/IPlaybackListener";
  21. import { CursorPosChangedData } from "../Common/DataObjects/CursorPosChangedData";
  22. import { PointF2D } from "../Common/DataObjects";
  23. /**
  24. * A cursor which can iterate through the music sheet.
  25. */
  26. export class Cursor implements IPlaybackListener {
  27. constructor(container: HTMLElement, openSheetMusicDisplay: OpenSheetMusicDisplay, cursorOptions: CursorOptions) {
  28. this.container = container;
  29. this.openSheetMusicDisplay = openSheetMusicDisplay;
  30. this.rules = this.openSheetMusicDisplay.EngravingRules;
  31. this.cursorOptions = cursorOptions;
  32. // set cursor id
  33. // TODO add this for the OSMD object as well and refactor this into a util method?
  34. let id: number = 0;
  35. this.cursorElementId = "cursorImg-0";
  36. // find unique cursor id in document
  37. while (document.getElementById(this.cursorElementId)) {
  38. id++;
  39. this.cursorElementId = `cursorImg-${id}`;
  40. }
  41. const curs: HTMLElement = document.createElement("img");
  42. curs.id = this.cursorElementId;
  43. curs.style.position = "absolute";
  44. if (this.cursorOptions.follow === true) {
  45. curs.style.zIndex = "-1";
  46. } else {
  47. curs.style.zIndex = "-2";
  48. }
  49. this.cursorElement = <HTMLImageElement>curs;
  50. this.container.appendChild(curs);
  51. }
  52. public cursorPositionChanged(timestamp: Fraction, data: CursorPosChangedData): void {
  53. // if (this.iterator.CurrentEnrolledTimestamp.lt(timestamp)) {
  54. // this.iterator.moveToNext();
  55. // while (this.iterator.CurrentEnrolledTimestamp.lt(timestamp)) {
  56. // this.iterator.moveToNext();
  57. // }
  58. // } else if (this.iterator.CurrentEnrolledTimestamp.gt(timestamp)) {
  59. // this.iterator = new MusicPartManagerIterator(this.manager.MusicSheet, timestamp);
  60. // }
  61. this.updateWithTimestamp(data.PredictedPosition);
  62. }
  63. public pauseOccurred(o: object): void {
  64. // throw new Error("Method not implemented.");
  65. }
  66. public selectionEndReached(o: object): void {
  67. // throw new Error("Method not implemented.");
  68. }
  69. public resetOccurred(o: object): void {
  70. this.reset();
  71. }
  72. public notesPlaybackEventOccurred(o: object): void {
  73. // throw new Error("Method not implemented.");
  74. }
  75. private container: HTMLElement;
  76. public cursorElement: HTMLImageElement;
  77. /** a unique id of the cursor's HTMLElement in the document.
  78. * Should be constant between re-renders and backend changes,
  79. * but different between different OSMD objects on the same page.
  80. */
  81. public cursorElementId: string;
  82. private openSheetMusicDisplay: OpenSheetMusicDisplay;
  83. private rules: EngravingRules;
  84. private manager: MusicPartManager;
  85. public iterator: MusicPartManagerIterator;
  86. private graphic: GraphicalMusicSheet;
  87. public hidden: boolean = false;
  88. public currentPageNumber: number = 1;
  89. private cursorOptions: CursorOptions;
  90. /** Initialize the cursor. Necessary before using functions like show() and next(). */
  91. public init(manager: MusicPartManager, graphic: GraphicalMusicSheet): void {
  92. this.manager = manager;
  93. this.graphic = graphic;
  94. this.reset();
  95. this.hidden = false;
  96. }
  97. /**
  98. * Make the cursor visible
  99. */
  100. public show(): void {
  101. this.hidden = false;
  102. //this.resetIterator(); // TODO maybe not here? though setting measure range to draw, rerendering, then handling cursor show is difficult
  103. this.update();
  104. }
  105. public resetIterator(): void {
  106. if (!this.openSheetMusicDisplay.Sheet || !this.openSheetMusicDisplay.Sheet.SourceMeasures) { // just a safety measure
  107. console.log("OSMD.Cursor.resetIterator(): sheet or measures were null/undefined.");
  108. return;
  109. }
  110. // set selection start, so that when there's MinMeasureToDraw set, the cursor starts there right away instead of at measure 1
  111. const lastSheetMeasureIndex: number = this.openSheetMusicDisplay.Sheet.SourceMeasures.length - 1; // last measure in data model
  112. let startMeasureIndex: number = this.rules.MinMeasureToDrawIndex;
  113. startMeasureIndex = Math.min(startMeasureIndex, lastSheetMeasureIndex);
  114. let endMeasureIndex: number = this.rules.MaxMeasureToDrawIndex;
  115. endMeasureIndex = Math.min(endMeasureIndex, lastSheetMeasureIndex);
  116. const updateSelectionStart: boolean = this.openSheetMusicDisplay.Sheet && (
  117. !this.openSheetMusicDisplay.Sheet.SelectionStart ||
  118. this.openSheetMusicDisplay.Sheet.SelectionStart.WholeValue < startMeasureIndex) &&
  119. this.openSheetMusicDisplay.Sheet.SourceMeasures.length > startMeasureIndex;
  120. if (updateSelectionStart) {
  121. this.openSheetMusicDisplay.Sheet.SelectionStart = this.openSheetMusicDisplay.Sheet.SourceMeasures[startMeasureIndex].AbsoluteTimestamp;
  122. }
  123. if (this.openSheetMusicDisplay.Sheet && this.openSheetMusicDisplay.Sheet.SourceMeasures.length > endMeasureIndex) {
  124. const lastMeasure: SourceMeasure = this.openSheetMusicDisplay.Sheet.SourceMeasures[endMeasureIndex];
  125. this.openSheetMusicDisplay.Sheet.SelectionEnd = Fraction.plus(lastMeasure.AbsoluteTimestamp, lastMeasure.Duration);
  126. }
  127. this.iterator = this.manager.getIterator();
  128. }
  129. private getStaffEntryFromVoiceEntry(voiceEntry: VoiceEntry): VexFlowStaffEntry {
  130. const measureIndex: number = voiceEntry.ParentSourceStaffEntry.VerticalContainerParent.ParentMeasure.measureListIndex;
  131. const staffIndex: number = voiceEntry.ParentSourceStaffEntry.ParentStaff.idInMusicSheet;
  132. return <VexFlowStaffEntry>this.graphic.findGraphicalStaffEntryFromMeasureList(staffIndex, measureIndex, voiceEntry.ParentSourceStaffEntry);
  133. }
  134. public updateWithTimestamp(timestamp: Fraction): void {
  135. const sheetTimestamp: Fraction = this.manager.absoluteEnrolledToSheetTimestamp(timestamp);
  136. const values: [number, MusicSystem, GraphicalStaffEntry] = this.graphic.calculateXPositionFromTimestamp(sheetTimestamp);
  137. const x: number = values[0];
  138. const currentSystem: MusicSystem = values[1];
  139. this.updateCurrentPageFromSystem(currentSystem);
  140. const previousStaffEntry: GraphicalStaffEntry = values[2];
  141. if (!previousStaffEntry) {
  142. return; // TODO maybe fix calculateXPositionFromTimestamp() instead
  143. }
  144. // for samples starting with a precount measure (e.g. Mozart - An Chloe), the measure number can be 0,
  145. // so without max(n, 1), [topMeasureNumber - 1] would be [-1], causing an error
  146. const topMeasureNumber: number = Math.max(previousStaffEntry.parentMeasure.MeasureNumber, 1);
  147. // we have to find the top measure, otherwise the cursor with type 3 "jumps around" between vertical measures
  148. let topMeasure: GraphicalMeasure;
  149. for (const measure of this.graphic.MeasureList[topMeasureNumber - 1]) {
  150. if (measure) {
  151. topMeasure = measure;
  152. break;
  153. }
  154. }
  155. const points: [PointF2D, PointF2D] = this.graphic.calculateCursorPoints(x, currentSystem);
  156. const y: number = points[0].y;
  157. const height: number = points[1].y - y;
  158. this.updateWidthAndStyle(topMeasure.PositionAndShape, x, y, height);
  159. if (this.openSheetMusicDisplay.FollowCursor) {
  160. const diff: number = this.cursorElement.getBoundingClientRect().top;
  161. this.cursorElement.scrollIntoView({behavior: diff < 1000 ? "smooth" : "auto", block: "center"});
  162. }
  163. // Show cursor
  164. // // Old cursor: this.graphic.Cursors.push(cursor);
  165. this.cursorElement.style.display = "";
  166. }
  167. public update(): void {
  168. if (this.hidden || this.hidden === undefined || this.hidden === null) {
  169. return;
  170. }
  171. this.updateCurrentPage(); // attach cursor to new page DOM if necessary
  172. // this.graphic?.Cursors?.length = 0;
  173. let iterator: MusicPartManagerIterator;
  174. if (this.openSheetMusicDisplay.PlaybackManager) {
  175. iterator = this.openSheetMusicDisplay.PlaybackManager.CursorIterator;
  176. } else {
  177. iterator = this.iterator;
  178. }
  179. // TODO when measure draw range (drawUpToMeasureNumber) was changed, next/update can fail to move cursor. but of course it can be reset before.
  180. const voiceEntries: VoiceEntry[] = iterator.CurrentVisibleVoiceEntries();
  181. if (iterator.EndReached || !iterator.CurrentVoiceEntries || voiceEntries.length === 0) {
  182. return;
  183. }
  184. let x: number = 0, y: number = 0, height: number = 0;
  185. let musicSystem: MusicSystem;
  186. if (iterator.CurrentMeasure.isReducedToMultiRest) {
  187. const multiRestGMeasure: GraphicalMeasure = this.graphic.findGraphicalMeasure(iterator.CurrentMeasureIndex, 0);
  188. const totalRestMeasures: number = multiRestGMeasure.parentSourceMeasure.multipleRestMeasures;
  189. const currentRestMeasureNumber: number = iterator.CurrentMeasure.multipleRestMeasureNumber;
  190. const progressRatio: number = currentRestMeasureNumber / (totalRestMeasures + 1);
  191. const effectiveWidth: number = multiRestGMeasure.PositionAndShape.Size.width - (multiRestGMeasure as VexFlowMeasure).beginInstructionsWidth;
  192. x = multiRestGMeasure.PositionAndShape.AbsolutePosition.x + (multiRestGMeasure as VexFlowMeasure).beginInstructionsWidth + progressRatio * effectiveWidth;
  193. musicSystem = multiRestGMeasure.ParentMusicSystem;
  194. } else {
  195. // get all staff entries inside the current voice entry
  196. const gseArr: VexFlowStaffEntry[] = voiceEntries.map(ve => this.getStaffEntryFromVoiceEntry(ve));
  197. // sort them by x position and take the leftmost entry
  198. const gse: VexFlowStaffEntry =
  199. gseArr.sort((a, b) => a?.PositionAndShape?.AbsolutePosition?.x <= b?.PositionAndShape?.AbsolutePosition?.x ? -1 : 1 )[0];
  200. if(gse){
  201. x = gse.PositionAndShape.AbsolutePosition.x;
  202. musicSystem = gse.parentMeasure.ParentMusicSystem;
  203. }
  204. // debug: change color of notes under cursor (needs re-render)
  205. // for (const gve of gse.graphicalVoiceEntries) {
  206. // for (const note of gve.notes) {
  207. // note.sourceNote.NoteheadColor = "#0000FF";
  208. // }
  209. // }
  210. }
  211. if (!musicSystem) {
  212. return;
  213. }
  214. // y is common for both multirest and non-multirest, given the MusicSystem
  215. y = musicSystem.PositionAndShape.AbsolutePosition.y + musicSystem.StaffLines[0].PositionAndShape.RelativePosition.y ?? 0;
  216. const bottomStaffline: StaffLine = musicSystem.StaffLines[musicSystem.StaffLines.length - 1];
  217. const endY: number = musicSystem.PositionAndShape.AbsolutePosition.y +
  218. bottomStaffline.PositionAndShape.RelativePosition.y + bottomStaffline.StaffHeight;
  219. height = endY - y;
  220. // Update the graphical cursor
  221. const measurePositionAndShape: BoundingBox = this.graphic.findGraphicalMeasure(iterator.CurrentMeasureIndex, 0).PositionAndShape;
  222. this.updateWidthAndStyle(measurePositionAndShape, x, y, height);
  223. if (this.openSheetMusicDisplay.FollowCursor) {
  224. if (!this.openSheetMusicDisplay.EngravingRules.RenderSingleHorizontalStaffline) {
  225. const diff: number = this.cursorElement.getBoundingClientRect().top;
  226. this.cursorElement.scrollIntoView({behavior: diff < 1000 ? "smooth" : "auto", block: "center"});
  227. } else {
  228. this.cursorElement.scrollIntoView({behavior: "smooth", inline: "center"});
  229. }
  230. }
  231. // Show cursor
  232. // // Old cursor: this.graphic.Cursors.push(cursor);
  233. this.cursorElement.style.display = "";
  234. }
  235. public updateWidthAndStyle(measurePositionAndShape: BoundingBox, x: number, y: number, height: number): void {
  236. const cursorElement: HTMLImageElement = this.cursorElement;
  237. let newWidth: number = 0;
  238. let heightCalc: number = height;
  239. switch (this.cursorOptions.type) {
  240. case 1:
  241. cursorElement.style.top = (y * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
  242. cursorElement.style.left = ((x - 1.5) * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
  243. heightCalc = (height * 10.0 * this.openSheetMusicDisplay.zoom);
  244. cursorElement.height = heightCalc;
  245. cursorElement.style.height = heightCalc + "px";
  246. newWidth = 5 * this.openSheetMusicDisplay.zoom;
  247. break;
  248. case 2:
  249. cursorElement.style.top = ((y-2.5) * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
  250. cursorElement.style.left = (x * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
  251. heightCalc = (1.5 * 10.0 * this.openSheetMusicDisplay.zoom);
  252. cursorElement.height = heightCalc;
  253. cursorElement.style.height = heightCalc + "px";
  254. newWidth = 5 * this.openSheetMusicDisplay.zoom;
  255. break;
  256. case 3:
  257. cursorElement.style.top = measurePositionAndShape.AbsolutePosition.y * 10.0 * this.openSheetMusicDisplay.zoom +"px";
  258. cursorElement.style.left = measurePositionAndShape.AbsolutePosition.x * 10.0 * this.openSheetMusicDisplay.zoom +"px";
  259. heightCalc = (height * 10.0 * this.openSheetMusicDisplay.zoom);
  260. cursorElement.height = heightCalc;
  261. cursorElement.style.height = heightCalc + "px";
  262. newWidth = measurePositionAndShape.Size.width * 10 * this.openSheetMusicDisplay.zoom;
  263. break;
  264. case 4:
  265. cursorElement.style.top = measurePositionAndShape.AbsolutePosition.y * 10.0 * this.openSheetMusicDisplay.zoom +"px";
  266. cursorElement.style.left = measurePositionAndShape.AbsolutePosition.x * 10.0 * this.openSheetMusicDisplay.zoom +"px";
  267. heightCalc = (height * 10.0 * this.openSheetMusicDisplay.zoom);
  268. cursorElement.height = heightCalc;
  269. cursorElement.style.height = heightCalc + "px";
  270. newWidth = (x-measurePositionAndShape.AbsolutePosition.x) * 10 * this.openSheetMusicDisplay.zoom;
  271. break;
  272. default:
  273. cursorElement.style.top = (y * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
  274. cursorElement.style.left = ((x - 1.5) * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
  275. heightCalc = (height * 10.0 * this.openSheetMusicDisplay.zoom);
  276. cursorElement.height = heightCalc;
  277. cursorElement.style.height = heightCalc + "px";
  278. newWidth = 3 * 10.0 * this.openSheetMusicDisplay.zoom;
  279. break;
  280. }
  281. if (newWidth !== cursorElement.width) {
  282. cursorElement.width = newWidth;
  283. this.updateStyle(newWidth, this.cursorOptions);
  284. }
  285. }
  286. /**
  287. * Hide the cursor
  288. */
  289. public hide(): void {
  290. // Hide the actual cursor element
  291. this.cursorElement.style.display = "none";
  292. //this.graphic.Cursors.length = 0;
  293. // Forcing the sheet to re-render is not necessary anymore
  294. //if (!this.hidden) {
  295. // this.openSheetMusicDisplay.render();
  296. //}
  297. this.hidden = true;
  298. }
  299. /**
  300. * Go to next entry
  301. */
  302. public next(): void {
  303. this.iterator.moveToNextVisibleVoiceEntry(false); // moveToNext() would not skip notes in hidden (visible = false) parts
  304. this.update();
  305. }
  306. /**
  307. * reset cursor to start
  308. */
  309. public reset(): void {
  310. this.resetIterator();
  311. //this.iterator.moveToNext();
  312. const iterTmp: MusicPartManagerIterator = this.manager.getIterator(this.graphic.ParentMusicSheet.SelectionStart);
  313. this.updateWithTimestamp(iterTmp.CurrentEnrolledTimestamp);
  314. }
  315. private updateStyle(width: number, cursorOptions: CursorOptions = undefined): void {
  316. if (cursorOptions !== undefined) {
  317. this.cursorOptions = cursorOptions;
  318. }
  319. // Create a dummy canvas to generate the gradient for the cursor
  320. // FIXME This approach needs to be improved
  321. const c: HTMLCanvasElement = document.createElement("canvas");
  322. c.width = this.cursorElement.width;
  323. c.height = 1;
  324. const ctx: CanvasRenderingContext2D = c.getContext("2d");
  325. ctx.globalAlpha = this.cursorOptions.alpha;
  326. // Generate the gradient
  327. const gradient: CanvasGradient = ctx.createLinearGradient(0, 0, this.cursorElement.width, 0);
  328. switch (this.cursorOptions.type) {
  329. case 1:
  330. case 2:
  331. case 3:
  332. case 4:
  333. gradient.addColorStop(1, this.cursorOptions.color);
  334. break;
  335. default:
  336. gradient.addColorStop(0, "white"); // it was: "transparent"
  337. gradient.addColorStop(0.2, this.cursorOptions.color);
  338. gradient.addColorStop(0.8, this.cursorOptions.color);
  339. gradient.addColorStop(1, "white"); // it was: "transparent"
  340. break;
  341. }
  342. ctx.fillStyle = gradient;
  343. ctx.fillRect(0, 0, width, 1);
  344. // Set the actual image
  345. this.cursorElement.src = c.toDataURL("image/png");
  346. }
  347. public get Iterator(): MusicPartManagerIterator {
  348. return this.iterator;
  349. }
  350. public get Hidden(): boolean {
  351. return this.hidden;
  352. }
  353. /** returns voices under the current Cursor position. Without instrument argument, all voices are returned. */
  354. public VoicesUnderCursor(instrument?: Instrument): VoiceEntry[] {
  355. return this.iterator.CurrentVisibleVoiceEntries(instrument);
  356. }
  357. public NotesUnderCursor(instrument?: Instrument): Note[] {
  358. const voiceEntries: VoiceEntry[] = this.VoicesUnderCursor(instrument);
  359. const notes: Note[] = [];
  360. voiceEntries.forEach(voiceEntry => {
  361. notes.push.apply(notes, voiceEntry.Notes);
  362. });
  363. return notes;
  364. }
  365. public GNotesUnderCursor(instrument?: Instrument): GraphicalNote[] {
  366. const voiceEntries: VoiceEntry[] = this.VoicesUnderCursor(instrument);
  367. const notes: GraphicalNote[] = [];
  368. voiceEntries.forEach(voiceEntry => {
  369. notes.push(...voiceEntry.Notes.map(note => this.rules.GNote(note)));
  370. });
  371. return notes;
  372. }
  373. /** Check if there was a change in current page, and attach cursor element to the corresponding HTMLElement (div).
  374. * This is only necessary if using PageFormat (multiple pages).
  375. */
  376. public updateCurrentPage(): number {
  377. const timestamp: Fraction = this.iterator.currentTimeStamp;
  378. for (const page of this.graphic.MusicPages) {
  379. const lastSystemTimestamp: Fraction = page.MusicSystems.last().GetSystemsLastTimeStamp();
  380. if (lastSystemTimestamp.gt(timestamp)) {
  381. // gt: the last timestamp of the last system is equal to the first of the next page,
  382. // so we do need to use gt, not gte here.
  383. const newPageNumber: number = page.PageNumber;
  384. if (newPageNumber !== this.currentPageNumber) {
  385. this.container.removeChild(this.cursorElement);
  386. this.container = document.getElementById("osmdCanvasPage" + newPageNumber);
  387. this.container.appendChild(this.cursorElement);
  388. // TODO maybe store this.pageCurrentlyAttachedTo, though right now it isn't necessary
  389. // alternative to remove/append:
  390. // this.openSheetMusicDisplay.enableOrDisableCursor(true);
  391. }
  392. return this.currentPageNumber = newPageNumber;
  393. }
  394. }
  395. return 1;
  396. }
  397. public updateCurrentPageFromSystem(system: MusicSystem): number {
  398. if (system !== undefined) {
  399. const newPageNumber: number = system.Parent.PageNumber;
  400. if (newPageNumber !== this.currentPageNumber) {
  401. this.container.removeChild(this.cursorElement);
  402. this.container = document.getElementById("osmdCanvasPage" + newPageNumber);
  403. this.container.appendChild(this.cursorElement);
  404. // TODO maybe store this.pageCurrentlyAttachedTo, though right now it isn't necessary
  405. // alternative to remove/append:
  406. // this.openSheetMusicDisplay.enableOrDisableCursor(true);
  407. }
  408. return this.currentPageNumber = newPageNumber;
  409. }
  410. return 1;
  411. }
  412. }