Redesign All view as stacked bands with horizontal scrolling sheet on top

This commit is contained in:
Fabricio Giglio
2026-06-21 21:50:01 +02:00
parent 27be691fe2
commit 6ba588aedb
5 changed files with 285 additions and 125 deletions
@@ -170,9 +170,13 @@ export function FallingNotesView() {
onWheel={handleWheel} onWheel={handleWheel}
style={{ style={{
minHeight: 200, minHeight: 200,
// In "all" the parent column carries one continuous glow (so sheet +
// falling read as one surface, no seam). Other modes paint their own.
background: background:
"radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)", settings.layoutMode === "all"
borderTop: "1px solid var(--color-notes-border)", ? "transparent"
: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)",
borderTop: settings.layoutMode === "all" ? "none" : "1px solid var(--color-notes-border)",
}} }}
> >
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} /> <canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
@@ -8,17 +8,17 @@ import { lighten, hexToNum } from "../../utils/colorUtils";
const MIDI_MIN = 21; // A0 const MIDI_MIN = 21; // A0
const MIDI_MAX = 108; // C8 const MIDI_MAX = 108; // C8
function isWhite(midi: number) { return !isBlackKey(midi); } function isWhite(midi: number) {
const WHITE_COUNT = Array.from({ length: MIDI_MAX - MIDI_MIN + 1 }, (_, i) => MIDI_MIN + i) return !isBlackKey(midi);
.filter(isWhite).length; }
const WHITE_COUNT = Array.from({ length: MIDI_MAX - MIDI_MIN + 1 }, (_, i) => MIDI_MIN + i).filter(
isWhite
).length;
// ── Color tokens ────────────────────────────────────────────────────────────── // ── Color tokens ──────────────────────────────────────────────────────────────
const C_WHITE_TOP = 0xc4c4bc; // subtle shadow at key hinge const C_WHITE_TOP = 0xc4c4bc; // subtle shadow at key hinge
const C_WHITE_MID = 0xf5f5f0; // main key face const C_WHITE_MID = 0xf5f5f0; // main key face
const C_WHITE_BTM = 0xe4e4dc; // front edge shadow const C_WHITE_BTM = 0xe4e4dc; // front edge shadow
const C_BLACK_TOP = 0x2c2c38; // top of black key
const C_BLACK_MID = 0x181820; // main body
const C_BLACK_BTM = 0x363644; // slight gloss at bottom
const C_BORDER = 0x9a9a90; const C_BORDER = 0x9a9a90;
const C_LABEL = 0x6b7280; const C_LABEL = 0x6b7280;
@@ -52,17 +52,51 @@ function whiteKeyColors(ivory: boolean): { top: number; mid: number; btm: number
const GRAD = { const GRAD = {
whiteNormal: (ivory: boolean) => { whiteNormal: (ivory: boolean) => {
const c = whiteKeyColors(ivory); const c = whiteKeyColors(ivory);
return lg([[0, c.top], [0.07, c.mid], [0.88, c.mid], [1, c.btm]]); return lg([
[0, c.top],
[0.07, c.mid],
[0.88, c.mid],
[1, c.btm],
]);
}, },
blackNormal: () => lg([[0, C_BLACK_TOP], [0.35, C_BLACK_MID], [0.88, C_BLACK_MID], [1, C_BLACK_BTM]]), // Horizontal overlay: soft shadow down each side, transparent in the middle.
activeWhite: (base: number) => lg([ // Overlaid on the (vertical) white-key gradient, it makes each white key read
// as rounded / raised instead of a flat slab - the main "fuller, more real"
// cue (black keys sit between white keys and cast a soft shadow onto them).
whiteSideShade: () =>
new PIXI.FillGradient({
type: "linear",
start: { x: 0, y: 0 },
end: { x: 1, y: 0 },
textureSpace: "local",
colorStops: [
{ offset: 0, color: "rgba(0,0,0,0.10)" },
{ offset: 0.16, color: "rgba(0,0,0,0)" },
{ offset: 0.84, color: "rgba(0,0,0,0)" },
{ offset: 1, color: "rgba(0,0,0,0.10)" },
],
}),
// Subtle dimension via the gradient alone: a soft sheen at the top, dark
// body, and a slightly lighter "front lip" at the bottom so the key isn't a
// flat 100% black slab. (Kept restrained - stronger glossy/face attempts read
// as plasticky on our renderer.)
blackNormal: () =>
lg([
[0, 0x34343f], // top sheen - catches a bit of light
[0.18, 0x1c1c24],
[0.82, 0x141419], // darkest part of the body
[1, 0x3a3a46], // lighter front lip at the bottom
]),
activeWhite: (base: number) =>
lg([
[0, 0x050508], // deep hinge shadow [0, 0x050508], // deep hinge shadow
[0.12, lighten(base, 0.58)], [0.12, lighten(base, 0.58)],
[0.22, base], [0.22, base],
[0.83, base], [0.83, base],
[1, lighten(base, 1.45)], // bright front lip [1, lighten(base, 1.45)], // bright front lip
]), ]),
activeBlack: (base: number) => lg([ activeBlack: (base: number) =>
lg([
[0, lighten(base, 0.6)], [0, lighten(base, 0.6)],
[0.5, base], [0.5, base],
[1, lighten(base, 1.5)], [1, lighten(base, 1.5)],
@@ -151,7 +185,7 @@ export class PianoKeyboard {
this.whiteW = width / WHITE_COUNT; this.whiteW = width / WHITE_COUNT;
this.whiteH = height; this.whiteH = height;
this.blackW = this.whiteW * 0.58; this.blackW = this.whiteW * 0.58;
this.blackH = height * 0.60; this.blackH = height * 0.6;
let wi = 0; let wi = 0;
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) { for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
@@ -192,9 +226,7 @@ export class PianoKeyboard {
// Note label (created for every key; text/visibility set by noteLabelMode) // Note label (created for every key; text/visibility set by noteLabelMode)
{ {
const labelStr = this.noteLabel(midi); const labelStr = this.noteLabel(midi);
const lFontSz = white const lFontSz = white ? Math.max(7, this.whiteW * 0.42) : Math.max(6, this.blackW * 0.55);
? Math.max(7, this.whiteW * 0.42)
: Math.max(6, this.blackW * 0.55);
const label = new PIXI.Text({ const label = new PIXI.Text({
text: labelStr, text: labelStr,
style: { style: {
@@ -244,7 +276,10 @@ export class PianoKeyboard {
} }
// Hover highlight // Hover highlight
const st = this.keyStates.get(midi); const st = this.keyStates.get(midi);
if (st && !st.hovered) { st.hovered = true; this.redrawKey(midi); } if (st && !st.hovered) {
st.hovered = true;
this.redrawKey(midi);
}
}); });
container.on("pointerout", () => { container.on("pointerout", () => {
if (this.dragKeys.has(midi)) { if (this.dragKeys.has(midi)) {
@@ -253,12 +288,21 @@ export class PianoKeyboard {
} }
// Remove hover highlight // Remove hover highlight
const st = this.keyStates.get(midi); const st = this.keyStates.get(midi);
if (st && st.hovered) { st.hovered = false; this.redrawKey(midi); } if (st && st.hovered) {
st.hovered = false;
this.redrawKey(midi);
}
}); });
this.keys.set(midi, g); this.keys.set(midi, g);
this.keyContainers.set(midi, container); this.keyContainers.set(midi, container);
this.keyStates.set(midi, { playbackActive: false, inputActive: false, hand: null, finger: null, hovered: false }); this.keyStates.set(midi, {
playbackActive: false,
inputActive: false,
hand: null,
finger: null,
hovered: false,
});
if (white) whiteLayer.addChild(container); if (white) whiteLayer.addChild(container);
else blackLayer.addChild(container); else blackLayer.addChild(container);
@@ -272,18 +316,22 @@ export class PianoKeyboard {
private getGrad(key: string, factory: () => PIXI.FillGradient): PIXI.FillGradient { private getGrad(key: string, factory: () => PIXI.FillGradient): PIXI.FillGradient {
let g = this.gradCache.get(key); let g = this.gradCache.get(key);
if (!g) { g = factory(); this.gradCache.set(key, g); } if (!g) {
g = factory();
this.gradCache.set(key, g);
}
return g; return g;
} }
private paintKey( private paintKey(
g: PIXI.Graphics, g: PIXI.Graphics,
w: number, h: number, w: number,
h: number,
white: boolean, white: boolean,
active: boolean, active: boolean,
activeColor: number | null, activeColor: number | null,
_hand: "left" | "right" | null, _hand: "left" | "right" | null,
hovered = false, hovered = false
) { ) {
g.clear(); g.clear();
const r = white ? 4 : 3; const r = white ? 4 : 3;
@@ -311,12 +359,18 @@ export class PianoKeyboard {
if (active && white) { if (active && white) {
// Keybed shadow strip at bottom (felt bumper contact) // Keybed shadow strip at bottom (felt bumper contact)
g.rect(1, h - 4, w - 2, 4); g.rect(1, h - 4, w - 2, 4);
g.fill({ color: 0x000000, alpha: 0.40 }); g.fill({ color: 0x000000, alpha: 0.4 });
// Side edge depth shadows // Side edge depth shadows
g.rect(0.5, h * 0.08, 2, h * 0.88); g.rect(0.5, h * 0.08, 2, h * 0.88);
g.fill({ color: 0x000000, alpha: 0.10 }); g.fill({ color: 0x000000, alpha: 0.1 });
g.rect(w - 2.5, h * 0.08, 2, h * 0.88); g.rect(w - 2.5, h * 0.08, 2, h * 0.88);
g.fill({ color: 0x000000, alpha: 0.10 }); g.fill({ color: 0x000000, alpha: 0.1 });
}
// ── Inactive white key: soft side shading for a rounded, fuller look ────
if (white && !active) {
g.roundRect(0.5, 0.5, w - 1, h - 1, r);
g.fill(this.getGrad("wshade", GRAD.whiteSideShade));
} }
// White highlight strip at top of active black keys // White highlight strip at top of active black keys
@@ -373,9 +427,14 @@ export class PianoKeyboard {
if (active) { if (active) {
const cc = this.opts.customColors; const cc = this.opts.customColors;
if (this.opts.showHandColors && state.hand) { if (this.opts.showHandColors && state.hand) {
color = state.hand === "left" color =
? (cc ? hexToNum(cc.leftHand) : C_LEFT) state.hand === "left"
: (cc ? hexToNum(cc.rightHand) : C_RIGHT); ? cc
? hexToNum(cc.leftHand)
: C_LEFT
: cc
? hexToNum(cc.rightHand)
: C_RIGHT;
} else if (state.inputActive) { } else if (state.inputActive) {
color = C_INPUT; color = C_INPUT;
} else { } else {
@@ -414,7 +473,7 @@ export class PianoKeyboard {
if (!state) return; if (!state) return;
if (source === "playback") { if (source === "playback") {
state.playbackActive = true; state.playbackActive = true;
state.hand = note.hand === "unknown" ? null : note.hand as "left" | "right"; state.hand = note.hand === "unknown" ? null : (note.hand as "left" | "right");
state.finger = note.finger; state.finger = note.finger;
} else { } else {
state.inputActive = true; state.inputActive = true;
@@ -427,7 +486,10 @@ export class PianoKeyboard {
if (!state) return; if (!state) return;
if (source === "playback") { if (source === "playback") {
state.playbackActive = false; state.playbackActive = false;
if (!state.inputActive) { state.hand = null; state.finger = null; } if (!state.inputActive) {
state.hand = null;
state.finger = null;
}
} else { } else {
state.inputActive = false; state.inputActive = false;
} }
@@ -463,11 +525,10 @@ export class PianoKeyboard {
} }
setOptions(opts: Partial<PianoKeyboardOptions>) { setOptions(opts: Partial<PianoKeyboardOptions>) {
const needsFullRebuild = ( const needsFullRebuild =
("pianoTheme" in opts && opts.pianoTheme !== this.opts.pianoTheme) || ("pianoTheme" in opts && opts.pianoTheme !== this.opts.pianoTheme) ||
("noteLabelMode" in opts && opts.noteLabelMode !== this.opts.noteLabelMode) || ("noteLabelMode" in opts && opts.noteLabelMode !== this.opts.noteLabelMode) ||
("useFlats" in opts && opts.useFlats !== this.opts.useFlats) ("useFlats" in opts && opts.useFlats !== this.opts.useFlats);
);
Object.assign(this.opts, opts); Object.assign(this.opts, opts);
@@ -25,6 +25,11 @@ export function SheetMusicView() {
const scoreLoaded = useRef(false); // true once score SVG is rendered const scoreLoaded = useRef(false); // true once score SVG is rendered
const lastScrollTop = useRef(-1); const lastScrollTop = useRef(-1);
const lastScrollMs = useRef(0); const lastScrollMs = useRef(0);
// Horizontal "all" mode: a rAF lerp loop slides scrollLeft toward this target
// every frame for continuous, buttery score-following (instead of stepped
// bar-to-bar jumps). onTick updates the target; the loop animates toward it.
const scrollTargetRef = useRef(0);
const rafScrollId = useRef<number | null>(null);
// ── AlphaTab init (runs once on mount) ────────────────────────────────── // ── AlphaTab init (runs once on mount) ──────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -34,11 +39,22 @@ export function SheetMusicView() {
import("@coderline/alphatab").then(({ AlphaTabApi }) => { import("@coderline/alphatab").then(({ AlphaTabApi }) => {
if (!containerRef.current) return; if (!containerRef.current) return;
// In the "all" view the sheet sits in a full-width band at the top and
// renders as a single horizontal strip (layoutMode 1) that scrolls
// left->right following playback. The dedicated "Sheet" mode keeps page
// mode (vertical, multi-line) which is better for reading a full score.
// SheetMusicView mounts fresh per layout mode (not a singleton), so reading
// the mode once at init is safe for this instance's lifetime.
const horizontal = useAppStore.getState().settings.layoutMode === "all";
const api = new AlphaTabApi(containerRef.current, { const api = new AlphaTabApi(containerRef.current, {
core: { engine: "svg", enableLazyLoading: false, logLevel: 0 }, core: { engine: "svg", enableLazyLoading: false, logLevel: 0 },
display: { display: {
layoutMode: 0, // page mode - vertical scroll layoutMode: horizontal ? 1 : 0, // 1 = horizontal strip, 0 = page (vertical)
scale: 0.9, // Smaller scale in the horizontal "all" band so the full grand staff
// (both clefs) fits the short band height without the bass clef being
// clipped. Page mode keeps the larger, more readable 0.9.
scale: horizontal ? 0.62 : 0.9,
// stretchForce 0 = notes keep their natural rhythmic proportions (like // stretchForce 0 = notes keep their natural rhythmic proportions (like
// a printed score). A very small value (0.1) avoids a ragged right // a printed score). A very small value (0.1) avoids a ragged right
// edge on the last system without visibly compressing the spacing. // edge on the last system without visibly compressing the spacing.
@@ -102,6 +118,32 @@ export function SheetMusicView() {
/* ignore */ /* ignore */
} }
// ── Continuous horizontal scroll loop (lerp toward scrollTargetRef) ────
// In horizontal mode the strip glides smoothly under the cursor instead of
// jumping bar to bar. The 0.12 factor is a gentle chase (~0.13s) that also
// averages out the ~8 fps cursor steps into continuous motion. Gated: the
// loop self-stops once paused AND settled; onTick (which only fires while
// playing, plus once on seek) restarts it via ensureScrollLoop().
const lerpScroll = () => {
const scrollEl = scrollRef.current;
let settling = false;
if (scrollEl) {
const diff = scrollTargetRef.current - scrollEl.scrollLeft;
if (Math.abs(diff) > 0.5) {
scrollEl.scrollLeft += diff * 0.12;
settling = true;
}
}
if (useAppStore.getState().status === "playing" || settling) {
rafScrollId.current = requestAnimationFrame(lerpScroll);
} else {
rafScrollId.current = null; // idle - stop until the next tick kicks it
}
};
const ensureScrollLoop = () => {
if (rafScrollId.current === null) rafScrollId.current = requestAnimationFrame(lerpScroll);
};
// ── Transport tick: cursor position + scroll ───────────────────────── // ── Transport tick: cursor position + scroll ─────────────────────────
// IMPORTANT: do NOT close over `doc` here - it would always be null // IMPORTANT: do NOT close over `doc` here - it would always be null
// (stale closure from first render). Always read fresh state instead. // (stale closure from first render). Always read fresh state instead.
@@ -119,11 +161,9 @@ export function SheetMusicView() {
return; return;
} }
// Scroll cursor into view - throttled to ~4 fps to avoid jitter // Update scroll to follow the cursor. Horizontal mode sets a lerp target
const now = performance.now(); // (the rAF loop animates toward it for a continuous glide); page mode
if (now - lastScrollMs.current < 250) return; // does a throttled jump with the browser's smooth-scroll.
lastScrollMs.current = now;
requestAnimationFrame(() => { requestAnimationFrame(() => {
const scrollEl = scrollRef.current; // outer scroll container const scrollEl = scrollRef.current; // outer scroll container
const innerEl = containerRef.current; // AlphaTab host (cursor lives here) const innerEl = containerRef.current; // AlphaTab host (cursor lives here)
@@ -133,16 +173,27 @@ export function SheetMusicView() {
innerEl.querySelector<HTMLElement>(".at-cursor-beat") ?? innerEl.querySelector<HTMLElement>(".at-cursor-beat") ??
innerEl.querySelector<HTMLElement>(".at-cursor-bar"); innerEl.querySelector<HTMLElement>(".at-cursor-bar");
if (!cursor) return; if (!cursor) return;
// cursor position relative to the scrollable content area const cRect = cursor.getBoundingClientRect();
const cursorTop = const sRect = scrollEl.getBoundingClientRect();
cursor.getBoundingClientRect().top -
scrollEl.getBoundingClientRect().top + if (horizontal) {
scrollEl.scrollTop; // Absolute content x of the cursor; keep it ~40% from the left so
// there's lookahead. The rAF loop slides scrollLeft toward this.
const cursorLeft = cRect.left - sRect.left + scrollEl.scrollLeft;
scrollTargetRef.current = Math.max(0, cursorLeft - scrollEl.clientWidth * 0.4);
ensureScrollLoop();
} else {
// Page mode: throttled (~4 fps) jump, keep cursor at ~30% from top.
const now = performance.now();
if (now - lastScrollMs.current < 250) return;
lastScrollMs.current = now;
const cursorTop = cRect.top - sRect.top + scrollEl.scrollTop;
const target = Math.max(0, cursorTop - scrollEl.clientHeight * 0.3); const target = Math.max(0, cursorTop - scrollEl.clientHeight * 0.3);
if (Math.abs(target - lastScrollTop.current) > 80) { if (Math.abs(target - lastScrollTop.current) > 80) {
lastScrollTop.current = target; lastScrollTop.current = target;
scrollEl.scrollTo({ top: target, behavior: "smooth" }); scrollEl.scrollTo({ top: target, behavior: "smooth" });
} }
}
}); });
}; };
@@ -152,6 +203,10 @@ export function SheetMusicView() {
return () => { return () => {
cleanupBus?.(); cleanupBus?.();
if (rafScrollId.current !== null) {
cancelAnimationFrame(rafScrollId.current);
rafScrollId.current = null;
}
playerReady.current = false; playerReady.current = false;
scoreLoaded.current = false; scoreLoaded.current = false;
if (apiRef.current) { if (apiRef.current) {
@@ -168,15 +223,14 @@ export function SheetMusicView() {
const isDark = settings.theme === "dark"; const isDark = settings.theme === "dark";
const keepWhite = isDark && settings.sheetMusicWhiteBackground; const keepWhite = isDark && settings.sheetMusicWhiteBackground;
const invertSheet = isDark && !settings.sheetMusicWhiteBackground; const invertSheet = isDark && !settings.sheetMusicWhiteBackground;
// The "all" view renders the sheet as a horizontal strip (see init effect).
const horizontalSheet = settings.layoutMode === "all";
// ── Adapt stretchForce to layout mode ──────────────────────────────────── // ── stretchForce ──────────────────────────────────────────────────────────
// "all" mode gives the sheet a narrow shared column, so we keep more // Near-natural note spacing (0.1) for a printed-score look in both page mode
// stretching (0.5) to fill each line. In dedicated sheet mode we use // and the horizontal "all" strip. (The old 0.5 was to fill the narrow "all"
// near-natural note spacing (0.1) for a printed-score look. // sidebar, which no longer exists - "all" is now a full-width band.)
// The value is stored in a ref so the load effect below can read it fresh.
const stretchForceRef = useRef(0.1); const stretchForceRef = useRef(0.1);
// eslint-disable-next-line react-hooks/refs -- intentional: keeps ref in sync with derived state for effect to read fresh
stretchForceRef.current = settings.layoutMode === "all" ? 0.5 : 0.1;
useEffect(() => { useEffect(() => {
// Re-render the current score when the user switches between layout modes. // Re-render the current score when the user switches between layout modes.
@@ -214,32 +268,47 @@ export function SheetMusicView() {
}, [doc?.musicXml, doc?.mxlBuffer]); }, [doc?.musicXml, doc?.mxlBuffer]);
// Background logic: // Background logic:
// light / dark normal: radial gradient + var(--color-notes-bg), matching FallingNotesView
// dark + keep white: explicit #fff so black notation stays readable on white // dark + keep white: explicit #fff so black notation stays readable on white
// The gradient (7% purple at bottom-center to transparent) is the same subtle glow used // "all" (horizontal band): TRANSPARENT - the parent column carries one
// by FallingNotesView and PianoModeBackground, keeping all canvas areas visually consistent. // continuous glow that both the sheet band and the falling-notes pane sit
// over, so there's no seam and the overall tone matches the other views.
// page mode: radial gradient + var(--color-notes-bg), matching FallingNotesView
const scrollBg = keepWhite const scrollBg = keepWhite
? "#ffffff" ? "#ffffff"
: horizontalSheet
? "transparent"
: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)"; : "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)";
return ( return (
<div <div
ref={scrollRef} ref={scrollRef}
className="flex-1 overflow-auto" className="flex-1"
style={{ style={{
background: scrollBg, background: scrollBg,
minHeight: 0, minHeight: 0,
// Top border sits against the header in every mode (matches the other
// views' header separator). In "all" this is the TOP of the sheet band,
// not the seam with the falling-notes pane (that seam stays borderless).
borderTop: "1px solid var(--color-notes-border)", borderTop: "1px solid var(--color-notes-border)",
// Horizontal strip scrolls left<->right; page mode scrolls up/down.
overflowX: horizontalSheet ? "auto" : "hidden",
overflowY: horizontalSheet ? "hidden" : "auto",
}} }}
> >
{/* {/*
Page-width wrapper - centres the score and caps it at ~A4 screen width Page mode: page-width wrapper centres the score and caps it at ~A4
(900 px). On wide monitors this creates margins on both sides so the screen width (900 px). Horizontal mode: no width cap - the strip must
score never looks like a stretched banner; on narrow screens it fills extend as wide as the score so it can scroll. position:relative keeps
the full width just like before. position:relative is needed so the the key-signature badge anchored.
key-signature badge stays anchored inside the content column.
*/} */}
<div style={{ maxWidth: 900, margin: "0 auto", padding: "8px 16px", position: "relative" }}> <div
style={{
maxWidth: horizontalSheet ? "none" : 900,
margin: horizontalSheet ? 0 : "0 auto",
padding: "8px 16px",
position: "relative",
}}
>
<div ref={containerRef} className={invertSheet ? "sheet-dark-invert" : ""} /> <div ref={containerRef} className={invertSheet ? "sheet-dark-invert" : ""} />
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && ( {settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && (
+3 -2
View File
@@ -297,9 +297,10 @@ input[type="range"]::-moz-range-progress {
border-radius: 1px; border-radius: 1px;
} }
/* Bar cursor - wider semi-transparent highlight for the current measure */ /* Bar cursor - the "spotlight" highlight on the current measure. Slightly more
prominent than a hairline so it's easy to track while following along. */
.at-cursor-bar { .at-cursor-bar {
background: rgba(147, 51, 234, 0.12); background: rgba(147, 51, 234, 0.16);
border-radius: 2px; border-radius: 2px;
} }
+40 -15
View File
@@ -268,29 +268,54 @@ export function PracticeView() {
</header> </header>
{/* ── Main content - layout-aware ────────────────────────────────────── {/* ── Main content - layout-aware ──────────────────────────────────────
PianoKeyboardView is a SINGLETON at the bottom of the main column so Single vertical column. The piano keyboard is a SINGLETON pinned at the
PixiJS doesn't re-init on every view switch (skeleton flash + perf cost). bottom (keyed) so PixiJS never re-inits on a view switch. FallingNotesView
Same for FallingNotesView in falling/all modes - kept mounted across is keyed too, so it stays mounted across the falling<->all toggle (the
the two modes that show it. Only the conditional top-content elements sheet band appears above it in "all" without remounting it).
(PianoModeBackground / SheetMusicView / sheet sidebar) mount and
unmount on layoutMode change. */} "all" mode = three full-width horizontal bands stacked vertically:
sheet (top, horizontal strip) / falling notes (middle) / piano (bottom).
The dedicated "sheet" mode keeps a full vertical page (better for reading). */}
<main className="flex flex-1 overflow-hidden min-h-0 relative"> <main className="flex flex-1 overflow-hidden min-h-0 relative">
{/* Optional sheet sidebar (only in "all" mode) */} <div
className="flex flex-col flex-1 min-h-0 overflow-hidden"
style={{
// "all": one continuous glow on the whole column (sheet + falling
// read transparently over it), so there's no seam and the overall
// tone matches the other views (whose panes carry the same glow).
// Anchored just above the piano (calc(100% - keyboard height)) so the
// glow's brightest point lands where the other views' do - otherwise
// it sits behind the opaque keybed and reads slightly dimmer (visible
// in dark mode, where the purple contrasts more).
background:
layoutMode === "all"
? "radial-gradient(ellipse at 50% calc(100% - 160px), rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)"
: undefined,
}}
>
{/* Piano-only mode: decorative backdrop above the keyboard */}
{layoutMode === "piano" && <PianoModeBackground key="piano-bg" />}
{/* Dedicated Sheet mode: full-height vertical page */}
{layoutMode === "sheet" && <SheetMusicView key="sheet-full" />}
{/* All mode: sheet as a full-width horizontal band at the top */}
{layoutMode === "all" && ( {layoutMode === "all" && (
<div <div
className="flex flex-col flex-shrink-0 min-h-0 overflow-hidden relative" key="sheet-band"
style={{ flex: "0 0 42%", minWidth: 260, maxWidth: 440 }} className="flex flex-col min-h-0 overflow-hidden"
style={{ flex: "0 0 32%", minHeight: 130 }}
> >
<SheetMusicView /> <SheetMusicView />
</div> </div>
)} )}
{/* Main column: top content varies, piano persists across all modes */} {/* Falling notes: middle (all) or full (falling). Keyed so it stays
<div className="flex flex-col flex-1 min-h-0 overflow-hidden"> mounted across the falling<->all toggle (no PixiJS re-init). */}
{layoutMode === "piano" && <PianoModeBackground />} {(layoutMode === "falling" || layoutMode === "all") && <FallingNotesView key="falling" />}
{(layoutMode === "falling" || layoutMode === "all") && <FallingNotesView />}
{layoutMode === "sheet" && <SheetMusicView />} {/* Piano keyboard: always the bottom singleton (keyed). */}
<PianoKeyboardView /> <PianoKeyboardView key="kbd" />
</div> </div>
</main> </main>