smoosic 1.0.34 → 1.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/build/html/smoosic.html +1 -0
  2. package/build/smoosic.js +36 -14
  3. package/package.json +1 -1
  4. package/release/html/smoosic.html +4 -3
  5. package/release/smoosic.js +36 -14
  6. package/release/styles/general.css +15 -0
  7. package/src/application/common.ts +20 -0
  8. package/src/application/eventHandler.ts +44 -8
  9. package/src/render/sui/NoteEntryCaret.ts +739 -0
  10. package/src/render/sui/NoteEntryMediator.ts +58 -0
  11. package/src/render/sui/mapper.ts +22 -4
  12. package/src/render/sui/scoreRender.ts +7 -7
  13. package/src/render/sui/scoreViewOperations.ts +46 -0
  14. package/src/render/sui/tracker.ts +93 -47
  15. package/src/render/vex/vxMeasure.ts +2 -1
  16. package/src/render/vex/vxNote.ts +1 -0
  17. package/src/smo/data/music.ts +17 -0
  18. package/src/smo/data/note.ts +3 -1
  19. package/src/smo/data/noteModifiers.ts +2 -0
  20. package/src/smo/data/scoreModifiers.ts +0 -3
  21. package/src/styles/general.css +22 -0
  22. package/src/ui/components/dialogs/scorePreferences.vue +1 -11
  23. package/types/src/application/application.d.ts +102 -102
  24. package/types/src/application/configuration.d.ts +74 -74
  25. package/types/src/application/dynamicInit.d.ts +1 -1
  26. package/types/src/application/eventHandler.d.ts +78 -78
  27. package/types/src/application/exports.d.ts +494 -494
  28. package/types/src/render/audio/oscillator.d.ts +98 -98
  29. package/types/src/render/audio/player.d.ts +141 -141
  30. package/types/src/render/audio/samples.d.ts +56 -56
  31. package/types/src/render/sui/configuration.d.ts +12 -12
  32. package/types/src/render/sui/formatter.d.ts +151 -151
  33. package/types/src/render/sui/layoutDebug.d.ts +43 -43
  34. package/types/src/render/sui/mapper.d.ts +116 -116
  35. package/types/src/render/sui/renderState.d.ts +88 -88
  36. package/types/src/render/sui/scoreRender.d.ts +93 -93
  37. package/types/src/render/sui/scoreView.d.ts +267 -267
  38. package/types/src/render/sui/scoreViewOperations.d.ts +594 -594
  39. package/types/src/render/sui/scroller.d.ts +34 -34
  40. package/types/src/render/sui/svgPageMap.d.ts +318 -318
  41. package/types/src/render/sui/textEdit.d.ts +310 -310
  42. package/types/src/render/vex/vxMeasure.d.ts +95 -95
  43. package/types/src/smo/data/common.d.ts +220 -220
  44. package/types/src/smo/data/measure.d.ts +510 -510
  45. package/types/src/smo/data/measureModifiers.d.ts +506 -506
  46. package/types/src/smo/data/scoreModifiers.d.ts +433 -433
  47. package/types/src/smo/xform/selections.d.ts +153 -153
  48. package/types/src/ui/common.d.ts +45 -45
  49. package/types/src/ui/configuration.d.ts +31 -31
  50. package/types/src/ui/dialogs/chordChange.d.ts +35 -35
  51. package/types/src/ui/dialogs/components/baseComponent.d.ts +158 -158
  52. package/types/src/ui/dialogs/components/button.d.ts +54 -54
  53. package/types/src/ui/dialogs/components/dropdown.d.ts +78 -78
  54. package/types/src/ui/dialogs/components/pitch.d.ts +95 -95
  55. package/types/src/ui/dialogs/components/rocker.d.ts +66 -66
  56. package/types/src/ui/dialogs/components/textInPlace.d.ts +90 -90
  57. package/types/src/ui/dialogs/components/textInput.d.ts +58 -58
  58. package/types/src/ui/dialogs/components/toggle.d.ts +53 -53
  59. package/types/src/ui/dialogs/dialog.d.ts +201 -201
  60. package/types/src/ui/dialogs/endings.d.ts +61 -61
  61. package/types/src/ui/dialogs/lyric.d.ts +39 -39
  62. package/types/src/ui/dialogs/measureFormat.d.ts +52 -52
  63. package/types/src/ui/dialogs/textBlock.d.ts +61 -61
  64. package/types/src/ui/exceptions.d.ts +12 -12
  65. package/types/src/ui/menus/manager.d.ts +57 -57
  66. package/types/src/ui/navigation.d.ts +15 -15
  67. package/types/typedoc.d.ts +158 -158
  68. package/release/styles/styles.css +0 -0
  69. package/src/styles/fonts/Roboto-Italic-VariableFont_wdth,wght.ttf +0 -0
  70. package/src/styles/fonts/Roboto-VariableFont_wdth,wght.ttf +0 -0
  71. package/src/styles/styles.css +0 -0
