@real-music-packages/web-core 0.39.6 → 0.39.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -100,12 +100,42 @@ interface SvgNotationPlayer {
|
|
|
100
100
|
/** Register a measure-click handler (measure index, matching
|
|
101
101
|
* `ScoreNote.measure`/the engraved index). Returns an unsubscribe fn. */
|
|
102
102
|
onMeasureClick(cb: (measureIndex: number) => void): () => void;
|
|
103
|
+
/**
|
|
104
|
+
* Mark the engraved noteheads at the given columns, in the same
|
|
105
|
+
* `measureIndex + fraction-through-the-measure` units as `noteCols`; pass
|
|
106
|
+
* `null` or `[]` to clear.
|
|
107
|
+
*
|
|
108
|
+
* For pointing a learner at the note being asked for — a playhead says
|
|
109
|
+
* WHERE you are, which is not the same as WHICH note, especially on a busy
|
|
110
|
+
* bar. Marked notes carry the `rmp-note-marked` class, so the consumer owns
|
|
111
|
+
* the look; this module does not inject styles.
|
|
112
|
+
*
|
|
113
|
+
* Survives re-engraving: the marks are reapplied after `setZoom`/`resize`.
|
|
114
|
+
*/
|
|
115
|
+
markNotes(cols: number[] | null): void;
|
|
103
116
|
/** Tear down: removes the mounted DOM (engraving + playhead overlay) from
|
|
104
117
|
* `host`, and drops every listener this instance added (click, window
|
|
105
118
|
* scroll) and pending async work (a token guard drops any in-flight
|
|
106
119
|
* reflow's effects). Idempotent. */
|
|
107
120
|
destroy(): void;
|
|
108
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Graphical notes of the staff entry nearest a column position, in the same
|
|
124
|
+
* `measureIndex + fraction-through-the-measure` units as `noteCols`.
|
|
125
|
+
*
|
|
126
|
+
* OSMD keeps each staff entry's position inside its bar as
|
|
127
|
+
* `relInMeasureTimestamp`, so the fractional part of a column maps onto an
|
|
128
|
+
* entry by comparing that against the bar's own duration — the same clock the
|
|
129
|
+
* caller built the columns from. Every staff of the bar is searched, so a
|
|
130
|
+
* grand-staff onset marks both hands.
|
|
131
|
+
*
|
|
132
|
+
* `osmd` is duck-typed `any` for the same reason `svgNotationLayout`'s is: it
|
|
133
|
+
* reads a small slice (`GraphicSheet.MeasureList[][].staffEntries[]`) that a
|
|
134
|
+
* plain fixture can stand in for, so this is testable without a browser render.
|
|
135
|
+
*/
|
|
136
|
+
declare function graphicalNotesAtColumn(osmd: any, col: number): any[];
|
|
137
|
+
/** Class put on every marked notehead group. */
|
|
138
|
+
declare const MARKED_NOTE_CLASS = "rmp-note-marked";
|
|
109
139
|
interface SvgNotationLayoutOpts {
|
|
110
140
|
/** CSS px per OSMD unit at zoom 1 — OSMD's own exported `unitInPixels`
|
|
111
141
|
* constant (see the derivation above). REQUIRED, no default: the point is
|
|
@@ -151,4 +181,4 @@ declare const MAX_ENGRAVE_WIDTH_SVG = 1200;
|
|
|
151
181
|
* stave-web-sightread) for the full design. */
|
|
152
182
|
declare function createSvgNotationPlayer(opts: CreateSvgNotationPlayerOpts): SvgNotationPlayer;
|
|
153
183
|
|
|
154
|
-
export { type CreateSvgNotationPlayerOpts, MAX_ENGRAVE_WIDTH_SVG, PROGRAMMATIC_SCROLL_EPSILON_PX, type SvgNotationLayoutOpts, type SvgNotationPlayer, computeReflowScrollDelta, createSvgNotationPlayer, hasReachedProgrammaticTarget, isWithinProgrammaticScroll, svgNotationLayout };
|
|
184
|
+
export { type CreateSvgNotationPlayerOpts, MARKED_NOTE_CLASS, MAX_ENGRAVE_WIDTH_SVG, PROGRAMMATIC_SCROLL_EPSILON_PX, type SvgNotationLayoutOpts, type SvgNotationPlayer, computeReflowScrollDelta, createSvgNotationPlayer, graphicalNotesAtColumn, hasReachedProgrammaticTarget, isWithinProgrammaticScroll, svgNotationLayout };
|
|
@@ -13,6 +13,34 @@ import {
|
|
|
13
13
|
import "./chunk-HXTRNE74.js";
|
|
14
14
|
|
|
15
15
|
// src/notationPlayerSvg.ts
|
|
16
|
+
function graphicalNotesAtColumn(osmd, col) {
|
|
17
|
+
const measureList = osmd?.GraphicSheet?.MeasureList ?? [];
|
|
18
|
+
if (!measureList.length || !Number.isFinite(col)) return [];
|
|
19
|
+
const measureIndex = Math.max(0, Math.min(measureList.length - 1, Math.floor(col)));
|
|
20
|
+
const staves = measureList[measureIndex] ?? [];
|
|
21
|
+
const wanted = col - measureIndex;
|
|
22
|
+
const found = [];
|
|
23
|
+
for (const staffMeasure of staves) {
|
|
24
|
+
const entries = staffMeasure?.staffEntries ?? [];
|
|
25
|
+
if (!entries.length) continue;
|
|
26
|
+
const barLength = staffMeasure?.parentSourceMeasure?.Duration?.RealValue || 1;
|
|
27
|
+
let best = null;
|
|
28
|
+
let bestGap = Number.POSITIVE_INFINITY;
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
const rel = entry?.relInMeasureTimestamp?.RealValue ?? 0;
|
|
31
|
+
const gap = Math.abs(rel / barLength - wanted);
|
|
32
|
+
if (gap < bestGap) {
|
|
33
|
+
best = entry;
|
|
34
|
+
bestGap = gap;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const voiceEntry of best?.graphicalVoiceEntries ?? []) {
|
|
38
|
+
for (const note of voiceEntry?.notes ?? []) found.push(note);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
var MARKED_NOTE_CLASS = "rmp-note-marked";
|
|
16
44
|
var EMPTY_LAYOUT = {
|
|
17
45
|
src: { x: 0, y: 0, w: 0, h: 0 },
|
|
18
46
|
rect: { dx: 0, dy: 0, dw: 0, dh: 0 },
|
|
@@ -185,6 +213,7 @@ function createSvgNotationPlayer(opts) {
|
|
|
185
213
|
lastEngravedWidthPx = widthPx;
|
|
186
214
|
currentZoom = newZoom;
|
|
187
215
|
currentLayout = svgNotationLayout(osmd, { unitInPixels: unitInPixelsConst });
|
|
216
|
+
applyMarks();
|
|
188
217
|
if (oldLayout && anchorY != null && hasWin) {
|
|
189
218
|
const delta = computeReflowScrollDelta(oldLayout, currentLayout, anchorX, anchorY);
|
|
190
219
|
if (Number.isFinite(delta) && Math.abs(delta) > 0.5) {
|
|
@@ -203,9 +232,26 @@ function createSvgNotationPlayer(opts) {
|
|
|
203
232
|
if (idx != null) for (const cb of clickListeners) cb(idx);
|
|
204
233
|
}
|
|
205
234
|
root.addEventListener("click", onRootClick);
|
|
235
|
+
let markedCols = [];
|
|
236
|
+
function applyMarks() {
|
|
237
|
+
for (const el of root.querySelectorAll(`.${MARKED_NOTE_CLASS}`)) {
|
|
238
|
+
el.classList.remove(MARKED_NOTE_CLASS);
|
|
239
|
+
}
|
|
240
|
+
if (!osmd) return;
|
|
241
|
+
for (const col of markedCols) {
|
|
242
|
+
for (const note of graphicalNotesAtColumn(osmd, col)) {
|
|
243
|
+
const el = typeof note?.getSVGGElement === "function" ? note.getSVGGElement() : null;
|
|
244
|
+
if (el) el.classList.add(MARKED_NOTE_CLASS);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
206
248
|
return {
|
|
207
249
|
ready,
|
|
208
250
|
setTime,
|
|
251
|
+
markNotes(cols) {
|
|
252
|
+
markedCols = cols ?? [];
|
|
253
|
+
applyMarks();
|
|
254
|
+
},
|
|
209
255
|
setFollowEnabled(on) {
|
|
210
256
|
follow.setEnabled(on);
|
|
211
257
|
},
|
|
@@ -242,10 +288,12 @@ function createSvgNotationPlayer(opts) {
|
|
|
242
288
|
};
|
|
243
289
|
}
|
|
244
290
|
export {
|
|
291
|
+
MARKED_NOTE_CLASS,
|
|
245
292
|
MAX_ENGRAVE_WIDTH_SVG,
|
|
246
293
|
PROGRAMMATIC_SCROLL_EPSILON_PX,
|
|
247
294
|
computeReflowScrollDelta,
|
|
248
295
|
createSvgNotationPlayer,
|
|
296
|
+
graphicalNotesAtColumn,
|
|
249
297
|
hasReachedProgrammaticTarget,
|
|
250
298
|
isWithinProgrammaticScroll,
|
|
251
299
|
svgNotationLayout
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/notationPlayerSvg.ts"],"sourcesContent":["// createSvgNotationPlayer — the SVG (vector) sibling of `createNotationPlayer`\n// (src/notationPlayer.ts). Same job (a live, caller-driven notation +\n// gliding-playhead widget), different medium: OSMD's native SVG backend\n// instead of a rasterized canvas. See\n// docs — stave-web-sightread's\n// docs/superpowers/specs/2026-08-11-svg-notation-player-design.md — for the\n// full design rationale (why: vectors don't blur on pinch/browser zoom, no\n// canvas-area cap, no raster tiling needed for full-score scroll).\n//\n// THE CANVAS MODULE IS NOT MODIFIED OR IMPORTED FOR ITS OSMD/RASTER PATH —\n// this is a parallel component. What IS reused, verbatim, no new math:\n// - `vstackAudioPlayheadLine` (scene/notationGeometry.ts) — the exact same\n// onset-anchored, carriage-return playhead interpolation the canvas path\n// uses. It operates purely on a `NotationLayout` (measure column boxes +\n// a vertical clamp band) — it does not care whether those boxes came from\n// a canvas raster or live SVG DOM geometry, so THIS module's job is only\n// to produce a `NotationLayout` in SVG/CSS px space (see\n// `svgNotationLayout` below) and everything downstream is identical.\n// - `hitTestMeasureAt` (scene/notationGeometry.ts, moved there in 0.38.0\n// specifically so this module never has a build-time edge into\n// notationPlayer.ts's canvas/raster implementation) — the exact same pure\n// point-in-measure-box hit-test, reused for click-to-seek.\n// - `distinctOnsets`, `measureColumnsFromLayout` — small pure helpers,\n// reused as-is.\n//\n// WHAT'S NEW (not a re-derivation of any of the above):\n// - `svgNotationLayout` — an SVG-backend geometry extractor, the SVG\n// counterpart to promo.ts's canvas-only `extractGeometry`. See its own\n// doc comment for the unit-conversion derivation.\n// - `computeReflowScrollDelta` — zoom/resize position-preservation math\n// (not playhead interpolation; a one-shot \"where did this same content\n// move to\" lookup using the existing hit-test + column helpers).\n// - `isWithinProgrammaticScroll` / `hasReachedProgrammaticTarget` — the\n// improved auto-follow discriminator (design doc §4): unlike the canvas\n// scroll-mode player's fixed 600ms \"programmatic scroll\" grace window,\n// this compares the ACTUAL scroll position against the EXACT target this\n// component itself requested, so classification is correct regardless of\n// how long a smooth-scroll animation takes (no grace-window race).\n//\n// IMPORT PATH: subpath-only — `@real-music-packages/web-core/notationPlayerSvg`\n// — not re-exported from the root barrel (same reasoning as notationPlayer.ts:\n// the root barrel is theory-only/zero-dependency).\n\nimport {\n vstackAudioPlayheadLine,\n distinctOnsets,\n hitTestMeasureAt,\n type NotationLayout,\n} from './scene/notationGeometry';\nimport type { Box, StaffMeasureBox } from './promo';\nimport {\n computeReflowScrollDelta,\n createFollowController,\n isWithinProgrammaticScroll,\n hasReachedProgrammaticTarget,\n PROGRAMMATIC_SCROLL_EPSILON_PX,\n} from './notationCommon';\n\n// Re-exported for backward compatibility — this module's own tests (and any\n// external importer) still pull these from `notationPlayerSvg`; the\n// implementations now live in `notationCommon.ts` (shared with\n// notationPlayerVerovio.ts, 0.39.0). See that module's doc for why.\nexport { computeReflowScrollDelta, isWithinProgrammaticScroll, hasReachedProgrammaticTarget, PROGRAMMATIC_SCROLL_EPSILON_PX };\n\nexport interface CreateSvgNotationPlayerOpts {\n /** Element the player's content is mounted into. Takes NATURAL content\n * height (the whole score, page-flow layout) — the host must not clip a\n * fixed height; the PAGE scrolls the score, there is no internal camera. */\n host: HTMLElement;\n /** MusicXML to engrave. Ignored when `rendered` (the test seam) is set. */\n musicXml: string;\n /** Distinct note onsets (ms) the playhead locks to — same contract as\n * `CreateNotationPlayerOpts.onsetsMs` in notationPlayer.ts. */\n onsetsMs: number[];\n /** Per-onset engraved column positions, 1:1 with the DEDUPED/sorted\n * `onsetsMs` — same semantics as the canvas player's `noteCols`. */\n noteCols?: number[];\n /** Parks a followed system this many px below the viewport top — pass the\n * height of any fixed top chrome (header, docked transport) plus a gap.\n * Default SYSTEM_TOP_MARGIN_PX. */\n followTopMarginPx?: number;\n /** Playhead line color. Default `'#2f6f4f'`. */\n playheadColor?: string;\n /** Initial OSMD zoom (a pure post-layout visual scale — see\n * `svgNotationLayout`'s doc). Default 1 (OSMD's own default — the\n * engraving fits the host's own width at normal note size). */\n zoom?: number;\n /**\n * Advanced / test seam: a pre-built `NotationLayout`, bypassing the real\n * OSMD SVG engrave (`musicXml` is still required by the type but is\n * ignored when this is set). Mirrors `CreateNotationPlayerOpts.rendered` in\n * notationPlayer.ts for the identical reason: real OSMD *rendering* needs\n * actual browser canvas glyph metrics (for its line-breaking pass) even\n * when the SVG backend is selected — headless/jsdom can't fully provide\n * that — so this is how this module stays unit-testable in Node. Not\n * needed in a real browser host. When set, `setZoom`/`resize` update the\n * tracked zoom/width but perform no real re-engrave (there is nothing to\n * re-engrave).\n */\n rendered?: NotationLayout;\n}\n\nexport interface SvgNotationPlayer {\n /** Resolves once the engraving is in the DOM and ready to draw. `setTime`/\n * click hit-testing are safe to call before this resolves (no-op until\n * ready, same contract as the canvas player). */\n readonly ready: Promise<void>;\n /** Drive the playhead for absolute playback time `tMs`. Caller owns the\n * audio clock + rAF loop. */\n setTime(tMs: number): void;\n /** Re-engrave at a new OSMD zoom (systems reflow). The scroll position is\n * restored afterward so the content that was centered in the viewport\n * before the reflow is still centered after it. */\n setZoom(z: number): Promise<void>;\n /** Re-measure the host and reflow to match its current width, with the\n * same scroll-position preservation as `setZoom`. Call on host resize /\n * orientation change. */\n resize(): Promise<void>;\n /** Gate the auto-follow scroll on the app's transport state: pass `true`\n * on play, `false` on pause/stop (see FollowController.setEnabled — while\n * disabled NOTHING may scroll the sheet, including the idle-rearm's\n * off-screen rescue). Defaults to enabled. */\n setFollowEnabled(on: boolean): void;\n /** Register a measure-click handler (measure index, matching\n * `ScoreNote.measure`/the engraved index). Returns an unsubscribe fn. */\n onMeasureClick(cb: (measureIndex: number) => void): () => void;\n /** Tear down: removes the mounted DOM (engraving + playhead overlay) from\n * `host`, and drops every listener this instance added (click, window\n * scroll) and pending async work (a token guard drops any in-flight\n * reflow's effects). Idempotent. */\n destroy(): void;\n}\n\n// ─── svgNotationLayout — the SVG-backend geometry extractor ───────────────\n//\n// UNIT CONVERSION — derived, not guessed (verified against the vendored\n// opensheetmusicdisplay + vexflow source, not just its .d.ts comments, which\n// are stale here — see below):\n//\n// 1. `GraphicalMusicSheet` (`osmd.GraphicSheet`) positions\n// (`PositionAndShape.AbsolutePosition`/`.Size`) are in backend-agnostic\n// \"OSMD units\" — the SAME object model promo.ts's canvas-only\n// `extractGeometry` reads (`osmd.GraphicSheet.MusicPages[0].MusicSystems`,\n// `.MeasureList`). Whichever backend (canvas/svg) was requested, this\n// layer is identical.\n// 2. OSMD's Vexflow draw layer (`VexFlowMusicSheetDrawer`) converts an OSMD\n// unit value to a \"raw\" Vexflow/SVG px value via an EXPORTED constant,\n// `unitInPixels` (currently 10 — NOT `EngravingRules.unit`, which is a\n// different, unrelated field that is NOT the px-per-unit factor despite\n// what its .d.ts comment implies; confirmed by reading the actual\n// compiled source, not trusting the stale doc comment). This raw value\n// is written directly into each SVG element's own coordinate attributes\n// — it does NOT yet include `zoom`.\n// 3. `osmd.Zoom` (current zoom) is applied SEPARATELY, at the SVG-backend\n// level, via a `viewBox` trick (`vexflow/src/svgcontext.js`'s\n// `scale(x,y)`): the `<svg>` element's `width`/`height` ATTRIBUTES are\n// set to the FINAL (zoomed) CSS px size, while its `viewBox` spans\n// `width/zoom .. height/zoom` — i.e. the RAW (unzoomed, step-2) content\n// coordinates. The browser therefore maps that raw coordinate space onto\n// the final CSS px box by exactly `zoom`.\n// Net result: `cssPx = unitValue * unitInPixels * zoom`. Both factors are\n// read from the live library/instance (never a hardcoded literal `10` or an\n// assumed zoom) — see `SvgNotationLayoutOpts.unitInPixels`'s doc — so a\n// future OSMD version changing either is picked up automatically, which is\n// exactly the \"10 units/staff-space assumptions have version drift\" risk\n// the design doc calls out.\n//\n// This mirrors, in spirit, promo.ts's canvas `extractGeometry` self-\n// calibration (`canvas.width / (contentRight+contentLeft)`, chosen there\n// specifically because a naive formula broke on overflowed layouts) — the SVG\n// backend doesn't have that raster-overflow failure mode (there is no\n// backing-store to overflow; the viewBox IS the content, always), so the\n// derived formula above is exact, not an approximation.\n\nexport interface SvgNotationLayoutOpts {\n /** CSS px per OSMD unit at zoom 1 — OSMD's own exported `unitInPixels`\n * constant (see the derivation above). REQUIRED, no default: the point is\n * to FORCE the real call site to source this from the live\n * `opensheetmusicdisplay` import\n * (`const { unitInPixels } = await import('opensheetmusicdisplay')`)\n * rather than this module assuming a value that could drift across OSMD\n * versions. Tests pin it explicitly. */\n unitInPixels: number;\n}\n\nconst EMPTY_LAYOUT: NotationLayout = {\n src: { x: 0, y: 0, w: 0, h: 0 },\n rect: { dx: 0, dy: 0, dw: 0, dh: 0 },\n systems: [],\n measures: [],\n};\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n/**\n * Pure SVG-backend geometry extractor — builds the SAME `NotationLayout`\n * shape the canvas path's `notationLayout()` does (measure column boxes +\n * system rows + a `rect` vertical band), but read directly from OSMD's\n * `GraphicalMusicSheet` in SVG/CSS px space (see the unit-conversion doc\n * above) instead of a rasterized bitmap. Rebuild on every render (zoom /\n * resize / new score) — cheap, pure array/object mapping, no DOM reads.\n *\n * `osmd` is duck-typed `any` — same contract as promo.ts's\n * `extractGeometry(osmd: any, ...)` — so tests can pass a plain fixture\n * object shaped like the minimal slice of a real `OpenSheetMusicDisplay`\n * instance this function reads (`{ Zoom, GraphicSheet: { MusicPages,\n * MeasureList } }`) without needing a real browser OSMD render (see\n * `CreateSvgNotationPlayerOpts.rendered`'s doc for why headless can't do a\n * real one).\n *\n * `rect` spans the FULL engraved page (top-aligned, `dy = 0`) — there is no\n * follow-camera crop in this component (the whole score is always in the\n * DOM; the PAGE scrolls it) — matching the canvas scroll-mode's `flowLayout`\n * in spirit. `vstackAudioPlayheadLine` only reads `rect.dy`/`rect.dh` to\n * clamp the playhead into the drawn band, so this is a correct, minimal\n * `rect` for that consumer.\n */\nexport function svgNotationLayout(osmd: any, opts: SvgNotationLayoutOpts): NotationLayout {\n try {\n const zoom =\n typeof osmd?.Zoom === 'number' && osmd.Zoom > 0\n ? osmd.Zoom\n : typeof osmd?.zoom === 'number' && osmd.zoom > 0\n ? osmd.zoom\n : 1;\n const f = opts.unitInPixels * zoom;\n\n const graphic: any = osmd?.GraphicSheet;\n const page: any = graphic?.MusicPages?.[0];\n const pageSize = page?.PositionAndShape?.Size;\n const musicSystems: any[] = page?.MusicSystems ?? [];\n if (!(pageSize?.width > 0) || !(pageSize?.height > 0) || !musicSystems.length) return EMPTY_LAYOUT;\n\n const toBox = (pas: any): Box | null => {\n const p = pas?.AbsolutePosition;\n const sz = pas?.Size;\n if (!p || !sz) return null;\n return { x: p.x * f, y: p.y * f, w: sz.width * f, h: sz.height * f };\n };\n\n const systems: Box[] = musicSystems\n .map((s) => toBox(s?.PositionAndShape))\n .filter((b): b is Box => !!b && b.w > 1 && b.h > 1)\n .sort((a, b) => a.y - b.y);\n if (!systems.length) return EMPTY_LAYOUT;\n\n const measureList: any[][] = graphic?.MeasureList ?? [];\n const measures: StaffMeasureBox[] = [];\n measureList.forEach((staves, index) => {\n (staves ?? []).forEach((m: any, staff: number) => {\n const box = toBox(m?.PositionAndShape);\n if (box && box.w > 1 && box.h > 1) {\n const seX = (m?.staffEntries ?? [])[0]?.PositionAndShape?.AbsolutePosition?.x;\n const noteStartX = typeof seX === 'number' ? seX * f : box.x;\n measures.push({ index, staff, box, noteStartX });\n }\n });\n });\n\n const dw = pageSize.width * f;\n const dh = pageSize.height * f;\n return {\n src: { x: 0, y: 0, w: dw, h: dh },\n rect: { dx: 0, dy: 0, dw, dh },\n systems,\n measures,\n };\n } catch {\n return EMPTY_LAYOUT;\n }\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n\n// ─── createSvgNotationPlayer ────────────────────────────────────────────────\n//\n// Zoom/resize position preservation (`computeReflowScrollDelta`) and the\n// auto-follow discriminator (`isWithinProgrammaticScroll` /\n// `hasReachedProgrammaticTarget` + the stateful `createFollowController`)\n// moved to `./notationCommon` (0.39.0) — shared with notationPlayerVerovio.ts.\n// Re-exported above for backward compatibility.\n\nconst DEFAULT_PLAYHEAD_COLOR = '#2f6f4f';\n/** Engrave-width ceiling, CSS px (design doc §\"Render\": \"cap ~1200px, the\n * 0.36.2 rule\" — the same reasoning as notationPlayer.ts's\n * `MAX_ENGRAVE_WIDTH`, restated here rather than imported since the two\n * players' constants are independently tunable, and this one is spec'd to a\n * slightly different value). */\nexport const MAX_ENGRAVE_WIDTH_SVG = 1200;\n/** Floor so a not-yet-laid-out / zero-width host never engraves at 0px. */\nconst MIN_ENGRAVE_WIDTH_SVG = 280;\n\n/** Build a live, interactive SVG (vector) notation player. See the module doc\n * + `docs/superpowers/specs/2026-08-11-svg-notation-player-design.md` (in\n * stave-web-sightread) for the full design. */\nexport function createSvgNotationPlayer(opts: CreateSvgNotationPlayerOpts): SvgNotationPlayer {\n const { host, musicXml, onsetsMs, noteCols } = opts;\n const playheadColor = opts.playheadColor ?? DEFAULT_PLAYHEAD_COLOR;\n const onsets = distinctOnsets(onsetsMs.map((onsetMs) => ({ onsetMs })));\n\n const root = document.createElement('div');\n root.style.position = 'relative';\n root.style.width = '100%';\n // Let the browser's native pinch-zoom AND vertical page-scroll gestures\n // through — the \"optical zoom is free\" half of the design (§5): vectors\n // stay crisp at any pinch/browser-zoom level, and this component adds\n // nothing to make that work beyond not blocking the gesture.\n root.style.touchAction = 'pan-y pinch-zoom';\n host.appendChild(root);\n\n const svgHost = document.createElement('div');\n root.appendChild(svgHost);\n\n const playheadEl = document.createElement('div');\n playheadEl.style.position = 'absolute';\n playheadEl.style.left = '0px';\n playheadEl.style.top = '0px';\n playheadEl.style.width = '2px';\n playheadEl.style.height = '0px';\n playheadEl.style.background = playheadColor;\n playheadEl.style.opacity = '0';\n playheadEl.style.pointerEvents = 'none';\n root.appendChild(playheadEl);\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let osmd: any = null;\n let currentLayout: NotationLayout | null = null;\n let currentZoom = opts.zoom ?? 1;\n let unitInPixelsConst = 10; // overwritten by the real import before first use (rendered-seam path never reads it)\n let lastEngravedWidthPx = 0;\n let destroyed = false;\n let lastTMs = 0;\n // Stale-table guard (design doc §\"Known risks\" — \"Overlay drift on\n // reflow\"): each async re-engrave captures its own token; if a NEWER\n // reflow starts before an older one's `await` resolves, the older one's\n // completion is a no-op instead of clobbering the newer geometry.\n let rebuildToken = 0;\n\n function desiredEngraveWidthPx(): number {\n const w = host.clientWidth || 0;\n return Math.max(MIN_ENGRAVE_WIDTH_SVG, Math.min(w || MAX_ENGRAVE_WIDTH_SVG, MAX_ENGRAVE_WIDTH_SVG));\n }\n\n // ─── Playhead ──────────────────────────────────────────────────────────\n\n function renderPlayhead(tMs: number): void {\n lastTMs = tMs;\n if (!currentLayout) return;\n const nBars = currentLayout.measures.length\n ? Math.max(...currentLayout.measures.map((m) => m.index)) + 1\n : 0;\n const line = vstackAudioPlayheadLine(currentLayout, onsets, tMs, nBars, noteCols);\n if (!line) {\n playheadEl.style.opacity = '0';\n return;\n }\n playheadEl.style.opacity = String(line.alpha);\n playheadEl.style.left = `${line.x}px`;\n playheadEl.style.top = `${line.y0}px`;\n playheadEl.style.height = `${Math.max(0, line.y1 - line.y0)}px`;\n const layoutForFollow = currentLayout;\n const sys = line.sys ?? -1;\n follow.follow({\n systemIndex: sys,\n getSystemRect: () => {\n const box = layoutForFollow.systems[sys];\n if (!box) return null;\n const rootRect = root.getBoundingClientRect();\n return { top: rootRect.top + box.y, bottom: rootRect.top + box.y + box.h };\n },\n getPlayheadRect: () =>\n typeof playheadEl.getBoundingClientRect === 'function' ? playheadEl.getBoundingClientRect() : null,\n });\n }\n\n // ─── Auto-follow (target-position discriminator — see notationCommon.ts) ─\n\n const follow = createFollowController({ topMarginPx: opts.followTopMarginPx });\n\n // ─── setTime ───────────────────────────────────────────────────────────\n\n function setTime(tMs: number): void {\n if (destroyed) return;\n follow.onSetTime(tMs);\n renderPlayhead(tMs);\n }\n\n // ─── Engrave / reflow ──────────────────────────────────────────────────\n\n async function initialEngrave(): Promise<void> {\n if (opts.rendered) {\n currentLayout = opts.rendered;\n lastEngravedWidthPx = desiredEngraveWidthPx();\n renderPlayhead(lastTMs);\n return;\n }\n // Literal dynamic import — consumers' bundlers must statically see the\n // specifier (same reasoning as promo.ts/notationPlayer.ts); a caller\n // that only ever uses the `rendered` test seam never pulls OSMD in.\n const { OpenSheetMusicDisplay, unitInPixels } = await import('opensheetmusicdisplay');\n unitInPixelsConst = unitInPixels;\n if (destroyed) return;\n\n const widthPx = desiredEngraveWidthPx();\n svgHost.style.width = `${widthPx}px`;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const inst: any = new OpenSheetMusicDisplay(svgHost, {\n backend: 'svg',\n autoResize: false,\n drawTitle: false,\n drawSubtitle: false,\n drawComposer: false,\n drawLyricist: false,\n drawPartNames: false,\n });\n await inst.load(musicXml);\n if (destroyed) return;\n inst.Zoom = currentZoom;\n inst.render();\n if (destroyed) return;\n\n osmd = inst;\n lastEngravedWidthPx = widthPx;\n currentLayout = svgNotationLayout(inst, { unitInPixels: unitInPixelsConst });\n renderPlayhead(lastTMs);\n }\n\n const ready = initialEngrave();\n\n /** Re-engrave at `newZoom` and the host's CURRENT width, preserving the\n * scroll position of whatever content is centered in the viewport right\n * now. Shared by both `setZoom` and `resize` (resize just passes the\n * unchanged `currentZoom`). No-op when neither the width nor the zoom\n * actually changed, or when using the `rendered` test seam (nothing to\n * re-engrave). */\n async function reflow(newZoom: number): Promise<void> {\n if (destroyed) return;\n const widthPx = desiredEngraveWidthPx();\n if (widthPx === lastEngravedWidthPx && newZoom === currentZoom) return;\n if (opts.rendered || !osmd) {\n currentZoom = newZoom;\n return;\n }\n\n const myToken = ++rebuildToken;\n const oldLayout = currentLayout;\n const hasWin = typeof window !== 'undefined';\n let anchorY: number | null = null;\n const anchorX = widthPx / 2;\n if (hasWin && typeof root.getBoundingClientRect === 'function') {\n const r = root.getBoundingClientRect();\n anchorY = window.innerHeight / 2 - r.top;\n }\n\n svgHost.style.width = `${widthPx}px`;\n osmd.Zoom = newZoom;\n // Re-run layout (not just a redraw): a width change must re-flow which\n // measures land on which system, and — since we can't be certain a pure\n // zoom change never affects line-breaking on every OSMD version — this is\n // called unconditionally rather than gated to the width-only case.\n osmd.updateGraphic();\n osmd.render();\n if (destroyed || myToken !== rebuildToken) return;\n\n lastEngravedWidthPx = widthPx;\n currentZoom = newZoom;\n currentLayout = svgNotationLayout(osmd, { unitInPixels: unitInPixelsConst });\n\n if (oldLayout && anchorY != null && hasWin) {\n const delta = computeReflowScrollDelta(oldLayout, currentLayout, anchorX, anchorY);\n if (Number.isFinite(delta) && Math.abs(delta) > 0.5) {\n window.scrollTo({ top: Math.max(0, window.scrollY + delta), left: window.scrollX, behavior: 'auto' });\n }\n }\n if (!destroyed) renderPlayhead(lastTMs);\n }\n\n // ─── Click-to-seek ─────────────────────────────────────────────────────\n\n const clickListeners: Array<(measureIndex: number) => void> = [];\n function onRootClick(e: MouseEvent): void {\n if (!currentLayout) return;\n const rect = root.getBoundingClientRect();\n const mx = e.clientX - rect.left;\n const my = e.clientY - rect.top;\n const idx = hitTestMeasureAt(currentLayout, mx, my);\n if (idx != null) for (const cb of clickListeners) cb(idx);\n }\n root.addEventListener('click', onRootClick);\n\n return {\n ready,\n setTime,\n setFollowEnabled(on) {\n follow.setEnabled(on);\n },\n async setZoom(z: number): Promise<void> {\n await ready;\n await reflow(z);\n },\n async resize(): Promise<void> {\n await ready;\n await reflow(currentZoom);\n },\n onMeasureClick(cb: (measureIndex: number) => void): () => void {\n clickListeners.push(cb);\n return () => {\n const i = clickListeners.indexOf(cb);\n if (i >= 0) clickListeners.splice(i, 1);\n };\n },\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n rebuildToken++;\n root.removeEventListener('click', onRootClick);\n follow.destroy();\n clickListeners.length = 0;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n try { (osmd as any)?.clear?.(); } catch { /* best-effort */ }\n osmd = null;\n currentLayout = null;\n if (root.parentNode === host) host.removeChild(root);\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;AAyLA,IAAM,eAA+B;AAAA,EACnC,KAAK,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,EAAE;AAAA,EAC9B,MAAM,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,EAAE;AAAA,EACnC,SAAS,CAAC;AAAA,EACV,UAAU,CAAC;AACb;AA0BO,SAAS,kBAAkB,MAAW,MAA6C;AACxF,MAAI;AACF,UAAM,OACJ,OAAO,MAAM,SAAS,YAAY,KAAK,OAAO,IAC1C,KAAK,OACL,OAAO,MAAM,SAAS,YAAY,KAAK,OAAO,IAC5C,KAAK,OACL;AACR,UAAM,IAAI,KAAK,eAAe;AAE9B,UAAM,UAAe,MAAM;AAC3B,UAAM,OAAY,SAAS,aAAa,CAAC;AACzC,UAAM,WAAW,MAAM,kBAAkB;AACzC,UAAM,eAAsB,MAAM,gBAAgB,CAAC;AACnD,QAAI,EAAE,UAAU,QAAQ,MAAM,EAAE,UAAU,SAAS,MAAM,CAAC,aAAa,OAAQ,QAAO;AAEtF,UAAM,QAAQ,CAAC,QAAyB;AACtC,YAAM,IAAI,KAAK;AACf,YAAM,KAAK,KAAK;AAChB,UAAI,CAAC,KAAK,CAAC,GAAI,QAAO;AACtB,aAAO,EAAE,GAAG,EAAE,IAAI,GAAG,GAAG,EAAE,IAAI,GAAG,GAAG,GAAG,QAAQ,GAAG,GAAG,GAAG,SAAS,EAAE;AAAA,IACrE;AAEA,UAAM,UAAiB,aACpB,IAAI,CAAC,MAAM,MAAM,GAAG,gBAAgB,CAAC,EACrC,OAAO,CAAC,MAAgB,CAAC,CAAC,KAAK,EAAE,IAAI,KAAK,EAAE,IAAI,CAAC,EACjD,KAAK,CAAC,GAAG,MAAM,EAAE,IAAI,EAAE,CAAC;AAC3B,QAAI,CAAC,QAAQ,OAAQ,QAAO;AAE5B,UAAM,cAAuB,SAAS,eAAe,CAAC;AACtD,UAAM,WAA8B,CAAC;AACrC,gBAAY,QAAQ,CAAC,QAAQ,UAAU;AACrC,OAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,GAAQ,UAAkB;AAChD,cAAM,MAAM,MAAM,GAAG,gBAAgB;AACrC,YAAI,OAAO,IAAI,IAAI,KAAK,IAAI,IAAI,GAAG;AACjC,gBAAM,OAAO,GAAG,gBAAgB,CAAC,GAAG,CAAC,GAAG,kBAAkB,kBAAkB;AAC5E,gBAAM,aAAa,OAAO,QAAQ,WAAW,MAAM,IAAI,IAAI;AAC3D,mBAAS,KAAK,EAAE,OAAO,OAAO,KAAK,WAAW,CAAC;AAAA,QACjD;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAED,UAAM,KAAK,SAAS,QAAQ;AAC5B,UAAM,KAAK,SAAS,SAAS;AAC7B,WAAO;AAAA,MACL,KAAK,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,GAAG;AAAA,MAChC,MAAM,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG;AAAA,MAC7B;AAAA,MACA;AAAA,IACF;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAWA,IAAM,yBAAyB;AAMxB,IAAM,wBAAwB;AAErC,IAAM,wBAAwB;AAKvB,SAAS,wBAAwB,MAAsD;AAC5F,QAAM,EAAE,MAAM,UAAU,UAAU,SAAS,IAAI;AAC/C,QAAM,gBAAgB,KAAK,iBAAiB;AAC5C,QAAM,SAAS,eAAe,SAAS,IAAI,CAAC,aAAa,EAAE,QAAQ,EAAE,CAAC;AAEtE,QAAM,OAAO,SAAS,cAAc,KAAK;AACzC,OAAK,MAAM,WAAW;AACtB,OAAK,MAAM,QAAQ;AAKnB,OAAK,MAAM,cAAc;AACzB,OAAK,YAAY,IAAI;AAErB,QAAM,UAAU,SAAS,cAAc,KAAK;AAC5C,OAAK,YAAY,OAAO;AAExB,QAAM,aAAa,SAAS,cAAc,KAAK;AAC/C,aAAW,MAAM,WAAW;AAC5B,aAAW,MAAM,OAAO;AACxB,aAAW,MAAM,MAAM;AACvB,aAAW,MAAM,QAAQ;AACzB,aAAW,MAAM,SAAS;AAC1B,aAAW,MAAM,aAAa;AAC9B,aAAW,MAAM,UAAU;AAC3B,aAAW,MAAM,gBAAgB;AACjC,OAAK,YAAY,UAAU;AAG3B,MAAI,OAAY;AAChB,MAAI,gBAAuC;AAC3C,MAAI,cAAc,KAAK,QAAQ;AAC/B,MAAI,oBAAoB;AACxB,MAAI,sBAAsB;AAC1B,MAAI,YAAY;AAChB,MAAI,UAAU;AAKd,MAAI,eAAe;AAEnB,WAAS,wBAAgC;AACvC,UAAM,IAAI,KAAK,eAAe;AAC9B,WAAO,KAAK,IAAI,uBAAuB,KAAK,IAAI,KAAK,uBAAuB,qBAAqB,CAAC;AAAA,EACpG;AAIA,WAAS,eAAe,KAAmB;AACzC,cAAU;AACV,QAAI,CAAC,cAAe;AACpB,UAAM,QAAQ,cAAc,SAAS,SACjC,KAAK,IAAI,GAAG,cAAc,SAAS,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,IAC1D;AACJ,UAAM,OAAO,wBAAwB,eAAe,QAAQ,KAAK,OAAO,QAAQ;AAChF,QAAI,CAAC,MAAM;AACT,iBAAW,MAAM,UAAU;AAC3B;AAAA,IACF;AACA,eAAW,MAAM,UAAU,OAAO,KAAK,KAAK;AAC5C,eAAW,MAAM,OAAO,GAAG,KAAK,CAAC;AACjC,eAAW,MAAM,MAAM,GAAG,KAAK,EAAE;AACjC,eAAW,MAAM,SAAS,GAAG,KAAK,IAAI,GAAG,KAAK,KAAK,KAAK,EAAE,CAAC;AAC3D,UAAM,kBAAkB;AACxB,UAAM,MAAM,KAAK,OAAO;AACxB,WAAO,OAAO;AAAA,MACZ,aAAa;AAAA,MACb,eAAe,MAAM;AACnB,cAAM,MAAM,gBAAgB,QAAQ,GAAG;AACvC,YAAI,CAAC,IAAK,QAAO;AACjB,cAAM,WAAW,KAAK,sBAAsB;AAC5C,eAAO,EAAE,KAAK,SAAS,MAAM,IAAI,GAAG,QAAQ,SAAS,MAAM,IAAI,IAAI,IAAI,EAAE;AAAA,MAC3E;AAAA,MACA,iBAAiB,MACf,OAAO,WAAW,0BAA0B,aAAa,WAAW,sBAAsB,IAAI;AAAA,IAClG,CAAC;AAAA,EACH;AAIA,QAAM,SAAS,uBAAuB,EAAE,aAAa,KAAK,kBAAkB,CAAC;AAI7E,WAAS,QAAQ,KAAmB;AAClC,QAAI,UAAW;AACf,WAAO,UAAU,GAAG;AACpB,mBAAe,GAAG;AAAA,EACpB;AAIA,iBAAe,iBAAgC;AAC7C,QAAI,KAAK,UAAU;AACjB,sBAAgB,KAAK;AACrB,4BAAsB,sBAAsB;AAC5C,qBAAe,OAAO;AACtB;AAAA,IACF;AAIA,UAAM,EAAE,uBAAuB,aAAa,IAAI,MAAM,OAAO,uBAAuB;AACpF,wBAAoB;AACpB,QAAI,UAAW;AAEf,UAAM,UAAU,sBAAsB;AACtC,YAAQ,MAAM,QAAQ,GAAG,OAAO;AAEhC,UAAM,OAAY,IAAI,sBAAsB,SAAS;AAAA,MACnD,SAAS;AAAA,MACT,YAAY;AAAA,MACZ,WAAW;AAAA,MACX,cAAc;AAAA,MACd,cAAc;AAAA,MACd,cAAc;AAAA,MACd,eAAe;AAAA,IACjB,CAAC;AACD,UAAM,KAAK,KAAK,QAAQ;AACxB,QAAI,UAAW;AACf,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,QAAI,UAAW;AAEf,WAAO;AACP,0BAAsB;AACtB,oBAAgB,kBAAkB,MAAM,EAAE,cAAc,kBAAkB,CAAC;AAC3E,mBAAe,OAAO;AAAA,EACxB;AAEA,QAAM,QAAQ,eAAe;AAQ7B,iBAAe,OAAO,SAAgC;AACpD,QAAI,UAAW;AACf,UAAM,UAAU,sBAAsB;AACtC,QAAI,YAAY,uBAAuB,YAAY,YAAa;AAChE,QAAI,KAAK,YAAY,CAAC,MAAM;AAC1B,oBAAc;AACd;AAAA,IACF;AAEA,UAAM,UAAU,EAAE;AAClB,UAAM,YAAY;AAClB,UAAM,SAAS,OAAO,WAAW;AACjC,QAAI,UAAyB;AAC7B,UAAM,UAAU,UAAU;AAC1B,QAAI,UAAU,OAAO,KAAK,0BAA0B,YAAY;AAC9D,YAAM,IAAI,KAAK,sBAAsB;AACrC,gBAAU,OAAO,cAAc,IAAI,EAAE;AAAA,IACvC;AAEA,YAAQ,MAAM,QAAQ,GAAG,OAAO;AAChC,SAAK,OAAO;AAKZ,SAAK,cAAc;AACnB,SAAK,OAAO;AACZ,QAAI,aAAa,YAAY,aAAc;AAE3C,0BAAsB;AACtB,kBAAc;AACd,oBAAgB,kBAAkB,MAAM,EAAE,cAAc,kBAAkB,CAAC;AAE3E,QAAI,aAAa,WAAW,QAAQ,QAAQ;AAC1C,YAAM,QAAQ,yBAAyB,WAAW,eAAe,SAAS,OAAO;AACjF,UAAI,OAAO,SAAS,KAAK,KAAK,KAAK,IAAI,KAAK,IAAI,KAAK;AACnD,eAAO,SAAS,EAAE,KAAK,KAAK,IAAI,GAAG,OAAO,UAAU,KAAK,GAAG,MAAM,OAAO,SAAS,UAAU,OAAO,CAAC;AAAA,MACtG;AAAA,IACF;AACA,QAAI,CAAC,UAAW,gBAAe,OAAO;AAAA,EACxC;AAIA,QAAM,iBAAwD,CAAC;AAC/D,WAAS,YAAY,GAAqB;AACxC,QAAI,CAAC,cAAe;AACpB,UAAM,OAAO,KAAK,sBAAsB;AACxC,UAAM,KAAK,EAAE,UAAU,KAAK;AAC5B,UAAM,KAAK,EAAE,UAAU,KAAK;AAC5B,UAAM,MAAM,iBAAiB,eAAe,IAAI,EAAE;AAClD,QAAI,OAAO,KAAM,YAAW,MAAM,eAAgB,IAAG,GAAG;AAAA,EAC1D;AACA,OAAK,iBAAiB,SAAS,WAAW;AAE1C,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,iBAAiB,IAAI;AACnB,aAAO,WAAW,EAAE;AAAA,IACtB;AAAA,IACA,MAAM,QAAQ,GAA0B;AACtC,YAAM;AACN,YAAM,OAAO,CAAC;AAAA,IAChB;AAAA,IACA,MAAM,SAAwB;AAC5B,YAAM;AACN,YAAM,OAAO,WAAW;AAAA,IAC1B;AAAA,IACA,eAAe,IAAgD;AAC7D,qBAAe,KAAK,EAAE;AACtB,aAAO,MAAM;AACX,cAAM,IAAI,eAAe,QAAQ,EAAE;AACnC,YAAI,KAAK,EAAG,gBAAe,OAAO,GAAG,CAAC;AAAA,MACxC;AAAA,IACF;AAAA,IACA,UAAgB;AACd,UAAI,UAAW;AACf,kBAAY;AACZ;AACA,WAAK,oBAAoB,SAAS,WAAW;AAC7C,aAAO,QAAQ;AACf,qBAAe,SAAS;AAExB,UAAI;AAAE,QAAC,MAAc,QAAQ;AAAA,MAAG,QAAQ;AAAA,MAAoB;AAC5D,aAAO;AACP,sBAAgB;AAChB,UAAI,KAAK,eAAe,KAAM,MAAK,YAAY,IAAI;AAAA,IACrD;AAAA,EACF;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/notationPlayerSvg.ts"],"sourcesContent":["// createSvgNotationPlayer — the SVG (vector) sibling of `createNotationPlayer`\n// (src/notationPlayer.ts). Same job (a live, caller-driven notation +\n// gliding-playhead widget), different medium: OSMD's native SVG backend\n// instead of a rasterized canvas. See\n// docs — stave-web-sightread's\n// docs/superpowers/specs/2026-08-11-svg-notation-player-design.md — for the\n// full design rationale (why: vectors don't blur on pinch/browser zoom, no\n// canvas-area cap, no raster tiling needed for full-score scroll).\n//\n// THE CANVAS MODULE IS NOT MODIFIED OR IMPORTED FOR ITS OSMD/RASTER PATH —\n// this is a parallel component. What IS reused, verbatim, no new math:\n// - `vstackAudioPlayheadLine` (scene/notationGeometry.ts) — the exact same\n// onset-anchored, carriage-return playhead interpolation the canvas path\n// uses. It operates purely on a `NotationLayout` (measure column boxes +\n// a vertical clamp band) — it does not care whether those boxes came from\n// a canvas raster or live SVG DOM geometry, so THIS module's job is only\n// to produce a `NotationLayout` in SVG/CSS px space (see\n// `svgNotationLayout` below) and everything downstream is identical.\n// - `hitTestMeasureAt` (scene/notationGeometry.ts, moved there in 0.38.0\n// specifically so this module never has a build-time edge into\n// notationPlayer.ts's canvas/raster implementation) — the exact same pure\n// point-in-measure-box hit-test, reused for click-to-seek.\n// - `distinctOnsets`, `measureColumnsFromLayout` — small pure helpers,\n// reused as-is.\n//\n// WHAT'S NEW (not a re-derivation of any of the above):\n// - `svgNotationLayout` — an SVG-backend geometry extractor, the SVG\n// counterpart to promo.ts's canvas-only `extractGeometry`. See its own\n// doc comment for the unit-conversion derivation.\n// - `computeReflowScrollDelta` — zoom/resize position-preservation math\n// (not playhead interpolation; a one-shot \"where did this same content\n// move to\" lookup using the existing hit-test + column helpers).\n// - `isWithinProgrammaticScroll` / `hasReachedProgrammaticTarget` — the\n// improved auto-follow discriminator (design doc §4): unlike the canvas\n// scroll-mode player's fixed 600ms \"programmatic scroll\" grace window,\n// this compares the ACTUAL scroll position against the EXACT target this\n// component itself requested, so classification is correct regardless of\n// how long a smooth-scroll animation takes (no grace-window race).\n//\n// IMPORT PATH: subpath-only — `@real-music-packages/web-core/notationPlayerSvg`\n// — not re-exported from the root barrel (same reasoning as notationPlayer.ts:\n// the root barrel is theory-only/zero-dependency).\n\nimport {\n vstackAudioPlayheadLine,\n distinctOnsets,\n hitTestMeasureAt,\n type NotationLayout,\n} from './scene/notationGeometry';\nimport type { Box, StaffMeasureBox } from './promo';\nimport {\n computeReflowScrollDelta,\n createFollowController,\n isWithinProgrammaticScroll,\n hasReachedProgrammaticTarget,\n PROGRAMMATIC_SCROLL_EPSILON_PX,\n} from './notationCommon';\n\n// Re-exported for backward compatibility — this module's own tests (and any\n// external importer) still pull these from `notationPlayerSvg`; the\n// implementations now live in `notationCommon.ts` (shared with\n// notationPlayerVerovio.ts, 0.39.0). See that module's doc for why.\nexport { computeReflowScrollDelta, isWithinProgrammaticScroll, hasReachedProgrammaticTarget, PROGRAMMATIC_SCROLL_EPSILON_PX };\n\nexport interface CreateSvgNotationPlayerOpts {\n /** Element the player's content is mounted into. Takes NATURAL content\n * height (the whole score, page-flow layout) — the host must not clip a\n * fixed height; the PAGE scrolls the score, there is no internal camera. */\n host: HTMLElement;\n /** MusicXML to engrave. Ignored when `rendered` (the test seam) is set. */\n musicXml: string;\n /** Distinct note onsets (ms) the playhead locks to — same contract as\n * `CreateNotationPlayerOpts.onsetsMs` in notationPlayer.ts. */\n onsetsMs: number[];\n /** Per-onset engraved column positions, 1:1 with the DEDUPED/sorted\n * `onsetsMs` — same semantics as the canvas player's `noteCols`. */\n noteCols?: number[];\n /** Parks a followed system this many px below the viewport top — pass the\n * height of any fixed top chrome (header, docked transport) plus a gap.\n * Default SYSTEM_TOP_MARGIN_PX. */\n followTopMarginPx?: number;\n /** Playhead line color. Default `'#2f6f4f'`. */\n playheadColor?: string;\n /** Initial OSMD zoom (a pure post-layout visual scale — see\n * `svgNotationLayout`'s doc). Default 1 (OSMD's own default — the\n * engraving fits the host's own width at normal note size). */\n zoom?: number;\n /**\n * Advanced / test seam: a pre-built `NotationLayout`, bypassing the real\n * OSMD SVG engrave (`musicXml` is still required by the type but is\n * ignored when this is set). Mirrors `CreateNotationPlayerOpts.rendered` in\n * notationPlayer.ts for the identical reason: real OSMD *rendering* needs\n * actual browser canvas glyph metrics (for its line-breaking pass) even\n * when the SVG backend is selected — headless/jsdom can't fully provide\n * that — so this is how this module stays unit-testable in Node. Not\n * needed in a real browser host. When set, `setZoom`/`resize` update the\n * tracked zoom/width but perform no real re-engrave (there is nothing to\n * re-engrave).\n */\n rendered?: NotationLayout;\n}\n\nexport interface SvgNotationPlayer {\n /** Resolves once the engraving is in the DOM and ready to draw. `setTime`/\n * click hit-testing are safe to call before this resolves (no-op until\n * ready, same contract as the canvas player). */\n readonly ready: Promise<void>;\n /** Drive the playhead for absolute playback time `tMs`. Caller owns the\n * audio clock + rAF loop. */\n setTime(tMs: number): void;\n /** Re-engrave at a new OSMD zoom (systems reflow). The scroll position is\n * restored afterward so the content that was centered in the viewport\n * before the reflow is still centered after it. */\n setZoom(z: number): Promise<void>;\n /** Re-measure the host and reflow to match its current width, with the\n * same scroll-position preservation as `setZoom`. Call on host resize /\n * orientation change. */\n resize(): Promise<void>;\n /** Gate the auto-follow scroll on the app's transport state: pass `true`\n * on play, `false` on pause/stop (see FollowController.setEnabled — while\n * disabled NOTHING may scroll the sheet, including the idle-rearm's\n * off-screen rescue). Defaults to enabled. */\n setFollowEnabled(on: boolean): void;\n /** Register a measure-click handler (measure index, matching\n * `ScoreNote.measure`/the engraved index). Returns an unsubscribe fn. */\n onMeasureClick(cb: (measureIndex: number) => void): () => void;\n /**\n * Mark the engraved noteheads at the given columns, in the same\n * `measureIndex + fraction-through-the-measure` units as `noteCols`; pass\n * `null` or `[]` to clear.\n *\n * For pointing a learner at the note being asked for — a playhead says\n * WHERE you are, which is not the same as WHICH note, especially on a busy\n * bar. Marked notes carry the `rmp-note-marked` class, so the consumer owns\n * the look; this module does not inject styles.\n *\n * Survives re-engraving: the marks are reapplied after `setZoom`/`resize`.\n */\n markNotes(cols: number[] | null): void;\n /** Tear down: removes the mounted DOM (engraving + playhead overlay) from\n * `host`, and drops every listener this instance added (click, window\n * scroll) and pending async work (a token guard drops any in-flight\n * reflow's effects). Idempotent. */\n destroy(): void;\n}\n\n\n/**\n * Graphical notes of the staff entry nearest a column position, in the same\n * `measureIndex + fraction-through-the-measure` units as `noteCols`.\n *\n * OSMD keeps each staff entry's position inside its bar as\n * `relInMeasureTimestamp`, so the fractional part of a column maps onto an\n * entry by comparing that against the bar's own duration — the same clock the\n * caller built the columns from. Every staff of the bar is searched, so a\n * grand-staff onset marks both hands.\n *\n * `osmd` is duck-typed `any` for the same reason `svgNotationLayout`'s is: it\n * reads a small slice (`GraphicSheet.MeasureList[][].staffEntries[]`) that a\n * plain fixture can stand in for, so this is testable without a browser render.\n */\nexport function graphicalNotesAtColumn(osmd: any, col: number): any[] {\n const measureList: any[][] = osmd?.GraphicSheet?.MeasureList ?? [];\n if (!measureList.length || !Number.isFinite(col)) return [];\n const measureIndex = Math.max(0, Math.min(measureList.length - 1, Math.floor(col)));\n const staves = measureList[measureIndex] ?? [];\n const wanted = col - measureIndex;\n const found: any[] = [];\n\n for (const staffMeasure of staves) {\n const entries: any[] = staffMeasure?.staffEntries ?? [];\n if (!entries.length) continue;\n const barLength = staffMeasure?.parentSourceMeasure?.Duration?.RealValue || 1;\n let best: any = null;\n let bestGap = Number.POSITIVE_INFINITY;\n for (const entry of entries) {\n const rel = entry?.relInMeasureTimestamp?.RealValue ?? 0;\n const gap = Math.abs(rel / barLength - wanted);\n if (gap < bestGap) {\n best = entry;\n bestGap = gap;\n }\n }\n for (const voiceEntry of best?.graphicalVoiceEntries ?? []) {\n for (const note of voiceEntry?.notes ?? []) found.push(note);\n }\n }\n return found;\n}\n\n/** Class put on every marked notehead group. */\nexport const MARKED_NOTE_CLASS = 'rmp-note-marked';\n\n// ─── svgNotationLayout — the SVG-backend geometry extractor ───────────────\n//\n// UNIT CONVERSION — derived, not guessed (verified against the vendored\n// opensheetmusicdisplay + vexflow source, not just its .d.ts comments, which\n// are stale here — see below):\n//\n// 1. `GraphicalMusicSheet` (`osmd.GraphicSheet`) positions\n// (`PositionAndShape.AbsolutePosition`/`.Size`) are in backend-agnostic\n// \"OSMD units\" — the SAME object model promo.ts's canvas-only\n// `extractGeometry` reads (`osmd.GraphicSheet.MusicPages[0].MusicSystems`,\n// `.MeasureList`). Whichever backend (canvas/svg) was requested, this\n// layer is identical.\n// 2. OSMD's Vexflow draw layer (`VexFlowMusicSheetDrawer`) converts an OSMD\n// unit value to a \"raw\" Vexflow/SVG px value via an EXPORTED constant,\n// `unitInPixels` (currently 10 — NOT `EngravingRules.unit`, which is a\n// different, unrelated field that is NOT the px-per-unit factor despite\n// what its .d.ts comment implies; confirmed by reading the actual\n// compiled source, not trusting the stale doc comment). This raw value\n// is written directly into each SVG element's own coordinate attributes\n// — it does NOT yet include `zoom`.\n// 3. `osmd.Zoom` (current zoom) is applied SEPARATELY, at the SVG-backend\n// level, via a `viewBox` trick (`vexflow/src/svgcontext.js`'s\n// `scale(x,y)`): the `<svg>` element's `width`/`height` ATTRIBUTES are\n// set to the FINAL (zoomed) CSS px size, while its `viewBox` spans\n// `width/zoom .. height/zoom` — i.e. the RAW (unzoomed, step-2) content\n// coordinates. The browser therefore maps that raw coordinate space onto\n// the final CSS px box by exactly `zoom`.\n// Net result: `cssPx = unitValue * unitInPixels * zoom`. Both factors are\n// read from the live library/instance (never a hardcoded literal `10` or an\n// assumed zoom) — see `SvgNotationLayoutOpts.unitInPixels`'s doc — so a\n// future OSMD version changing either is picked up automatically, which is\n// exactly the \"10 units/staff-space assumptions have version drift\" risk\n// the design doc calls out.\n//\n// This mirrors, in spirit, promo.ts's canvas `extractGeometry` self-\n// calibration (`canvas.width / (contentRight+contentLeft)`, chosen there\n// specifically because a naive formula broke on overflowed layouts) — the SVG\n// backend doesn't have that raster-overflow failure mode (there is no\n// backing-store to overflow; the viewBox IS the content, always), so the\n// derived formula above is exact, not an approximation.\n\nexport interface SvgNotationLayoutOpts {\n /** CSS px per OSMD unit at zoom 1 — OSMD's own exported `unitInPixels`\n * constant (see the derivation above). REQUIRED, no default: the point is\n * to FORCE the real call site to source this from the live\n * `opensheetmusicdisplay` import\n * (`const { unitInPixels } = await import('opensheetmusicdisplay')`)\n * rather than this module assuming a value that could drift across OSMD\n * versions. Tests pin it explicitly. */\n unitInPixels: number;\n}\n\nconst EMPTY_LAYOUT: NotationLayout = {\n src: { x: 0, y: 0, w: 0, h: 0 },\n rect: { dx: 0, dy: 0, dw: 0, dh: 0 },\n systems: [],\n measures: [],\n};\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n/**\n * Pure SVG-backend geometry extractor — builds the SAME `NotationLayout`\n * shape the canvas path's `notationLayout()` does (measure column boxes +\n * system rows + a `rect` vertical band), but read directly from OSMD's\n * `GraphicalMusicSheet` in SVG/CSS px space (see the unit-conversion doc\n * above) instead of a rasterized bitmap. Rebuild on every render (zoom /\n * resize / new score) — cheap, pure array/object mapping, no DOM reads.\n *\n * `osmd` is duck-typed `any` — same contract as promo.ts's\n * `extractGeometry(osmd: any, ...)` — so tests can pass a plain fixture\n * object shaped like the minimal slice of a real `OpenSheetMusicDisplay`\n * instance this function reads (`{ Zoom, GraphicSheet: { MusicPages,\n * MeasureList } }`) without needing a real browser OSMD render (see\n * `CreateSvgNotationPlayerOpts.rendered`'s doc for why headless can't do a\n * real one).\n *\n * `rect` spans the FULL engraved page (top-aligned, `dy = 0`) — there is no\n * follow-camera crop in this component (the whole score is always in the\n * DOM; the PAGE scrolls it) — matching the canvas scroll-mode's `flowLayout`\n * in spirit. `vstackAudioPlayheadLine` only reads `rect.dy`/`rect.dh` to\n * clamp the playhead into the drawn band, so this is a correct, minimal\n * `rect` for that consumer.\n */\nexport function svgNotationLayout(osmd: any, opts: SvgNotationLayoutOpts): NotationLayout {\n try {\n const zoom =\n typeof osmd?.Zoom === 'number' && osmd.Zoom > 0\n ? osmd.Zoom\n : typeof osmd?.zoom === 'number' && osmd.zoom > 0\n ? osmd.zoom\n : 1;\n const f = opts.unitInPixels * zoom;\n\n const graphic: any = osmd?.GraphicSheet;\n const page: any = graphic?.MusicPages?.[0];\n const pageSize = page?.PositionAndShape?.Size;\n const musicSystems: any[] = page?.MusicSystems ?? [];\n if (!(pageSize?.width > 0) || !(pageSize?.height > 0) || !musicSystems.length) return EMPTY_LAYOUT;\n\n const toBox = (pas: any): Box | null => {\n const p = pas?.AbsolutePosition;\n const sz = pas?.Size;\n if (!p || !sz) return null;\n return { x: p.x * f, y: p.y * f, w: sz.width * f, h: sz.height * f };\n };\n\n const systems: Box[] = musicSystems\n .map((s) => toBox(s?.PositionAndShape))\n .filter((b): b is Box => !!b && b.w > 1 && b.h > 1)\n .sort((a, b) => a.y - b.y);\n if (!systems.length) return EMPTY_LAYOUT;\n\n const measureList: any[][] = graphic?.MeasureList ?? [];\n const measures: StaffMeasureBox[] = [];\n measureList.forEach((staves, index) => {\n (staves ?? []).forEach((m: any, staff: number) => {\n const box = toBox(m?.PositionAndShape);\n if (box && box.w > 1 && box.h > 1) {\n const seX = (m?.staffEntries ?? [])[0]?.PositionAndShape?.AbsolutePosition?.x;\n const noteStartX = typeof seX === 'number' ? seX * f : box.x;\n measures.push({ index, staff, box, noteStartX });\n }\n });\n });\n\n const dw = pageSize.width * f;\n const dh = pageSize.height * f;\n return {\n src: { x: 0, y: 0, w: dw, h: dh },\n rect: { dx: 0, dy: 0, dw, dh },\n systems,\n measures,\n };\n } catch {\n return EMPTY_LAYOUT;\n }\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n\n// ─── createSvgNotationPlayer ────────────────────────────────────────────────\n//\n// Zoom/resize position preservation (`computeReflowScrollDelta`) and the\n// auto-follow discriminator (`isWithinProgrammaticScroll` /\n// `hasReachedProgrammaticTarget` + the stateful `createFollowController`)\n// moved to `./notationCommon` (0.39.0) — shared with notationPlayerVerovio.ts.\n// Re-exported above for backward compatibility.\n\nconst DEFAULT_PLAYHEAD_COLOR = '#2f6f4f';\n/** Engrave-width ceiling, CSS px (design doc §\"Render\": \"cap ~1200px, the\n * 0.36.2 rule\" — the same reasoning as notationPlayer.ts's\n * `MAX_ENGRAVE_WIDTH`, restated here rather than imported since the two\n * players' constants are independently tunable, and this one is spec'd to a\n * slightly different value). */\nexport const MAX_ENGRAVE_WIDTH_SVG = 1200;\n/** Floor so a not-yet-laid-out / zero-width host never engraves at 0px. */\nconst MIN_ENGRAVE_WIDTH_SVG = 280;\n\n/** Build a live, interactive SVG (vector) notation player. See the module doc\n * + `docs/superpowers/specs/2026-08-11-svg-notation-player-design.md` (in\n * stave-web-sightread) for the full design. */\nexport function createSvgNotationPlayer(opts: CreateSvgNotationPlayerOpts): SvgNotationPlayer {\n const { host, musicXml, onsetsMs, noteCols } = opts;\n const playheadColor = opts.playheadColor ?? DEFAULT_PLAYHEAD_COLOR;\n const onsets = distinctOnsets(onsetsMs.map((onsetMs) => ({ onsetMs })));\n\n const root = document.createElement('div');\n root.style.position = 'relative';\n root.style.width = '100%';\n // Let the browser's native pinch-zoom AND vertical page-scroll gestures\n // through — the \"optical zoom is free\" half of the design (§5): vectors\n // stay crisp at any pinch/browser-zoom level, and this component adds\n // nothing to make that work beyond not blocking the gesture.\n root.style.touchAction = 'pan-y pinch-zoom';\n host.appendChild(root);\n\n const svgHost = document.createElement('div');\n root.appendChild(svgHost);\n\n const playheadEl = document.createElement('div');\n playheadEl.style.position = 'absolute';\n playheadEl.style.left = '0px';\n playheadEl.style.top = '0px';\n playheadEl.style.width = '2px';\n playheadEl.style.height = '0px';\n playheadEl.style.background = playheadColor;\n playheadEl.style.opacity = '0';\n playheadEl.style.pointerEvents = 'none';\n root.appendChild(playheadEl);\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let osmd: any = null;\n let currentLayout: NotationLayout | null = null;\n let currentZoom = opts.zoom ?? 1;\n let unitInPixelsConst = 10; // overwritten by the real import before first use (rendered-seam path never reads it)\n let lastEngravedWidthPx = 0;\n let destroyed = false;\n let lastTMs = 0;\n // Stale-table guard (design doc §\"Known risks\" — \"Overlay drift on\n // reflow\"): each async re-engrave captures its own token; if a NEWER\n // reflow starts before an older one's `await` resolves, the older one's\n // completion is a no-op instead of clobbering the newer geometry.\n let rebuildToken = 0;\n\n function desiredEngraveWidthPx(): number {\n const w = host.clientWidth || 0;\n return Math.max(MIN_ENGRAVE_WIDTH_SVG, Math.min(w || MAX_ENGRAVE_WIDTH_SVG, MAX_ENGRAVE_WIDTH_SVG));\n }\n\n // ─── Playhead ──────────────────────────────────────────────────────────\n\n function renderPlayhead(tMs: number): void {\n lastTMs = tMs;\n if (!currentLayout) return;\n const nBars = currentLayout.measures.length\n ? Math.max(...currentLayout.measures.map((m) => m.index)) + 1\n : 0;\n const line = vstackAudioPlayheadLine(currentLayout, onsets, tMs, nBars, noteCols);\n if (!line) {\n playheadEl.style.opacity = '0';\n return;\n }\n playheadEl.style.opacity = String(line.alpha);\n playheadEl.style.left = `${line.x}px`;\n playheadEl.style.top = `${line.y0}px`;\n playheadEl.style.height = `${Math.max(0, line.y1 - line.y0)}px`;\n const layoutForFollow = currentLayout;\n const sys = line.sys ?? -1;\n follow.follow({\n systemIndex: sys,\n getSystemRect: () => {\n const box = layoutForFollow.systems[sys];\n if (!box) return null;\n const rootRect = root.getBoundingClientRect();\n return { top: rootRect.top + box.y, bottom: rootRect.top + box.y + box.h };\n },\n getPlayheadRect: () =>\n typeof playheadEl.getBoundingClientRect === 'function' ? playheadEl.getBoundingClientRect() : null,\n });\n }\n\n // ─── Auto-follow (target-position discriminator — see notationCommon.ts) ─\n\n const follow = createFollowController({ topMarginPx: opts.followTopMarginPx });\n\n // ─── setTime ───────────────────────────────────────────────────────────\n\n function setTime(tMs: number): void {\n if (destroyed) return;\n follow.onSetTime(tMs);\n renderPlayhead(tMs);\n }\n\n // ─── Engrave / reflow ──────────────────────────────────────────────────\n\n async function initialEngrave(): Promise<void> {\n if (opts.rendered) {\n currentLayout = opts.rendered;\n lastEngravedWidthPx = desiredEngraveWidthPx();\n renderPlayhead(lastTMs);\n return;\n }\n // Literal dynamic import — consumers' bundlers must statically see the\n // specifier (same reasoning as promo.ts/notationPlayer.ts); a caller\n // that only ever uses the `rendered` test seam never pulls OSMD in.\n const { OpenSheetMusicDisplay, unitInPixels } = await import('opensheetmusicdisplay');\n unitInPixelsConst = unitInPixels;\n if (destroyed) return;\n\n const widthPx = desiredEngraveWidthPx();\n svgHost.style.width = `${widthPx}px`;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const inst: any = new OpenSheetMusicDisplay(svgHost, {\n backend: 'svg',\n autoResize: false,\n drawTitle: false,\n drawSubtitle: false,\n drawComposer: false,\n drawLyricist: false,\n drawPartNames: false,\n });\n await inst.load(musicXml);\n if (destroyed) return;\n inst.Zoom = currentZoom;\n inst.render();\n if (destroyed) return;\n\n osmd = inst;\n lastEngravedWidthPx = widthPx;\n currentLayout = svgNotationLayout(inst, { unitInPixels: unitInPixelsConst });\n renderPlayhead(lastTMs);\n }\n\n const ready = initialEngrave();\n\n /** Re-engrave at `newZoom` and the host's CURRENT width, preserving the\n * scroll position of whatever content is centered in the viewport right\n * now. Shared by both `setZoom` and `resize` (resize just passes the\n * unchanged `currentZoom`). No-op when neither the width nor the zoom\n * actually changed, or when using the `rendered` test seam (nothing to\n * re-engrave). */\n async function reflow(newZoom: number): Promise<void> {\n if (destroyed) return;\n const widthPx = desiredEngraveWidthPx();\n if (widthPx === lastEngravedWidthPx && newZoom === currentZoom) return;\n if (opts.rendered || !osmd) {\n currentZoom = newZoom;\n return;\n }\n\n const myToken = ++rebuildToken;\n const oldLayout = currentLayout;\n const hasWin = typeof window !== 'undefined';\n let anchorY: number | null = null;\n const anchorX = widthPx / 2;\n if (hasWin && typeof root.getBoundingClientRect === 'function') {\n const r = root.getBoundingClientRect();\n anchorY = window.innerHeight / 2 - r.top;\n }\n\n svgHost.style.width = `${widthPx}px`;\n osmd.Zoom = newZoom;\n // Re-run layout (not just a redraw): a width change must re-flow which\n // measures land on which system, and — since we can't be certain a pure\n // zoom change never affects line-breaking on every OSMD version — this is\n // called unconditionally rather than gated to the width-only case.\n osmd.updateGraphic();\n osmd.render();\n if (destroyed || myToken !== rebuildToken) return;\n\n lastEngravedWidthPx = widthPx;\n currentZoom = newZoom;\n currentLayout = svgNotationLayout(osmd, { unitInPixels: unitInPixelsConst });\n applyMarks();\n\n if (oldLayout && anchorY != null && hasWin) {\n const delta = computeReflowScrollDelta(oldLayout, currentLayout, anchorX, anchorY);\n if (Number.isFinite(delta) && Math.abs(delta) > 0.5) {\n window.scrollTo({ top: Math.max(0, window.scrollY + delta), left: window.scrollX, behavior: 'auto' });\n }\n }\n if (!destroyed) renderPlayhead(lastTMs);\n }\n\n // ─── Click-to-seek ─────────────────────────────────────────────────────\n\n const clickListeners: Array<(measureIndex: number) => void> = [];\n function onRootClick(e: MouseEvent): void {\n if (!currentLayout) return;\n const rect = root.getBoundingClientRect();\n const mx = e.clientX - rect.left;\n const my = e.clientY - rect.top;\n const idx = hitTestMeasureAt(currentLayout, mx, my);\n if (idx != null) for (const cb of clickListeners) cb(idx);\n }\n root.addEventListener('click', onRootClick);\n\n let markedCols: number[] = [];\n\n function applyMarks(): void {\n // Clear first: a re-engrave hands back different elements, and stale marks\n // would otherwise stay lit on notes nobody asked about.\n for (const el of root.querySelectorAll(`.${MARKED_NOTE_CLASS}`)) {\n el.classList.remove(MARKED_NOTE_CLASS);\n }\n if (!osmd) return;\n for (const col of markedCols) {\n for (const note of graphicalNotesAtColumn(osmd, col)) {\n const el = typeof note?.getSVGGElement === 'function' ? note.getSVGGElement() : null;\n if (el) el.classList.add(MARKED_NOTE_CLASS);\n }\n }\n }\n\n return {\n ready,\n setTime,\n markNotes(cols: number[] | null): void {\n markedCols = cols ?? [];\n applyMarks();\n },\n setFollowEnabled(on) {\n follow.setEnabled(on);\n },\n async setZoom(z: number): Promise<void> {\n await ready;\n await reflow(z);\n },\n async resize(): Promise<void> {\n await ready;\n await reflow(currentZoom);\n },\n onMeasureClick(cb: (measureIndex: number) => void): () => void {\n clickListeners.push(cb);\n return () => {\n const i = clickListeners.indexOf(cb);\n if (i >= 0) clickListeners.splice(i, 1);\n };\n },\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n rebuildToken++;\n root.removeEventListener('click', onRootClick);\n follow.destroy();\n clickListeners.length = 0;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n try { (osmd as any)?.clear?.(); } catch { /* best-effort */ }\n osmd = null;\n currentLayout = null;\n if (root.parentNode === host) host.removeChild(root);\n },\n };\n}\n"],"mappings":"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","names":[]}
|