From b57f702025ed3508cc5d55a927e23e0da7909f4d Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Sun, 28 Jun 2026 20:24:04 -0400 Subject: [PATCH 1/7] complete phases 1 and 2 of PLAN.md for events --- EXAMPLES.md | 294 ++++++++++++++++++++++++++++++++++++++++++++ PLAN.md | 113 +++++++++++++++++ src/draw.ts | 155 ++++++++++++++++++++--- src/hit.test.ts | 134 ++++++++++++++++++++ src/hit.ts | 146 ++++++++++++++++++++++ src/render.ts | 22 +++- src/targets.test.ts | 187 ++++++++++++++++++++++++++++ src/targets.ts | 217 ++++++++++++++++++++++++++++++++ 8 files changed, 1249 insertions(+), 19 deletions(-) create mode 100644 EXAMPLES.md create mode 100644 PLAN.md create mode 100644 src/hit.test.ts create mode 100644 src/hit.ts create mode 100644 src/targets.test.ts create mode 100644 src/targets.ts diff --git a/EXAMPLES.md b/EXAMPLES.md new file mode 100644 index 000000000..0af215ed7 --- /dev/null +++ b/EXAMPLES.md @@ -0,0 +1,294 @@ +- accent-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/accent-element/ SKIP: already tested +- accidental-mark-element-notation: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/accidental-mark-element-notation/ +- accidental-mark-element-ornament: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/accidental-mark-element-ornament/ +- accidental-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/accidental-element/ SKIP: already tested +- accordion-high-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/accordion-high-element/ SKIP: esoteric +- accordion-low-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/accordion-low-element/ SKIP: esoteric +- accordion-middle-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/accordion-middle-element/ SKIP: esoteric +- accordion-registration-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/accordion-registration-element/ SKIP: esoteric +- alter-element-microtones: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/alter-element-microtones/ SKIP: esoteric +- alter-element-semitones: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/alter-element-semitones/ SKIP: already tested +- arpeggiate-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/arpeggiate-element/ SKIP: already tested +- arrow-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/arrow-element/ +- arrowhead-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/arrowhead-element/ +- articulations-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/articulations-element/ SKIP: already tested +- artificial-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/artificial-element/ +- assess-and-player-elements: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/assess-and-player-elements/ SKIP: esoteric +- attributes-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/attributes-element/ SKIP: already tested +- backup-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/backup-element/ SKIP: already tested +- barline-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/barline-element/ +- barre-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/barre-element/ SKIP: already tested +- bass-alter-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/bass-alter-element/ SKIP: already tested +- bass-separator-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/bass-separator-element/ +- bass-step-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/bass-step-element/ SKIP: already tested +- beam-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/beam-element/ SKIP: already tested +- beat-repeat-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/beat-repeat-element/ +- beat-type-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/beat-type-element/ SKIP: already tested +- beat-unit-dot-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/beat-unit-dot-element/ SKIP: already tested +- beat-unit-tied-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/beat-unit-tied-element/ +- beat-unit-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/beat-unit-element/ SKIP: already tested +- beater-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/beater-element/ SKIP: esoteric +- beats-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/beats-element/ SKIP: already tested +- bend-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/bend-element/ SKIP: already tested +- bookmark-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/bookmark-element/ SKIP: esoteric +- bracket-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/bracket-element/ +- brass-bend-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/brass-bend-element/ +- breath-mark-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/breath-mark-element/ +- caesura-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/caesura-element/ +- cancel-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/cancel-element/ SKIP: already tested +- capo-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/capo-element/ SKIP: esoteric +- chord-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/chord-element/ SKIP: already tested +- chord-element-multiple-stop: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/chord-element-multiple-stop/ SKIP: already tested +- circular-arrow-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/circular-arrow-element/ +- coda-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/coda-element/ +- concert-score-and-for-part-elements: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/concert-score-and-for-part-elements/ SKIP: esoteric +- credit-image-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/credit-image-element/ SKIP: esoteric +- credit-symbol-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/credit-symbol-element/ SKIP: esoteric +- credit-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/credit-element/ SKIP: esoteric +- cue-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/cue-element/ +- damp-all-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/damp-all-element/ SKIP: esoteric +- damp-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/damp-element/ SKIP: esoteric +- dashes-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/dashes-element/ +- defaults-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/defaults-element/ SKIP: esoteric +- degree-alter-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/degree-alter-element/ SKIP: already tested +- degree-type-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/degree-type-element/ SKIP: already tested +- degree-value-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/degree-value-element/ SKIP: already tested +- delayed-inverted-turn-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/delayed-inverted-turn-element/ +- delayed-turn-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/delayed-turn-element/ +- detached-legato-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/detached-legato-element/ +- divisions-and-duration-elements: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/divisions-and-duration-elements/ SKIP: already tested +- doit-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/doit-element/ +- dot-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/dot-element/ SKIP: already tested +- double-tongue-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/double-tongue-element/ +- double-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/double-element/ SKIP: esoteric +- down-bow-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/down-bow-element/ +- effect-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/effect-element/ SKIP: esoteric +- elision-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/elision-element/ +- end-line-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/end-line-element/ SKIP: esoteric +- end-paragraph-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/end-paragraph-element/ SKIP: esoteric +- ending-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/ending-element/ +- ensemble-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/ensemble-element/ SKIP: esoteric +- except-voice-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/except-voice-element/ SKIP: esoteric +- extend-element-figure: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/extend-element-figure/ +- extend-element-lyric: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/extend-element-lyric/ +- eyeglasses-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/eyeglasses-element/ SKIP: esoteric +- f-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/f-element/ +- falloff-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/falloff-element/ +- fermata-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/fermata-element/ SKIP: already tested +- ff-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/ff-element/ +- fff-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/fff-element/ +- ffff-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/ffff-element/ +- fffff-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/fffff-element/ SKIP: esoteric +- ffffff-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/ffffff-element/ SKIP: esoteric +- figure-number-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/figure-number-element/ +- fingering-element-frame: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/fingering-element-frame/ SKIP: already tested +- fingering-element-notation: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/fingering-element-notation/ +- fingernails-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/fingernails-element/ SKIP: esoteric +- flip-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/flip-element/ +- footnote-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/footnote-element/ SKIP: esoteric +- forward-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/forward-element/ SKIP: already tested +- fp-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/fp-element/ +- fret-element-frame: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/fret-element-frame/ SKIP: already tested +- fz-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/fz-element/ +- glass-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/glass-element/ SKIP: esoteric +- glissando-element-multiple: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/glissando-element-multiple/ +- glissando-element-single: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/glissando-element-single/ +- glyph-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/glyph-element/ SKIP: esoteric +- golpe-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/golpe-element/ SKIP: esoteric +- grace-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/grace-element/ SKIP: already tested +- grace-element-appoggiatura: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/grace-element-appoggiatura/ SKIP: already tested +- group-abbreviation-display-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/group-abbreviation-display-element/ SKIP: esoteric +- group-abbreviation-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/group-abbreviation-element/ SKIP: esoteric +- group-barline-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/group-barline-element/ SKIP: already tested +- group-name-display-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/group-name-display-element/ SKIP: esoteric +- group-time-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/group-time-element/ SKIP: already tested +- grouping-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/grouping-element/ SKIP: esoteric +- half-muted-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/half-muted-element/ +- handbell-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/handbell-element/ SKIP: esoteric +- harmon-mute-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/harmon-mute-element/ +- harp-pedals-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/harp-pedals-element/ SKIP: esoteric +- haydn-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/haydn-element/ +- heel-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/heel-element/ SKIP: esoteric +- heel-toe-substitution: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/heel-toe-substitution/ SKIP: esoteric +- hole-type-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/hole-type-element/ SKIP: esoteric +- hole-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/hole-element/ SKIP: esoteric +- humming-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/humming-element/ SKIP: esoteric +- identification-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/identification-element/ SKIP: esoteric +- image-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/image-element/ SKIP: esoteric +- instrument-change-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/instrument-change-element/ SKIP: esoteric +- instrument-link-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/instrument-link-element/ SKIP: esoteric +- interchangeable-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/interchangeable-element/ +- inversion-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/inversion-element/ SKIP: already tested +- inverted-mordent-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/inverted-mordent-element/ +- inverted-turn-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/inverted-turn-element/ +- inverted-vertical-turn-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/inverted-vertical-turn-element/ +- ipa-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/ipa-element/ SKIP: esoteric +- key-octave-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/key-octave-element/ SKIP: esoteric +- key-element-non-traditional: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/key-element-non-traditional/ SKIP: esoteric +- key-element-traditional: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/key-element-traditional/ SKIP: already tested +- kind-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/kind-element/ SKIP: already tested +- laughing-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/laughing-element/ SKIP: esoteric +- level-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/level-element/ SKIP: esoteric +- line-detail-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/line-detail-element/ SKIP: esoteric +- line-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/line-element/ +- link-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/link-element/ SKIP: esoteric +- lyric-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/lyric-element/ +- measure-distance-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/measure-distance-element/ SKIP: esoteric +- measure-numbering-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/measure-numbering-element/ SKIP: already tested +- measure-repeat-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/measure-repeat-element/ +- membrane-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/membrane-element/ SKIP: esoteric +- metal-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/metal-element/ SKIP: esoteric +- metronome-arrows-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/metronome-arrows-element/ SKIP: esoteric +- metronome-note-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/metronome-note-element/ +- metronome-tied-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/metronome-tied-element/ SKIP: esoteric +- metronome-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/metronome-element/ SKIP: already tested +- mf-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/mf-element/ +- midi-device-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/midi-device-element/ SKIP: esoteric +- midi-instrument-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/midi-instrument-element/ SKIP: esoteric +- midi-name-and-midi-bank-elements: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/midi-name-and-midi-bank-elements/ SKIP: esoteric +- midi-unpitched-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/midi-unpitched-element/ SKIP: esoteric +- mordent-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/mordent-element/ +- movement-number-and-movement-title-elements: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/movement-number-and-movement-title-elements/ SKIP: esoteric +- mp-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/mp-element/ +- multiple-rest-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/multiple-rest-element/ +- n-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/n-element/ SKIP: esoteric +- natural-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/natural-element/ SKIP: already tested +- non-arpeggiate-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/non-arpeggiate-element/ +- normal-dot-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/normal-dot-element/ SKIP: esoteric +- notehead-text-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/notehead-text-element/ +- numeral-alter-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/numeral-alter-element/ SKIP: already tested +- numeral-key-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/numeral-key-element/ SKIP: esoteric +- numeral-root-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/numeral-root-element/ SKIP: already tested +- octave-change-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/octave-change-element/ SKIP: esoteric +- octave-shift-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/octave-shift-element/ +- octave-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/octave-element/ SKIP: already tested +- open-string-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/open-string-element/ SKIP: already tested +- open-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/open-element/ +- p-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/p-element/ +- pan-and-elevation-elements: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/pan-and-elevation-elements/ SKIP: esoteric +- part-abbreviation-display-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/part-abbreviation-display-element/ SKIP: esoteric +- part-link-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/part-link-element/ SKIP: esoteric +- part-name-display-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/part-name-display-element/ SKIP: esoteric +- part-symbol-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/part-symbol-element/ SKIP: already tested +- pedal-element-lines: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/pedal-element-lines/ SKIP: already tested +- pedal-element-symbols: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/pedal-element-symbols/ SKIP: already tested +- per-minute-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/per-minute-element/ SKIP: already tested +- pf-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/pf-element/ +- pitch-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/pitch-element/ SKIP: already tested +- pitched-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/pitched-element/ SKIP: esoteric +- plop-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/plop-element/ SKIP: esoteric +- pluck-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/pluck-element/ SKIP: esoteric +- pp-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/pp-element/ +- ppp-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/ppp-element/ +- pppp-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/pppp-element/ +- ppppp-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/ppppp-element/ SKIP: esoteric +- pppppp-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/pppppp-element/ SKIP: esoteric +- pre-bend-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/pre-bend-element/ SKIP: already tested +- prefix-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/prefix-element/ +- principal-voice-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/principal-voice-element/ SKIP: esoteric +- rehearsal-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/rehearsal-element/ +- release-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/release-element/ SKIP: already tested +- repeat-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/repeat-element/ +- rest-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/rest-element/ SKIP: already tested +- rf-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/rf-element/ +- rfz-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/rfz-element/ +- root-alter-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/root-alter-element/ SKIP: already tested +- root-step-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/root-step-element/ SKIP: already tested +- schleifer-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/schleifer-element/ +- scoop-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/scoop-element/ SKIP: esoteric +- scordatura-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/scordatura-element/ SKIP: esoteric +- score-timewise-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/score-timewise-element/ SKIP: esoteric +- segno-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/segno-element/ +- semi-pitched-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/semi-pitched-element/ SKIP: esoteric +- senza-misura-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/senza-misura-element/ +- sf-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/sf-element/ +- sffz-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/sffz-element/ +- sfp-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/sfp-element/ +- sfpp-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/sfpp-element/ +- sfz-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/sfz-element/ +- sfzp-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/sfzp-element/ +- shake-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/shake-element/ +- slash-type-and-slash-dot-elements: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/slash-type-and-slash-dot-elements/ SKIP: esoteric +- slash-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/slash-element/ SKIP: esoteric +- slide-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/slide-element/ +- slur-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/slur-element/ SKIP: already tested +- smear-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/smear-element/ SKIP: esoteric +- snap-pizzicato-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/snap-pizzicato-element/ +- soft-accent-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/soft-accent-element/ +- spiccato-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/spiccato-element/ +- staccatissimo-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/staccatissimo-element/ SKIP: already tested +- staccato-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/staccato-element/ SKIP: already tested +- staff-distance-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/staff-distance-element/ SKIP: esoteric +- staff-divide-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/staff-divide-element/ SKIP: esoteric +- staff-lines-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/staff-lines-element/ SKIP: already tested +- staff-size-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/staff-size-element/ SKIP: esoteric +- staff-tuning-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/staff-tuning-element/ SKIP: already tested +- staff-type-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/staff-type-element/ SKIP: already tested +- staff-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/staff-element/ SKIP: already tested +- staves-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/staves-element/ SKIP: already tested +- step-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/step-element/ SKIP: already tested +- stick-location-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/stick-location-element/ SKIP: esoteric +- stick-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/stick-element/ SKIP: esoteric +- stopped-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/stopped-element/ +- straight-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/straight-element/ SKIP: esoteric +- stress-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/stress-element/ +- string-mute-element-off: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/string-mute-element-off/ +- string-mute-element-on: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/string-mute-element-on/ +- strong-accent-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/strong-accent-element/ SKIP: already tested +- suffix-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/suffix-element/ SKIP: esoteric +- supports-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/supports-element/ SKIP: esoteric +- swing-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/swing-element/ SKIP: esoteric +- syllabic-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/syllabic-element/ +- symbol-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/symbol-element/ SKIP: esoteric +- sync-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/sync-element/ SKIP: esoteric +- system-distance-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/system-distance-element/ SKIP: esoteric +- system-dividers-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/system-dividers-element/ SKIP: esoteric +- tap-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tap-element/ SKIP: already tested +- technical-element-tablature: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/technical-element-tablature/ SKIP: already tested +- tenuto-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tenuto-element/ SKIP: already tested +- thumb-position-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/thumb-position-element/ +- tied-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tied-element/ SKIP: already tested +- time-modification-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/time-modification-element/ +- timpani-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/timpani-element/ SKIP: esoteric +- toe-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/toe-element/ SKIP: esoteric +- transpose-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/transpose-element/ SKIP: esoteric +- tremolo-element-double: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tremolo-element-double/ +- tremolo-element-single: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tremolo-element-single/ +- trill-mark-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/trill-mark-element/ +- triple-tongue-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/triple-tongue-element/ SKIP: esoteric +- tuplet-dot-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tuplet-dot-element/ SKIP: esoteric +- tuplet-element-nested: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tuplet-element-nested/ SKIP: already tested +- tuplet-element-regular: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tuplet-element-regular/ SKIP: already tested +- turn-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/turn-element/ +- unpitched-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/unpitched-element/ SKIP: esoteric +- unstress-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/unstress-element/ SKIP: esoteric +- up-bow-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/up-bow-element/ +- vertical-turn-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/vertical-turn-element/ +- virtual-instrument-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/virtual-instrument-element/ SKIP: esoteric +- voice-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/voice-element/ SKIP: already tested +- wait-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/wait-element/ SKIP: esoteric +- wavy-line-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/wavy-line-element/ SKIP: esoteric +- wedge-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/wedge-element/ +- with-bar-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/with-bar-element/ SKIP: already tested +- wood-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/wood-element/ SKIP: esoteric +- work-element: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/work-element/ SKIP: esoteric +- alto-clef: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/alto-clef/ +- baritone-c-clef: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/baritone-c-clef/ +- baritone-f-clef: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/baritone-f-clef/ +- bass-clef: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/bass-clef/ SKIP: already tested +- bass-clef-down-octave: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/bass-clef-down-octave/ SKIP: already tested +- mezzo-soprano-clef: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/mezzo-soprano-clef/ +- percussion-clef: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/percussion-clef/ +- soprano-clef: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/soprano-clef/ +- system-attribute-also-top: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/system-attribute-also-top/ SKIP: esoteric +- system-attribute-only-top: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/system-attribute-only-top/ SKIP: esoteric +- tab-clef: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tab-clef/ SKIP: already tested +- tenor-clef: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tenor-clef/ +- treble-clef: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/treble-clef/ SKIP: already tested +- tutorial-apres-un-reve: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tutorial-apres-un-reve/ +- tutorial-chopin-prelude: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tutorial-chopin-prelude/ +- tutorial-chord-symbols: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tutorial-chord-symbols/ SKIP: already tested +- tutorial-hello-world: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tutorial-hello-world/ SKIP: already tested +- tutorial-percussion: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tutorial-percussion/ +- tutorial-tablature: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/tutorial-tablature/ SKIP: already tested +- vocal-tenor-clef: https://w3c-cg.github.io/musicxml/musicxml-reference/examples/vocal-tenor-clef/ diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..8bd058bb3 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,113 @@ +# PLAN.md — Pointer events, layers, and decorations + +> **This is a live checklist.** It is updated continuously as the feature is built — items +> get checked off as they land, and notes are added when decisions change. When the feature +> is fully fleshed out and accepted, this file is deleted. If you are reading this, the work +> is still in progress. + +## Goal + +Let callers interact with a rendered score: subscribe to pointer/scroll/resize events, hit-test +to vexml-owned target objects (`Note` / `Measure` / `TabPosition`), decorate notes (color / halo +toggles), and add their own drawing layers. Callers are coupled **only** to vexml types — never to +`@stringsync/mdom`. + +## Final public API (exported from `src/index.ts`) + +- `render(input: string | Blob, container: HTMLDivElement, config?): Promise` (was: canvas) +- `Rect` (geometry, now public via `Bounded`) +- `Bounded { rect; getBoundingClientRect() }` +- `Toggle { on(value); off(); active }` +- `Note` (Bounded; `type`, `isChordMember`, `getChordSiblings`, `getPitch`, `getBeats`, `isGrace`, + `getMeasure`, `getTabPosition`, `color: Toggle`, `halo: Toggle`) +- `Measure` (Bounded; `type`) +- `TabPosition` (Bounded; `type`, `getString`, `getFret`, `getNote`) +- `PointerTarget = Note | Measure | TabPosition` +- `Layer { ctx; dispose() }` (NO canvas, NO clear) +- `LayerKind = 'viewport' | 'content'` +- `EventListenable` (interface for add/removeEventListener) +- `Score` (EventListenable; `scroll`, `addLayer`, `removeLayer`, `dispose` — NO clearHighlights) +- `ScoreEventMap` + event payload types + +## Invariants + +- [ ] No `@stringsync/mdom` type appears in the public surface. +- [ ] Rects are stored in score space; crop/scroll/zoom applied only at the boundary. +- [ ] Anything a caller uses is exported from `src/index.ts`. +- [ ] `vex fix` and `vex test` pass at the end of every phase. + +## Testing approach (per the Java-y DI request) + +Every seam is an interface with a production class and a **separate fake class** (not a bun mock) +that fulfills it. Inject fakes in unit tests. Seams: `Decorator`, `HitTester`, the event bus, +coordinate transform. Fakes live beside the tests that use them. + +--- + +## Phase 1 — Model (`targets.ts` + `targets.test.ts`) ✅ DONE + +- [x] `Bounded`, `Toggle` interfaces +- [x] `Decorator` interface (the decoration seam) + `ColorToggle` / `HaloToggle` implementers + (no callbacks-to-constructors — interface/implementer throughout) +- [x] `Note` over mdom (geometry + `Decorator` injected): `getPitch`, `getBeats`, `isGrace`, + `isChordMember`, `getChordSiblings`, `getMeasure`, `getTabPosition`, `color`, `halo` +- [x] `Measure` +- [x] `TabPosition`: `getString`, `getFret`, `getNote` +- [x] cross-links resolve through `NoteLookup` / `TabLookup` interfaces (a Map implements them) — + single-phase construction, no thunks; `Note -> Measure` is a direct ref +- [x] `PointerTarget` union +- [x] `Viewport` seam added (coordinate authority) so `getBoundingClientRect` is testable +- [x] Unit tests with `FakeDecorator` + `FakeViewport` classes over an `MDOMParser` fixture (8 tests) + +## Phase 2 — Geometry emission + hit index (`hit.ts`, `draw.ts`) + +- [x] `HitTester` interface + `QuadTreeHitTester` impl `hitTest(point)` (foreground-over-background, smallest-area) +- [x] testable index builder `buildTargets(RawGeometry, Viewport, Decorator)` — cross-links note/tab, inserts visible glyph +- [x] `RawGeometry` boundary type (score space) +- [x] Unit tests for `hitTest` + builder (chord stack, notehead vs measure, fret hits, cross-links) — 6 tests +- [x] `drawScore` emits `RawGeometry`: per-notehead rects, per-fret rects, per-measure rects (crop applied once) +- [x] thread chord through `PendingStave` (`noteChords`/`tabChords`); only the final pass's arrays kept +- [x] `render.ts` propagates the geometry (transient return until Phase 3 wires it to `Score`) +- [x] compiles; `vex render` on notation + tab fixtures renders intact (no runtime errors) +- [ ] exact-geometry correctness (rect positions) verified at Phase 3/4 once hit-testing is wired live +- [ ] **gate not yet run:** full Docker `vex test` (screenshot regression) — run at Phase 3 with the harness migration + +## Phase 3 — Host + render (`stage.ts`, `score.ts`, `render.ts`, migration) + +- [ ] `stage.ts`: build DOM layers in the div + coordinate transform (`toScoreSpace`, `clientRectOf`) +- [ ] `Layer` (ctx + dispose), `LayerKind`, dpr-scaled ctx +- [ ] `score.ts`: `Score` shell (owns stage, index, decorations, event bus); `dispose` +- [ ] `render.ts`: take a div, build stage, draw into managed canvas, return `Score` +- [ ] migrate `tests/testing/index.html`, `entry.ts`, `harness.ts`, `cli/render.ts` (canvas -> div) +- [ ] integration screenshots still match baselines (visual output unchanged) + +## Phase 4 — Events (`events.ts`, `score.ts`) + +- [ ] `EventListenable` interface + reusable `EventBus` impl (dispatch) +- [ ] `Score` pointer events (toScoreSpace -> hitTest -> `{target, native}`) +- [ ] `scroll`, `resize` events; `score.scroll` getter +- [ ] Unit tests: event bus, dispatch with a `FakeHitTester`; browser smoke test (real pointer lands) + +## Phase 5 — Layers (public) + +- [ ] `addLayer('viewport' | 'content')` / `removeLayer` / `layer.dispose()` +- [ ] sizing policy per kind; resize -> resize+clear+dispatch contract +- [ ] Tests: dimensions per kind; resize fires + +## Phase 6 — Decorations + toggles live (`decorations.ts`) + +- [ ] `Decorations implements Decorator`: internal `content` layer, retained active-set, repaint (halo under color) +- [ ] wire `Note.color` / `Note.halo` +- [ ] Unit tests with a recording 2D context (clear+redraw, `off()` removes) +- [ ] integration screenshots: a colored note, a halo + +--- + +## Deferred (explicitly out of scope for now) + +- Editing mutations + undo/redo history (caller owns the stack) +- `HaloOptions` richness (halo is argument-less for v1) +- Decorating `TabPosition` (no toggles on it yet) — revisit if tab activity coloring is needed +- Dirty-region repaint (full repaint until profiled) +- DOM/`html` layers, public `hitTest`/`notesIn`, animation `invalidate()`, MIDI accessors +- `Chord` as a distinct click target diff --git a/src/draw.ts b/src/draw.ts index 5a1474b31..45a939d9c 100644 --- a/src/draw.ts +++ b/src/draw.ts @@ -49,6 +49,7 @@ import { WORDS_Y_OFFSET, } from './constants'; import { Rect } from './geometry'; +import type { RawGeometry, RawMeasure, RawNote } from './hit'; import type { MeasureNumbering, ScoreLayout } from './layout'; import { endBeatOf, @@ -177,6 +178,10 @@ type PendingStave = { // StaveNotes whose lead carries a tie — they get a tie-apex collision obstacle once their // stem direction is final (stem-down ties bow up over the noteheads). See tieApexRect. tiedNotes: Set; + // Each real (non-grace) note paired with its mdom chord, so the hit index can map every + // notehead/fret back to its note after formatting. One of these is populated per stave kind. + noteChords: Array<{ note: StaveNote; chord: Chord }>; + tabChords: Array<{ note: TabNote; chord: Chord }>; }; /* @@ -199,9 +204,17 @@ function buildNotes( const endBeat = Math.max(endBeatOf(voices), meterFloor); const staveNotes: StaveNote[] = []; const tiedNotes = new Set(); + const noteChords: Array<{ note: StaveNote; chord: Chord }> = []; const vexVoices = voices.map((voice) => { + const chords = voice.chords; + // lead note -> its chord, so the record callback (which only gets the lead) can pair + // each StaveNote with the chord whose noteheads it draws (for the hit index). + const chordByLead = new Map(); + for (const chord of chords) { + chordByLead.set(chord.lead, chord); + } const tickables = vexflowVoiceTickables( - voice.chords, + chords, clef, endBeat, (lead, note) => { @@ -210,6 +223,10 @@ function buildNotes( if (lead.ties.length > 0) { tiedNotes.add(note); } + const chord = chordByLead.get(lead); + if (chord && !lead.isGrace) { + noteChords.push({ note, chord }); + } }, ); return softVoice(tickables, softmaxFactor); @@ -231,6 +248,8 @@ function buildNotes( tuplets, staveNotes, tiedNotes, + noteChords, + tabChords: [], }; } @@ -595,14 +614,24 @@ function buildTabNotes( softmaxFactor: number, byTabLead: Map, ): PendingStave { - const vexVoices = voices.map((voice) => - softVoice( - vexflowTabTickables(voice.chords, (lead, tabNote) => - byTabLead.set(lead, tabNote), - ), + const tabChords: Array<{ note: TabNote; chord: Chord }> = []; + const vexVoices = voices.map((voice) => { + const chords = voice.chords; + const chordByLead = new Map(); + for (const chord of chords) { + chordByLead.set(chord.lead, chord); + } + return softVoice( + vexflowTabTickables(chords, (lead, tabNote) => { + byTabLead.set(lead, tabNote); + const chord = chordByLead.get(lead); + if (chord && !lead.isGrace) { + tabChords.push({ note: tabNote, chord }); + } + }), softmaxFactor, - ), - ); + ); + }); // Build (but discard) the tab tuplets: their construction rescales the notes' // ticks (Tuplet.attach), which the part's shared formatter needs so a triplet's // tab frets stay aligned under their notation notes. The bracket/number is drawn @@ -618,6 +647,8 @@ function buildTabNotes( tuplets: [], staveNotes: [], tiedNotes: new Set(), + noteChords: [], + tabChords, }; } @@ -775,17 +806,25 @@ function showsMeasureNumber( } } +// Approximate half-extents (CSS px) for the hit-index boxes. The notehead/fret glyphs aren't +// measured exactly here — a box centered on the laid-out position is enough to pick a target; +// decoration drawing refines the exact shape later. Notehead width comes from getNoteheadHalfWidth. +const NOTEHEAD_HALF_H = 5; +const FRET_HALF_W = 6; +const FRET_HALF_H = 7; + /* * Draw the whole score onto the element: one SVG stave per part-staff per measure, * placed at the boxes computed by computeLayout, with clefs/keys/time signatures, - * notes, and the brace/barline connectors that group parts into systems. + * notes, and the brace/barline connectors that group parts into systems. Returns the + * hit-index geometry (notehead/fret/measure boxes) in final score space. */ export function drawScore( canvas: HTMLCanvasElement, parts: Part[], layout: ScoreLayout, config: Config, -): void { +): RawGeometry { const { measureCount, boxes, @@ -835,6 +874,8 @@ export function drawScore( pageTop: number; pageBottom: number; observedOverflow: Map; + rawNotes: RawNote[]; + rawMeasures: RawMeasure[]; } => { // One note map for the whole score: ties and slurs can span a barline, so their // two endpoints may live in different measures. Notes are drawn measure by @@ -857,6 +898,10 @@ export function drawScore( // top stave into that gap — is covered by topOverflow, measured on a prior pass. let pageBottom = 0; let pageTop = Infinity; + // Hit-index geometry collected this pass, in scratch space; the caller shifts it into + // final score space once cropTop is known. Only the final pass's arrays are kept. + const rawNotes: RawNote[] = []; + const rawMeasures: RawMeasure[] = []; let systemTopY = layout.top + topSlack; let systemContentBottom = systemTopY; let currentSystem = -1; @@ -1235,6 +1280,74 @@ export function drawScore( pageBottom = Math.max(pageBottom, noteExtent.bottom); systemContentBottom = Math.max(systemContentBottom, noteExtent.bottom); pageTop = Math.min(pageTop, noteExtent.top); + + // Collect hit-index boxes now that this measure's notes are formatted (positions + // final). Each notehead/fret maps back to its mdom note; measure boxes back each + // measure's staff column. Still scratch space — shifted to score space by the caller. + for (const p of systemPending) { + if (p.isTab) { + const tabStave = p.stave as TabStave; + for (const { note, chord } of p.tabChords) { + const x = note.getAbsoluteX(); + for (const mnote of chord.notes) { + const string = mnote.string; + const fret = mnote.fret; + if (string === null || fret === null) { + continue; + } + const y = tabStave.getYForLine(string - 1); + rawNotes.push({ + mnote, + rect: new Rect( + x - FRET_HALF_W, + y - FRET_HALF_H, + 2 * FRET_HALF_W, + 2 * FRET_HALF_H, + ), + chord: chord.notes, + measureIndex: m, + tab: { string, fret }, + }); + } + } + } else { + const hw = getNoteheadHalfWidth(); + for (const { note, chord } of p.noteChords) { + const x = note.getAbsoluteX(); + const ys = note.getYs(); + chord.notes.forEach((mnote, i) => { + const y = ys[i]; + if (y === undefined) { + return; + } + rawNotes.push({ + mnote, + rect: new Rect( + x - hw, + y - NOTEHEAD_HALF_H, + 2 * hw, + 2 * NOTEHEAD_HALF_H, + ), + chord: chord.notes, + measureIndex: m, + tab: null, + }); + }); + } + } + } + if (systemTop && systemBottom) { + rawMeasures.push({ + rect: new Rect( + measureX, + systemY, + measureWidth, + Math.max(0, systemContentBottom - systemY), + ), + index: m, + }); + } + if (noteExtent.top < Infinity) { systemHighestTop.set( systemIndex, @@ -1405,18 +1518,22 @@ export function drawScore( observedOverflow.set(idx, Math.max(0, topY - highest)); } } - return { pageTop, pageBottom, observedOverflow }; + return { pageTop, pageBottom, observedOverflow, rawNotes, rawMeasures }; }; // Multi-system scores can clash where a system's notes rise above its top stave into // the previous system's depth. Pass one measures that per-system overflow; if any is // found, pass two redraws (onto the freshly cleared scratch) with the overflow reserved // above each system. Single-system scores never stack, so one pass suffices. - let { pageTop, pageBottom, observedOverflow } = runPass(new Map()); - if (systemCount > 1 && [...observedOverflow.values()].some((v) => v > 0)) { + let pass = runPass(new Map()); + if ( + systemCount > 1 && + [...pass.observedOverflow.values()].some((v) => v > 0) + ) { renderer.resize(width, scratchHeight); - ({ pageTop, pageBottom } = runPass(observedOverflow)); + pass = runPass(pass.observedOverflow); } + const { pageTop, pageBottom } = pass; // Crop to the lowest thing actually drawn so deep ledger lines in the bottom // system aren't clipped and there's no trailing whitespace. Sizing the real @@ -1450,4 +1567,14 @@ export function drawScore( scratch.width, canvas.height, ); + + // The geometry was collected in scratch space; the blit shifts content up by cropTop, so + // translate every box into final score space (the canvas's own coordinates). dpr stays out — + // these are CSS px, like getAbsoluteX/getYs. + const toScore = (r: Rect) => r.translate(0, -cropTop); + return { + bounds: new Rect(0, 0, width, cssHeight), + notes: pass.rawNotes.map((n) => ({ ...n, rect: toScore(n.rect) })), + measures: pass.rawMeasures.map((mm) => ({ ...mm, rect: toScore(mm.rect) })), + }; } diff --git a/src/hit.test.ts b/src/hit.test.ts new file mode 100644 index 000000000..68195a85d --- /dev/null +++ b/src/hit.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from 'bun:test'; +import { MDOMParser } from '@stringsync/mdom'; +import { Rect } from './geometry'; +import { buildTargets, type RawGeometry, type RawNote } from './hit'; +import type { + Bounded, + Decorator, + Note, + TabPosition, + Viewport, +} from './targets'; + +class FakeViewport implements Viewport { + clientRectOf(rect: Rect): DOMRect { + return { x: rect.x, y: rect.y, width: rect.w, height: rect.h } as DOMRect; + } + toScoreSpace(clientX: number, clientY: number): { x: number; y: number } { + return { x: clientX, y: clientY }; + } +} + +class FakeDecorator implements Decorator { + setColor(): void {} + setHalo(): void {} + isColored(_target: Bounded): boolean { + return false; + } + isHaloed(_target: Bounded): boolean { + return false; + } +} + +const XML = ` + + M + + + 1 + C41quarter + E41quarter + B-131quarter + + +`; + +function must(value: T | undefined, what: string): T { + if (value === undefined) { + throw new Error(`missing ${what}`); + } + return value; +} + +function build() { + const mdoc = new MDOMParser().parseFromString(XML); + const chords = must( + mdoc.score.parts[0]?.measures[0]?.voices[0], + 'voice', + ).chords; + const mC = must(chords[0]?.notes[0], 'C'); + const mE = must(chords[0]?.notes[1], 'E'); + const mBb = must(chords[1]?.notes[0], 'Bb'); + + // C and E stack as a chord; Bb is a lone tab note (fret 3 on string 2). + const notes: RawNote[] = [ + { + mnote: mC, + rect: new Rect(50, 40, 8, 8), + chord: [mC, mE], + measureIndex: 0, + tab: null, + }, + { + mnote: mE, + rect: new Rect(50, 50, 8, 8), + chord: [mC, mE], + measureIndex: 0, + tab: null, + }, + { + mnote: mBb, + rect: new Rect(90, 40, 6, 6), + chord: [mBb], + measureIndex: 0, + tab: { string: 2, fret: 3 }, + }, + ]; + const geometry: RawGeometry = { + bounds: new Rect(0, 0, 200, 100), + notes, + measures: [{ rect: new Rect(0, 0, 200, 100), index: 0 }], + }; + return buildTargets(geometry, new FakeViewport(), new FakeDecorator()); +} + +test('a notehead beats the measure background under it', () => { + const hit = build().hitTest({ x: 54, y: 44 }); + expect(hit?.type).toBe('note'); + expect((hit as Note).getPitch()).toBe('C/4'); +}); + +test('empty staff space hits the measure', () => { + const hit = build().hitTest({ x: 150, y: 80 }); + expect(hit?.type).toBe('measure'); +}); + +test('a chord stack resolves to the notehead under the point', () => { + const tester = build(); + expect((tester.hitTest({ x: 54, y: 44 }) as Note).getPitch()).toBe('C/4'); + expect((tester.hitTest({ x: 54, y: 54 }) as Note).getPitch()).toBe('E/4'); +}); + +test('a tab fret hits a TabPosition, not the note; both cross-link', () => { + const hit = build().hitTest({ x: 92, y: 42 }); + expect(hit?.type).toBe('tab-position'); + const tab = hit as TabPosition; + expect(tab.getString()).toBe(2); + expect(tab.getFret()).toBe(3); + const note = tab.getNote(); + expect(note.getPitch()).toBe('Bb/3'); + expect(note.getTabPosition()).toBe(tab); +}); + +test('a notation notehead has no tab position', () => { + const note = build().hitTest({ x: 54, y: 44 }) as Note; + expect(note.getTabPosition()).toBeNull(); +}); + +test('chordmates are wired from the raw chord grouping', () => { + const tester = build(); + const c = tester.hitTest({ x: 54, y: 44 }) as Note; + const e = tester.hitTest({ x: 54, y: 54 }) as Note; + expect(c.getChordSiblings({ includeSelf: false })).toEqual([e]); + expect(c.isChordMember()).toBe(true); +}); diff --git a/src/hit.ts b/src/hit.ts new file mode 100644 index 000000000..566cf480e --- /dev/null +++ b/src/hit.ts @@ -0,0 +1,146 @@ +import type { Note as MNote } from '@stringsync/mdom'; +import { Rect } from './geometry'; +import { QuadTree } from './quadtree'; +import { + type Decorator, + Measure, + Note, + type PointerTarget, + TabPosition, + type Viewport, +} from './targets'; + +/* + * The hit index: a spatial map from a point in score space to the target under it. Built once + * per render from the geometry the draw pass emits, then queried on every pointer event. The + * QuadTree (the renderer's collision broad-phase) doubles as the index — targets are Bounded, + * so they're valid items. + */ + +/* A notehead or fret the draw pass laid out, in score space. `tab` is set when this is a tab + * fret rendering (the note's string/fret); null for a notation notehead. `chord` lists every + * mdom note sharing this note's onset so chordmates resolve. mnote stays internal. */ +export interface RawNote { + mnote: MNote; + rect: Rect; + chord: MNote[]; + measureIndex: number; + tab: { string: number; fret: number } | null; +} + +export interface RawMeasure { + rect: Rect; + index: number; +} + +/* Everything the draw pass emits for the index, in score space (crop already applied). */ +export interface RawGeometry { + bounds: Rect; + notes: RawNote[]; + measures: RawMeasure[]; +} + +export interface HitTester { + hitTest(point: { x: number; y: number }): PointerTarget | null; +} + +export class QuadTreeHitTester implements HitTester { + constructor(private readonly tree: QuadTree) {} + + /* + * The target under `point`: a foreground glyph (note / fret) beats the measure background it + * sits on, and among same-tier overlaps the tighter (smaller-area) box wins — so a notehead + * is picked over the measure, and the nearer notehead of a chord over its neighbor. + */ + hitTest(point: { x: number; y: number }): PointerTarget | null { + const probe = new Rect(point.x, point.y, 1, 1); + let best: PointerTarget | null = null; + let bestArea = Number.POSITIVE_INFINITY; + let bestForeground = false; + for (const target of this.tree.query(probe)) { + const foreground = target.type !== 'measure'; + const area = target.rect.w * target.rect.h; + const better = + best === null || + (foreground && !bestForeground) || + (foreground === bestForeground && area < bestArea); + if (better) { + best = target; + bestArea = area; + bestForeground = foreground; + } + } + return best; + } +} + +/* + * Turn the draw pass's raw geometry into linked target wrappers and index them. Pure given its + * inputs (no DOM, no rendering), so it's unit-tested directly. A tab note becomes both a Note + * (its pitch/beats) and a TabPosition (its fret); only the visible glyph is inserted into the + * tree — the TabPosition for a tab note, the Note for a notation notehead — so a point hits one + * target, while the other stays reachable via getNote()/getTabPosition(). + * + * The two maps double as the NoteLookup/TabLookup the wrappers resolve their cross-links through; + * they're fully populated before any query runs, so the deferred resolution always lands. + */ +export function buildTargets( + geometry: RawGeometry, + viewport: Viewport, + decorator: Decorator, +): HitTester { + const measures = new Map(); + for (const m of geometry.measures) { + measures.set(m.index, new Measure(m.rect, viewport)); + } + + const noteByMnote = new Map(); + const tabByMnote = new Map(); + + for (const rn of geometry.notes) { + const measure = measures.get(rn.measureIndex); + if (!measure) { + continue; + } + noteByMnote.set( + rn.mnote, + new Note({ + mnote: rn.mnote, + rect: rn.rect, + viewport, + decorator, + measure, + chord: rn.chord, + notes: noteByMnote, + tabs: tabByMnote, + }), + ); + } + + for (const rn of geometry.notes) { + const note = noteByMnote.get(rn.mnote); + if (!rn.tab || !note) { + continue; + } + tabByMnote.set( + rn.mnote, + new TabPosition(rn.rect, viewport, { + string: rn.tab.string, + fret: rn.tab.fret, + note, + }), + ); + } + + const tree = new QuadTree(geometry.bounds); + for (const rn of geometry.notes) { + const target = tabByMnote.get(rn.mnote) ?? noteByMnote.get(rn.mnote); + if (target) { + tree.insert(target); + } + } + for (const measure of measures.values()) { + tree.insert(measure); + } + return new QuadTreeHitTester(tree); +} diff --git a/src/render.ts b/src/render.ts index 62d948dbb..18b591170 100644 --- a/src/render.ts +++ b/src/render.ts @@ -3,6 +3,8 @@ import { VexFlow } from 'vexflow'; import { type Config, DEFAULT_CONFIG } from './config'; import { drawScore } from './draw'; import { loadFonts } from './fonts'; +import { Rect } from './geometry'; +import type { RawGeometry } from './hit'; import { computeLayout } from './layout'; /* @@ -43,26 +45,36 @@ function renderMusicXML( musicXML: string, canvas: HTMLCanvasElement, config: Config, -) { +): RawGeometry { const parser = new MDOMParser(); const mdoc = parser.parseFromString(musicXML); return renderMDoc(mdoc, canvas, config); } -async function renderMXL(mxl: Blob, canvas: HTMLCanvasElement, config: Config) { +async function renderMXL( + mxl: Blob, + canvas: HTMLCanvasElement, + config: Config, +): Promise { const parser = new MDOMParser(); const mdoc = await parser.parseFromBlob(mxl); return renderMDoc(mdoc, canvas, config); } +const EMPTY_GEOMETRY: RawGeometry = { + bounds: new Rect(0, 0, 0, 0), + notes: [], + measures: [], +}; + function renderMDoc( mdoc: MDocument, canvas: HTMLCanvasElement, config: Config, -) { +): RawGeometry { const parts = mdoc.score.parts; if (parts.length === 0) { - return; + return EMPTY_GEOMETRY; } - drawScore(canvas, parts, computeLayout(parts, config), config); + return drawScore(canvas, parts, computeLayout(parts, config), config); } diff --git a/src/targets.test.ts b/src/targets.test.ts new file mode 100644 index 000000000..00148085f --- /dev/null +++ b/src/targets.test.ts @@ -0,0 +1,187 @@ +import { expect, test } from 'bun:test'; +import { MDOMParser, type Note as MNote } from '@stringsync/mdom'; +import { Rect } from './geometry'; +import { + type Bounded, + type Decorator, + Measure, + Note, + TabPosition, + type Viewport, +} from './targets'; + +// Separate fake classes that fulfill the injected interfaces (preferred over mocks). + +class FakeViewport implements Viewport { + clientRectOf(rect: Rect): DOMRect { + // bun's runtime has no DOMRect; a structural literal is enough for unit reads. + return { + x: rect.x, + y: rect.y, + width: rect.w, + height: rect.h, + top: rect.y, + left: rect.x, + right: rect.right, + bottom: rect.bottom, + toJSON: () => ({}), + } as DOMRect; + } + toScoreSpace(clientX: number, clientY: number): { x: number; y: number } { + return { x: clientX, y: clientY }; + } +} + +class FakeDecorator implements Decorator { + readonly colors = new Map(); + readonly halos = new Set(); + setColor(target: Bounded, color: string | null): void { + if (color === null) { + this.colors.delete(target); + } else { + this.colors.set(target, color); + } + } + setHalo(target: Bounded, on: boolean): void { + if (on) { + this.halos.add(target); + } else { + this.halos.delete(target); + } + } + isColored(target: Bounded): boolean { + return this.colors.has(target); + } + isHaloed(target: Bounded): boolean { + return this.halos.has(target); + } +} + +const XML = ` + + Music + + + 1 + C41quarter + E41quarter + 1quarter + B-131quarter + + +`; + +function must(value: T | undefined, what: string): T { + if (value === undefined) { + throw new Error(`fixture: missing ${what}`); + } + return value; +} + +function fixture() { + const mdoc = new MDOMParser().parseFromString(XML); + const voice = must(mdoc.score.parts[0]?.measures[0]?.voices[0], 'voice'); + const chords = voice.chords; + const mC = must(chords[0]?.notes[0], 'C'); + const mE = must(chords[0]?.notes[1], 'E'); + const mRest = must(chords[1]?.notes[0], 'rest'); + const mBb = must(chords[2]?.notes[0], 'Bb'); + + const viewport = new FakeViewport(); + const decorator = new FakeDecorator(); + const measure = new Measure(new Rect(0, 0, 100, 50), viewport); + + // The shared registries the wrappers resolve their cross-links through (a Map fulfills the + // NoteLookup / TabLookup interfaces). Populated as each note is built. + const notesByMnote = new Map(); + const tabsByMnote = new Map(); + const base = (mnote: MNote, rect: Rect, chord: MNote[]): Note => { + const note = new Note({ + mnote, + rect, + viewport, + decorator, + measure, + chord, + notes: notesByMnote, + tabs: tabsByMnote, + }); + notesByMnote.set(mnote, note); + return note; + }; + + // Chord [C4, E4], then a rest, then Bb3 (each its own solo chord). + const noteC = base(mC, new Rect(10, 10, 8, 8), [mC, mE]); + const noteE = base(mE, new Rect(10, 18, 8, 8), [mC, mE]); + const noteRest = base(mRest, new Rect(20, 10, 8, 8), [mRest]); + const noteBb = base(mBb, new Rect(30, 10, 8, 8), [mBb]); + + return { viewport, decorator, measure, noteC, noteE, noteRest, noteBb }; +} + +test('getPitch formats vexflow keys and returns null for rests', () => { + const { noteC, noteE, noteRest, noteBb } = fixture(); + expect(noteC.getPitch()).toBe('C/4'); + expect(noteE.getPitch()).toBe('E/4'); + expect(noteRest.getPitch()).toBeNull(); + expect(noteBb.getPitch()).toBe('Bb/3'); +}); + +test('getBeats and isGrace read the underlying note', () => { + const { noteC } = fixture(); + expect(noteC.getBeats()).toBe(1); + expect(noteC.isGrace()).toBe(false); +}); + +test('chord membership and siblings', () => { + const { noteC, noteE, noteBb } = fixture(); + expect(noteC.isChordMember()).toBe(true); + expect(noteBb.isChordMember()).toBe(false); + expect(noteC.getChordSiblings({ includeSelf: false })).toEqual([noteE]); + expect(noteC.getChordSiblings({ includeSelf: true })).toEqual([noteC, noteE]); +}); + +test('getMeasure and getTabPosition return the linked objects', () => { + const { noteC, measure } = fixture(); + expect(noteC.getMeasure()).toBe(measure); + expect(noteC.getTabPosition()).toBeNull(); +}); + +test('color toggle delegates to the decorator and reflects active state', () => { + const { noteC, decorator } = fixture(); + expect(noteC.color.active).toBe(false); + noteC.color.on('#2962ff'); + expect(decorator.colors.get(noteC)).toBe('#2962ff'); + expect(noteC.color.active).toBe(true); + noteC.color.off(); + expect(decorator.colors.has(noteC)).toBe(false); + expect(noteC.color.active).toBe(false); +}); + +test('halo toggle delegates to the decorator', () => { + const { noteC, decorator } = fixture(); + noteC.halo.on(); + expect(decorator.halos.has(noteC)).toBe(true); + expect(noteC.halo.active).toBe(true); + noteC.halo.off(); + expect(noteC.halo.active).toBe(false); +}); + +test('getBoundingClientRect maps the score-space rect through the viewport', () => { + const { noteC } = fixture(); + const r = noteC.getBoundingClientRect(); + expect([r.x, r.y, r.width, r.height]).toEqual([10, 10, 8, 8]); +}); + +test('TabPosition exposes string/fret and links back to its note', () => { + const { noteC, viewport } = fixture(); + const tab = new TabPosition(new Rect(0, 0, 6, 6), viewport, { + string: 3, + fret: 5, + note: noteC, + }); + expect(tab.getString()).toBe(3); + expect(tab.getFret()).toBe(5); + expect(tab.getNote()).toBe(noteC); + expect(tab.type).toBe('tab-position'); +}); diff --git a/src/targets.ts b/src/targets.ts new file mode 100644 index 000000000..9e667242d --- /dev/null +++ b/src/targets.ts @@ -0,0 +1,217 @@ +import type { Note as MNote } from '@stringsync/mdom'; +import type { Rect } from './geometry'; + +/* + * The interaction model: vexml-owned objects a caller gets from hit-testing a rendered score. + * They wrap the underlying @stringsync/mdom nodes but never expose them — a caller is coupled + * to these types only. Every target is laid out (Bounded) and built once during rendering, so + * identities are stable for the lifetime of a Score (reference equality works). + */ + +/* Something with a known box. `rect` is in score space; getBoundingClientRect() maps it to the + * page through the live scroll/zoom transform (mirrors DOM Element.getBoundingClientRect). */ +export interface Bounded { + readonly rect: Rect; + getBoundingClientRect(): DOMRect; +} + +/* A reversible on/off effect carrying an optional value (color string, etc.). `off()` is the + * whole undo — these are view state, not document edits, so there is no history. */ +export interface Toggle { + on(value: T): void; + off(): void; + readonly active: boolean; +} + +/* + * The coordinate authority: converts between score space (where rects live) and client/page + * space (where pointer events and DOM popups live). The stage implements it for real; tests + * inject a fake. The target wrappers are its first consumers, so the interface lives here. + */ +export interface Viewport { + clientRectOf(rect: Rect): DOMRect; + toScoreSpace(clientX: number, clientY: number): { x: number; y: number }; +} + +export type DecorationKind = 'color' | 'halo'; + +/* + * The decoration seam. A target's color/halo toggles delegate here rather than drawing + * themselves, so the drawing surface stays out of the model. Production: Decorations (owns the + * overlay layer, repaints from the active set). Tests: a FakeDecorator that records state. + */ +export interface Decorator { + setColor(target: Bounded, color: string | null): void; + setHalo(target: Bounded, on: boolean): void; + isColored(target: Bounded): boolean; + isHaloed(target: Bounded): boolean; +} + +/* + * Resolves an mdom note to the wrapper built for it. The targets reference one another (a note + * to its chordmates, a note to its tab fret), which would be circular at construction; instead + * each holds a lookup and resolves on demand, once the builder has registered every wrapper. A + * Map is the production implementer; tests pass their own. + */ +export interface NoteLookup { + get(mnote: MNote): Note | undefined; +} +export interface TabLookup { + get(mnote: MNote): TabPosition | undefined; +} + +/* The color decoration as an on/off toggle, delegating to the Decorator. */ +class ColorToggle implements Toggle { + constructor( + private readonly target: Bounded, + private readonly decorator: Decorator, + ) {} + on(color: string): void { + this.decorator.setColor(this.target, color); + } + off(): void { + this.decorator.setColor(this.target, null); + } + get active(): boolean { + return this.decorator.isColored(this.target); + } +} + +/* The halo decoration as an on/off toggle, delegating to the Decorator. */ +class HaloToggle implements Toggle { + constructor( + private readonly target: Bounded, + private readonly decorator: Decorator, + ) {} + on(): void { + this.decorator.setHalo(this.target, true); + } + off(): void { + this.decorator.setHalo(this.target, false); + } + get active(): boolean { + return this.decorator.isHaloed(this.target); + } +} + +/* Shared base for every target: holds the score-space rect and maps it to the page on demand. */ +abstract class BoundedTarget implements Bounded { + constructor( + readonly rect: Rect, + protected readonly viewport: Viewport, + ) {} + getBoundingClientRect(): DOMRect { + return this.viewport.clientRectOf(this.rect); + } +} + +/* MusicXML -> vexflow key string, e.g. {step:'B', alter:-1, octave:3} -> "Bb/3". */ +function pitchToKey(p: { + step: string; + octave: number; + alter: number; +}): string { + const n = Math.round(p.alter); + const accidental = n > 0 ? '#'.repeat(n) : n < 0 ? 'b'.repeat(-n) : ''; + return `${p.step}${accidental}/${p.octave}`; +} + +/* The dependencies a Note needs. Cross-links resolve through the lookups (see NoteLookup), so + * construction stays single-phase despite the mutual references. mnote is private — never exposed. */ +export interface NoteDeps { + mnote: MNote; + rect: Rect; + viewport: Viewport; + decorator: Decorator; + measure: Measure; + /* Every mdom note in this note's chord, including itself (a solo note is a 1-member chord). */ + chord: MNote[]; + /* Resolves chord members to their Notes, and this note's mnote to its tab fret rendering. */ + notes: NoteLookup; + tabs: TabLookup; +} + +/* A single musical note (one notehead). The unit of selection, playback, and editing. */ +export class Note extends BoundedTarget { + readonly type = 'note'; + readonly color: Toggle; + readonly halo: Toggle; + + constructor(private readonly deps: NoteDeps) { + super(deps.rect, deps.viewport); + this.color = new ColorToggle(this, deps.decorator); + this.halo = new HaloToggle(this, deps.decorator); + } + + /* The sounding pitch as a vexflow key ("E/4"), or null for a rest. */ + getPitch(): string | null { + const pitch = this.deps.mnote.pitch; + return pitch ? pitchToKey(pitch) : null; + } + + /* Duration in quarter-note beats; 0 for a grace note (which steals time — see isGrace). */ + getBeats(): number { + return this.deps.mnote.beats ?? 0; + } + + isGrace(): boolean { + return this.deps.mnote.isGrace; + } + + /* True when this note is part of a chord of two or more notes (the lead counts too). */ + isChordMember(): boolean { + return this.deps.chord.length > 1; + } + + getChordSiblings(opts: { includeSelf: boolean }): Note[] { + const all: Note[] = []; + for (const mnote of this.deps.chord) { + const note = this.deps.notes.get(mnote); + if (note) { + all.push(note); + } + } + return opts.includeSelf ? all : all.filter((n) => n !== this); + } + + getMeasure(): Measure { + return this.deps.measure; + } + + getTabPosition(): TabPosition | null { + return this.deps.tabs.get(this.deps.mnote) ?? null; + } +} + +/* A measure's box — the background target, hit when a pointer lands on staff space (not a note). */ +export class Measure extends BoundedTarget { + readonly type = 'measure'; +} + +/* A fret number on a tab string. The same note can render as both a Note (notehead) and a + * TabPosition (fret); they cross-reference via Note.getTabPosition() / TabPosition.getNote(). */ +export class TabPosition extends BoundedTarget { + readonly type = 'tab-position'; + + constructor( + rect: Rect, + viewport: Viewport, + private readonly opts: { string: number; fret: number; note: Note }, + ) { + super(rect, viewport); + } + + getString(): number { + return this.opts.string; + } + + getFret(): number { + return this.opts.fret; + } + + getNote(): Note { + return this.opts.note; + } +} + +export type PointerTarget = Note | Measure | TabPosition; From d324e08389fd0b490ff9a68399f2589b705b0cf4 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Sun, 28 Jun 2026 20:46:42 -0400 Subject: [PATCH 2/7] complete phase 3 of the PLAN to update the vexml host container --- PLAN.md | 47 ++++++++++++++++++------ cli/render.ts | 8 ++-- site/src/App.tsx | 50 ++++++++++++++++--------- src/index.ts | 10 +++++ src/render.ts | 79 +++++++++++++++------------------------- src/score.ts | 15 ++++++++ src/stage.ts | 63 ++++++++++++++++++++++++++++++++ tests/testing/harness.ts | 8 ++-- tests/testing/index.html | 2 +- 9 files changed, 195 insertions(+), 87 deletions(-) create mode 100644 src/score.ts create mode 100644 src/stage.ts diff --git a/PLAN.md b/PLAN.md index 8bd058bb3..4a8c1d468 100644 --- a/PLAN.md +++ b/PLAN.md @@ -31,10 +31,13 @@ toggles), and add their own drawing layers. Callers are coupled **only** to vexm ## Invariants -- [ ] No `@stringsync/mdom` type appears in the public surface. -- [ ] Rects are stored in score space; crop/scroll/zoom applied only at the boundary. -- [ ] Anything a caller uses is exported from `src/index.ts`. -- [ ] `vex fix` and `vex test` pass at the end of every phase. +- [x] No `@stringsync/mdom` type appears in the public surface (callers never need to name one; + `mnote` stays private behind the unexported `NoteDeps`). +- [x] Rects are stored in score space; crop/scroll/zoom applied only at the boundary + (`Stage.frame()` is the only place the transform is read). +- [x] Anything a caller uses is exported from `src/index.ts` (`render`, `Score`, `Rect`, `Bounded`, + `Toggle`, `Note`, `Measure`, `TabPosition`, `PointerTarget`). +- [x] `vex fix` and `vex test` pass (Phase 3: 131 pass / 0 fail, **no screenshot changes**). ## Testing approach (per the Java-y DI request) @@ -72,17 +75,37 @@ coordinate transform. Fakes live beside the tests that use them. - [ ] exact-geometry correctness (rect positions) verified at Phase 3/4 once hit-testing is wired live - [ ] **gate not yet run:** full Docker `vex test` (screenshot regression) — run at Phase 3 with the harness migration -## Phase 3 — Host + render (`stage.ts`, `score.ts`, `render.ts`, migration) - -- [ ] `stage.ts`: build DOM layers in the div + coordinate transform (`toScoreSpace`, `clientRectOf`) -- [ ] `Layer` (ctx + dispose), `LayerKind`, dpr-scaled ctx -- [ ] `score.ts`: `Score` shell (owns stage, index, decorations, event bus); `dispose` -- [ ] `render.ts`: take a div, build stage, draw into managed canvas, return `Score` -- [ ] migrate `tests/testing/index.html`, `entry.ts`, `harness.ts`, `cli/render.ts` (canvas -> div) -- [ ] integration screenshots still match baselines (visual output unchanged) +## Phase 3 — Host + render (`stage.ts`, `score.ts`, `render.ts`, migration) ✅ DONE + +- [x] `stage.ts`: `Stage` builds the managed canvas in the div + coordinate transform + (`toScoreSpace`, `clientRectOf`), reading the live `getBoundingClientRect` so scroll and CSS + scaling fall out for free; `dispose` removes the canvas and restores the container +- [x] `score.ts`: `Score` shell — owns the stage, `dispose` +- [x] `render.ts`: takes a div, builds the stage, draws into the managed canvas, returns `Score` +- [x] `index.ts` exports the public surface (render/Score/Rect/Bounded/Toggle/targets) +- [x] migrate `tests/testing/index.html`, `harness.ts`, `cli/render.ts`, and `site/src/App.tsx` + (canvas -> div; the site disposes the prior `Score` between renders so canvases don't stack) +- [x] integration screenshots still match baselines — **131 pass, no screenshot changes** + +> **Plan refinements made here (kept lazy):** +> - `Layer` / `LayerKind` / `createLayer` and the dpr-scaled overlay ctx are **deferred to the +> phase that consumes them** (decorations' content layer in Phase 6; public layers in Phase 5) +> rather than built speculatively in Phase 3. +> - The hit **index** and **decorations**/**event bus** wire to the `Score` in their own phases +> (4/6). `drawScore` still returns `RawGeometry`; `render` drops it for now (it's collected +> inside the draw pass regardless, so ignoring the return costs nothing). Phase 4 threads it +> through to build the index. +> - No DOM wrapper: the managed canvas is a plain in-flow child of the caller's div, so the box +> model is identical to the old hand-placed `` — that's what keeps screenshots +> byte-identical. Overlay layers (Phase 5/6) anchor via the `position: relative` Stage sets on +> the container. ## Phase 4 — Events (`events.ts`, `score.ts`) +- [ ] wire the hit **index** into `Score` (deferred from Phase 3): thread `drawScore`'s + `RawGeometry` through `render` into `buildTargets(geometry, stage, decorator)`; this needs a + `Decorator`, so a minimal `Decorations` (state-only; layer/repaint lands in Phase 6) or a + no-op stand-in is built here first - [ ] `EventListenable` interface + reusable `EventBus` impl (dispatch) - [ ] `Score` pointer events (toScoreSpace -> hitTest -> `{target, native}`) - [ ] `scroll`, `resize` events; `score.scroll` getter diff --git a/cli/render.ts b/cli/render.ts index 8ee0f5efb..6c0f2af9d 100644 --- a/cli/render.ts +++ b/cli/render.ts @@ -29,11 +29,11 @@ export async function render(opts: { await page.goto('http://localhost:3101/'); await page.evaluate( async ({ musicXML, config }) => { - const canvas = document.getElementById('vexml'); - if (!(canvas instanceof HTMLCanvasElement)) { - throw new Error('canvas not found'); + const container = document.getElementById('screenshot'); + if (!(container instanceof HTMLDivElement)) { + throw new Error('container not found'); } - await window.render(musicXML, canvas, config); + await window.render(musicXML, container, config); }, { musicXML, config }, ); diff --git a/site/src/App.tsx b/site/src/App.tsx index 1967f97fd..a4f249a7b 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import type { Config } from '../../src'; -import { render } from '../../src'; +import { render, type Score } from '../../src'; // Vite reads the test fixtures straight from ../tests at build time (fs.allow: ['..'] in // vite.config permits it) and hands us the file list — no symlink or hand-written manifest. @@ -68,7 +68,8 @@ function Or() { } export default function App() { - const canvasRef = useRef(null); + const containerRef = useRef(null); + const scoreRef = useRef(null); const [text, setText] = useState(''); const [input, setInput] = useState(null); const [fixture, setFixture] = useState(''); @@ -139,10 +140,14 @@ export default function App() { }, [config, renderMs]); useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || input == null) { + const container = containerRef.current; + if (!container || input == null) { return; } + // Replace the previous render before starting a new one: render() appends a fresh + // managed canvas, so the old Score must be disposed or canvases would stack. + scoreRef.current?.dispose(); + scoreRef.current = null; setError(null); const start = performance.now(); // Engrave once at the configured reference width; CSS then scales the canvas to fit @@ -152,19 +157,30 @@ export default function App() { renderConfig.layout?.type === 'standard' ? renderConfig.layout.width : undefined; - render(input, canvas, { + let cancelled = false; + render(input, container, { ...renderConfig, layout: { type: 'standard', width: layoutWidth }, }) - .then(() => { - canvas.style.width = '100%'; - canvas.style.height = 'auto'; + .then((score) => { + // The effect can re-run before this resolves; drop the late score so it + // doesn't leak a canvas into a container a newer render already owns. + if (cancelled) { + score.dispose(); + return; + } + scoreRef.current = score; setRenderMs(performance.now() - start); }) .catch((e: unknown) => { setRenderMs(null); setError(e instanceof Error ? e.message : String(e)); }); + return () => { + cancelled = true; + scoreRef.current?.dispose(); + scoreRef.current = null; + }; }, [input, renderConfig]); // Restore the last-edited MusicXML, or open with a random example. @@ -694,16 +710,16 @@ export default function App() { ) )} {input != null && ( - // The canvas is engraved at that width and CSS-scaled to fit, shrinking on narrow viewports, never past 100%. + // vexml appends its managed canvas here; React manages only this div's + // attributes, never its children. The canvas is engraved at the reference + // width and CSS-scaled to fit (down when narrow, never past 100%); the + // child-selector classes style the managed canvas declaratively, so the + // dark-mode invert reacts without re-rendering. ponytail: invert the black + // glyphs to light rather than re-engraving in a light color.
- {/* ponytail: invert the black glyphs to light for dark mode instead of re-engraving in a light color. */} - -
+ ref={containerRef} + className={`relative mx-auto w-full max-w-237.5 py-8 px-4 shadow-md ring-1 sm:py-16 [&>canvas]:block [&>canvas]:!h-auto [&>canvas]:!w-full ${dark ? 'bg-zinc-900 ring-zinc-700 [&>canvas]:invert' : 'bg-white ring-zinc-200'}`} + /> )} {debouncing && (
diff --git a/src/index.ts b/src/index.ts index d4e2c5337..297b253ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,5 +7,15 @@ export { } from './chord-diagram'; export type { Config } from './config'; export type { FontConfig, FontOverride } from './fonts'; +export { Rect } from './geometry'; export type { Layout, MeasureNumbering } from './layout'; export * from './render'; +export { Score } from './score'; +export { + type Bounded, + Measure, + Note, + type PointerTarget, + TabPosition, + type Toggle, +} from './targets'; diff --git a/src/render.ts b/src/render.ts index 18b591170..61b51d156 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,27 +1,33 @@ -import { MDOMParser, type MDocument } from '@stringsync/mdom'; +import { MDOMParser } from '@stringsync/mdom'; import { VexFlow } from 'vexflow'; import { type Config, DEFAULT_CONFIG } from './config'; import { drawScore } from './draw'; import { loadFonts } from './fonts'; -import { Rect } from './geometry'; -import type { RawGeometry } from './hit'; import { computeLayout } from './layout'; +import { Score } from './score'; +import { Stage } from './stage'; /* - * Render a MusicXML score onto a canvas: parse the input (a MusicXML string or a - * compressed .mxl Blob), lay it out, and draw it. Merges the caller's partial config - * over the defaults and sets VexFlow's global glyph fonts before drawing. + * Render a MusicXML score into a container: parse the input (a MusicXML string or a compressed + * .mxl Blob), build the stage inside the div, lay the score out, and draw it onto the stage's + * managed canvas. The caller never sees the canvas — only the returned Score, which owns the DOM + * and is the handle for events/decorations/layers (and dispose). Merges the caller's partial + * config over the defaults and sets VexFlow's global glyph fonts before drawing. */ export async function render( input: string | Blob, - canvas: HTMLCanvasElement, + container: HTMLDivElement, config?: Partial, -) { +): Promise { const resolved: Config = { ...DEFAULT_CONFIG, ...config }; if (resolved.minLastSystemFill < 0 || resolved.minLastSystemFill > 1) { throw new RangeError('render: minLastSystemFill must be between 0 and 1'); } - const { notation, text } = loadFonts(canvas, resolved.fonts); + + const stage = new Stage(container); + // Fonts and CSS vars go on the container; the managed canvas inherits them, so drawScore's + // getComputedStyle(canvas) read of --vexml-font-text still resolves. + const { notation, text } = loadFonts(container, resolved.fonts); // VexFlow engraves glyphs from its own bundled font modules via global state, not the // --vexml-font-notation CSS var. setFonts sets a CSS font-family stack the browser falls // through per glyph: music glyphs (noteheads, clefs, the stacked "TAB" clef) come from the @@ -32,49 +38,24 @@ export async function render( // the whole CSS font string invalid and every glyph falls back to serif. Reset each call // so one render's font choice can't leak into the next. VexFlow.setFonts(`'${notation}'`, `'${text}'`, 'sans-serif'); - if (typeof input === 'string') { - return renderMusicXML(input, canvas, resolved); - } - if (input instanceof Blob) { - return renderMXL(input, canvas, resolved); - } - throw new TypeError('render: input is not a string or Blob'); -} - -function renderMusicXML( - musicXML: string, - canvas: HTMLCanvasElement, - config: Config, -): RawGeometry { - const parser = new MDOMParser(); - const mdoc = parser.parseFromString(musicXML); - return renderMDoc(mdoc, canvas, config); -} -async function renderMXL( - mxl: Blob, - canvas: HTMLCanvasElement, - config: Config, -): Promise { const parser = new MDOMParser(); - const mdoc = await parser.parseFromBlob(mxl); - return renderMDoc(mdoc, canvas, config); -} - -const EMPTY_GEOMETRY: RawGeometry = { - bounds: new Rect(0, 0, 0, 0), - notes: [], - measures: [], -}; + const mdoc = + typeof input === 'string' + ? parser.parseFromString(input) + : input instanceof Blob + ? await parser.parseFromBlob(input) + : null; + if (mdoc === null) { + throw new TypeError('render: input is not a string or Blob'); + } -function renderMDoc( - mdoc: MDocument, - canvas: HTMLCanvasElement, - config: Config, -): RawGeometry { const parts = mdoc.score.parts; - if (parts.length === 0) { - return EMPTY_GEOMETRY; + if (parts.length > 0) { + // drawScore also returns the hit-index geometry; the index/events/decorations wire it to + // the Score in a later phase. ponytail: dropped here, but it's collected inside drawScore + // regardless, so ignoring the return costs nothing — wire it through when events land. + drawScore(stage.base, parts, computeLayout(parts, resolved), resolved); } - return drawScore(canvas, parts, computeLayout(parts, config), config); + return new Score(stage); } diff --git a/src/score.ts b/src/score.ts new file mode 100644 index 000000000..4dc744d94 --- /dev/null +++ b/src/score.ts @@ -0,0 +1,15 @@ +import type { Stage } from './stage'; + +/* + * A rendered score: the handle render() returns. Owns the DOM vexml built (the Stage) and is + * where pointer events, decorations, and custom layers will hang in the phases that follow. + * dispose() tears the whole thing down — removing the managed canvas and restoring the + * container — so a caller can re-render or unmount cleanly. + */ +export class Score { + constructor(private readonly stage: Stage) {} + + dispose(): void { + this.stage.dispose(); + } +} diff --git a/src/stage.ts b/src/stage.ts new file mode 100644 index 000000000..38a54d029 --- /dev/null +++ b/src/stage.ts @@ -0,0 +1,63 @@ +import type { Rect } from './geometry'; +import type { Viewport } from './targets'; + +/* + * The host: the DOM vexml builds inside the caller's container, and the coordinate authority + * between score space (where target rects live) and client/page space (where pointer events and + * DOM popups live). The caller hands render() a
; the Stage owns the canvas it draws the + * score onto and never exposes it to callers — they see only the Score. + * + * The base canvas is a plain in-flow child, so the container sizes to it exactly as a + * hand-placed did — output stays pixel-identical. The transform falls out of that + * canvas's own getBoundingClientRect: score space is its CSS-pixel space with the origin at its + * top-left. Reading the live rect each call means page scroll and any CSS scaling of the canvas + * are handled for free. + */ +export class Stage implements Viewport { + readonly base: HTMLCanvasElement; + private readonly prevPosition: string; + + constructor(private readonly container: HTMLDivElement) { + // A positioned container is the containing block the overlay layers (later phases) anchor + // to. Only set it when the caller left position static, and remember it so dispose restores. + this.prevPosition = container.style.position; + if (!container.style.position) { + container.style.position = 'relative'; + } + this.base = document.createElement('canvas'); + container.appendChild(this.base); + } + + clientRectOf(rect: Rect): DOMRect { + const { left, top, sx, sy } = this.frame(); + return new DOMRect( + left + rect.x * sx, + top + rect.y * sy, + rect.w * sx, + rect.h * sy, + ); + } + + toScoreSpace(clientX: number, clientY: number): { x: number; y: number } { + const { left, top, sx, sy } = this.frame(); + return { x: (clientX - left) / sx, y: (clientY - top) / sy }; + } + + dispose(): void { + this.base.remove(); + this.container.style.position = this.prevPosition; + } + + /* + * The live score-space -> client-space mapping: the canvas's page offset plus the scale + * between its rendered size and its score-space (CSS-px) size. Scale is 1 unless the caller + * stretched the canvas with CSS; the `|| 1` guards an unsized (empty-score) canvas so it + * maps 1:1 instead of dividing by zero. + */ + private frame(): { left: number; top: number; sx: number; sy: number } { + const r = this.base.getBoundingClientRect(); + const w = parseFloat(this.base.style.width) || r.width || 1; + const h = parseFloat(this.base.style.height) || r.height || 1; + return { left: r.left, top: r.top, sx: r.width / w, sy: r.height / h }; + } +} diff --git a/tests/testing/harness.ts b/tests/testing/harness.ts index 21df8ffed..6e1e86cbc 100644 --- a/tests/testing/harness.ts +++ b/tests/testing/harness.ts @@ -43,11 +43,11 @@ export async function render( const input = file.endsWith('.mxl') ? await res.blob() : await res.text(); - const canvas = document.getElementById('vexml'); - if (!(canvas instanceof HTMLCanvasElement)) { - throw new Error('canvas not found'); + const container = document.getElementById('screenshot'); + if (!(container instanceof HTMLDivElement)) { + throw new Error('container not found'); } - await window.render(input, canvas, config); + await window.render(input, container, config); }, { file, config }, ); diff --git a/tests/testing/index.html b/tests/testing/index.html index 3d785f318..a0a9f1390 100644 --- a/tests/testing/index.html +++ b/tests/testing/index.html @@ -13,7 +13,7 @@ -
+
From 9b54ff848eb6391267a1c396985a9c4e18ef2e38 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Sun, 28 Jun 2026 21:11:19 -0400 Subject: [PATCH 3/7] create the hit index and map the client space to the score space --- PLAN.md | 44 ++++++--- src/decorations.ts | 43 ++++++++ src/events.test.ts | 66 +++++++++++++ src/events.ts | 91 +++++++++++++++++ src/index.ts | 7 ++ src/render.ts | 27 +++-- src/score.test.ts | 165 +++++++++++++++++++++++++++++++ src/score.ts | 118 ++++++++++++++++++++-- src/stage.ts | 42 +++++++- tests/integration/events.test.ts | 62 ++++++++++++ tests/testing/harness.ts | 26 +---- tests/testing/setup.ts | 36 ++++++- 12 files changed, 676 insertions(+), 51 deletions(-) create mode 100644 src/decorations.ts create mode 100644 src/events.test.ts create mode 100644 src/events.ts create mode 100644 src/score.test.ts create mode 100644 tests/integration/events.test.ts diff --git a/PLAN.md b/PLAN.md index 4a8c1d468..373a88453 100644 --- a/PLAN.md +++ b/PLAN.md @@ -37,7 +37,7 @@ toggles), and add their own drawing layers. Callers are coupled **only** to vexm (`Stage.frame()` is the only place the transform is read). - [x] Anything a caller uses is exported from `src/index.ts` (`render`, `Score`, `Rect`, `Bounded`, `Toggle`, `Note`, `Measure`, `TabPosition`, `PointerTarget`). -- [x] `vex fix` and `vex test` pass (Phase 3: 131 pass / 0 fail, **no screenshot changes**). +- [x] `vex fix` and `vex test` pass (Phase 4: 144 pass / 0 fail, **no screenshot changes**). ## Testing approach (per the Java-y DI request) @@ -100,16 +100,30 @@ coordinate transform. Fakes live beside the tests that use them. > byte-identical. Overlay layers (Phase 5/6) anchor via the `position: relative` Stage sets on > the container. -## Phase 4 — Events (`events.ts`, `score.ts`) - -- [ ] wire the hit **index** into `Score` (deferred from Phase 3): thread `drawScore`'s - `RawGeometry` through `render` into `buildTargets(geometry, stage, decorator)`; this needs a - `Decorator`, so a minimal `Decorations` (state-only; layer/repaint lands in Phase 6) or a - no-op stand-in is built here first -- [ ] `EventListenable` interface + reusable `EventBus` impl (dispatch) -- [ ] `Score` pointer events (toScoreSpace -> hitTest -> `{target, native}`) -- [ ] `scroll`, `resize` events; `score.scroll` getter -- [ ] Unit tests: event bus, dispatch with a `FakeHitTester`; browser smoke test (real pointer lands) +## Phase 4 — Events (`events.ts`, `score.ts`, `decorations.ts`) ✅ DONE + +- [x] wired the hit **index** into `Score` (deferred from Phase 3): `render` threads `drawScore`'s + `RawGeometry` into `buildTargets(geometry, stage, decorations)`; a state-only `Decorations` + (the real `Decorator`; layer/repaint lands in Phase 6) is built here +- [x] `EventListenable` interface + reusable `EventBus` impl (per-type Sets, `count`, + snapshot-iterating `emit`) +- [x] `Score` pointer events (`pointermove`/`pointerdown`/`pointerup`/`click`): + toScoreSpace -> hitTest -> `{ target, point, native }` +- [x] `scroll` + `resize` events; `score.scroll` getter (resize via `Stage.observeResize`/ResizeObserver) +- [x] DOM listeners bound **lazily** — attached on the first subscriber, detached on the last, so an + unobserved score does no per-pointer hit-testing +- [x] `Host` seam (what `Score` needs from the stage: `toScoreSpace`, `events`, `scroll`, + `observeResize`, `dispose`) so `Score` is unit-testable with a `FakeHost` +- [x] Unit tests: `EventBus` (events.test.ts, 5); `Score` dispatch/lazy-bind/scroll/resize/dispose + with `FakeHost`+`FakeHitTester` (score.test.ts, 8); browser smoke test — real pointer maps to + score space and hit-tests the measure box (tests/integration/events.test.ts) +- [x] `index.ts` exports `EventListenable`, `ScoreEventMap`, `PointerTargetEvent`, + `ScoreScrollEvent`, `ScoreResizeEvent` + +> **Test-infra change:** the browser test needed a second page, but a second Chromium in one +> `bun test` run hangs on teardown in Docker. Fixed at the root: the browser + page server now live +> in the preloaded `setup.ts` as a single shared instance (`testBrowser()` / `testServer()` / +> `TEST_URL`), launched once and closed once. `harness.ts` and the events smoke test both reuse it. ## Phase 5 — Layers (public) @@ -119,8 +133,12 @@ coordinate transform. Fakes live beside the tests that use them. ## Phase 6 — Decorations + toggles live (`decorations.ts`) -- [ ] `Decorations implements Decorator`: internal `content` layer, retained active-set, repaint (halo under color) -- [ ] wire `Note.color` / `Note.halo` +`decorations.ts` already exists from Phase 4 as the state-only `Decorations implements Decorator` +(color/halo Maps + `dispose`). Phase 6 adds the drawing: + +- [ ] give `Decorations` an internal `content` layer (needs Stage `createLayer` — deferred from + Phase 3) and repaint from the retained active-set (halo under color) +- [ ] `Note.color` / `Note.halo` already delegate here (Phase 1) — verify they paint end to end - [ ] Unit tests with a recording 2D context (clear+redraw, `off()` removes) - [ ] integration screenshots: a colored note, a halo diff --git a/src/decorations.ts b/src/decorations.ts new file mode 100644 index 000000000..58aedd779 --- /dev/null +++ b/src/decorations.ts @@ -0,0 +1,43 @@ +import type { Bounded, Decorator } from './targets'; + +/* + * The decoration store, the production `Decorator`. A target's color/halo toggles delegate here. + * + * Phase 4 records state only — which targets are colored/haloed — so the toggles have a real + * Decorator to talk to and the hit index can be built. The overlay layer and the repaint that + * actually draws the colors/halos land in Phase 6; `dispose()` will release that layer (a no-op + * until then). + */ +export class Decorations implements Decorator { + private readonly colors = new Map(); + private readonly halos = new Set(); + + setColor(target: Bounded, color: string | null): void { + if (color === null) { + this.colors.delete(target); + } else { + this.colors.set(target, color); + } + } + + setHalo(target: Bounded, on: boolean): void { + if (on) { + this.halos.add(target); + } else { + this.halos.delete(target); + } + } + + isColored(target: Bounded): boolean { + return this.colors.has(target); + } + + isHaloed(target: Bounded): boolean { + return this.halos.has(target); + } + + dispose(): void { + this.colors.clear(); + this.halos.clear(); + } +} diff --git a/src/events.test.ts b/src/events.test.ts new file mode 100644 index 000000000..6f57a5c6e --- /dev/null +++ b/src/events.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from 'bun:test'; +import { EventBus } from './events'; + +// A tiny event map for exercising the generic bus in isolation. +interface TestMap { + ping: { n: number }; + pong: string; +} + +test('emit delivers to every listener for the type, not others', () => { + const bus = new EventBus(); + const pings: number[] = []; + const pongs: string[] = []; + bus.addEventListener('ping', (e) => pings.push(e.n)); + bus.addEventListener('ping', (e) => pings.push(e.n * 10)); + bus.addEventListener('pong', (e) => pongs.push(e)); + + bus.emit('ping', { n: 2 }); + expect(pings).toEqual([2, 20]); + expect(pongs).toEqual([]); +}); + +test('count reflects registrations and dedups the same listener', () => { + const bus = new EventBus(); + const listener = (_e: { n: number }) => {}; + expect(bus.count('ping')).toBe(0); + bus.addEventListener('ping', listener); + bus.addEventListener('ping', listener); // same ref -> still one + expect(bus.count('ping')).toBe(1); + bus.addEventListener('ping', (_e) => {}); + expect(bus.count('ping')).toBe(2); +}); + +test('removeEventListener stops delivery and decrements count', () => { + const bus = new EventBus(); + const seen: number[] = []; + const listener = (e: { n: number }) => seen.push(e.n); + bus.addEventListener('ping', listener); + bus.emit('ping', { n: 1 }); + bus.removeEventListener('ping', listener); + bus.emit('ping', { n: 2 }); + expect(seen).toEqual([1]); + expect(bus.count('ping')).toBe(0); +}); + +test('emit with no listeners is a no-op', () => { + const bus = new EventBus(); + expect(() => bus.emit('pong', 'x')).not.toThrow(); +}); + +test('a listener unsubscribing mid-dispatch does not perturb the current fan-out', () => { + const bus = new EventBus(); + const seen: string[] = []; + const a = (_e: { n: number }) => { + seen.push('a'); + bus.removeEventListener('ping', b); // remove a not-yet-called listener mid-dispatch + }; + const b = (_e: { n: number }) => seen.push('b'); + bus.addEventListener('ping', a); + bus.addEventListener('ping', b); + bus.emit('ping', { n: 0 }); + // b was still called this round (iteration is over a snapshot), but is gone next round. + expect(seen).toEqual(['a', 'b']); + bus.emit('ping', { n: 0 }); + expect(seen).toEqual(['a', 'b', 'a']); +}); diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 000000000..14d2b3ea1 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,91 @@ +import type { PointerTarget } from './targets'; + +/* + * The event seam. `EventListenable` is the abstraction over add/removeEventListener that the + * public Score implements (M maps an event name to its payload type); `EventBus` is the + * reusable production implementer the Score delegates to. Callers are coupled to the interface, + * never the bus. Tests drive the bus directly. + */ +export interface EventListenable { + addEventListener( + type: K, + listener: (event: M[K]) => void, + ): void; + removeEventListener( + type: K, + listener: (event: M[K]) => void, + ): void; +} + +type Listener = (event: M[K]) => void; + +/* A typed multi-listener dispatcher. A per-type Set keeps registration idempotent (adding the + * same listener twice is one entry) and `count` lets an owner bind/unbind an underlying source + * lazily — see Score, which only attaches a DOM listener while someone is subscribed. */ +export class EventBus implements EventListenable { + private readonly listeners: { [K in keyof M]?: Set> } = {}; + + addEventListener(type: K, listener: Listener): void { + let set = this.listeners[type]; + if (!set) { + set = new Set(); + this.listeners[type] = set; + } + set.add(listener); + } + + removeEventListener( + type: K, + listener: Listener, + ): void { + this.listeners[type]?.delete(listener); + } + + /* Dispatch to every listener for `type`. Iterates a copy so a listener that unsubscribes + * (or subscribes) mid-dispatch doesn't perturb the in-progress fan-out. */ + emit(type: K, event: M[K]): void { + const set = this.listeners[type]; + if (!set) { + return; + } + for (const listener of [...set]) { + listener(event); + } + } + + count(type: K): number { + return this.listeners[type]?.size ?? 0; + } +} + +/* A pointer interaction over the score: the target under the pointer (null on empty space), the + * pointer position in score space, and the raw DOM event for everything else (buttons, modifier + * keys, preventDefault). */ +export interface PointerTargetEvent { + readonly target: PointerTarget | null; + readonly point: { x: number; y: number }; + readonly native: PointerEvent; +} + +/* The container scrolled: its new scroll offset plus the raw event. */ +export interface ScoreScrollEvent { + readonly left: number; + readonly top: number; + readonly native: Event; +} + +/* The rendered area changed size (the caller's container resized): its new content-box size. */ +export interface ScoreResizeEvent { + readonly width: number; + readonly height: number; +} + +/* The events a Score dispatches, keyed by name. Pointer events hit-test; scroll/resize don't. */ +export interface ScoreEventMap { + pointermove: PointerTargetEvent; + pointerdown: PointerTargetEvent; + pointerup: PointerTargetEvent; + click: PointerTargetEvent; + scroll: ScoreScrollEvent; + resize: ScoreResizeEvent; +} diff --git a/src/index.ts b/src/index.ts index 297b253ac..047eaee88 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,13 @@ export { type ChordSpec, } from './chord-diagram'; export type { Config } from './config'; +export type { + EventListenable, + PointerTargetEvent, + ScoreEventMap, + ScoreResizeEvent, + ScoreScrollEvent, +} from './events'; export type { FontConfig, FontOverride } from './fonts'; export { Rect } from './geometry'; export type { Layout, MeasureNumbering } from './layout'; diff --git a/src/render.ts b/src/render.ts index 61b51d156..cdeaf3fe9 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,12 +1,21 @@ import { MDOMParser } from '@stringsync/mdom'; import { VexFlow } from 'vexflow'; import { type Config, DEFAULT_CONFIG } from './config'; +import { Decorations } from './decorations'; import { drawScore } from './draw'; import { loadFonts } from './fonts'; +import { Rect } from './geometry'; +import { buildTargets, type RawGeometry } from './hit'; import { computeLayout } from './layout'; import { Score } from './score'; import { Stage } from './stage'; +const EMPTY_GEOMETRY: RawGeometry = { + bounds: new Rect(0, 0, 0, 0), + notes: [], + measures: [], +}; + /* * Render a MusicXML score into a container: parse the input (a MusicXML string or a compressed * .mxl Blob), build the stage inside the div, lay the score out, and draw it onto the stage's @@ -51,11 +60,15 @@ export async function render( } const parts = mdoc.score.parts; - if (parts.length > 0) { - // drawScore also returns the hit-index geometry; the index/events/decorations wire it to - // the Score in a later phase. ponytail: dropped here, but it's collected inside drawScore - // regardless, so ignoring the return costs nothing — wire it through when events land. - drawScore(stage.base, parts, computeLayout(parts, resolved), resolved); - } - return new Score(stage); + const geometry = + parts.length > 0 + ? drawScore(stage.base, parts, computeLayout(parts, resolved), resolved) + : EMPTY_GEOMETRY; + + // The stage is the Viewport (score<->client transform) the targets map through, and the + // decorations are the Decorator their color/halo toggles delegate to. Both feed buildTargets, + // which links the targets and indexes them for hit-testing. + const decorations = new Decorations(); + const index = buildTargets(geometry, stage, decorations); + return new Score(stage, index, decorations); } diff --git a/src/score.test.ts b/src/score.test.ts new file mode 100644 index 000000000..3d6ae9c5b --- /dev/null +++ b/src/score.test.ts @@ -0,0 +1,165 @@ +import { expect, test } from 'bun:test'; +import { Decorations } from './decorations'; +import { Rect } from './geometry'; +import type { HitTester } from './hit'; +import { Score } from './score'; +import type { Host } from './stage'; +import { Measure, type PointerTarget, type Viewport } from './targets'; + +// Separate fake classes fulfilling the injected seams (preferred over mocks). + +class FakeHost implements Host { + readonly events = new EventTarget(); + scroll = { left: 0, top: 0 }; + resizeListener: ((size: { width: number; height: number }) => void) | null = + null; + resizeUnobserved = false; + disposed = false; + // Identity transform: client coords are score coords, so tests assert on the input directly. + toScoreSpace(clientX: number, clientY: number): { x: number; y: number } { + return { x: clientX, y: clientY }; + } + observeResize( + onResize: (size: { width: number; height: number }) => void, + ): () => void { + this.resizeListener = onResize; + return () => { + this.resizeUnobserved = true; + this.resizeListener = null; + }; + } + dispose(): void { + this.disposed = true; + } +} + +class FakeHitTester implements HitTester { + readonly probes: Array<{ x: number; y: number }> = []; + constructor(private readonly result: PointerTarget | null) {} + hitTest(point: { x: number; y: number }): PointerTarget | null { + this.probes.push(point); + return this.result; + } +} + +// A bare EventTarget has no DOM tree, so a synthetic Event with the coords the handler reads is +// enough to drive a pointer event through. +class FakePointerEvent extends Event { + constructor( + type: string, + readonly clientX: number, + readonly clientY: number, + ) { + super(type); + } +} + +const viewport: Viewport = { + clientRectOf: (r) => ({ x: r.x, y: r.y, width: r.w, height: r.h }) as DOMRect, + toScoreSpace: (x, y) => ({ x, y }), +}; + +function fixture(target: PointerTarget | null) { + const host = new FakeHost(); + const index = new FakeHitTester(target); + const decorations = new Decorations(); + const score = new Score(host, index, decorations); + return { host, index, decorations, score }; +} + +test('a pointer event hit-tests the point and emits target, score-space point, and native', () => { + const target = new Measure(new Rect(0, 0, 10, 10), viewport); + const { host, index, score } = fixture(target); + const seen: Array<{ type: string; x: number; y: number; native: Event }> = []; + score.addEventListener('pointermove', (e) => + seen.push({ + type: e.target?.type ?? 'none', + x: e.point.x, + y: e.point.y, + native: e.native, + }), + ); + + const native = new FakePointerEvent('pointermove', 30, 40); + host.events.dispatchEvent(native); + + expect(index.probes).toEqual([{ x: 30, y: 40 }]); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ type: 'measure', x: 30, y: 40, native }); +}); + +test('listeners are bound lazily: no subscriber means no hit-testing', () => { + const { host, index } = fixture(null); + host.events.dispatchEvent(new FakePointerEvent('pointermove', 1, 2)); + expect(index.probes).toHaveLength(0); +}); + +test('the source is detached when the last listener leaves', () => { + const { host, index, score } = fixture(null); + const listener = () => {}; + score.addEventListener('pointermove', listener); + host.events.dispatchEvent(new FakePointerEvent('pointermove', 1, 1)); + score.removeEventListener('pointermove', listener); + host.events.dispatchEvent(new FakePointerEvent('pointermove', 2, 2)); + // Only the dispatch made while subscribed reached the hit tester. + expect(index.probes).toEqual([{ x: 1, y: 1 }]); +}); + +test('the source stays bound until every listener for the type is removed', () => { + const { host, index, score } = fixture(null); + const a = () => {}; + const b = () => {}; + score.addEventListener('pointermove', a); + score.addEventListener('pointermove', b); + score.removeEventListener('pointermove', a); + host.events.dispatchEvent(new FakePointerEvent('pointermove', 5, 5)); + expect(index.probes).toEqual([{ x: 5, y: 5 }]); // still bound for b + score.removeEventListener('pointermove', b); + host.events.dispatchEvent(new FakePointerEvent('pointermove', 6, 6)); + expect(index.probes).toEqual([{ x: 5, y: 5 }]); // now detached +}); + +test('scroll events carry the offset and the score.scroll getter reflects the host', () => { + const { host, score } = fixture(null); + host.scroll = { left: 12, top: 34 }; + const seen: Array<{ left: number; top: number }> = []; + score.addEventListener('scroll', (e) => + seen.push({ left: e.left, top: e.top }), + ); + host.events.dispatchEvent(new Event('scroll')); + expect(seen).toEqual([{ left: 12, top: 34 }]); + expect(score.scroll).toEqual({ left: 12, top: 34 }); +}); + +test('resize subscribes through observeResize and unsubscribes on the last removal', () => { + const { host, score } = fixture(null); + const seen: Array<{ width: number; height: number }> = []; + const a = (_e: { width: number; height: number }) => {}; + const b = (e: { width: number; height: number }) => seen.push(e); + score.addEventListener('resize', a); + score.addEventListener('resize', b); + expect(host.resizeListener).not.toBeNull(); + host.resizeListener?.({ width: 100, height: 50 }); + expect(seen).toEqual([{ width: 100, height: 50 }]); + + score.removeEventListener('resize', a); + expect(host.resizeUnobserved).toBe(false); // b still subscribed + score.removeEventListener('resize', b); + expect(host.resizeUnobserved).toBe(true); // last one gone -> ResizeObserver disconnected +}); + +test('dispose detaches every listener and tears down decorations and host', () => { + const target = new Measure(new Rect(0, 0, 10, 10), viewport); + const { host, index, decorations, score } = fixture(target); + score.addEventListener('pointermove', () => {}); + score.addEventListener('resize', () => {}); + decorations.setColor(target, '#ff0000'); + + score.dispose(); + + expect(host.disposed).toBe(true); + expect(host.resizeUnobserved).toBe(true); + expect(decorations.isColored(target)).toBe(false); // decorations.dispose() ran + host.events.dispatchEvent(new FakePointerEvent('pointermove', 9, 9)); + expect(index.probes).toHaveLength(0); // pointer handler detached +}); diff --git a/src/score.ts b/src/score.ts index 4dc744d94..d5c141fdc 100644 --- a/src/score.ts +++ b/src/score.ts @@ -1,15 +1,117 @@ -import type { Stage } from './stage'; +import type { Decorations } from './decorations'; +import { EventBus, type EventListenable, type ScoreEventMap } from './events'; +import type { HitTester } from './hit'; +import type { Host } from './stage'; /* - * A rendered score: the handle render() returns. Owns the DOM vexml built (the Stage) and is - * where pointer events, decorations, and custom layers will hang in the phases that follow. - * dispose() tears the whole thing down — removing the managed canvas and restoring the - * container — so a caller can re-render or unmount cleanly. + * A rendered score: the handle render() returns. Owns the DOM vexml built (the Stage/Host) and + * lets callers subscribe to pointer/scroll/resize events through the EventListenable interface; + * pointer events are hit-tested against the index to the target under the pointer. dispose() + * tears the whole thing down so a caller can re-render or unmount cleanly. + * + * DOM listeners are bound lazily: the underlying source is attached only while at least one + * caller is subscribed to that event, so an unobserved score does no per-pointer hit-testing. */ -export class Score { - constructor(private readonly stage: Stage) {} +export class Score implements EventListenable { + private readonly bus = new EventBus(); + // The live DOM listeners (pointer/scroll), keyed by event name so unbind can remove the exact + // reference. Resize isn't here — it's a ResizeObserver, torn down via unobserveResize. + private readonly bound = new Map(); + private unobserveResize: (() => void) | null = null; + + constructor( + private readonly host: Host, + private readonly index: HitTester, + private readonly decorations: Decorations, + ) {} + + /* The container's current scroll offset (score space and client space differ only by it and + * any zoom — getBoundingClientRect already folds scroll into hit-testing). */ + get scroll(): { left: number; top: number } { + return this.host.scroll; + } + + addEventListener( + type: K, + listener: (event: ScoreEventMap[K]) => void, + ): void { + const first = this.bus.count(type) === 0; + this.bus.addEventListener(type, listener); + if (first) { + this.bind(type); + } + } + + removeEventListener( + type: K, + listener: (event: ScoreEventMap[K]) => void, + ): void { + this.bus.removeEventListener(type, listener); + if (this.bus.count(type) === 0) { + this.unbind(type); + } + } dispose(): void { - this.stage.dispose(); + for (const [type, handler] of this.bound) { + this.host.events.removeEventListener(type, handler); + } + this.bound.clear(); + this.unobserveResize?.(); + this.unobserveResize = null; + this.decorations.dispose(); + this.host.dispose(); + } + + // Attach the underlying source for a Score event on its first subscriber. Resize is a + // ResizeObserver; everything else is a DOM listener on the host's event source. Pointer + // events hit-test the point under them; scroll carries the new offset. + private bind(type: keyof ScoreEventMap): void { + switch (type) { + case 'resize': { + this.unobserveResize = this.host.observeResize((size) => + this.bus.emit('resize', size), + ); + return; + } + case 'scroll': { + const handler: EventListener = (native) => { + this.bus.emit('scroll', { ...this.host.scroll, native }); + }; + this.bound.set(type, handler); + this.host.events.addEventListener(type, handler); + return; + } + default: { + const handler: EventListener = (native) => { + const pointer = native as PointerEvent; + const point = this.host.toScoreSpace( + pointer.clientX, + pointer.clientY, + ); + this.bus.emit(type, { + target: this.index.hitTest(point), + point, + native: pointer, + }); + }; + this.bound.set(type, handler); + this.host.events.addEventListener(type, handler); + } + } + } + + // Detach the underlying source when the last subscriber for a Score event leaves. + private unbind(type: keyof ScoreEventMap): void { + if (type === 'resize') { + this.unobserveResize?.(); + this.unobserveResize = null; + return; + } + const handler = this.bound.get(type); + if (handler) { + this.host.events.removeEventListener(type, handler); + this.bound.delete(type); + } } } diff --git a/src/stage.ts b/src/stage.ts index 38a54d029..055ba9c65 100644 --- a/src/stage.ts +++ b/src/stage.ts @@ -1,6 +1,23 @@ import type { Rect } from './geometry'; import type { Viewport } from './targets'; +/* + * What a Score needs from its host: the score<-client transform (toScoreSpace), a raw event + * source to bind pointer/scroll listeners on, the current scroll offset, a resize subscription, + * and teardown. Stage is the production implementer; a Score unit test injects a fake. Kept + * separate from Viewport (the targets' coordinate seam) so each consumer depends only on what it + * uses, even though Stage satisfies both. + */ +export interface Host { + toScoreSpace(clientX: number, clientY: number): { x: number; y: number }; + readonly events: EventTarget; + readonly scroll: { left: number; top: number }; + observeResize( + onResize: (size: { width: number; height: number }) => void, + ): () => void; + dispose(): void; +} + /* * The host: the DOM vexml builds inside the caller's container, and the coordinate authority * between score space (where target rects live) and client/page space (where pointer events and @@ -13,7 +30,7 @@ import type { Viewport } from './targets'; * top-left. Reading the live rect each call means page scroll and any CSS scaling of the canvas * are handled for free. */ -export class Stage implements Viewport { +export class Stage implements Viewport, Host { readonly base: HTMLCanvasElement; private readonly prevPosition: string; @@ -43,6 +60,29 @@ export class Stage implements Viewport { return { x: (clientX - left) / sx, y: (clientY - top) / sy }; } + // Bind on the container, not the canvas: canvas pointer/scroll events bubble up to it, and + // it's where the overlay layers (later phases) live too, so one source covers the whole stage. + get events(): EventTarget { + return this.container; + } + + get scroll(): { left: number; top: number } { + return { left: this.container.scrollLeft, top: this.container.scrollTop }; + } + + observeResize( + onResize: (size: { width: number; height: number }) => void, + ): () => void { + const observer = new ResizeObserver((entries) => { + const box = entries[0]?.contentRect; + if (box) { + onResize({ width: box.width, height: box.height }); + } + }); + observer.observe(this.container); + return () => observer.disconnect(); + } + dispose(): void { this.base.remove(); this.container.style.position = this.prevPosition; diff --git a/tests/integration/events.test.ts b/tests/integration/events.test.ts new file mode 100644 index 000000000..7eecbb321 --- /dev/null +++ b/tests/integration/events.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from 'bun:test'; +import { TEST_URL, testBrowser } from '../testing/setup'; + +// The unit tests cover the wiring with fakes; this proves the real chain end to end — a DOM +// pointer event on the managed canvas bubbles to the Score, gets mapped to score space through +// the live Stage transform, and hit-tests against the index built from real geometry. Uses the +// run's shared browser/server (see setup.ts). +test('a real pointer event maps to score space and hit-tests a target', async () => { + const browser = await testBrowser(); + const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); + try { + await page.goto(TEST_URL); + const result = await page.evaluate(async () => { + const container = document.getElementById('screenshot'); + if (!(container instanceof HTMLDivElement)) { + throw new Error('container not found'); + } + const xml = await ( + await fetch('/data/structure_single_stave.musicxml') + ).text(); + const score = await window.render(xml, container, {}); + const canvas = container.querySelector('canvas'); + if (!canvas) { + throw new Error('canvas not found'); + } + + const types = new Set(); + const points: Array<{ x: number; y: number }> = []; + score.addEventListener('pointerdown', (e) => { + if (e.target) { + types.add(e.target.type); + } + points.push({ x: e.point.x, y: e.point.y }); + }); + + // Scan down the vertical center line so the stave is crossed wherever the crop + // places it — robust to the exact engraved height. + const rect = canvas.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + for (let dy = 4; dy < rect.height; dy += 4) { + canvas.dispatchEvent( + new PointerEvent('pointerdown', { + clientX: cx, + clientY: rect.top + dy, + bubbles: true, + }), + ); + } + return { types: [...types], points, width: rect.width }; + }); + + // The event reached the listener with its point mapped into score space (the unscaled + // harness canvas means client x == score x, offset by the canvas origin). + expect(result.points.length).toBeGreaterThan(0); + expect(result.points[0]?.x).toBeCloseTo(result.width / 2, 0); + expect(result.points[0]?.y).toBeCloseTo(4, 0); + // The measure box, built from real geometry, was hittable. + expect(result.types).toContain('measure'); + } finally { + await page.close(); + } +}, 30_000); diff --git a/tests/testing/harness.ts b/tests/testing/harness.ts index 6e1e86cbc..12c63cbd7 100644 --- a/tests/testing/harness.ts +++ b/tests/testing/harness.ts @@ -1,28 +1,11 @@ -import { afterAll, beforeAll } from 'bun:test'; -import { type Browser, chromium } from 'playwright'; import type { Config } from '../../src'; import { DEFAULT_WIDTH } from '../../src/constants'; -import { serve } from './serve'; - -const PORT = 3100; - -let browser: Browser; -let server: ReturnType; - -// Importing this module registers the hooks against the importing test file. -beforeAll(async () => { - server = serve(PORT); - browser = await chromium.launch(); -}); - -afterAll(async () => { - await browser?.close(); - server?.stop(true); -}); +import { TEST_URL, testBrowser } from './setup'; // A fixture is laid out to its reference width (8.5in unless the test overrides it); // the result scales to any container at runtime, so a static viewport exercises the -// layout deterministically. +// layout deterministically. The browser and page server are shared across the whole run +// (see setup.ts) — launching a second Chromium per file is flaky in Docker. /** Render a corpus file in the browser and return its screenshot PNG. */ export async function render( @@ -32,11 +15,12 @@ export async function render( const width = (config.layout?.type === 'standard' ? config.layout.width : undefined) ?? DEFAULT_WIDTH; + const browser = await testBrowser(); const page = await browser.newPage({ viewport: { width: width + 64, height: 600 }, }); try { - await page.goto(`http://localhost:${PORT}/`); + await page.goto(TEST_URL); await page.evaluate( async ({ file, config }) => { const res = await fetch(`/data/${file}`); diff --git a/tests/testing/setup.ts b/tests/testing/setup.ts index cb226d9b7..9bb38854c 100644 --- a/tests/testing/setup.ts +++ b/tests/testing/setup.ts @@ -1,4 +1,4 @@ -import { afterAll, expect } from 'bun:test'; +import { afterAll, beforeAll, expect } from 'bun:test'; import { existsSync, mkdirSync, @@ -11,7 +11,9 @@ import * as path from 'node:path'; import { createCanvas, createImageData } from 'canvas'; import chalk from 'chalk'; import pixelmatch from 'pixelmatch'; +import { type Browser, chromium } from 'playwright'; import { PNG } from 'pngjs'; +import { serve } from './serve'; // Guard: tests must go through `vex test`, which renders in the pinned Docker // image. Bare `bun test` on the host compares against the committed Docker @@ -26,6 +28,38 @@ if (process.env.I_AM_RUNNING_TESTS_USING_VEX_TEST !== '1') { process.exit(1); } +// One browser and one page server for the whole `bun test` run. Launching a second Chromium in +// the same run is flaky in Docker — its teardown hangs past the hook timeout — so every browser +// test (the screenshot harness and the events smoke test) reuses these. Preloaded, so the +// lifecycle scopes to the run, not one file. Eager (beforeAll) to keep the launch out of the +// first test's own timeout; lazy getters so a unit-only run still pays for them only if used. +const TEST_PORT = 3100; +export const TEST_URL = `http://localhost:${TEST_PORT}/`; +let sharedServer: ReturnType | null = null; +let sharedBrowser: Promise | null = null; + +export function testServer(): ReturnType { + sharedServer ??= serve(TEST_PORT); + return sharedServer; +} + +export function testBrowser(): Promise { + testServer(); + sharedBrowser ??= chromium.launch(); + return sharedBrowser; +} + +beforeAll(async () => { + await testBrowser(); +}); + +afterAll(async () => { + if (sharedBrowser) { + await (await sharedBrowser).close(); + } + sharedServer?.stop(true); +}); + // [old][diff][new] stacked vertically, each captioned, returned as a PNG buffer. function composite( expected: PNG, From d8f098eec19bdf0b6cf0326b53daae805f2c6907 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Sun, 28 Jun 2026 22:07:25 -0400 Subject: [PATCH 4/7] render glyphs and halos! --- PLAN.md | 60 +++++-- site/src/App.tsx | 106 +++++++++++- src/decorations.test.ts | 162 ++++++++++++++++++ src/decorations.ts | 121 +++++++++++-- src/draw.ts | 44 ++++- src/hit.test.ts | 3 + src/hit.ts | 4 + src/index.ts | 2 + src/render.ts | 6 +- src/score.test.ts | 70 ++++++-- src/score.ts | 59 ++++--- src/stage.ts | 145 ++++++++++++++-- src/targets.test.ts | 15 +- src/targets.ts | 44 ++++- .../__screenshots__/decoration_color.png | Bin 0 -> 8419 bytes .../__screenshots__/decoration_halo.png | Bin 0 -> 11460 bytes tests/integration/decorations.test.ts | 72 ++++++++ tests/integration/layers.test.ts | 76 ++++++++ 18 files changed, 883 insertions(+), 106 deletions(-) create mode 100644 src/decorations.test.ts create mode 100644 tests/integration/__screenshots__/decoration_color.png create mode 100644 tests/integration/__screenshots__/decoration_halo.png create mode 100644 tests/integration/decorations.test.ts create mode 100644 tests/integration/layers.test.ts diff --git a/PLAN.md b/PLAN.md index 373a88453..85b3531db 100644 --- a/PLAN.md +++ b/PLAN.md @@ -37,7 +37,7 @@ toggles), and add their own drawing layers. Callers are coupled **only** to vexm (`Stage.frame()` is the only place the transform is read). - [x] Anything a caller uses is exported from `src/index.ts` (`render`, `Score`, `Rect`, `Bounded`, `Toggle`, `Note`, `Measure`, `TabPosition`, `PointerTarget`). -- [x] `vex fix` and `vex test` pass (Phase 4: 144 pass / 0 fail, **no screenshot changes**). +- [x] `vex fix` and `vex test` pass (Phase 6: 155 pass / 0 fail; 2 decoration baselines added). ## Testing approach (per the Java-y DI request) @@ -125,22 +125,48 @@ coordinate transform. Fakes live beside the tests that use them. > in the preloaded `setup.ts` as a single shared instance (`testBrowser()` / `testServer()` / > `TEST_URL`), launched once and closed once. `harness.ts` and the events smoke test both reuse it. -## Phase 5 — Layers (public) - -- [ ] `addLayer('viewport' | 'content')` / `removeLayer` / `layer.dispose()` -- [ ] sizing policy per kind; resize -> resize+clear+dispatch contract -- [ ] Tests: dimensions per kind; resize fires - -## Phase 6 — Decorations + toggles live (`decorations.ts`) - -`decorations.ts` already exists from Phase 4 as the state-only `Decorations implements Decorator` -(color/halo Maps + `dispose`). Phase 6 adds the drawing: - -- [ ] give `Decorations` an internal `content` layer (needs Stage `createLayer` — deferred from - Phase 3) and repaint from the retained active-set (halo under color) -- [ ] `Note.color` / `Note.halo` already delegate here (Phase 1) — verify they paint end to end -- [ ] Unit tests with a recording 2D context (clear+redraw, `off()` removes) -- [ ] integration screenshots: a colored note, a halo +## Phase 5 — Layers (public) ✅ DONE + +- [x] `Stage.createLayer` (deferred from Phase 3): dpr-scaled absolute overlay canvas, `Layer` + ({ ctx, dispose }) + `LayerKind`; `pointer-events: none` so layers never capture input + (the Score hit-tests centrally), aligned to the base canvas via its offset +- [x] `Score.addLayer('content' | 'viewport')` / `removeLayer` / `layer.dispose()` +- [x] sizing policy per kind: content = base canvas (score space), viewport = container visible box +- [x] resize -> re-fit (clear) viewport layers **then** dispatch `resize`, so the caller's redraw + lands on a correctly sized, cleared surface; content layers are fixed (engraved once) +- [x] `index.ts` exports `Layer`, `LayerKind` +- [x] Tests: `Score.addLayer`/`removeLayer` delegation + resize-before-emit ordering (score.test.ts, + with `FakeHost`/`FakeLayer`); browser test — content vs base size, viewport vs visible box, + resize re-fits the viewport layer (tests/integration/layers.test.ts) + +> **Plan refinements made here:** +> - Resize observation is now **always-on** (set up in the Score constructor), not lazy as in +> Phase 4 — it has to drive viewport-layer sizing even with no `resize` subscriber. Pointer/scroll +> stay lazy (the frequent, hit-testing ones). The Phase 4 lazy-resize unit test was updated to +> match. +> - Viewport layers are positioned at the content origin (absolute). Scroll-**following** (staying +> fixed in view as content scrolls) is deferred until an overflow/scroll container is actually +> wired — the container has no `overflow` yet, so it doesn't manifest. + +## Phase 6 — Decorations + toggles live (`decorations.ts`) ✅ DONE + +`decorations.ts` existed from Phase 4 as the state-only `Decorations implements Decorator`. Phase 6 +added the drawing: + +- [x] `Decorations` lazily creates an internal `content` layer (via a `LayerHost` seam — `Stage`, + now that `createLayer` exists) and repaints the whole overlay from the retained active set on + every change (halos under colors). Repaint-the-lot is the answer to "how does `off()` work + without disturbing neighbors": clearing one rect could wipe an overlapping decoration. +- [x] color = a filled ellipse over the notehead box in the caller's color; halo = a soft + semi-transparent circle centered on the notehead (the halo toggle is argument-less, fixed look) +- [x] **notehead geometry fix**: the hit box now uses the glyph's true x-span + (`getNoteHeadBeginX/EndX`), not `getAbsoluteX()` (the tick anchor, left of the notehead) — so + decorations land *on* the note (color) and *evenly around* it (halo), not offset +- [x] `Note.color` / `Note.halo` delegate here (Phase 1); verified end to end in the browser +- [x] Unit tests with a recording 2D context: lazy layer, clear-then-redraw, halo-under-color, + `off()` removes, dispose (decorations.test.ts, 7) +- [x] integration screenshots: a colored note, a halo (tests/integration/decorations.test.ts) — + driven through the real hover -> hit-test -> toggle flow --- diff --git a/site/src/App.tsx b/site/src/App.tsx index a4f249a7b..1d7076aa4 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -1,7 +1,34 @@ import { useEffect, useRef, useState } from 'react'; -import type { Config } from '../../src'; +import type { + Config, + Note, + PointerTarget, + PointerTargetEvent, +} from '../../src'; import { render, type Score } from '../../src'; +// One-line summary of the hovered target for the tooltip. +function describe(target: PointerTarget): string { + if (target.type === 'note') { + const beats = target.getBeats(); + const parts = [ + target.getPitch() ?? 'rest', + `${beats} beat${beats === 1 ? '' : 's'}`, + ]; + if (target.isGrace()) { + parts.push('grace'); + } + if (target.isChordMember()) { + parts.push('chord'); + } + return parts.join(' · '); + } + if (target.type === 'tab-position') { + return `string ${target.getString()} · fret ${target.getFret()} · ${target.getNote().getPitch() ?? 'rest'}`; + } + return ''; +} + // Vite reads the test fixtures straight from ../tests at build time (fs.allow: ['..'] in // vite.config permits it) and hands us the file list — no symlink or hand-written manifest. // Keyed by basename, each value lazily loads the file's raw text. @@ -84,6 +111,17 @@ export default function App() { ); const [cleared, setCleared] = useState(false); const [restored, setRestored] = useState(false); + const [showInfo, setShowInfo] = useState(true); + const [tooltip, setTooltip] = useState<{ + x: number; + y: number; + text: string; + } | null>(null); + // Read live inside the pointer handler so toggling the checkbox doesn't re-subscribe. + const showInfoRef = useRef(showInfo); + showInfoRef.current = showInfo; + // The note whose halo is currently lit, so the next move can turn it back off. + const haloRef = useRef(null); const debounceRef = useRef | undefined>( undefined, ); @@ -158,6 +196,13 @@ export default function App() { ? renderConfig.layout.width : undefined; let cancelled = false; + // Turn off the lit halo and hide the tooltip; called on move-to-empty and on leave. + const clearHalo = () => { + haloRef.current?.halo.off(); + haloRef.current = null; + setTooltip(null); + }; + let detach: (() => void) | undefined; render(input, container, { ...renderConfig, layout: { type: 'standard', width: layoutWidth }, @@ -171,6 +216,37 @@ export default function App() { } scoreRef.current = score; setRenderMs(performance.now() - start); + + const onPointer = (e: PointerTargetEvent) => { + const note = + e.target?.type === 'note' + ? e.target + : e.target?.type === 'tab-position' + ? e.target.getNote() + : null; + if (note !== haloRef.current) { + haloRef.current?.halo.off(); + note?.halo.on(); + haloRef.current = note; + } + if (note && e.target && showInfoRef.current) { + const r = e.target.getBoundingClientRect(); + setTooltip({ + x: r.left + r.width / 2, + y: r.top, + text: describe(e.target), + }); + } else { + setTooltip(null); + } + }; + score.addEventListener('pointermove', onPointer); + score.addEventListener('pointerdown', onPointer); + container.addEventListener('pointerleave', clearHalo); + detach = () => { + container.removeEventListener('pointerleave', clearHalo); + clearHalo(); + }; }) .catch((e: unknown) => { setRenderMs(null); @@ -178,6 +254,8 @@ export default function App() { }); return () => { cancelled = true; + // score.dispose() drops its own listeners; this only unbinds the DOM-level leave handler. + detach?.(); scoreRef.current?.dispose(); scoreRef.current = null; }; @@ -442,6 +520,23 @@ export default function App() { /> Dark mode +
); } diff --git a/src/decorations.test.ts b/src/decorations.test.ts new file mode 100644 index 000000000..7b3c7d344 --- /dev/null +++ b/src/decorations.test.ts @@ -0,0 +1,162 @@ +import { expect, test } from 'bun:test'; +import { Decorations } from './decorations'; +import { Rect } from './geometry'; +import type { Layer, LayerHost, LayerKind } from './stage'; +import type { Decoratable, NoteGlyph } from './targets'; + +// A recording 2D context: logs the operations Decorations performs so a test can assert what was +// painted (and in what order) without a real canvas. Cast to CanvasRenderingContext2D — only the +// members Decorations touches are implemented. + +class RecordingContext { + fillStyle: string | CanvasGradient | CanvasPattern = '#000000'; + font = ''; + textAlign = 'left'; + textBaseline = 'alphabetic'; + readonly canvas = { width: 800, height: 600 } as HTMLCanvasElement; + // In call order: 'clear', 'fill::