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
71 changes: 14 additions & 57 deletions site/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { Config } from '../../src';
import { render } from '../../src';

Expand Down Expand Up @@ -76,23 +76,15 @@ export default function App() {
const [renderMs, setRenderMs] = useState<number | null>(null);
const [dragging, setDragging] = useState(false);
const [debouncing, setDebouncing] = useState(false);
const [resizing, setResizing] = useState(false);
const [width, setWidth] = useState<number | null>(null);
const [height, setHeight] = useState<number | null>(null);
const [mobileOpen, setMobileOpen] = useState(false);
const [stored, setStored] = useState(
() => localStorage.getItem(STORAGE_KEY) !== null,
);
const [cleared, setCleared] = useState(false);
const [restored, setRestored] = useState(false);
const lastWidthRef = useRef<number | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const resizeRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const roRef = useRef<ResizeObserver | null>(null);
// The effect below re-renders the last input whenever config changes.
const [config, setConfig] = useState<Partial<Config>>({});
const noteSpacing = config.noteSpacing ?? 36;
Expand Down Expand Up @@ -122,53 +114,25 @@ export default function App() {

useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || input == null || width == null) {
if (!canvas || input == null) {
return;
}
setError(null);
const start = performance.now();
render(input, canvas, {
...renderConfig,
layout: { type: 'standard', width },
})
// Engrave once at the default (8.5in) width; CSS then scales the canvas to fit its
// container — down when narrow, never past 100% when wide — so resizing the window
// re-scales instantly without re-rendering.
render(input, canvas, { ...renderConfig, layout: { type: 'standard' } })
.then(() => {
canvas.style.width = '100%';
canvas.style.height = 'auto';
setRenderMs(performance.now() - start);
setHeight(canvas.clientHeight);
})
.catch((e: unknown) => {
setRenderMs(null);
setError(e instanceof Error ? e.message : String(e));
});
}, [input, renderConfig, width]);

// Reflow the score to the container's width. Callback ref so the observer attaches
// exactly when the page div mounts (it only exists once there's input). The observer
// fires once on observe for the initial width, then on viewport changes; debounce so
// dragging the window doesn't re-render every frame, showing a spinner meanwhile.
const pageRef = useCallback((el: HTMLDivElement | null) => {
roRef.current?.disconnect();
if (!el) {
return;
}
lastWidthRef.current = el.clientWidth;
setWidth(el.clientWidth);
const ro = new ResizeObserver(() => {
// Ignore height-only changes (the canvas grows after each render); only a
// width change means the score must re-flow.
if (el.clientWidth === lastWidthRef.current) {
return;
}
clearTimeout(resizeRef.current);
setResizing(true);
resizeRef.current = setTimeout(() => {
setResizing(false);
lastWidthRef.current = el.clientWidth;
setWidth(el.clientWidth);
}, 300);
});
ro.observe(el);
roRef.current = ro;
}, []);
}, [input, renderConfig]);