@@ -0,0 +1,739 @@
1
+ import {Pitch, SvgBox, Ticks, Transposable} from "../../smo/data/common";
2
+ import {SuiTracker} from "./tracker";
3
+ import {SmoSelection} from "../../smo/xform/selections";
4
+ import {SvgPage} from "./svgPageMap";
5
+ import {SvgHelpers} from "./svgHelpers";
6
+ import {SmoMeasure} from "../../smo/data/measure";
7
+ import {SmoNote} from "../../smo/data/note";
8
+ import {SmoMusic} from "../../smo/data/music";
9
+ import {SuiScoreViewOperations} from "./scoreViewOperations";
10
+ import {SmoGraceNote} from "../../smo/data/noteModifiers";
11
+ import {Note} from "../../common/vex";
12
+ import {GraceNote, StaveNote} from "vexflow_smoosic";
13
+ import {CaretDelegate} from "./NoteEntryMediator";
14
+
15
+ declare var $: any;
16
+
17
+ type PitchHighlightType = 'selection' | 'drag-init';
18
+
19
+ export class NoteEntryCaret {
20
+
21
+ static readonly STAFF_LINE_HEIGHT = 10;
22
+ static readonly STAFF_LINE_COUNT = 5;
23
+ static readonly STAFF_HEIGHT = NoteEntryCaret.STAFF_LINE_HEIGHT * NoteEntryCaret.STAFF_LINE_COUNT;
24
+
25
+ static readonly LEDGER_POSITIONS_ABOVE = 6;
26
+ static readonly LEDGER_POSITIONS_BELOW = 6;
27
+
28
+ static readonly CARET_HEIGHT = NoteEntryCaret.STAFF_HEIGHT +
29
+ (NoteEntryCaret.LEDGER_POSITIONS_ABOVE * NoteEntryCaret.STAFF_LINE_HEIGHT) +
30
+ (NoteEntryCaret.LEDGER_POSITIONS_BELOW * NoteEntryCaret.STAFF_LINE_HEIGHT);
31
+
32
+ // Highlight colors
33
+ static readonly PITCH_SELECTION_COLOR = '#933';
34
+ static readonly PITCH_DRAG_ORIGINAL_COLOR = '#aaaaaa7f'; // Light grey for the pitch being dragged
35
+
36
+ static readonly VOICE_1_NOTEHEAD_COLOR = '#000000';
37
+ static readonly VOICE_2_NOTEHEAD_COLOR = '#115511';
38
+ static readonly VOICE_3_NOTEHEAD_COLOR = '#555511';
39
+ static readonly VOICE_4_NOTEHEAD_COLOR = '#883344';
40
+
41
+ static readonly VOICE_1_PREVIEW_NOTEHEAD_COLOR = '#808080';
42
+ static readonly VOICE_2_PREVIEW_NOTEHEAD_COLOR = '#88aa88';
43
+ static readonly VOICE_3_PREVIEW_NOTEHEAD_COLOR = '#aaaa88';
44
+ static readonly VOICE_4_PREVIEW_NOTEHEAD_COLOR = '#c499a2';
45
+
46
+ static readonly VOICE_1_CURSOR_RECTANGLE_COLOR = '#99aadd';
47
+ static readonly VOICE_2_CURSOR_RECTANGLE_COLOR = '#99dd99';
48
+ static readonly VOICE_3_CURSOR_RECTANGLE_COLOR = '#dddd99';
49
+ static readonly VOICE_4_CURSOR_RECTANGLE_COLOR = '#dd99aa';
50
+
51
+ //TODO: check if this is needed
52
+ static readonly DEFAULT_NOTE_DURATION: Ticks = { numerator: 4096, denominator: 1, remainder: 0 };
53
+ static readonly DEFAULT_NOTE_MODE: 'note' | 'rest' = 'note';
54
+
55
+ caretBoundaryBox: SvgBox;
56
+
57
+ tracker: SuiTracker;
58
+ view: SuiScoreViewOperations;
59
+
60
+ selection: SmoSelection | null = null;
61
+ note: SmoNote | null = null;
62
+ graceNote: SmoGraceNote | null = null;
63
+ activeNote: Transposable | null = null;
64
+ voice: number = 0;
65
+ vexNoteAbsoluteX: number = 0;
66
+ vexNoteLeftDisplacedHeadPx: number = 0;
67
+ vexNoteRightDisplacedHeadPx: number = 0;
68
+ vexNoteHeadWidth: number = 0;
69
+ vexNoteXShift: number = 0;
70
+
71
+ context: SvgPage | undefined;
72
+
73
+ mode: 'note' | 'rest' = NoteEntryCaret.DEFAULT_NOTE_MODE;
74
+ duration: Ticks = NoteEntryCaret.DEFAULT_NOTE_DURATION;
75
+
76
+ cursorRectangleElement: SVGRectElement | null = null;
77
+
78
+ // Pitch preview
79
+ pitchPreviewElement: SVGTextElement | null = null;
80
+ pitchPreviewLedgerElements: SVGLineElement[] = [];
81
+
82
+ // Pitch selection highlight (persistent)
83
+ pitchSelectionElementId: string | null = null;
84
+
85
+ // Drag-init highlight (cleared on mouse leave)
86
+ pitchDragInitElementId: string | null = null;
87
+
88
+ currentStaffLine: number | null = null;
89
+ occupiedStaffLines: number[] = [];
90
+
91
+ staffLineOnMouseUp: number | null = null;
92
+ staffLineOnMouseDown: number | null = null;
93
+
94
+ delegate: CaretDelegate | null = null;
95
+
96
+ constructor(view: SuiScoreViewOperations, tracker: SuiTracker) {
97
+ this.caretBoundaryBox = SvgHelpers.boxPoints(0, 0, 0, 0);
98
+ this.tracker = tracker;
99
+ this.view = view;
100
+ }
101
+
102
+ public setSelection(selection: SmoSelection, graceNote: SmoGraceNote | null = null): void {
103
+ this._unsetPreviousSelection();
104
+ const targetVexNote = graceNote ? graceNote.vexGraceNote : selection.note?.vexNote;
105
+
106
+ if (!targetVexNote) {
107
+ console.warn(`${graceNote ? 'Grace note' : 'Note'} does not have coordinates`);
108
+ return;
109
+ }
110
+
111
+ this.selection = selection;
112
+ this.note = selection.note;
113
+ this.graceNote = graceNote;
114
+ this.activeNote = graceNote ?? selection.note;
115
+ this.voice = selection.selector.voice;
116
+ this.vexNoteAbsoluteX = targetVexNote.getAbsoluteX();
117
+ this.vexNoteHeadWidth = targetVexNote.getMetrics().glyphWidth!;
118
+ this.vexNoteXShift = targetVexNote.getXShift();
119
+ this._calculateDisplacedHeadPosition();
120
+ }
121
+
122
+ public handleMouseDown(ev: any): void {
123
+ this.staffLineOnMouseUp = null;
124
+ if (!this.selection || this.currentStaffLine === null) {
125
+ return;
126
+ }
127
+ this.staffLineOnMouseDown = this.currentStaffLine;
128
+ }
129
+
130
+ public handleMouseMove(ev: any): void {
131
+ const staffLine = this._calculateStaffLineFromY(ev.clientY);
132
+ if (this.currentStaffLine !== staffLine) {
133
+ this.currentStaffLine = staffLine;
134
+ this._renderPitchPreview(staffLine);
135
+ }
136
+
137
+ //drag initiated
138
+ if (this.staffLineOnMouseDown !== null && this.staffLineOnMouseUp === null) {
139
+ const pitchIndex = this._findPitchIndexForStaffLineAndMouseX(this.staffLineOnMouseDown, ev.clientX);
140
+ if (pitchIndex !== -1) {
141
+ // Update visual
142
+ this.renderPitchHighlight(pitchIndex, 'drag-init');
143
+ }
144
+ }
145
+ }
146
+
147
+ public handleMouseUp(ev: any): void {
148
+ this.staffLineOnMouseUp = this.currentStaffLine;
149
+
150
+ if (this.staffLineOnMouseDown === null || this.staffLineOnMouseUp === null) return;
151
+
152
+ if (this.staffLineOnMouseDown === this.staffLineOnMouseUp) return;
153
+
154
+ if (this.occupiedStaffLines.includes(this.staffLineOnMouseDown) && !this.occupiedStaffLines.includes(this.staffLineOnMouseUp)) {
155
+ //drag and drop detected - update staff lines
156
+ this._removeOccupiedStaffLine(this.staffLineOnMouseDown);
157
+ this._addOccupiedStaffLine(this.staffLineOnMouseUp);
158
+
159
+ //calculate new pitches
160
+ const newPitches = this._getPitchesFromOccupiedStaffLines();
161
+
162
+ //notify via callback
163
+ this.delegate?.onPitchesChanged(newPitches);
164
+ }
165
+ }
166
+
167
+ public handleMouseClick(ev: any): void {
168
+ if (!this.selection || this.currentStaffLine === null) {
169
+ return;
170
+ }
171
+
172
+ // Click on existing pitch - select it
173
+ if (this.occupiedStaffLines.includes(this.currentStaffLine) &&
174
+ this.staffLineOnMouseDown === this.staffLineOnMouseUp) {
175
+ const pitchIndex = this._findPitchIndexForStaffLineAndMouseX(this.currentStaffLine, ev.clientX);
176
+ if (pitchIndex !== -1) {
177
+ //notify via callback
178
+ this.delegate?.onPitchClicked(pitchIndex);
179
+ }
180
+ }
181
+ // Click on the staff line without pitch - add it
182
+ else if (!this.occupiedStaffLines.includes(this.currentStaffLine) &&
183
+ this.staffLineOnMouseDown === this.staffLineOnMouseUp) {
184
+ this._addOccupiedStaffLine(this.currentStaffLine);
185
+ const newPitches = this._getPitchesFromOccupiedStaffLines();
186
+ //notify via callback
187
+ this.delegate?.onPitchesChanged(newPitches);
188
+
189
+ this._unrenderPitchHighlight();
190
+ }
191
+ }
192
+
193
+ private _calculateDisplacedHeadPosition(): void {
194
+ const vexNote = this._getActiveVexNote();
195
+ if (!vexNote) {
196
+ return;
197
+ }
198
+
199
+ const staveNote = vexNote as StaveNote;
200
+ const noteHeads = staveNote.noteHeads;
201
+
202
+ this.vexNoteLeftDisplacedHeadPx = staveNote.getMetrics().leftDisplacedHeadPx;
203
+ this.vexNoteRightDisplacedHeadPx = staveNote.getMetrics().rightDisplacedHeadPx;
204
+
205
+ const hasMultipleHeadsOnSameLine = NoteEntryCaret._hasMultipleNoteHeadsOnSameLine(noteHeads);
206
+
207
+ // When there are two pitches on the same line, vexflow does not set values for notehead displacement.
208
+ // In this case we calculate head displacement manually.
209
+ if (hasMultipleHeadsOnSameLine && this.vexNoteLeftDisplacedHeadPx === 0 && this.vexNoteRightDisplacedHeadPx === 0) {
210
+ if (staveNote.getStemDirection() === 1) {
211
+ //stem up, add right displacement
212
+ this.vexNoteRightDisplacedHeadPx = this.vexNoteHeadWidth;
213
+ } else {
214
+ //stem down, add left displacement
215
+ this.vexNoteLeftDisplacedHeadPx = this.vexNoteHeadWidth;
216
+ }
217
+ }
218
+ }
219
+
220
+ private _unsetPreviousSelection(): void {
221
+ this.selection = null;
222
+ this.note = null;
223
+ this.graceNote = null;
224
+ this.activeNote = null;
225
+ this.voice = 0;
226
+ this.vexNoteAbsoluteX = 0;
227
+ this.vexNoteLeftDisplacedHeadPx = 0;
228
+ this.vexNoteRightDisplacedHeadPx = 0;
229
+ this.vexNoteHeadWidth = 0;
230
+ this.vexNoteXShift = 0;
231
+ this.staffLineOnMouseUp = null;
232
+ this.staffLineOnMouseDown = null;
233
+ this._unrenderPitchHighlight();
234
+ }
235
+
236
+ public render(): void {
237
+ this._unrender();
238
+ if (!this.selection || !this.note || !this.activeNote) {
239
+ return;
240
+ }
241
+ this._calculateCaretBoundaryBoxCoordinates(this.selection.measure, this.note!, this.graceNote);
242
+ this._resolveContext();
243
+ this._fillOccupiedStaffLines(this.selection.measure, this.activeNote);
244
+ this._renderCursorRectangleElement();
245
+
246
+ // Render pitch selection highlight if a pitch is selected
247
+ if (this.selection.selector.pitches.length === 1) {
248
+ const pitchIndex = this.selection.selector.pitches[0];
249
+ this.renderPitchHighlight(pitchIndex);
250
+ }
251
+ }
252
+
253
+ private _calculateCaretBoundaryBoxCoordinates(measure: SmoMeasure, note: SmoNote, graceNote: SmoGraceNote | null): void {
254
+ const staffY = measure.staffY;
255
+ // Calculate top Y: staff Y minus ledger lines above
256
+ const y = staffY - (NoteEntryCaret.LEDGER_POSITIONS_ABOVE * NoteEntryCaret.STAFF_LINE_HEIGHT);
257
+
258
+ this.caretBoundaryBox = SvgHelpers.boxPoints(
259
+ this.vexNoteAbsoluteX - this.vexNoteLeftDisplacedHeadPx + this.vexNoteXShift,
260
+ y,
261
+ this.vexNoteHeadWidth + this.vexNoteLeftDisplacedHeadPx + this.vexNoteRightDisplacedHeadPx,
262
+ NoteEntryCaret.CARET_HEIGHT
263
+ );
264
+ }
265
+
266
+ private _resolveContext(): void {
267
+ this.context = this.tracker.renderer.pageMap.getRenderer(this.caretBoundaryBox);
268
+ }
269
+
270
+ private _fillOccupiedStaffLines(measure: SmoMeasure, note: Transposable) {
271
+ this.occupiedStaffLines = [];
272
+ for (const pitch of note.pitches) {
273
+ //todo: we are ignoring rests. For that to work we need to implement some sort of note type picker
274
+ if (note.noteType === 'n') {
275
+ this.occupiedStaffLines.push(SmoMusic.pitchToStaffLine(measure.clef, pitch));
276
+ }
277
+ }
278
+ }
279
+
280
+ private _addOccupiedStaffLine(staffLine: number): void {
281
+ if (!this.occupiedStaffLines.includes(staffLine)) {
282
+ this.occupiedStaffLines.push(staffLine);
283
+ }
284
+ }
285
+
286
+ private _removeOccupiedStaffLine(staffLine: number): void {
287
+ const index = this.occupiedStaffLines.indexOf(staffLine);
288
+ if (index !== -1) {
289
+ this.occupiedStaffLines.splice(index, 1);
290
+ }
291
+ }
292
+
293
+ private _getPitchesFromOccupiedStaffLines(): Pitch[] {
294
+ if (!this.selection) {
295
+ return [];
296
+ }
297
+
298
+ const pitches: Pitch[] = [];
299
+ const clef = this.selection.measure.clef;
300
+
301
+ for (const staffLine of this.occupiedStaffLines) {
302
+ pitches.push(SmoMusic.staffLineToPitch(clef, staffLine));
303
+ }
304
+
305
+ return pitches;
306
+ }
307
+
308
+ private _renderCursorRectangleElement(): void {
309
+ if (this.context) {
310
+ // Adjust coordinates for the page context
311
+ const adjustedY = this.caretBoundaryBox.y - this.context.box.y;
312
+ // Create the cursor rectangle element
313
+ const x = this.vexNoteAbsoluteX + this.vexNoteXShift;
314
+ const y = adjustedY + (NoteEntryCaret.LEDGER_POSITIONS_ABOVE * NoteEntryCaret.STAFF_LINE_HEIGHT) - (NoteEntryCaret.STAFF_LINE_HEIGHT / 2);
315
+ const width = this.vexNoteHeadWidth;
316
+ const height = NoteEntryCaret.STAFF_HEIGHT;
317
+
318
+ const cursorRectangleElement = this._getCursorRectangleElement(x, y, width, height);
319
+ // Add to the correct SVG context - insert at beginning so it appears behind other elements
320
+ this.context.svg.insertBefore(cursorRectangleElement, this.context.svg.firstChild);
321
+ this.cursorRectangleElement = cursorRectangleElement;
322
+ }
323
+ }
324
+
325
+ private _getCursorRectangleElement(x: number, y: number, width: number, height: number): SVGRectElement {
326
+ const color = this._getVoiceCursorRectangleColor();
327
+
328
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
329
+ rect.setAttribute('x', x.toString());
330
+ rect.setAttribute('y', y.toString());
331
+ rect.setAttribute('width', width.toString());
332
+ rect.setAttribute('height', height.toString());
333
+ rect.setAttribute('stroke', 'none');
334
+ rect.setAttribute('fill', color);
335
+ rect.setAttribute('opacity', '0.5');
336
+ rect.setAttribute('class', 'note-entry-caret');
337
+
338
+ return rect;
339
+ }
340
+
341
+ public containsPoint(ev: any): boolean {
342
+ const scrollState = this.tracker?.scroller.scrollState;
343
+ const bb = SvgHelpers.boxPoints(ev.clientX + scrollState.x, ev.clientY + scrollState.y, 1, 1);
344
+ const point = this.tracker.renderer.pageMap.clientToSvg(bb);
345
+
346
+ return SvgHelpers.doesBox1ContainBox2(this.caretBoundaryBox, point);
347
+ }
348
+
349
+ private _renderPitchPreview(staffLine: number): void {
350
+ this._unrenderPitchPreview();
351
+
352
+ if (this.context !== undefined) {
353
+ //check if staffLine has a pitch on it
354
+ //in case it does, do not render pitch preview
355
+ if (!this.occupiedStaffLines.includes(staffLine)) {
356
+ const staffLineY = this._calculateYFromStaffLine(staffLine);
357
+ const adjustedY = staffLineY - this.context.box.y;
358
+
359
+ const x = this.vexNoteAbsoluteX + this.vexNoteXShift;
360
+ // Determine color based on drag state: black during drag, blue otherwise
361
+ const isDragging = this.staffLineOnMouseDown !== null && this.staffLineOnMouseUp === null;
362
+
363
+ const color = isDragging
364
+ ? this._getVoiceNoteheadColor()
365
+ : this._getVoicePreviewColor();
366
+
367
+ // Render ledger lines if needed
368
+ const ledgerPositions = this._getLedgerLinePositions(staffLine);
369
+ for (const ledgerPos of ledgerPositions) {
370
+ const ledgerLine = this._createLedgerLineElement(ledgerPos, color);
371
+ this.context.svg.appendChild(ledgerLine);
372
+ this.pitchPreviewLedgerElements.push(ledgerLine);
373
+ }
374
+
375
+ // Render notehead preview
376
+ const preview = this._getPitchPreviewElement(color);
377
+ preview.setAttribute('x', x.toString());
378
+ preview.setAttribute('y', adjustedY.toString());
379
+
380
+ // Add to the correct SVG context
381
+ this.context.svg.appendChild(preview);
382
+ this.pitchPreviewElement = preview;
383
+ }
384
+ }
385
+ }
386
+
387
+ private _unrenderPitchPreview(): void {
388
+ if (this.pitchPreviewElement) {
389
+ this.pitchPreviewElement.remove();
390
+ this.pitchPreviewElement = null;
391
+ }
392
+ // Remove ledger lines
393
+ for (const ledger of this.pitchPreviewLedgerElements) {
394
+ ledger.remove();
395
+ }
396
+ this.pitchPreviewLedgerElements = [];
397
+ }
398
+
399
+ public renderPitchHighlight(pitchIndex: number, highlightType: PitchHighlightType = 'selection'): void {
400
+ // Only clear the highlight of the type we're about to render
401
+ if (highlightType === 'selection') {
402
+ this._unrenderPitchSelectionHighlight();
403
+ } else {
404
+ this._unrenderPitchDragInitHighlight();
405
+ }
406
+
407
+ if (!this.context || !this.activeNote || !this.selection) {
408
+ return;
409
+ }
410
+
411
+ if (pitchIndex < 0 || pitchIndex >= this.activeNote.pitches.length) {
412
+ return;
413
+ }
414
+
415
+ const vexNote = this._getActiveVexNote();
416
+ if (!vexNote) {
417
+ return;
418
+ }
419
+
420
+ const staveNote = vexNote as StaveNote;
421
+ const noteHeads = staveNote.noteHeads;
422
+
423
+ // Directly access the notehead by pitch index
424
+ // noteHeads array corresponds to pitches array
425
+ const noteHead = noteHeads[pitchIndex];
426
+ if (!noteHead) {
427
+ return;
428
+ }
429
+
430
+ this._highlightNoteHead(noteHead, highlightType);
431
+ }
432
+
433
+ private _highlightNoteHead(noteHead: any, highlightType: PitchHighlightType): void {
434
+ const attrs = noteHead.attrs;
435
+ if (attrs?.id) {
436
+ // VexFlow adds 'vf-' prefix to element IDs
437
+ const elementId = `vf-${attrs.id}`;
438
+ const element = document.getElementById(elementId);
439
+
440
+ if (element) {
441
+ // Find the first <path> element inside
442
+ const pathElement = element.querySelector('path');
443
+ if (!pathElement) {
444
+ return;
445
+ }
446
+
447
+ // Apply highlight color based on type and store element ID
448
+ if (highlightType === 'selection') {
449
+ this.pitchSelectionElementId = elementId;
450
+ // Pitch selection: red color
451
+ pathElement.setAttribute('fill', NoteEntryCaret.PITCH_SELECTION_COLOR);
452
+ pathElement.setAttribute('stroke', NoteEntryCaret.PITCH_SELECTION_COLOR);
453
+ } else {
454
+ this.pitchDragInitElementId = elementId;
455
+ // Drag initiation: light grey color
456
+ pathElement.setAttribute('fill', NoteEntryCaret.PITCH_DRAG_ORIGINAL_COLOR);
457
+ pathElement.setAttribute('stroke', NoteEntryCaret.PITCH_DRAG_ORIGINAL_COLOR);
458
+ }
459
+ }
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Unrenders both pitch selection and drag-init highlights
465
+ */
466
+ private _unrenderPitchHighlight(): void {
467
+ this._unrenderPitchSelectionHighlight();
468
+ this._unrenderPitchDragInitHighlight();
469
+ }
470
+
471
+ /**
472
+ * Unrenders only the pitch selection highlight (persistent red highlight)
473
+ */
474
+ private _unrenderPitchSelectionHighlight(): void {
475
+ if (this.pitchSelectionElementId) {
476
+ const element = document.getElementById(this.pitchSelectionElementId);
477
+
478
+ if (element) {
479
+ const pathElement = element.querySelector('path');
480
+ if (pathElement) {
481
+ const color = this._getVoiceNoteheadColor();
482
+ pathElement.setAttribute('fill', color);
483
+ pathElement.setAttribute('stroke', color);
484
+ }
485
+ }
486
+
487
+ // Clear reference
488
+ this.pitchSelectionElementId = null;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Unrenders only the drag-init highlight (temporary highlight during drag)
494
+ */
495
+ private _unrenderPitchDragInitHighlight(): void {
496
+ if (this.pitchDragInitElementId) {
497
+ const element = document.getElementById(this.pitchDragInitElementId);
498
+
499
+ if (element) {
500
+ const pathElement = element.querySelector('path');
501
+ if (pathElement) {
502
+ const color = this._getVoiceNoteheadColor();
503
+ pathElement.setAttribute('fill', color);
504
+ pathElement.setAttribute('stroke', color);
505
+ }
506
+ }
507
+
508
+ // Clear reference
509
+ this.pitchDragInitElementId = null;
510
+ }
511
+ }
512
+
513
+ private _getPitchPreviewElement(color: string): SVGTextElement {
514
+ // Create pitch preview element - use Unicode note head character
515
+ const preview = document.createElementNS('http://www.w3.org/2000/svg', 'text');
516
+ preview.textContent = '\uE0A4'; // Unicode character U+E0A4
517
+ const fontSize = this.graceNote !== null ? 27 : 41;
518
+ preview.setAttribute('fill', color);
519
+ preview.setAttribute('stroke', color);
520
+ preview.setAttribute('opacity', '1');
521
+ preview.setAttribute('font-family', 'Bravura, serif');
522
+ preview.setAttribute('font-size', fontSize.toString());
523
+ preview.setAttribute('lengthAdjust', 'spacingAndGlyphs');
524
+
525
+ preview.setAttribute('class', 'pitch-preview');
526
+
527
+ return preview;
528
+ }
529
+
530
+ private _getVoiceNoteheadColor(): string {
531
+ const colors = [
532
+ NoteEntryCaret.VOICE_1_NOTEHEAD_COLOR,
533
+ NoteEntryCaret.VOICE_2_NOTEHEAD_COLOR,
534
+ NoteEntryCaret.VOICE_3_NOTEHEAD_COLOR,
535
+ NoteEntryCaret.VOICE_4_NOTEHEAD_COLOR,
536
+ ];
537
+
538
+ return colors[this.voice] ?? NoteEntryCaret.VOICE_1_NOTEHEAD_COLOR;
539
+ }
540
+
541
+ private _getVoicePreviewColor(): string {
542
+ const colors = [
543
+ NoteEntryCaret.VOICE_1_PREVIEW_NOTEHEAD_COLOR,
544
+ NoteEntryCaret.VOICE_2_PREVIEW_NOTEHEAD_COLOR,
545
+ NoteEntryCaret.VOICE_3_PREVIEW_NOTEHEAD_COLOR,
546
+ NoteEntryCaret.VOICE_4_PREVIEW_NOTEHEAD_COLOR,
547
+ ];
548
+
549
+ return colors[this.voice] ?? NoteEntryCaret.VOICE_1_PREVIEW_NOTEHEAD_COLOR;
550
+ }
551
+
552
+ private _getVoiceCursorRectangleColor(): string {
553
+ const colors = [
554
+ NoteEntryCaret.VOICE_1_CURSOR_RECTANGLE_COLOR,
555
+ NoteEntryCaret.VOICE_2_CURSOR_RECTANGLE_COLOR,
556
+ NoteEntryCaret.VOICE_3_CURSOR_RECTANGLE_COLOR,
557
+ NoteEntryCaret.VOICE_4_CURSOR_RECTANGLE_COLOR,
558
+ ];
559
+
560
+ return colors[this.voice] ?? NoteEntryCaret.VOICE_1_PREVIEW_NOTEHEAD_COLOR;
561
+ }
562
+
563
+ //todo: vexnote has maxLine and minLine and maybe we should use these properties instead of calculating
564
+ private _getLedgerLinePositions(staffLine: number): number[] {
565
+ const positions: number[] = [];
566
+
567
+ if (staffLine >= 6) {
568
+ // Find highest existing pitch above staff
569
+ const occupiedAbove = this.occupiedStaffLines.filter(l => l >= 6);
570
+ const highestOccupied = occupiedAbove.length > 0 ? Math.max(...occupiedAbove) : 5;
571
+
572
+ // Only draw ledger lines beyond the highest existing pitch
573
+ const startLine = Math.max(6, Math.floor(highestOccupied + 1));
574
+ for (let line = startLine; line <= Math.floor(staffLine); line++) {
575
+ positions.push(line);
576
+ }
577
+ } else if (staffLine <= 0) {
578
+ // Find lowest existing pitch below staff
579
+ const occupiedBelow = this.occupiedStaffLines.filter(l => l <= 0);
580
+ const lowestOccupied = occupiedBelow.length > 0 ? Math.min(...occupiedBelow) : 1;
581
+
582
+ // Only draw ledger lines beyond the lowest existing pitch
583
+ const startLine = Math.min(0, Math.ceil(lowestOccupied - 1));
584
+ for (let line = startLine; line >= Math.ceil(staffLine); line--) {
585
+ positions.push(line);
586
+ }
587
+ }
588
+
589
+ return positions;
590
+ }
591
+
592
+ private _createLedgerLineElement(staffLine: number, color: string): SVGLineElement {
593
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
594
+
595
+ const y = this._calculateYFromStaffLine(staffLine) - this.context!.box.y;
596
+ // const x = this.vexNoteAbsoluteX + this.vexNoteXShift;
597
+
598
+ const ledgerLineWidth = 15;
599
+ const x1 = this.vexNoteAbsoluteX + this.vexNoteXShift - 3;
600
+ const x2 = x1 + ledgerLineWidth + 3;
601
+
602
+ line.setAttribute('x1', x1.toString());
603
+ line.setAttribute('y1', y.toString());
604
+ line.setAttribute('x2', x2.toString());
605
+ line.setAttribute('y2', y.toString());
606
+ line.setAttribute('stroke-width', '1.4');
607
+ line.setAttribute('fill', 'none');
608
+ line.setAttribute('stroke', color);
609
+ line.setAttribute('class', 'pitch-preview-ledger');
610
+
611
+ return line;
612
+ }
613
+
614
+ private _calculateStaffLineFromY(mouseY: number): number {
615
+ const scrollState = this.tracker?.scroller.scrollState;
616
+ const bb = SvgHelpers.boxPoints(0, mouseY + scrollState.y, 1, 1);
617
+ const mouseSvgPoint = this.tracker.renderer.pageMap.clientToSvg(bb);
618
+
619
+ const relativeY = mouseSvgPoint.y - this.caretBoundaryBox.y;
620
+
621
+ const firstLegderBellowStaffY = NoteEntryCaret.LEDGER_POSITIONS_ABOVE * NoteEntryCaret.STAFF_LINE_HEIGHT + NoteEntryCaret.STAFF_HEIGHT;
622
+
623
+ // Calculate staff line position from Y coordinate
624
+ const staffLine = Math.round((firstLegderBellowStaffY - relativeY) / (NoteEntryCaret.STAFF_LINE_HEIGHT / 2)) / 2;
625
+
626
+ return staffLine;
627
+ }
628
+
629
+ private _calculateYFromStaffLine(staffLine: number): number {
630
+ const firstLegderBellowStaffY = NoteEntryCaret.LEDGER_POSITIONS_ABOVE * NoteEntryCaret.STAFF_LINE_HEIGHT + NoteEntryCaret.STAFF_HEIGHT;
631
+ const relativeY = firstLegderBellowStaffY - (staffLine * NoteEntryCaret.STAFF_LINE_HEIGHT);
632
+
633
+ return relativeY + this.caretBoundaryBox.y;
634
+ }
635
+
636
+ private _unrender(): void {
637
+ // Restore pitch highlight colors before removing elements
638
+ this._unrenderPitchHighlight();
639
+
640
+ // Remove caret from display
641
+ if (this.cursorRectangleElement) {
642
+ this.cursorRectangleElement.remove();
643
+ this.cursorRectangleElement = null;
644
+ }
645
+ // Also clear pitch preview
646
+ this.resetInteraction();
647
+ }
648
+
649
+ public resetInteraction(): void {
650
+ // Clear drag-init highlight when mouse leaves the caret boundary
651
+ // but preserve pitch selection highlight (if any)
652
+ this._unrenderPitchDragInitHighlight();
653
+ this._unrenderPitchPreview();
654
+ this.currentStaffLine = null;
655
+ this.staffLineOnMouseDown = null;
656
+ this.staffLineOnMouseUp = null;
657
+ }
658
+
659
+ private _getActiveVexNote(): Note | GraceNote | null {
660
+ if (this.graceNote) {
661
+ return this.graceNote.vexGraceNote;
662
+ }
663
+ return this.note?.vexNote ?? null;
664
+ }
665
+
666
+ private static _hasMultipleNoteHeadsOnSameLine(noteHeads: any[]): boolean {
667
+ const staffLineCounts = new Map<number, number>();
668
+
669
+ for (const noteHead of noteHeads) {
670
+ const staffLine = noteHead.getLine();
671
+ const count = (staffLineCounts.get(staffLine) || 0) + 1;
672
+
673
+ // Found two noteheads on the same line
674
+ if (count > 1) {
675
+ return true;
676
+ }
677
+
678
+ staffLineCounts.set(staffLine, count);
679
+ }
680
+
681
+ return false;
682
+ }
683
+
684
+ private _findPitchIndexForStaffLineAndMouseX(staffLine: number, mouseX: number): number {
685
+ if (!this.selection || !this.activeNote) {
686
+ return -1;
687
+ }
688
+
689
+ const vexNote = this._getActiveVexNote();
690
+ if (!vexNote) {
691
+ return -1;
692
+ }
693
+
694
+ const staveNote = vexNote as StaveNote;
695
+ const noteHeads = staveNote.noteHeads;
696
+
697
+ // Find all noteheads on this staff line
698
+ const matchingNoteHeads: { index: number; noteHead: any; x: number }[] = [];
699
+
700
+ noteHeads.forEach((noteHead, index) => {
701
+ if (noteHead.getLine() === staffLine) {
702
+ // Get the X position of the notehead
703
+ const noteHeadX = noteHead.getAbsoluteX();
704
+ matchingNoteHeads.push({ index, noteHead, x: noteHeadX });
705
+ }
706
+ });
707
+
708
+ if (matchingNoteHeads.length === 0) {
709
+ return -1;
710
+ }
711
+
712
+ // If only one notehead on this staff line, return it
713
+ if (matchingNoteHeads.length === 1) {
714
+ return matchingNoteHeads[0].index;
715
+ }
716
+
717
+ // Multiple noteheads on same staff line - find which one contains the mouse X
718
+ // Convert mouse clientX to SVG coordinates
719
+ const scrollState = this.tracker?.scroller.scrollState;
720
+ const bb = SvgHelpers.boxPoints(mouseX + scrollState.x, 0, 1, 1);
721
+ const mouseSvgPoint = this.tracker.renderer.pageMap.clientToSvg(bb);
722
+ const mouseSvgX = mouseSvgPoint.x;
723
+
724
+ // Find the notehead that contains the mouse X position
725
+ for (const match of matchingNoteHeads) {
726
+ const noteHeadWidth = match.noteHead.getWidth();
727
+ const noteHeadStartX = match.x;
728
+ const noteHeadEndX = noteHeadStartX + noteHeadWidth;
729
+
730
+ // Check if mouse X is within this notehead's bounds
731
+ if (mouseSvgX >= noteHeadStartX && mouseSvgX <= noteHeadEndX) {
732
+ return match.index;
733
+ }
734
+ }
735
+
736
+ // If no notehead contains the mouse (clicked between noteheads), return -1
737
+ return -1;
738
+ }
739
+ }