diff --git a/AGENTS.md b/AGENTS.md index c34bcdadf..45be9fab9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,9 @@ After making code changes: - `vex fix` typecheck, format, and lint the project. - `vex test` test the project. +- `vex test --update` update the test snapshots. MusicXML tools: -- `vex validate -i ` validate a MusicXML file -- `vex render -i ` render a MusicXML file to a PNG - -Please delete screenshots when you are done, unless you're showing the user something. +- `vex validate -i ` validate a MusicXML file. +- `vex render -i ` render a MusicXML file to a PNG. Delete screenshots when you are done, unless you're showing the user something. diff --git a/README.md b/README.md index 7faf5dc98..11b220a2b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ score.addEventListener('pointermove', (e) => { : null; if (current !== previous) { previous?.halo.off(); - current?.halo.on(); + current?.halo.on('rgba(41, 98, 255, 0.35)'); previous = current; } }); @@ -63,15 +63,23 @@ await render(musicXML, element, { ## Adding a canvas layer +A layer is a `` that you can draw arbitrary content on without affecting the sheet music. vexml controls its size and position. + ```ts const score = await render(musicXML, element); -const layer = score.createLayer('content'); -// ctx is a standard CanvasRenderingContext2D, you can draw anything you want here -layer.ctx.fillStyle = 'rgba(255, 0, 0, 0.3)'; -layer.ctx.fillRect(50, 50, 100, 80); +const background = score.addLayer('content', -1); // draws behind the score +// ctx is a standard CanvasRenderingContext2D +background.ctx.fillStyle = 'rgba(0, 0, 255, 0.3)'; +background.ctx.fillRect(50, 50, 100, 80); + +const foreground = score.addLayer('content', 1); // draws in front of the score +foreground.ctx.fillStyle = 'rgba(255, 0, 0, 0.3)'; +foreground.ctx.fillRect(50, 50, 100, 80); ``` +Pass an optional `zIndex` to order a layer relative to the canvas the score is drawn on, which sits at `zIndex` 0. A positive value draws in front; a negative value draws behind, showing through the score's transparent pixels. Layers with the same `zIndex` stack in the order they were created. + ## Cleaning up When you're done with a layer or the entire rendered score, call `.dispose()` to clean up resources. diff --git a/site/src/App.tsx b/site/src/App.tsx index 90fb80e35..73af0fe61 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import type { Config, + HoverEvent, Note, PointerTarget, PointerTargetEvent, @@ -21,7 +22,7 @@ function describe(target: PointerTarget): string { if (target.isChordMember()) { parts.push('chord'); } - return parts.join(' · '); + return `${parts.join(' · ')}\nmeasure ${target.getMeasure().getNumber()}`; } if (target.type === 'tab-position') { return `string ${target.getString()} · fret ${target.getFret()} · ${target.getNote().getPitch() ?? 'rest'}`; @@ -102,8 +103,13 @@ export default function App() { const [fixture, setFixture] = useState(''); const [error, setError] = useState(null); const [renderMs, setRenderMs] = useState(null); + // Mirror of renderMs the debounce effect reads without depending on it — otherwise a + // render-time report would re-fire the effect and flash a phantom debounce. + const renderMsRef = useRef(null); const [dragging, setDragging] = useState(false); const [debouncing, setDebouncing] = useState(false); + // Loading overlay until the first render settles; the app always renders on mount. + const [initialized, setInitialized] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); const [dark, setDark] = useState(false); const [stored, setStored] = useState( @@ -155,7 +161,9 @@ export default function App() { // `config` stays live so the sliders/reset respond instantly; `renderConfig` lags // behind it by the debounce so dragging a slider re-renders once it settles, not on // every step. The loading overlay shows while waiting (shared `debouncing` flag). - const [renderConfig, setRenderConfig] = useState>({}); + // Seed from `config` (same reference) so the first render uses the real config, not {}. + // Otherwise the first setRenderMs flips renderConfig {} -> config and double-renders on mount. + const [renderConfig, setRenderConfig] = useState>(config); const skipConfigDebounce = useRef(true); useEffect(() => { if (skipConfigDebounce.current) { @@ -164,7 +172,7 @@ export default function App() { } // If the last render was fast, apply config changes immediately; only debounce // once renders get slow enough to lag the sliders. - if (renderMs != null && renderMs <= 50) { + if (renderMsRef.current != null && renderMsRef.current <= 50) { setRenderConfig(config); setDebouncing(false); return; @@ -175,7 +183,7 @@ export default function App() { setDebouncing(false); }, 500); return () => clearTimeout(t); - }, [config, renderMs]); + }, [config]); useEffect(() => { const container = containerRef.current; @@ -196,7 +204,7 @@ 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. + // Turn off the lit halo and hide the tooltip; used to reset on teardown/re-render. const clearHalo = () => { haloRef.current?.halo.off(); haloRef.current?.color.off(); @@ -217,45 +225,67 @@ export default function App() { return; } scoreRef.current = score; - setRenderMs(performance.now() - start); - - const onPointer = (e: PointerTargetEvent) => { + renderMsRef.current = performance.now() - start; + setRenderMs(renderMsRef.current); + setInitialized(true); + + // A click/tap pins a target (toggle); hover is transient. The pinned one wins, so + // hovering elsewhere — or scrolling it out from under the pointer — never clears the + // pin. Clicking it again, or clicking empty space, unpins. + let pinned: PointerTarget | null = null; + let hovered: PointerTarget | null = null; + const apply = () => { + const target = pinned ?? hovered; const note = - e.target?.type === 'note' - ? e.target - : e.target?.type === 'tab-position' - ? e.target.getNote() + target?.type === 'note' + ? target + : target?.type === 'tab-position' + ? target.getNote() : null; if (note !== haloRef.current) { haloRef.current?.halo.off(); haloRef.current?.color.off(); - note?.halo.on(); - note?.color.on('#2962ff'); + note?.halo.on('rgba(255, 0, 105, 0.9)'); + note?.color.on('#f4f800'); haloRef.current = note; - container.style.cursor = note ? 'pointer' : ''; } - if (note && e.target && showInfoRef.current) { - const r = e.target.getBoundingClientRect(); + container.style.cursor = note ? 'pointer' : ''; + // Only note-bearing targets get a tooltip; describe() is empty for a measure. + if (note && target && showInfoRef.current) { + const r = target.getBoundingClientRect(); setTooltip({ x: r.left + r.width / 2, y: r.top, - text: describe(e.target), + text: describe(target), }); } else { setTooltip(null); } }; - score.addEventListener('pointermove', onPointer); - score.addEventListener('pointerdown', onPointer); - container.addEventListener('pointerleave', clearHalo); - detach = () => { - container.removeEventListener('pointerleave', clearHalo); - clearHalo(); + // hover fires once per target change — on move, and (unlike pointermove) when a scroll + // slides a different target under the pointer, so it tracks what's actually hovered. + const onHover = (e: HoverEvent) => { + hovered = e.target; + apply(); + }; + const onClick = (e: PointerTargetEvent) => { + // Only notes/frets are pinnable; clicking a measure or empty space unpins. + const t = + e.target?.type === 'note' || e.target?.type === 'tab-position' + ? e.target + : null; + pinned = pinned === t ? null : t; + apply(); }; + score.addEventListener('hover', onHover); + score.addEventListener('click', onClick); + detach = clearHalo; }) .catch((e: unknown) => { + renderMsRef.current = null; setRenderMs(null); setError(e instanceof Error ? e.message : String(e)); + setInitialized(true); }); return () => { cancelled = true; @@ -819,10 +849,12 @@ export default function App() { // re-engraving in a light color.
)} - {debouncing && ( + {(!initialized || debouncing) && (
{/* sticky so the badge stays centered in the viewport even when the backdrop is taller than the screen */}
@@ -841,8 +873,8 @@ export default function App() { {tooltip && (
{tooltip.text}
diff --git a/src/collision.test.ts b/src/collision.test.ts index 8d6ac4dc4..1d0ed63ac 100644 --- a/src/collision.test.ts +++ b/src/collision.test.ts @@ -71,6 +71,17 @@ test('pushRightOf ignores diagrams in a different vertical band', () => { expect(d.pushRightOf(new Rect(50, 500, 88, 84), 'diagram', 6).x).toBe(50); }); +test('nudgeInsideX pulls an over-right box back in, leaves an inside box put, and never overshoots left', () => { + const d = detector(); + const bounds = new Rect(0, 0, 100, 100); + // Overruns the right edge -> pulled left so its right edge lands on the (margin-inset) edge. + expect(d.nudgeInsideX(new Rect(80, 0, 30, 10), bounds, 5).x).toBe(65); + // Already inside -> untouched. + expect(d.nudgeInsideX(new Rect(20, 0, 30, 10), bounds, 5).x).toBe(20); + // Wider than the span -> clamps to the left edge rather than overshooting it. + expect(d.nudgeInsideX(new Rect(80, 0, 200, 10), bounds, 5).x).toBe(5); +}); + test('escaping flags rects that cross the viewport edges', () => { const d = detector(); d.addRect(new Rect(10, 10, 5, 5), 'note'); // inside diff --git a/src/collision.ts b/src/collision.ts index 50feebbb6..872526b04 100644 --- a/src/collision.ts +++ b/src/collision.ts @@ -113,6 +113,25 @@ export class CollisionDetector { return rect.translate(targetX - rect.x, 0); } + /* + * Shift `rect` horizontally so it sits within `bounds` (the canvas), pulling a box that + * overruns the right edge back inside, or pushing one off the left edge back right. Only + * moves along x — vertical clipping is handled by growing the crop, not nudging. The left + * edge wins if the rect is wider than the available span. `margin` insets both edges. + */ + nudgeInsideX(rect: Rect, bounds: Rect, margin = 0): Rect { + const left = bounds.x + margin; + const right = bounds.right - margin; + let dx = 0; + if (rect.right > right) { + dx = right - rect.right; // pull left + } + if (rect.x + dx < left) { + dx = left - rect.x; // but never past the left edge + } + return rect.translate(dx, 0); + } + /* * Registered items that escape `viewport` (the rendered/crop rectangle), with which edges * they cross — the "no-man's land" where content gets clipped. The caller decides whether diff --git a/src/constants.ts b/src/constants.ts index 944bcdae6..467092573 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -121,6 +121,13 @@ export const HARMONY_Y_OFFSET = 14; * sits over, so the symbol lifts clear instead of colliding with the notehead. */ export const HARMONY_NOTE_CLEARANCE = 8; +/** Padding added below a chord symbol's collision box, reaching down to the top staff line + * (past its text baseline, which sits HARMONY_Y_OFFSET above that line). Lets the lift-clear + * probe reach a notehead sitting in the top stave space — just under the baseline — so the + * symbol nudges up off it instead of touching, leaving a little breathing room above the note. + * Set a hair past HARMONY_Y_OFFSET so a note on the top line/space is reliably caught. */ +export const HARMONY_PADDING = 15; + /** How far a single note's tie ribbon peaks above the notehead center when it bows * upward (stem-down note). Vexflow draws the tie as a bezier whose outer edge clears * the notehead by its yShift (7) plus the deeper control-point excursion (cp2 12) — @@ -136,6 +143,12 @@ export const CHORD_DIAGRAM_HEIGHT = 84; /** Gap kept between the bottom of a chord diagram and the top staff line. */ export const CHORD_DIAGRAM_GAP = 6; +/** Padding added below a chord diagram's collision box (which sits CHORD_DIAGRAM_GAP above + * the top staff line). Lets the lift-clear probe reach a note sitting in the top stave space + * — just under the box — so the box rises off it instead of overlapping, the same padding + * treatment a chord symbol's box uses (see HARMONY_PADDING). */ +export const CHORD_DIAGRAM_PADDING = 15; + /** Words-direction (e.g. "ritardando") text size — matches the chord-symbol size so * both read as annotations above the notes. */ export const WORDS_FONT_SIZE = 13; diff --git a/src/decorations.test.ts b/src/decorations.test.ts index 7b3c7d344..e1a947d57 100644 --- a/src/decorations.test.ts +++ b/src/decorations.test.ts @@ -46,29 +46,58 @@ class FakeLayer implements Layer { } } +// Colors and halos draw on separate layers ('content' over the score, 'background' behind it), so +// the host keeps a recorder per kind and the tests assert against the relevant one. class FakeLayerHost implements LayerHost { - readonly recorder = new RecordingContext(); + readonly recorders = new Map(); + readonly layers = new Map(); createLayerCalls = 0; - layer: FakeLayer | null = null; - createLayer(_kind: LayerKind): Layer { + createLayer(kind: LayerKind): Layer { this.createLayerCalls++; - this.layer = new FakeLayer( - this.recorder as unknown as CanvasRenderingContext2D, + const recorder = new RecordingContext(); + this.recorders.set(kind, recorder); + const layer = new FakeLayer( + recorder as unknown as CanvasRenderingContext2D, ); - return this.layer; + this.layers.set(kind, layer); + return layer; + } + ops(kind: LayerKind): string[] { + return this.recorders.get(kind)?.ops ?? []; } } const HALO = 'fill:arc:rgba(41, 98, 255, 0.35)'; const GLYPH: NoteGlyph = { text: 'q', font: '30px Bravura', x: 12, y: 20 }; +// A fake target standing in for a real Note/Measure: drawColor stamps the glyph (a notehead) in +// the color, or falls back to a filled ellipse over the box when there's none — mirroring the +// production targets, which now own their own color stamping (see Decoratable.drawColor). const decoratable = ( rect: Rect, glyph: NoteGlyph | null = null, ): Decoratable => ({ rect, - glyph, getBoundingClientRect: () => ({}) as DOMRect, + drawColor(ctx: CanvasRenderingContext2D, color: string): void { + ctx.fillStyle = color; + if (glyph) { + ctx.font = glyph.font; + ctx.fillText(glyph.text, glyph.x, glyph.y); + } else { + ctx.beginPath(); + ctx.ellipse( + rect.x + rect.w / 2, + rect.y + rect.h / 2, + rect.w / 2, + rect.h / 2, + 0, + 0, + 2 * Math.PI, + ); + ctx.fill(); + } + }, }); // The marks (fills/texts) recorded since the last clear — i.e., the result of the latest repaint. @@ -91,7 +120,7 @@ test('setColor stamps the notehead glyph in the color and reports isColored', () decorations.setColor(target, '#2962ff'); expect(decorations.isColored(target)).toBe(true); // The exact glyph (text + font) vexflow drew, replayed in the chosen color. - expect(marksSinceLastClear(host.recorder.ops)).toEqual([ + expect(marksSinceLastClear(host.ops('content'))).toEqual([ 'text:q:#2962ff:30px Bravura', ]); }); @@ -100,7 +129,7 @@ test('a glyph-less target (a rest) falls back to a filled ellipse', () => { const host = new FakeLayerHost(); const decorations = new Decorations(host); decorations.setColor(decoratable(new Rect(0, 0, 12, 10), null), '#2962ff'); - expect(marksSinceLastClear(host.recorder.ops)).toEqual([ + expect(marksSinceLastClear(host.ops('content'))).toEqual([ 'fill:ellipse:#2962ff', ]); }); @@ -112,31 +141,33 @@ test('setColor(null) clears the decoration, drawing nothing', () => { decorations.setColor(target, '#ff0000'); decorations.setColor(target, null); expect(decorations.isColored(target)).toBe(false); - expect(host.recorder.ops.at(-1)).toBe('clear'); - expect(marksSinceLastClear(host.recorder.ops)).toEqual([]); + expect(host.ops('content').at(-1)).toBe('clear'); + expect(marksSinceLastClear(host.ops('content'))).toEqual([]); }); -test('halo draws under color', () => { +test('the halo draws on a background layer in its color, behind the color layer', () => { const host = new FakeLayerHost(); const decorations = new Decorations(host); const target = decoratable(new Rect(0, 0, 12, 10), GLYPH); decorations.setColor(target, '#2962ff'); - decorations.setHalo(target, true); - expect(marksSinceLastClear(host.recorder.ops)).toEqual([ - HALO, + decorations.setHalo(target, 'rgba(41, 98, 255, 0.35)'); + // The color stamps the notehead on the content (over) layer; the halo fills its circle on the + // background (behind) layer in the chosen color. + expect(marksSinceLastClear(host.ops('content'))).toEqual([ 'text:q:#2962ff:30px Bravura', ]); + expect(marksSinceLastClear(host.ops('background'))).toEqual([HALO]); }); -test('setHalo(false) removes the halo', () => { +test('setHalo(null) removes the halo', () => { const host = new FakeLayerHost(); const decorations = new Decorations(host); const target = decoratable(new Rect(0, 0, 12, 10), GLYPH); - decorations.setHalo(target, true); + decorations.setHalo(target, 'rgba(41, 98, 255, 0.35)'); expect(decorations.isHaloed(target)).toBe(true); - decorations.setHalo(target, false); + decorations.setHalo(target, null); expect(decorations.isHaloed(target)).toBe(false); - expect(marksSinceLastClear(host.recorder.ops)).toEqual([]); + expect(marksSinceLastClear(host.ops('background'))).toEqual([]); }); test('every repaint clears first, then redraws the whole active set', () => { @@ -144,19 +175,23 @@ test('every repaint clears first, then redraws the whole active set', () => { const decorations = new Decorations(host); decorations.setColor(decoratable(new Rect(0, 0, 12, 10), GLYPH), '#111111'); decorations.setColor(decoratable(new Rect(20, 0, 12, 10), GLYPH), '#222222'); - expect(marksSinceLastClear(host.recorder.ops)).toEqual([ + expect(marksSinceLastClear(host.ops('content'))).toEqual([ 'text:q:#111111:30px Bravura', 'text:q:#222222:30px Bravura', ]); }); -test('dispose disposes the layer and clears state', () => { +test('dispose disposes every layer and clears state', () => { const host = new FakeLayerHost(); const decorations = new Decorations(host); const target = decoratable(new Rect(0, 0, 12, 10), GLYPH); decorations.setColor(target, '#2962ff'); - const layer = host.layer; + decorations.setHalo(target, 'rgba(41, 98, 255, 0.35)'); + const content = host.layers.get('content'); + const background = host.layers.get('background'); decorations.dispose(); - expect(layer?.disposed).toBe(true); + expect(content?.disposed).toBe(true); + expect(background?.disposed).toBe(true); expect(decorations.isColored(target)).toBe(false); + expect(decorations.isHaloed(target)).toBe(false); }); diff --git a/src/decorations.ts b/src/decorations.ts index aa1420f1c..6d0d49423 100644 --- a/src/decorations.ts +++ b/src/decorations.ts @@ -2,27 +2,28 @@ import type { Rect } from './geometry'; import type { Layer, LayerHost } from './stage'; import type { Decoratable, Decorator } from './targets'; -// A soft round highlight drawn behind a note to denote activity. Fixed (the halo toggle takes no -// argument); the color toggle is what carries a caller-chosen color. HALO_MARGIN is how far the -// circle extends past the notehead's half-extent, so the note sits evenly inside it. -const HALO_COLOR = 'rgba(41, 98, 255, 0.35)'; +// A soft round highlight drawn behind a note to denote activity, in the caller-chosen halo color. +// HALO_MARGIN is how far the circle extends past the notehead's half-extent, so the note sits +// evenly inside it. const HALO_MARGIN = 8; /* * The decoration store and painter — the production `Decorator`. A target's color/halo toggles - * delegate here. Decorations are retained as an active set (which targets are colored/haloed) and - * drawn on a single `content` overlay layer that sits in score space over the engraving. + * delegate here. Decorations are retained as active sets (which targets are colored/haloed) and + * drawn on two score-space overlay layers: colors on a `content` layer over the engraving (they + * recolor the notehead, so they sit on top), halos on a `background` layer behind the base canvas + * (so they glow through the score's transparent pixels, under the notes). * - * Every change repaints the whole layer from the active set rather than erasing one rect. That's - * the answer to "how does off() work without disturbing neighbors": clearing one decoration's box - * could wipe part of an overlapping one, so instead the lot is cleared and redrawn — halos first - * (under), colors on top. The layer is created lazily on the first decoration, so an undecorated - * score never allocates an overlay. + * Every change repaints a whole layer from its active set rather than erasing one rect. That's the + * answer to "how does off() work without disturbing neighbors": clearing one decoration's box could + * wipe part of an overlapping one, so instead the lot is cleared and redrawn. Each layer is created + * lazily on its first decoration, so an undecorated score never allocates an overlay. */ export class Decorations implements Decorator { private readonly colors = new Map(); - private readonly halos = new Set(); - private layer: Layer | null = null; + private readonly halos = new Map(); + private colorLayer: Layer | null = null; + private haloLayer: Layer | null = null; constructor(private readonly host: LayerHost) {} @@ -32,16 +33,16 @@ export class Decorations implements Decorator { } else { this.colors.set(target, color); } - this.repaint(); + this.repaintColors(); } - setHalo(target: Decoratable, on: boolean): void { - if (on) { - this.halos.add(target); - } else { + setHalo(target: Decoratable, color: string | null): void { + if (color === null) { this.halos.delete(target); + } else { + this.halos.set(target, color); } - this.repaint(); + this.repaintHalos(); } isColored(target: Decoratable): boolean { @@ -53,36 +54,44 @@ export class Decorations implements Decorator { } dispose(): void { - this.layer?.dispose(); - this.layer = null; + this.colorLayer?.dispose(); + this.haloLayer?.dispose(); + this.colorLayer = null; + this.haloLayer = null; this.colors.clear(); this.halos.clear(); } - // Repaint from the retained active set: clear, then halos (under) then colors (over). - private repaint(): void { - if (this.colors.size === 0 && this.halos.size === 0) { - // Nothing active: clear an existing layer, but don't allocate one just to clear it. - if (this.layer) { - this.clear(this.layer.ctx); + private repaintColors(): void { + if (this.colors.size === 0) { + if (this.colorLayer) { + this.clear(this.colorLayer.ctx); } return; } - const ctx = this.ensureLayer().ctx; + this.colorLayer ??= this.host.createLayer('content'); + const ctx = this.colorLayer.ctx; this.clear(ctx); - for (const target of this.halos) { - this.drawHalo(ctx, target.rect); - } for (const [target, color] of this.colors) { - this.drawColor(ctx, target, color); + // The target knows what to stamp (a notehead glyph, a fret number, a box); we just + // hand it the overlay and the color. See Decoratable.drawColor. + target.drawColor(ctx, color); } } - private ensureLayer(): Layer { - if (!this.layer) { - this.layer = this.host.createLayer('content'); + private repaintHalos(): void { + if (this.halos.size === 0) { + if (this.haloLayer) { + this.clear(this.haloLayer.ctx); + } + return; + } + this.haloLayer ??= this.host.createLayer('background'); + const ctx = this.haloLayer.ctx; + this.clear(ctx); + for (const [target, color] of this.halos) { + this.drawHalo(ctx, target.rect, color); } - return this.layer; } // Clear the whole bitmap regardless of the dpr transform the layer applied. @@ -93,45 +102,16 @@ export class Decorations implements Decorator { ctx.restore(); } - // Recolor the note: replay vexflow's own notehead render (same glyph text, font, and baseline) - // in the chosen color, so the actual notehead is recolored and hollow heads stay hollow. A - // glyph-less target (a rest, or a non-note) falls back to a filled ellipse over its box. - private drawColor( + private drawHalo( ctx: CanvasRenderingContext2D, - target: Decoratable, + rect: Rect, color: string, ): void { - ctx.save(); - ctx.fillStyle = color; - const glyph = target.glyph; - if (glyph) { - ctx.font = glyph.font; - ctx.textAlign = 'left'; - ctx.textBaseline = 'alphabetic'; - ctx.fillText(glyph.text, glyph.x, glyph.y); - } else { - const r = target.rect; - ctx.beginPath(); - ctx.ellipse( - r.x + r.w / 2, - r.y + r.h / 2, - r.w / 2, - r.h / 2, - 0, - 0, - 2 * Math.PI, - ); - ctx.fill(); - } - ctx.restore(); - } - - private drawHalo(ctx: CanvasRenderingContext2D, rect: Rect): void { // A circle centered on the notehead box, a fixed margin larger than its half-extent, so it // encircles the note evenly regardless of the notehead's width. const radius = Math.max(rect.w, rect.h) / 2 + HALO_MARGIN; ctx.save(); - ctx.fillStyle = HALO_COLOR; + ctx.fillStyle = color; ctx.beginPath(); ctx.arc(rect.x + rect.w / 2, rect.y + rect.h / 2, radius, 0, 2 * Math.PI); ctx.fill(); diff --git a/src/draw.ts b/src/draw.ts index dcde1c913..6e5588c6f 100644 --- a/src/draw.ts +++ b/src/draw.ts @@ -30,15 +30,18 @@ import { BRACKET_X_SHIFT, CHORD_DIAGRAM_GAP, CHORD_DIAGRAM_HEIGHT, + CHORD_DIAGRAM_PADDING, CHORD_DIAGRAM_WIDTH, HARMONY_FONT_SIZE, HARMONY_NOTE_CLEARANCE, + HARMONY_PADDING, HARMONY_Y_OFFSET, LABEL_FONT_SIZE, LABEL_GAP, LEDGER_HEADROOM, PAGE_MARGIN_BOTTOM, PAGE_MARGIN_TOP, + PAGE_MARGIN_X, PEDAL_BOTTOM_MARGIN, PEDAL_BOTTOM_TEXT_LINE, TEMPO_NOTE_CLEARANCE, @@ -526,18 +529,22 @@ function drawHarmony( const baseY = stave.getYForLine(0) - HARMONY_Y_OFFSET; context.save(); context.setFillStyle('#000000'); + // Pad the box below the text baseline so liftClear's downward probe reaches a notehead + // sitting just under the baseline (a note in the top stave space) and nudges the symbol + // clear of it, leaving a little breathing room. The drawn baseline stays HARMONY_PADDING + // above the box bottom, so with nothing in the way the symbol keeps its default position. const natural = new Rect( staveNote.getAbsoluteX(), baseY - HARMONY_FONT_SIZE, harmonyWidth(context, text, font), - HARMONY_FONT_SIZE, + HARMONY_FONT_SIZE + HARMONY_PADDING, ); const placed = detector.liftClear( natural, HARMONY_NOTE_CLEARANCE, TEXT_CLEAR_KINDS, ); - const y = placed.bottom; + const y = placed.bottom - HARMONY_PADDING; // The ♯/♭/♮ glyphs carry wide side-bearings in the text font, so a single fillText // of "B♭" reads as "B ♭". Draw char by char and pull the accidental in on both sides // so it sits tight against its root letter. @@ -1289,6 +1296,20 @@ export function drawScore( const tabStave = p.stave as TabStave; for (const { note, chord } of p.tabChords) { const x = note.getAbsoluteX(); + // The drawn fret glyphs, parallel to getPositions() (one per struck string), so a + // decoration can replay the exact fret text vexflow drew — "<12>", "(2)", "✕" — + // in color. The tab analog of the notation path's note.noteHeads. + const positions = note.getPositions(); + const fretEls = ( + note as unknown as { + fretElement: { + getText(): string; + getFont(): string; + getWidth(): number; + getYShift(): number; + }[]; + } + ).fretElement; for (const mnote of chord.notes) { const string = mnote.string; const fret = mnote.fret; @@ -1296,6 +1317,9 @@ export function drawScore( continue; } const y = tabStave.getYForLine(string - 1); + // Match this string's drawn fret glyph (positions carry one entry per string). + const el = + fretEls[positions.findIndex((pos) => pos.str === string)]; rawNotes.push({ mnote, rect: new Rect( @@ -1307,7 +1331,19 @@ export function drawScore( chord: chord.notes, measureIndex: m, tab: { string, fret }, - glyph: null, + // Replay vexflow's own fret glyph for recoloring, the tab analog of the + // notehead path: its left-anchored baseline x (drawPositions uses + // tabX = absoluteX - width/2) and baseline y (the string line plus the + // element's yShift, which is how TabNote vertically centers the digit). + // Drawn left/alphabetic, a colored fret overlays the engraved one exactly. + glyph: el + ? { + text: el.getText(), + font: el.getFont(), + x: x - el.getWidth() / 2, + y: y + el.getYShift(), + } + : null, }); } } @@ -1367,6 +1403,7 @@ export function drawScore( Math.max(0, systemContentBottom - systemY), ), index: m, + number: parts[0]?.measures[m]?.number ?? String(m + 1), }); } @@ -1393,7 +1430,10 @@ export function drawScore( // Diagrams sit at their lead note's x; two on notes either side of a barline can be // close enough to overlap (especially at a narrow width). The detector pushes each // box clear of any already-placed diagram in its band (replacing the old running - // cursor) so crowded diagrams separate instead of stacking. + // cursor) so crowded diagrams separate instead of stacking. It also lifts each box + // above any notes, ties, or words in its column (the diagrams pass runs after the + // notes and words), so a high note or a word like "(as taught)" stays put and the + // box rises over it. for (const h of harmonyTasks) { // A with a draws as a fret box (chord name as its title) // above the stave; one without draws as the plain chord-symbol text. @@ -1413,13 +1453,43 @@ export function drawScore( CHORD_DIAGRAM_WIDTH, CHORD_DIAGRAM_HEIGHT, ); - const placed = detector.pushRightOf( + const spaced = detector.pushRightOf( natural, 'diagram', CHORD_DIAGRAM_GAP, ); + // Pad the box below its bottom so the lift-clear probe reaches a high note + // (or its tie) poking up into the box's column — the same padding treatment + // a chord symbol uses. The box then rises off the note instead of overlapping + // it; with nothing in the way it keeps its default position. + const padded = new Rect( + spaced.x, + spaced.y, + spaced.w, + CHORD_DIAGRAM_HEIGHT + CHORD_DIAGRAM_PADDING, + ); + const lifted = detector.liftClear( + padded, + CHORD_DIAGRAM_GAP, + TEXT_CLEAR_KINDS, + ); + // Recover the real (unpadded) box; the padding only extended the probe. + const unclamped = new Rect( + lifted.x, + lifted.y, + CHORD_DIAGRAM_WIDTH, + CHORD_DIAGRAM_HEIGHT, + ); + // A box anchored at a note near the right edge would overrun the canvas and be + // clipped (page overflow has no crop-growth knob like the vertical edges do), so + // nudge it back inside the drawable region. + const placed = detector.nudgeInsideX( + unclamped, + scratchViewport, + PAGE_MARGIN_X, + ); detector.add({ rect: placed, kind: 'diagram' }); - const diagram = new ChordDiagram(placed.x, top, { + const diagram = new ChordDiagram(placed.x, placed.y, { width: CHORD_DIAGRAM_WIDTH, height: CHORD_DIAGRAM_HEIGHT, numStrings: h.frame.chord.length, diff --git a/src/events.ts b/src/events.ts index 14d2b3ea1..bc2cd615a 100644 --- a/src/events.ts +++ b/src/events.ts @@ -67,6 +67,16 @@ export interface PointerTargetEvent { readonly native: PointerEvent; } +/* The target under the pointer changed: entered, left, or moved between targets. `target` is null + * when nothing is under the pointer (empty space, or the pointer left the score); `point` is the + * pointer in score space, or null once the pointer is off the score. Unlike pointermove, this also + * fires when scrolling slides a different target under a stationary pointer — so it fires at most + * once per change, not once per pixel. */ +export interface HoverEvent { + readonly target: PointerTarget | null; + readonly point: { x: number; y: number } | null; +} + /* The container scrolled: its new scroll offset plus the raw event. */ export interface ScoreScrollEvent { readonly left: number; @@ -86,6 +96,7 @@ export interface ScoreEventMap { pointerdown: PointerTargetEvent; pointerup: PointerTargetEvent; click: PointerTargetEvent; + hover: HoverEvent; scroll: ScoreScrollEvent; resize: ScoreResizeEvent; } diff --git a/src/hit.test.ts b/src/hit.test.ts index e8569083e..cfdcfb0a3 100644 --- a/src/hit.test.ts +++ b/src/hit.test.ts @@ -90,7 +90,7 @@ function build() { const geometry: RawGeometry = { bounds: new Rect(0, 0, 200, 100), notes, - measures: [{ rect: new Rect(0, 0, 200, 100), index: 0 }], + measures: [{ rect: new Rect(0, 0, 200, 100), index: 0, number: '1' }], }; return buildTargets(geometry, new FakeViewport(), new FakeDecorator()); } diff --git a/src/hit.ts b/src/hit.ts index 594a8aca6..f0e7f7955 100644 --- a/src/hit.ts +++ b/src/hit.ts @@ -19,21 +19,24 @@ import { */ /* 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. */ + * fret rendering (the note's string/fret, plus the fret as drawn and its font so a decoration can + * recolor the digit); 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; - /* The engraved notehead glyph (for recoloring); null for a tab fret or a rest. */ + /* The engraved glyph for recoloring — a notehead, or a tab fret; null for a rest. */ glyph: NoteGlyph | null; } export interface RawMeasure { rect: Rect; index: number; + /* The MusicXML measure number (a string — handles pickups, "X1" etc.). */ + number: string; } /* Everything the draw pass emits for the index, in score space (crop already applied). */ @@ -94,7 +97,7 @@ export function buildTargets( ): HitTester { const measures = new Map(); for (const m of geometry.measures) { - measures.set(m.index, new Measure(m.rect, viewport)); + measures.set(m.index, new Measure(m.rect, viewport, m.number)); } const noteByMnote = new Map(); @@ -132,6 +135,8 @@ export function buildTargets( string: rn.tab.string, fret: rn.tab.fret, note, + decorator, + glyph: rn.glyph, }), ); } diff --git a/src/index.ts b/src/index.ts index df7ca44d9..139bd7a5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export { export type { Config } from './config'; export type { EventListenable, + HoverEvent, PointerTargetEvent, ScoreEventMap, ScoreResizeEvent, diff --git a/src/layout.ts b/src/layout.ts index 633d8032b..4dd5b84cf 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -304,10 +304,16 @@ export function computeLayout(parts: Part[], config: Config): ScoreLayout { let rowWidth = 0; for (let m = 0; m < measureCount; m++) { const area = noteAreas[m] ?? BASE_VOICE_WIDTH; + // A forces a break before this measure regardless of + // width; otherwise wrap once the next measure's note area would overrun the line. + const forcedBreak = parts.some( + (part) => part.measures[m]?.print?.newSystem, + ); if ( row.length > 0 && - rowWidth + LEAD_BARLINE + area > - usableOf(systems.length) * config.maxSystemFill + (forcedBreak || + rowWidth + LEAD_BARLINE + area > + usableOf(systems.length) * config.maxSystemFill) ) { systems.push(row); row = []; diff --git a/src/score.test.ts b/src/score.test.ts index 321355688..46cb433e6 100644 --- a/src/score.test.ts +++ b/src/score.test.ts @@ -28,7 +28,10 @@ function noopContext(): CanvasRenderingContext2D { class FakeLayer implements Layer { disposed = false; readonly ctx = noopContext(); - constructor(readonly kind: LayerKind) {} + constructor( + readonly kind: LayerKind, + readonly zIndex?: number, + ) {} dispose(): void { this.disposed = true; } @@ -47,6 +50,7 @@ class FakeHost implements Host { toScoreSpace(clientX: number, clientY: number): { x: number; y: number } { return { x: clientX, y: clientY }; } + scrollListener: (() => void) | null = null; observeResize( onResize: (size: { width: number; height: number }) => void, ): () => void { @@ -56,8 +60,14 @@ class FakeHost implements Host { this.resizeListener = null; }; } - createLayer(kind: LayerKind): Layer { - const layer = new FakeLayer(kind); + observeScroll(onScroll: () => void): () => void { + this.scrollListener = onScroll; + return () => { + this.scrollListener = null; + }; + } + createLayer(kind: LayerKind, zIndex?: number): Layer { + const layer = new FakeLayer(kind, zIndex); this.created.push(layer); return layer; } @@ -104,7 +114,7 @@ function fixture(target: PointerTarget | null) { } 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 target = new Measure(new Rect(0, 0, 10, 10), viewport, '1'); const { host, index, score } = fixture(target); const seen: Array<{ type: string; x: number; y: number; native: Event }> = []; score.addEventListener('pointermove', (e) => @@ -167,6 +177,31 @@ test('scroll events carry the offset and the score.scroll getter reflects the ho expect(score.scroll).toEqual({ left: 12, top: 34 }); }); +test('hover fires only on target change and recomputes on scroll; unsubscribe detaches scroll', () => { + const target = new Measure(new Rect(0, 0, 10, 10), viewport, '1'); + const host = new FakeHost(); + // A mutable hit result lets the test flip what's "under the pointer" to simulate scrolling the + // target out from under a stationary pointer (FakeHost.toScoreSpace is identity). + let hit: PointerTarget | null = target; + const index: HitTester = { hitTest: () => hit }; + const score = new Score(host, index, new Decorations(host)); + + const seen: Array = []; + const listener = (e: { target: PointerTarget | null }) => seen.push(e.target); + score.addEventListener('hover', listener); + + host.events.dispatchEvent(new FakePointerEvent('pointermove', 5, 5)); // enter target + host.events.dispatchEvent(new FakePointerEvent('pointermove', 6, 6)); // same target, quiet + expect(seen).toEqual([target]); + + hit = null; // scroll slid the target away + host.scrollListener?.(); + expect(seen).toEqual([target, null]); + + score.removeEventListener('hover', listener); + expect(host.scrollListener).toBeNull(); // window-scroll subscription released +}); + test('resize is observed from construction and re-fits viewport layers before emitting', () => { const { host, score } = fixture(null); // Observed eagerly (it also drives viewport-layer sizing), not lazily on first subscriber. @@ -194,8 +229,16 @@ test('addLayer delegates to the host; removeLayer disposes the layer', () => { expect(host.created[0]?.disposed).toBe(true); }); +test('addLayer forwards zIndex to the host and rejects non-integers', () => { + const { host, score } = fixture(null); + score.addLayer('content', -2); + expect(host.created[0]?.zIndex).toBe(-2); + expect(() => score.addLayer('content', 1.5)).toThrow(); + expect(() => score.addLayer('content', Number.NaN)).toThrow(); +}); + test('dispose detaches every listener and tears down decorations and host', () => { - const target = new Measure(new Rect(0, 0, 10, 10), viewport); + const target = new Measure(new Rect(0, 0, 10, 10), viewport, '1'); const { host, index, decorations, score } = fixture(target); score.addEventListener('pointermove', () => {}); score.addEventListener('resize', () => {}); diff --git a/src/score.ts b/src/score.ts index 9d953a42a..c938a3f66 100644 --- a/src/score.ts +++ b/src/score.ts @@ -2,6 +2,7 @@ import type { Decorations } from './decorations'; import { EventBus, type EventListenable, type ScoreEventMap } from './events'; import type { HitTester } from './hit'; import type { Host, Layer, LayerKind } from './stage'; +import type { PointerTarget } from './targets'; /* * A rendered score: the handle render() returns. Owns the DOM vexml built (the Stage/Host) and @@ -17,10 +18,19 @@ import type { Host, Layer, LayerKind } from './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, set up once below. - private readonly bound = new Map(); + // The live DOM listeners backing each Score event, so unbind can remove the exact references. + // Most events map to one DOM listener; hover maps to several (move/down/leave). Resize isn't + // here — it's a ResizeObserver, set up once below. + private readonly bound = new Map< + keyof ScoreEventMap, + Array<[string, EventListener]> + >(); private readonly unobserveResize: () => void; + // Hover state: the target last reported and the last pointer position (client coords) to + // re-hit-test on scroll. unobserveScroll is hover's window-scroll subscription. + private hovered: PointerTarget | null = null; + private lastClient: { x: number; y: number } | null = null; + private unobserveScroll: (() => void) | null = null; constructor( private readonly host: Host, @@ -44,11 +54,20 @@ export class Score implements EventListenable { /* Add a caller-owned drawing layer over the score; returns it for drawing (via ctx) and removal * (via dispose, or removeLayer). A content layer spans the engraved score; a viewport layer - * spans the visible box and is re-fit on resize. */ - addLayer(kind: LayerKind): Layer { - return this.host.createLayer(kind); + * spans the visible box and is re-fit on resize. + * + * zIndex (an integer, may be negative) orders the layer relative to the canvas the score is drawn + * on, which sits at zIndex 0: positive draws in front, negative behind (showing through the + * score's transparent pixels). Layers with the same zIndex stack in creation order. Omit it to + * use the kind's default (background behind, everything else in front). */ + addLayer(kind: LayerKind, zIndex?: number): Layer { + if (zIndex !== undefined && !Number.isInteger(zIndex)) { + throw new Error('vexml: layer zIndex must be an integer'); + } + return this.host.createLayer(kind, zIndex); } + /* Remove a layer added with addLayer (a shorthand for layer.dispose()). */ removeLayer(layer: Layer): void { layer.dispose(); } @@ -75,32 +94,57 @@ export class Score implements EventListenable { } dispose(): void { - for (const [type, handler] of this.bound) { - this.host.events.removeEventListener(type, handler); + for (const handlers of this.bound.values()) { + for (const [domType, handler] of handlers) { + this.host.events.removeEventListener(domType, handler); + } } this.bound.clear(); + this.unobserveScroll?.(); + this.unobserveScroll = null; this.unobserveResize(); this.decorations.dispose(); this.host.dispose(); } // Attach the underlying source for a Score event on its first subscriber. Pointer events - // hit-test the point under them; scroll carries the new offset; resize is already observed - // from construction (for the layers), so there's nothing to bind here. + // hit-test the point under them; scroll carries the new offset; hover tracks the target under + // the pointer (recomputed on move/down/leave and scroll); resize is already observed from + // construction (for the layers), so there's nothing to bind here. private bind(type: keyof ScoreEventMap): void { switch (type) { case 'resize': return; case 'scroll': { - const handler: EventListener = (native) => { + this.listen(type, 'scroll', (native) => { this.bus.emit('scroll', { ...this.host.scroll, native }); + }); + return; + } + case 'hover': { + const track: EventListener = (native) => { + const pointer = native as PointerEvent; + this.lastClient = { x: pointer.clientX, y: pointer.clientY }; + this.recomputeHover(); + }; + this.listen(type, 'pointermove', track); + this.listen(type, 'pointerdown', track); + // Clear on leave and on cancel: a touch pointer ceases to exist on lift (pointerleave + // follows pointerup) or when the UA steals the gesture to scroll (pointercancel) — drop + // the stale position so a momentum-scroll recompute doesn't relight a phantom target. + const clear: EventListener = () => { + this.lastClient = null; + this.recomputeHover(); }; - this.bound.set(type, handler); - this.host.events.addEventListener(type, handler); + this.listen(type, 'pointerleave', clear); + this.listen(type, 'pointercancel', clear); + this.unobserveScroll = this.host.observeScroll(() => + this.recomputeHover(), + ); return; } default: { - const handler: EventListener = (native) => { + this.listen(type, type, (native) => { const pointer = native as PointerEvent; const point = this.host.toScoreSpace( pointer.clientX, @@ -111,9 +155,7 @@ export class Score implements EventListenable { point, native: pointer, }); - }; - this.bound.set(type, handler); - this.host.events.addEventListener(type, handler); + }); } } } @@ -124,10 +166,43 @@ export class Score implements EventListenable { if (type === 'resize') { return; } - const handler = this.bound.get(type); - if (handler) { - this.host.events.removeEventListener(type, handler); + const handlers = this.bound.get(type); + if (handlers) { + for (const [domType, handler] of handlers) { + this.host.events.removeEventListener(domType, handler); + } this.bound.delete(type); } + if (type === 'hover') { + this.unobserveScroll?.(); + this.unobserveScroll = null; + this.hovered = null; + this.lastClient = null; + } + } + + // Bind a DOM listener for a Score event and record it for later removal. + private listen( + type: keyof ScoreEventMap, + domType: string, + handler: EventListener, + ): void { + this.host.events.addEventListener(domType, handler); + const handlers = this.bound.get(type) ?? []; + handlers.push([domType, handler]); + this.bound.set(type, handlers); + } + + // Re-hit-test the last pointer position and emit hover only when the target changes — so a + // scroll or a move within the same target stays quiet, but sliding onto/off a target fires. + private recomputeHover(): void { + const point = this.lastClient + ? this.host.toScoreSpace(this.lastClient.x, this.lastClient.y) + : null; + const target = point ? this.index.hitTest(point) : null; + if (target !== this.hovered) { + this.hovered = target; + this.bus.emit('hover', { target, point }); + } } } diff --git a/src/stage.ts b/src/stage.ts index 58a5e0252..fb65b9d33 100644 --- a/src/stage.ts +++ b/src/stage.ts @@ -2,9 +2,11 @@ import type { Rect } from './geometry'; import type { Viewport } from './targets'; /* Where a custom drawing layer sits. A `content` layer covers the whole engraved score (score - * space, scrolls with the content) — what decorations draw on. A `viewport` layer covers only the - * visible box (client space) and is resized as the container resizes. */ -export type LayerKind = 'content' | 'viewport'; + * space, scrolls with the content) — what decorations draw on. A `background` layer is a content + * layer placed *behind* the base canvas (z-index -1), so it shows through the score's transparent + * pixels — e.g. a halo glowing behind the noteheads. A `viewport` layer covers only the visible box + * (client space) and is resized as the container resizes. */ +export type LayerKind = 'content' | 'background' | 'viewport'; /* A caller-owned drawing surface stacked over the score. Only the 2D context is exposed — never * the canvas, its size, or a clear — so the layer's lifecycle stays vexml's. The caller draws via @@ -18,7 +20,7 @@ export interface Layer { * on its own content layer but needs nothing else). Stage satisfies it; a unit test injects a * fake whose layer carries a recording context. */ export interface LayerHost { - createLayer(kind: LayerKind): Layer; + createLayer(kind: LayerKind, zIndex?: number): Layer; } /* @@ -35,6 +37,11 @@ export interface Host extends LayerHost { observeResize( onResize: (size: { width: number; height: number }) => void, ): () => void; + /* Subscribe to any scroll that slides the score within the viewport — the container's own, or + * any ancestor's (scroll doesn't bubble, so the real host listens on window in the capture + * phase). Returns an unsubscribe. Drives hover: content can move under a stationary pointer with + * no pointer event. */ + observeScroll(onScroll: () => void): () => void; /* Re-sync every layer to the container's current geometry (called on resize). Viewport layers * are refit to the visible box (clearing them); content layers keep their score-resolution bitmap * (no clear) but re-track the base canvas's rendered box, so they stay aligned however the @@ -106,6 +113,7 @@ class ManagedLayer implements Layer { export class Stage implements Viewport, Host { readonly base: HTMLCanvasElement; private readonly prevPosition: string; + private readonly prevIsolation: string; private readonly layers = new Set(); constructor(private readonly container: HTMLDivElement) { @@ -115,6 +123,13 @@ export class Stage implements Viewport, Host { if (!container.style.position) { container.style.position = 'relative'; } + // Isolate the container into its own stacking context so the background layer's z-index:-1 + // stays trapped here — above the container's (possibly opaque) background but below the base + // canvas — rather than escaping behind an ancestor's background, where it'd be invisible. + this.prevIsolation = container.style.isolation; + if (!container.style.isolation) { + container.style.isolation = 'isolate'; + } this.base = document.createElement('canvas'); // `vexml-canvas` is the stable hook callers style to size/scale the rendered score. They style // this class (or the container), never the bare element — that keeps the overlay canvases @@ -164,7 +179,19 @@ export class Stage implements Viewport, Host { return () => observer.disconnect(); } - createLayer(kind: LayerKind): Layer { + observeScroll(onScroll: () => void): () => void { + // Capture phase on window catches every scroll container (the score's own or any ancestor), + // since scroll events don't bubble. passive: we only read positions, never preventDefault. + const handler = () => onScroll(); + window.addEventListener('scroll', handler, { + capture: true, + passive: true, + }); + return () => + window.removeEventListener('scroll', handler, { capture: true }); + } + + createLayer(kind: LayerKind, zIndex?: number): Layer { const canvas = document.createElement('canvas'); // Overlay absolutely positioned within the (positioned) container. Purely visual: pointer // events pass through to the container, where the Score hit-tests them — layers never capture @@ -172,6 +199,15 @@ export class Stage implements Viewport, Host { canvas.className = 'vexml-layer'; canvas.style.position = 'absolute'; canvas.style.pointerEvents = 'none'; + // The base canvas is in-flow at z-index 0. An explicit zIndex orders the layer against it + // (negative drops behind, where it shows through the score's transparent pixels); otherwise a + // background layer defaults behind and everything else stacks over it. Equal z-indexes fall + // back to DOM order, which is creation order since layers are appended as created. + if (zIndex !== undefined) { + canvas.style.zIndex = String(zIndex); + } else if (kind === 'background') { + canvas.style.zIndex = '-1'; + } const layer = new ManagedLayer(kind, canvas, this); this.container.appendChild(canvas); this.layers.add(layer); @@ -203,13 +239,14 @@ export class Stage implements Viewport, Host { } this.base.remove(); this.container.style.position = this.prevPosition; + this.container.style.isolation = this.prevIsolation; } // Size a layer's drawing bitmap. A content layer's bitmap is fixed to the engraved score (the // base canvas's intrinsic CSS box), so the caller always draws in score px — its element is then // stretched over the base's rendered box by placeLayer. A viewport bitmap matches the visible box. private sizeBitmap(layer: ManagedLayer): void { - if (layer.kind === 'content') { + if (layer.kind !== 'viewport') { layer.resize( parseFloat(this.base.style.width) || 0, parseFloat(this.base.style.height) || 0, @@ -226,7 +263,7 @@ export class Stage implements Viewport, Host { private placeLayer(layer: ManagedLayer): void { const left = this.base.offsetLeft; const top = this.base.offsetTop; - if (layer.kind === 'content') { + if (layer.kind !== 'viewport') { layer.place(left, top, this.base.offsetWidth, this.base.offsetHeight); } else { layer.place( diff --git a/src/targets.test.ts b/src/targets.test.ts index b3438ce0e..4e6f4bf02 100644 --- a/src/targets.test.ts +++ b/src/targets.test.ts @@ -34,7 +34,7 @@ class FakeViewport implements Viewport { class FakeDecorator implements Decorator { readonly colors = new Map(); - readonly halos = new Set(); + readonly halos = new Map(); setColor(target: Decoratable, color: string | null): void { if (color === null) { this.colors.delete(target); @@ -42,11 +42,11 @@ class FakeDecorator implements Decorator { this.colors.set(target, color); } } - setHalo(target: Decoratable, on: boolean): void { - if (on) { - this.halos.add(target); - } else { + setHalo(target: Decoratable, color: string | null): void { + if (color === null) { this.halos.delete(target); + } else { + this.halos.set(target, color); } } isColored(target: Decoratable): boolean { @@ -89,7 +89,7 @@ function fixture() { const viewport = new FakeViewport(); const decorator = new FakeDecorator(); - const measure = new Measure(new Rect(0, 0, 100, 50), viewport); + const measure = new Measure(new Rect(0, 0, 100, 50), viewport, '1'); // The shared registries the wrappers resolve their cross-links through (a Map fulfills the // NoteLookup / TabLookup interfaces). Populated as each note is built. @@ -159,12 +159,13 @@ test('color toggle delegates to the decorator and reflects active state', () => expect(noteC.color.active).toBe(false); }); -test('halo toggle delegates to the decorator', () => { +test('halo toggle delegates to the decorator and carries its color', () => { const { noteC, decorator } = fixture(); - noteC.halo.on(); - expect(decorator.halos.has(noteC)).toBe(true); + noteC.halo.on('#2962ff'); + expect(decorator.halos.get(noteC)).toBe('#2962ff'); expect(noteC.halo.active).toBe(true); noteC.halo.off(); + expect(decorator.halos.has(noteC)).toBe(false); expect(noteC.halo.active).toBe(false); }); @@ -175,11 +176,13 @@ test('getBoundingClientRect maps the score-space rect through the viewport', () }); test('TabPosition exposes string/fret and links back to its note', () => { - const { noteC, viewport } = fixture(); + const { noteC, viewport, decorator } = fixture(); const tab = new TabPosition(new Rect(0, 0, 6, 6), viewport, { string: 3, fret: 5, note: noteC, + decorator, + glyph: null, }); expect(tab.getString()).toBe(3); expect(tab.getFret()).toBe(5); diff --git a/src/targets.ts b/src/targets.ts index 0cbbf710c..6da1419ef 100644 --- a/src/targets.ts +++ b/src/targets.ts @@ -25,11 +25,29 @@ export interface NoteGlyph { readonly y: number; } -/* What a decoration paints: a target's box (for the halo) and its glyph (to recolor the notehead), - * the glyph being null when there is none (a measure, a rest). The decoration seam operates on - * this rather than bare Bounded so it can stamp the actual notehead glyph. */ +/* Replay a captured glyph (a notehead or a tab fret) recolored on the overlay: vexflow's own + * text, font, and left/alphabetic baseline, exactly as it engraved it, so the color stamp + * overlays the original precisely instead of being centered by a different rule. */ +function stampGlyph( + ctx: CanvasRenderingContext2D, + glyph: NoteGlyph, + color: string, +): void { + ctx.save(); + ctx.fillStyle = color; + ctx.font = glyph.font; + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + ctx.fillText(glyph.text, glyph.x, glyph.y); + ctx.restore(); +} + +/* What a decoration paints. The Decorator draws the halo from the target's box, but the color is + * the target's own job: only it knows what it is — a notehead glyph (Note), a fret number + * (TabPosition), or a plain box (the filled-ellipse fallback). So the Decorator hands over the + * overlay ctx and the chosen color and the target stamps itself recolored. */ export interface Decoratable extends Bounded { - readonly glyph: NoteGlyph | null; + drawColor(ctx: CanvasRenderingContext2D, color: string): void; } /* A reversible on/off effect carrying an optional value (color string, etc.). `off()` is the @@ -59,7 +77,7 @@ export type DecorationKind = 'color' | 'halo'; */ export interface Decorator { setColor(target: Decoratable, color: string | null): void; - setHalo(target: Decoratable, on: boolean): void; + setHalo(target: Decoratable, color: string | null): void; isColored(target: Decoratable): boolean; isHaloed(target: Decoratable): boolean; } @@ -94,17 +112,17 @@ class ColorToggle implements Toggle { } } -/* The halo decoration as an on/off toggle, delegating to the Decorator. */ -class HaloToggle implements Toggle { +/* The halo decoration as an on/off toggle carrying its color, delegating to the Decorator. */ +class HaloToggle implements Toggle { constructor( private readonly target: Decoratable, private readonly decorator: Decorator, ) {} - on(): void { - this.decorator.setHalo(this.target, true); + on(color: string): void { + this.decorator.setHalo(this.target, color); } off(): void { - this.decorator.setHalo(this.target, false); + this.decorator.setHalo(this.target, null); } get active(): boolean { return this.decorator.isHaloed(this.target); @@ -112,14 +130,29 @@ class HaloToggle implements Toggle { } /* Shared base for every target: holds the score-space rect and maps it to the page on demand. - * Decoratable with no glyph by default; Note overrides glyph with its notehead stamp. */ + * The default color is a filled ellipse over the box — the fallback for a target with no glyph or + * text of its own (a rest, a measure). Note and TabPosition override it with their own stamp. */ abstract class BoundedTarget implements Decoratable { constructor( readonly rect: Rect, protected readonly viewport: Viewport, ) {} - get glyph(): NoteGlyph | null { - return null; + drawColor(ctx: CanvasRenderingContext2D, color: string): void { + const r = this.rect; + ctx.save(); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.ellipse( + r.x + r.w / 2, + r.y + r.h / 2, + r.w / 2, + r.h / 2, + 0, + 0, + 2 * Math.PI, + ); + ctx.fill(); + ctx.restore(); } getBoundingClientRect(): DOMRect { return this.viewport.clientRectOf(this.rect); @@ -158,7 +191,7 @@ export interface NoteDeps { export class Note extends BoundedTarget { readonly type = 'note'; readonly color: Toggle; - readonly halo: Toggle; + readonly halo: Toggle; constructor(private readonly deps: NoteDeps) { super(deps.rect, deps.viewport); @@ -166,9 +199,15 @@ export class Note extends BoundedTarget { this.halo = new HaloToggle(this, deps.decorator); } - /* The engraved notehead glyph (for recoloring), or null for a rest. */ - override get glyph(): NoteGlyph | null { - return this.deps.glyph; + /* The glyph case: replay vexflow's own notehead (same glyph text, font, baseline) in the + * chosen color, so the actual head recolors and a hollow head stays hollow. A rest has no + * glyph, so it falls back to the base ellipse. */ + override drawColor(ctx: CanvasRenderingContext2D, color: string): void { + if (this.deps.glyph) { + stampGlyph(ctx, this.deps.glyph, color); + } else { + super.drawColor(ctx, color); + } } /* The sounding pitch as a vexflow key ("E/4"), or null for a rest. */ @@ -214,19 +253,54 @@ export class Note extends BoundedTarget { /* 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'; + + constructor( + rect: Rect, + viewport: Viewport, + private readonly number: string, + ) { + super(rect, viewport); + } + + /* The MusicXML measure number, e.g. "1" (or "0" for a pickup). */ + getNumber(): string { + return this.number; + } } /* 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'; + readonly color: Toggle; + readonly halo: Toggle; constructor( rect: Rect, viewport: Viewport, - private readonly opts: { string: number; fret: number; note: Note }, + private readonly opts: { + string: number; + fret: number; + note: Note; + decorator: Decorator; + /* The engraved fret glyph ("5", "<7>", "(2)", "✕") captured with vexflow's exact + * baseline, so a decoration replays the digit recolored; null falls back to an ellipse. */ + glyph: NoteGlyph | null; + }, ) { super(rect, viewport); + this.color = new ColorToggle(this, opts.decorator); + this.halo = new HaloToggle(this, opts.decorator); + } + + /* Replay vexflow's own fret glyph recolored so the digit lights up exactly where it was + * engraved, rather than vanishing under a filled ellipse. Same approach as a notehead. */ + override drawColor(ctx: CanvasRenderingContext2D, color: string): void { + if (this.opts.glyph) { + stampGlyph(ctx, this.opts.glyph, color); + } else { + super.drawColor(ctx, color); + } } getString(): number { diff --git a/tests/integration/__data__/chord_diagram.musicxml b/tests/integration/__data__/chord_diagram.musicxml index 2ad68c8a1..f0104436f 100644 --- a/tests/integration/__data__/chord_diagram.musicxml +++ b/tests/integration/__data__/chord_diagram.musicxml @@ -635,5 +635,126 @@ quarter + + + + B + + minor-seventh + + 6 + 1 + + 4 + 7 + + + 3 + 7 + + + 2 + 7 + + + + + + D + 5 + + 1 + quarter + + + + + F + 1 + 5 + + 1 + quarter + + + + 1 + quarter + + + + 1 + quarter + + + + (as taught) + + + + + B + 4 + + 1 + quarter + + + + + + B + + minor-seventh + + 6 + 1 + + 4 + 7 + + + 3 + 7 + + + 2 + 7 + + + + + + C + 6 + + 1 + quarter + + + + C + 6 + + 1 + quarter + + + + C + 6 + + 1 + quarter + + + + C + 6 + + 1 + quarter + + diff --git a/tests/integration/__data__/chord_diagram_edge.musicxml b/tests/integration/__data__/chord_diagram_edge.musicxml new file mode 100644 index 000000000..87bb721f7 --- /dev/null +++ b/tests/integration/__data__/chord_diagram_edge.musicxml @@ -0,0 +1,78 @@ + + + + + Music + + + + + + 1 + + 1 + + G + 2 + + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + + minor-seventh + + 6 + 1 + + 4 + 7 + + + 3 + 7 + + + 2 + 7 + + + + + + B + 4 + + 1 + quarter + + + + diff --git a/tests/integration/__data__/harmony.musicxml b/tests/integration/__data__/harmony.musicxml index 6e6cbddc7..ca74e7c07 100644 --- a/tests/integration/__data__/harmony.musicxml +++ b/tests/integration/__data__/harmony.musicxml @@ -512,5 +512,45 @@ half + + + + E + + minor-seventh + + + + E + 5 + + 1 + quarter + + + + E + 5 + + 1 + quarter + + + + E + 5 + + 1 + quarter + + + + E + 5 + + 1 + quarter + + diff --git a/tests/integration/__data__/print_new_system.musicxml b/tests/integration/__data__/print_new_system.musicxml new file mode 100644 index 000000000..b5dab5e08 --- /dev/null +++ b/tests/integration/__data__/print_new_system.musicxml @@ -0,0 +1,51 @@ + + + + + Music + + + + + + 1 + + 1 + + G + 2 + + + + C5 + 4 + whole + + + + + C5 + 4 + whole + + + + + + C5 + 4 + whole + + + + + C5 + 4 + whole + + + + diff --git a/tests/integration/__screenshots__/chord_diagram.png b/tests/integration/__screenshots__/chord_diagram.png index 722e42efe..84750bf5d 100644 Binary files a/tests/integration/__screenshots__/chord_diagram.png and b/tests/integration/__screenshots__/chord_diagram.png differ diff --git a/tests/integration/__screenshots__/chord_diagram_edge.png b/tests/integration/__screenshots__/chord_diagram_edge.png new file mode 100644 index 000000000..cf134eed7 Binary files /dev/null and b/tests/integration/__screenshots__/chord_diagram_edge.png differ diff --git a/tests/integration/__screenshots__/decoration_halo.png b/tests/integration/__screenshots__/decoration_halo.png index 248ebc0a4..53a728ef5 100644 Binary files a/tests/integration/__screenshots__/decoration_halo.png and b/tests/integration/__screenshots__/decoration_halo.png differ diff --git a/tests/integration/__screenshots__/decoration_tab_color.png b/tests/integration/__screenshots__/decoration_tab_color.png new file mode 100644 index 000000000..459eafcd0 Binary files /dev/null and b/tests/integration/__screenshots__/decoration_tab_color.png differ diff --git a/tests/integration/__screenshots__/decoration_tab_halo.png b/tests/integration/__screenshots__/decoration_tab_halo.png new file mode 100644 index 000000000..50837c1f5 Binary files /dev/null and b/tests/integration/__screenshots__/decoration_tab_halo.png differ diff --git a/tests/integration/__screenshots__/grace_spacing.png b/tests/integration/__screenshots__/grace_spacing.png index 8cd8975f8..873276fce 100644 Binary files a/tests/integration/__screenshots__/grace_spacing.png and b/tests/integration/__screenshots__/grace_spacing.png differ diff --git a/tests/integration/__screenshots__/harmony.png b/tests/integration/__screenshots__/harmony.png index 750b07d9b..02f7178da 100644 Binary files a/tests/integration/__screenshots__/harmony.png and b/tests/integration/__screenshots__/harmony.png differ diff --git a/tests/integration/__screenshots__/harmony_grace.png b/tests/integration/__screenshots__/harmony_grace.png index 03d85a95b..1bfbae329 100644 Binary files a/tests/integration/__screenshots__/harmony_grace.png and b/tests/integration/__screenshots__/harmony_grace.png differ diff --git a/tests/integration/__screenshots__/print_new_system.png b/tests/integration/__screenshots__/print_new_system.png new file mode 100644 index 000000000..f3cdb80f5 Binary files /dev/null and b/tests/integration/__screenshots__/print_new_system.png differ diff --git a/tests/integration/decorations.test.ts b/tests/integration/decorations.test.ts index 77b47a6c9..a0f7948ab 100644 --- a/tests/integration/decorations.test.ts +++ b/tests/integration/decorations.test.ts @@ -1,61 +1,69 @@ import { expect, test } from 'bun:test'; -import type { Note } from '../../src'; +import type { Note, TabPosition } from '../../src'; import { TEST_URL, testBrowser } from '../testing/setup'; // Decorations end to end, the way a caller actually reaches them: render, hover to hit-test the -// notes, toggle a decoration, and screenshot the composite (base engraving + the decoration +// targets, toggle a decoration, and screenshot the composite (base engraving + the decoration // overlay). The drawing logic itself is unit-tested in src/decorations.test.ts; this proves it // lands on the score, aligned. Uses the run's shared browser/server (see setup.ts). +// +// Both noteheads (Note) and tab fret numbers (TabPosition) are decoratable, with their own +// drawColor stamps, so we collect both: a notation-only document yields only notes, a tab +// document lights up both the heads and the frets. -async function decorate(mode: 'color' | 'halo'): Promise { +async function decorate(mode: 'color' | 'halo', file: string): Promise { const browser = await testBrowser(); const page = await browser.newPage({ viewport: { width: 900, height: 400 } }); try { await page.goto(TEST_URL); - const count = await page.evaluate(async (mode) => { - const container = document.getElementById('screenshot'); - if (!(container instanceof HTMLDivElement)) { - throw new Error('container not found'); - } - const xml = await (await fetch('/data/note.musicxml')).text(); - const score = await window.render(xml, container, {}); - const canvas = container.querySelector('canvas'); - if (!canvas) { - throw new Error('canvas not found'); - } - - // Hover the whole canvas to collect every note under the pointer (deduped by identity). - const notes = new Set(); - score.addEventListener('pointermove', (e) => { - if (e.target?.type === 'note') { - notes.add(e.target); + const count = await page.evaluate( + async ({ mode, file }) => { + const container = document.getElementById('screenshot'); + if (!(container instanceof HTMLDivElement)) { + throw new Error('container not found'); + } + const xml = await (await fetch(`/data/${file}`)).text(); + const score = await window.render(xml, container, {}); + const canvas = container.querySelector('canvas'); + if (!canvas) { + throw new Error('canvas not found'); } - }); - const rect = canvas.getBoundingClientRect(); - for (let dy = 2; dy < rect.height; dy += 4) { - for (let dx = 2; dx < rect.width; dx += 4) { - canvas.dispatchEvent( - new PointerEvent('pointermove', { - clientX: rect.left + dx, - clientY: rect.top + dy, - bubbles: true, - }), - ); + + // Hover the whole canvas to collect every decoratable target under the pointer + // (noteheads and tab frets), deduped by identity. + const targets = new Set(); + score.addEventListener('pointermove', (e) => { + if (e.target?.type === 'note' || e.target?.type === 'tab-position') { + targets.add(e.target); + } + }); + const rect = canvas.getBoundingClientRect(); + for (let dy = 2; dy < rect.height; dy += 4) { + for (let dx = 2; dx < rect.width; dx += 4) { + canvas.dispatchEvent( + new PointerEvent('pointermove', { + clientX: rect.left + dx, + clientY: rect.top + dy, + bubbles: true, + }), + ); + } } - } - for (const note of notes) { - if (mode === 'color') { - note.color.on('#2962ff'); - } else { - note.halo.on(); + for (const target of targets) { + if (mode === 'color') { + target.color.on('#2962ff'); + } else { + target.halo.on('rgba(41, 98, 255, 0.35)'); + } } - } - return notes.size; - }, mode); + return targets.size; + }, + { mode, file }, + ); if (count === 0) { - throw new Error('no notes found to decorate'); + throw new Error('no targets found to decorate'); } return await page.locator('#screenshot').screenshot(); } finally { @@ -64,9 +72,28 @@ async function decorate(mode: 'color' | 'halo'): Promise { } test('a colored note', async () => { - expect(await decorate('color')).toMatchScreenshot('decoration_color.png'); + expect(await decorate('color', 'note.musicxml')).toMatchScreenshot( + 'decoration_color.png', + ); }, 30_000); test('a haloed note', async () => { - expect(await decorate('halo')).toMatchScreenshot('decoration_halo.png'); + expect(await decorate('halo', 'note.musicxml')).toMatchScreenshot( + 'decoration_halo.png', + ); +}, 30_000); + +// A notation+tab document: the notation staff's noteheads and the tab staff's fret numbers both +// light up. Color restamps each notehead glyph and each fret digit in blue; halo draws a soft +// blue circle behind every notehead and every fret. +test('colored notes and frets', async () => { + expect( + await decorate('color', 'structure_notation_and_tab_parts.musicxml'), + ).toMatchScreenshot('decoration_tab_color.png'); +}, 30_000); + +test('haloed notes and frets', async () => { + expect( + await decorate('halo', 'structure_notation_and_tab_parts.musicxml'), + ).toMatchScreenshot('decoration_tab_halo.png'); }, 30_000); diff --git a/tests/integration/render.test.ts b/tests/integration/render.test.ts index 3832eebe0..a8e60b8b1 100644 --- a/tests/integration/render.test.ts +++ b/tests/integration/render.test.ts @@ -567,6 +567,11 @@ const TEST_CASES = [ // quarters) + half rest, under a "B13" symbol. The chord's upper tie bows up over // the high top note (G♯5), so the symbol lifts to clear that arc, not just the // noteheads — like M9 but the tie sits on a chord member, not a lone note. + // - M12: a top-stave-space note under its symbol — four E5 quarters under an "Em7" + // symbol. E5 sits in the top space, just under the symbol's baseline (not above the + // staff like M4), so it falls in the symbol's padding band: the padded collision box + // reaches down to the notehead and nudges the symbol up off it, instead of the + // baseline sitting tight against the note. testCase('harmony.musicxml', 'harmony.png'), // Treble stave, 4/4: a chord symbol over a note that carries a grace note. The grace @@ -601,8 +606,14 @@ const TEST_CASES = [ // string-5's dot one row down. // - M9: G♯m7♭5, string 6 at fret 4, string 5 muted, strings 4/3/2 across frets 3-4 → box // from fret 3, "4" beside string-6's dot one row down. - // - M10: Bm7 fret box plus an italic "(as taught)" words direction in the same measure — - // the diagram draws on top, staying fully legible where the text overlaps it. + // - M10: Bm7 fret box plus an italic "(as taught)" words direction — the word draws at its + // normal above-stave spot and the box lifts to sit clear above it (boxes yield to text by + // rising, not by overlapping). + // - M11: same Bm7 box but with a high D5/F♯5 chord on beat 1 that pushes the "(as taught)" + // text up — the box lifts further so it still clears the raised word. + // - M12: same Bm7 box over four C6 quarters (two ledger lines above the staff) — the box + // lifts until it clears the high noteheads/ledger lines, using the same padded + // lift-clear treatment a chord symbol uses, instead of overlapping them. testCase('chord_diagram.musicxml', 'chord_diagram.png'), // Treble stave, 4/4, two measures at a narrow 500px width: a chord diagram bound to a @@ -618,6 +629,15 @@ const TEST_CASES = [ layout: { type: 'standard', width: 500 }, }), + // Treble stave, 4/4, one measure at a narrow 500px width: a Bm7 fret box bound to the + // LAST quarter, whose note sits right against the system's right edge. Anchored at that + // note's x, the box's natural right edge overruns the canvas; it must nudge left so the + // whole board — including the far-right muted "X" — stays inside the drawable region + // instead of being clipped. Four B4 quarters so only the diagram's clamp is exercised. + testCase('chord_diagram_edge.musicxml', 'chord_diagram_edge.png', { + layout: { type: 'standard', width: 500 }, + }), + // Treble stave, 4/4: natural harmonics drawn as diamond noteheads (from // ). The tab counterpart (angle-bracketed frets) is tab_harmonic. // - M1: single notes on E5 — an open diamond for the half note, then filled diamonds for @@ -703,6 +723,13 @@ const TEST_CASES = [ // and a "10" above the bottom system's first measure. testCase('system_break.musicxml', 'system_break.png'), + // Four C5 whole-note measures, treble 4/4, that would all fit on one system — but + // M3 carries a , forcing a system break before it. So the + // score wraps to two systems: M1-2 on top, M3-4 below (each re-stating the treble + // clef; the time signature prints only on M1). Proves an explicit break overrides + // width-based wrapping. + testCase('print_new_system.musicxml', 'print_new_system.png'), + // The same sixteen C5 whole-note measures, but with panoramic layout: all sixteen sit // on a single uninterrupted system (no system break). testCase('system_break.musicxml', 'layout_panoramic.png', {