// Restore the last-edited MusicXML, or open with a random example.
useEffect(() => {
Expand Down Expand Up @@ -578,20 +542,13 @@ export default function App() {
)
)}
{input != null && (
// White page sized like a real sheet; music reflows to its width. Horizontal
// padding (and the paper inset) collapse on small viewports for max room.
<div className="relative mx-auto w-full max-w-257 bg-white px-2 py-8 shadow-md ring-1 ring-zinc-200 sm:p-16">
{width != null && height != null && (
<span className="absolute top-1 left-1 font-mono text-[10px] text-zinc-400">
{Math.round(width)}×{Math.round(height)}
</span>
)}
<div ref={pageRef}>
<canvas ref={canvasRef} className="block" />
</div>
// White page capped at 8.5in (US Letter). The canvas is engraved at that width
// and CSS-scaled to fit, shrinking on narrow viewports, never past 100%.
<div className="relative mx-auto w-full max-w-204 bg-white py-8 shadow-md ring-1 ring-zinc-200 sm:py-16">
<canvas ref={canvasRef} className="block" />
</div>
)}
{(resizing || debouncing) && (
{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 Down
4 changes: 2 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type Config = {
/** Font overrides. CSS custom properties on the container are the primary override API;
* use this for self-hosted or offline fonts. */
fonts: FontConfig;
/** How measures are placed across systems (default: standard at 1000px). */
/** How measures are placed across systems (default: standard at 8.5in / 816px). */
layout: Layout;
/** *How much space the notes get* (not how it's divided): the px a quarter note gets,
* the base of a logarithmic spacing curve. A note gets a little more space per doubling
Expand Down Expand Up @@ -53,7 +53,7 @@ export const DEFAULT_FONT_CONFIG = {
/** The defaults `render` merges a caller's `Partial<Config>` onto. */
export const DEFAULT_CONFIG: Config = {
fonts: DEFAULT_FONT_CONFIG,
layout: { type: 'standard', width: 1000 },
layout: { type: 'standard' },
noteSpacing: 36,
softmaxFactor: 10,
systemSpacing: SYSTEM_GAP,
Expand Down
7 changes: 4 additions & 3 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Tunable magic numbers, centralized. Spacing/margins are px at the reference layout
// width; the SVG viewBox scales the finished result to its container.
// width; the finished result is then scaled to its container.

/** Page width floor, and the panoramic mode's starting width. */
export const REFERENCE_WIDTH = 1000;
/** Default standard-layout width: US Letter portrait (8.5in) at 96dpi = 816px. Also
* the panoramic mode's starting width / page-width floor. */
export const LETTER_WIDTH = 8.5 * 96;

/** Left/right page margin. Leaves room for the brace/bracket drawn left of the stave. */
export const PAGE_MARGIN_X = 30;
Expand Down
20 changes: 11 additions & 9 deletions src/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import {
LEAD_CLEF,
LEAD_KEY,
LEAD_TIME,
LETTER_WIDTH,
LOG_SPACING_RATIO,
MIN_LOG_FACTOR,
PAGE_MARGIN_BOTTOM,
PAGE_MARGIN_TOP,
PAGE_MARGIN_TOP_WITH_TEMPO,
PAGE_MARGIN_X,
QUARTER_NOTE_TICKS,
REFERENCE_WIDTH,
TAB_MIN_NOTE_SPACING,
} from './constants';
import {
Expand All @@ -36,10 +36,10 @@ export type Layout =
| {
/** Wrap measures onto stacked systems (print-like). */
type: 'standard';
/** Reference layout width in px. The score is laid out to this width once;
* the SVG viewBox then scales the result to whatever container it's placed
* in, so resizing the container never re-flows or re-spaces it. */
width: number;
/** Reference layout width in px (default: US Letter, 8.5in → 816px). The score
* is laid out to this width once; the result is then scaled to whatever container
* it's placed in, so resizing the container never re-flows or re-spaces it. */
width?: number;
}
| {
/** Lay every measure on one system (horizontal scroll); width is computed
Expand Down Expand Up @@ -165,13 +165,15 @@ function measureNoteArea(

/** Lay the parts out at the reference width: where every measure box sits, how
* staves stack within a system, and how tall/wide the page starts. Depends only on
* the music and the options, never on the live container — the SVG viewBox scales
* the finished result to fit. */
* the music and the options, never on the live container — the finished result is
* scaled to fit its container. */
export function computeLayout(parts: Part[], config: Config): ScoreLayout {
const layout = config.layout;
const layoutMode = layout.type;
// Panoramic computes its own width; REFERENCE_WIDTH is the page's starting floor.
const width = layout.type === 'standard' ? layout.width : REFERENCE_WIDTH;
// Standard without an explicit width, and panoramic's starting floor, both default
// to LETTER_WIDTH (panoramic then grows the page to fit its single system).
const width =
(layout.type === 'standard' ? layout.width : undefined) ?? LETTER_WIDTH;
const noteSpacing = config.noteSpacing;
const softmaxFactor = config.softmaxFactor;

Expand Down
Binary file modified tests/integration/__screenshots__/accidentals.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/aloof_measure_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/aloof_measure_14.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/aloof_measure_15.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/aloof_measure_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/aloof_measure_7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/articulations.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/beam_variations.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/chord.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/clef_notation_and_tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/clef_tab_4_string.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/clef_tab_6_string.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/clef_treble.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/clef_treble_bass.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/dotted_notes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/fermata.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/font_notation_petaluma.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/font_text.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/grace_notes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/harmonic.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/harmony.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/harmony_grace.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/key.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/ledger_lines.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/integration/__screenshots__/measure_numbering_every.png
Binary file modified tests/integration/__screenshots__/measure_numbering_every_2.png
Binary file modified tests/integration/__screenshots__/measure_numbering_every_3.png
Binary file modified tests/integration/__screenshots__/measure_numbering_none.png
Binary file modified tests/integration/__screenshots__/measures_end_barline.png
Binary file modified tests/integration/__screenshots__/measures_light_light.png
Binary file modified tests/integration/__screenshots__/measures_two.png
Binary file modified tests/integration/__screenshots__/note.png
Binary file modified tests/integration/__screenshots__/note_density.png
Binary file modified tests/integration/__screenshots__/notehead_parentheses.png
Binary file modified tests/integration/__screenshots__/notehead_x.png
Binary file modified tests/integration/__screenshots__/rest.png
Binary file modified tests/integration/__screenshots__/slur_above.png
Binary file modified tests/integration/__screenshots__/slur_beamed.png
Binary file modified tests/integration/__screenshots__/slur_chained.png
Binary file modified tests/integration/__screenshots__/slur_default.png
Binary file modified tests/integration/__screenshots__/slur_leap.png
Binary file modified tests/integration/__screenshots__/slur_mixed_stems.png
Binary file modified tests/integration/__screenshots__/slur_multiple.png
Binary file modified tests/integration/__screenshots__/slur_stem_up.png
Binary file modified tests/integration/__screenshots__/structure_grand_staff.png
Binary file modified tests/integration/__screenshots__/structure_mixed_staves.png
Binary file modified tests/integration/__screenshots__/structure_part_labels.png
Binary file modified tests/integration/__screenshots__/structure_single_stave.png
Binary file modified tests/integration/__screenshots__/structure_two_parts.png
Binary file modified tests/integration/__screenshots__/system_break.png
Binary file modified tests/integration/__screenshots__/tab_annotation.png
Binary file modified tests/integration/__screenshots__/tab_bend.png
Binary file modified tests/integration/__screenshots__/tab_chord.png
Binary file modified tests/integration/__screenshots__/tab_grace.png
Binary file modified tests/integration/__screenshots__/tab_hammer_pull.png
Binary file modified tests/integration/__screenshots__/tab_hammer_pull_text.png
Binary file modified tests/integration/__screenshots__/tab_harmonic.png
Binary file modified tests/integration/__screenshots__/tab_notation_durations.png
Binary file modified tests/integration/__screenshots__/tab_notation_rest.png
Binary file modified tests/integration/__screenshots__/tab_slide.png
Binary file modified tests/integration/__screenshots__/tab_slide_text.png
Binary file modified tests/integration/__screenshots__/tab_tie.png
Binary file modified tests/integration/__screenshots__/tab_vibrato.png
Binary file modified tests/integration/__screenshots__/tempo.png
Binary file modified tests/integration/__screenshots__/tie.png
Binary file modified tests/integration/__screenshots__/tie_chain.png
Binary file modified tests/integration/__screenshots__/tie_chord_cluster.png
Binary file modified tests/integration/__screenshots__/tie_chord_dyad.png
Binary file modified tests/integration/__screenshots__/tie_chord_octave.png
Binary file modified tests/integration/__screenshots__/tie_chord_second.png
Binary file modified tests/integration/__screenshots__/tie_chord_triad.png
Binary file modified tests/integration/__screenshots__/time.png
Binary file modified tests/integration/__screenshots__/tuplet_triplet.png
Binary file modified tests/integration/__screenshots__/two_voices.png
Binary file modified tests/integration/__screenshots__/voices_grand_staff.png
Binary file modified tests/integration/__screenshots__/words.png
11 changes: 6 additions & 5 deletions tests/testing/harness.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterAll, beforeAll } from 'bun:test';
import { type Browser, chromium } from 'playwright';
import type { Config } from '../../src';
import { LETTER_WIDTH } from '../../src/constants';
import { serve } from './serve';

const PORT = 3100;
Expand All @@ -19,18 +20,18 @@ afterAll(async () => {
server?.stop(true);
});

// A fixture is laid out to its reference width (default 1000); the SVG viewBox
// scales the result to any container at runtime, so a single static width
// exercises the layout deterministically.
const DEFAULT_WIDTH = 1000;
// A fixture is laid out to its reference width (8.5in unless the test overrides it);
// the result scales to any container at runtime, so a static viewport exercises the
// layout deterministically.

/** Render a corpus file in the browser and return its screenshot PNG. */
export async function render(
file: string,
config: Partial<Config>,
): Promise<Buffer> {
const width =
config.layout?.type === 'standard' ? config.layout.width : DEFAULT_WIDTH;
(config.layout?.type === 'standard' ? config.layout.width : undefined) ??
LETTER_WIDTH;
const page = await browser.newPage({
viewport: { width: width + 64, height: 600 },
});
Expand Down
Loading