Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` validate a MusicXML file
- `vex render -i <path>` render a MusicXML file to a PNG

Please delete screenshots when you are done, unless you're showing the user something.
- `vex validate -i <path>` validate a MusicXML file.
- `vex render -i <path>` render a MusicXML file to a PNG. Delete screenshots when you are done, unless you're showing the user something.
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
Expand All @@ -63,15 +63,23 @@ await render(musicXML, element, {

## Adding a canvas layer

A layer is a `<canvas>` 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.
Expand Down
88 changes: 60 additions & 28 deletions site/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import type {
Config,
HoverEvent,
Note,
PointerTarget,
PointerTargetEvent,
Expand All @@ -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'}`;
Expand Down Expand Up @@ -102,8 +103,13 @@ export default function App() {
const [fixture, setFixture] = useState('');
const [error, setError] = useState<string | null>(null);
const [renderMs, setRenderMs] = useState<number | null>(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<number | null>(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(
Expand Down Expand Up @@ -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<Partial<Config>>({});
// 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<Partial<Config>>(config);
const skipConfigDebounce = useRef(true);
useEffect(() => {
if (skipConfigDebounce.current) {
Expand All @@ -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;
Expand All @@ -175,7 +183,7 @@ export default function App() {
setDebouncing(false);
}, 500);
return () => clearTimeout(t);
}, [config, renderMs]);
}, [config]);

useEffect(() => {
const container = containerRef.current;
Expand All @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -819,10 +849,12 @@ export default function App() {
// re-engraving in a light color.
<div
ref={containerRef}
className={`relative mx-auto w-full max-w-237.5 py-8 px-4 shadow-md ring-1 sm:py-16 [&_.vexml-canvas]:block [&_.vexml-canvas]:h-auto! [&_.vexml-canvas]:w-full! ${dark ? 'bg-zinc-900 ring-zinc-700 [&_.vexml-canvas]:invert' : 'bg-white ring-zinc-200'}`}
// invisible (not hidden) until initialized so the container keeps its
// width — the canvas is CSS-scaled to w-full and would scale against 0.
className={`relative mx-auto w-full max-w-237.5 py-8 px-4 shadow-md ring-1 sm:py-16 [&_.vexml-canvas]:block [&_.vexml-canvas]:h-auto! [&_.vexml-canvas]:w-full! ${initialized ? '' : 'invisible'} ${dark ? 'bg-zinc-900 ring-zinc-700 [&_.vexml-canvas]:invert' : 'bg-white ring-zinc-200'}`}
/>
)}
{debouncing && (
{(!initialized || debouncing) && (
<div className="pointer-events-none absolute inset-0 bg-black/40">
{/* sticky so the badge stays centered in the viewport even when the backdrop is taller than the screen */}
<div className="sticky top-0 flex h-screen items-center justify-center">
Expand All @@ -841,8 +873,8 @@ export default function App() {

{tooltip && (
<div
className="pointer-events-none fixed z-30 -translate-x-1/2 -translate-y-full rounded bg-zinc-900/90 px-2 py-1 font-mono text-xs text-white shadow-lg"
style={{ left: tooltip.x, top: tooltip.y - 8 }}
className="pointer-events-none fixed z-30 -translate-x-1/2 -translate-y-full whitespace-pre-line rounded text-center bg-zinc-900/90 px-2 py-1 font-mono text-xs text-white shadow-lg"
style={{ left: tooltip.x, top: tooltip.y - 16 }}
>
{tooltip.text}
</div>
Expand Down
11 changes: 11 additions & 0 deletions src/collision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/collision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) —
Expand All @@ -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;
Expand Down
Loading
Loading