diff --git a/static/css/daw.css b/static/css/daw.css index fcb64625..14dbd481 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -2053,6 +2053,7 @@ input, textarea { font-family: inherit; } .loop-region.hidden { display: none !important; } + /* Wave loading overlay */ .wave-loading-overlay { position: absolute; inset: 0; z-index: 10; diff --git a/static/js/player.js b/static/js/player.js index 174bd204..b284861a 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -22,7 +22,7 @@ import { setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, waveScroll, selectedStems, footerTitle, footerMeta, footerThumb, - setFooterWaveDrawFn, + setFooterWaveDrawFn, setOverviewRerenderFn, metronome, setMetronome, metronomeEnabled, metronomeVolume, metronomeBeatsPerBar, exportClickEl, exportClickWrap, exportCountInEl, exportCountInWrap, setMetronomeHasBars, @@ -42,7 +42,8 @@ import { } from "./mixer.js"; import { buildRuler, updatePlayheadMarker, updateLoopRegionVisual, - applyWaveZoom, buildPresenceRuler, buildFooterWaveTicks, updateFooterTimes, + applyWaveZoom, resetWaveZoom, WAVE_ZOOM_MAX, + buildPresenceRuler, buildFooterWaveTicks, updateFooterTimes, updatePresencePlayhead, resetSpeed, resetPitch, updatePitchAvailability, updateMetronomeAvailability, applyMetronomeAccent, } from "./transport.js"; @@ -433,7 +434,21 @@ function overviewLaneNames(stems) { return present.has("original") ? ["original", ...order] : order; } +// What the overview bars were last drawn from, so a zoom change can redraw them +// at the new resolution without reloading the track. Zoom widens .waves-column, +// overviewBarCount() reads that width, and the bars come back the same 3px wide +// with more of them -- redrawing is what keeps the art identical, where +// stretching the same SVG would smear it. +let _overviewSource = null; + +function rerenderOverviewWaveforms() { + if (!_overviewSource) return; + renderAllOverviewWaveformsFromPeaks(_overviewSource.stems, _overviewSource.data); +} +setOverviewRerenderFn(rerenderOverviewWaveforms); + function renderAllOverviewWaveformsFromPeaks(stems, peaksData) { + _overviewSource = { stems, data: peaksData }; const laneNames = overviewLaneNames(stems); // Only the extracted/selected stems (plus original) get a waveform, even if // peaks.json carries data for stems the user didn't keep (Demucs separates @@ -464,23 +479,19 @@ function renderAllOverviewWaveformsFromPeaks(stems, peaksData) { // fill its row regardless of how loud the stem actually was. function renderAllOverviewWaveforms(stems, decodedMap) { const laneNames = overviewLaneNames(stems); - const peaksByStem = new Map(); - let globalMax = 0; + const peaksByStem = {}; for (const name of laneNames) { const buf = decodedMap.get(name); if (!isAudioBufferLike(buf)) continue; - const peaks = bufferMinMaxPeaks(buf, OVERVIEW_WAVE_POINTS); - peaksByStem.set(name, peaks); - for (const [mn, mx] of peaks) { - if (mx > globalMax) globalMax = mx; - if (-mn > globalMax) globalMax = -mn; - } + // Scanned once per track, at the finest resolution any zoom will ask for. + // bufferMinMaxPeaks walks every sample, so doing this per zoom step would + // re-read the whole song per stem on each wheel notch. The bars are + // downsampled from this cache instead, which is what peaks.json already is + // -- the two sources are the same shape from here on, they just differ in + // how many points they carry. + peaksByStem[name] = bufferMinMaxPeaks(buf, OVERVIEW_WAVE_POINTS * WAVE_ZOOM_MAX); } - const norm = globalMax > 0 ? 1 / globalMax : 0; - const bars = overviewBarCount(); - laneNames.forEach((name, i) => { - renderOverviewWaveformPath(name, peaksByStem.get(name), norm, STEM_COLORS[name] || "#a0a0a0", bars, i); - }); + renderAllOverviewWaveformsFromPeaks(stems, peaksByStem); } function renderDecodedStemVisuals(stemName, audioBuffer, color) { @@ -1019,6 +1030,12 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti setLoopEnd(0); loopBtn.classList.remove("active"); loopRegionEl.classList.add("hidden"); + // After the loop is cleared, never before: resetWaveZoom redraws the loop + // region, so running it first would paint the previous track's loop against + // this track's duration for a frame. A new track also starts fitted -- the + // previous track's zoom would open this one scrolled into the middle of a + // song the user has not seen yet. + resetWaveZoom(); // Refresh loop UI so the exact-loop inputs enable + reset to 00:00.000 now // that the track duration is known. updateLoopRegionVisual(); diff --git a/static/js/state.js b/static/js/state.js index 0f778813..2f3636c7 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -195,6 +195,13 @@ export function setAudioContext(v) { audioContext = v; } export function setMasterVolume(v) { masterVolume = v; } export let playbackSpeed = 1.0; export function setPlaybackSpeed(v) { playbackSpeed = v; } +// Horizontal waveform zoom. 1 is the whole track fitted to the panel and is +// also the floor: there is nothing to see below it, the track is already +// entirely on screen. Shared state because three modules read it -- transport.js +// drives it, player.js redraws the bars at the new resolution, and the loop +// tools are only available at 1. +export let waveZoom = 1; +export function setWaveZoom(v) { waveZoom = v; } export function setVuRafId(v) { vuRafId = v; } export function setMasterBusGain(v) { masterBusGain = v; } export function setMasterLimiter(v) { masterLimiter = v; } @@ -203,6 +210,13 @@ export function setMasterLimiter(v) { masterLimiter = v; } export let footerWaveDrawFn = null; export function setFooterWaveDrawFn(fn) { footerWaveDrawFn = fn; } +// Redraws the overview bars at the current zoom. Registered by player.js, which +// owns the renderer, and called by transport.js, which owns the zoom. Passed as +// a callback rather than imported so the two modules do not form a cycle: +// player.js already imports transport.js. +export let overviewRerenderFn = null; +export function setOverviewRerenderFn(fn) { overviewRerenderFn = fn; } + // Click track. `metronome` is the scheduler bound to the current engine (null // when the job has no beat grid or the streaming path is in use); the enabled // flag and volume survive track switches so the user's choice sticks. diff --git a/static/js/transport.js b/static/js/transport.js index a1f83ea8..7b6e92fc 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -19,6 +19,7 @@ import { setMetronomeHasBars, setMetronomeEnabled, setMetronomeVolume, setMetronomeBeatsPerBar, setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, setPlaybackSpeed, + waveZoom, setWaveZoom, overviewRerenderFn, } from "./state.js"; import { applyMix, nudgeAllLanePitches, resetAllLanePitches } from "./mixer.js"; import { isDownbeatIndex, getBeats as getGridBeats, getBars as getGridBars } from "./beatgrid.js"; @@ -26,6 +27,16 @@ import { computeCountIn } from "./metronome.js"; import { t } from "./i18n.js"; const MIN_LOOP_SEC = 0.2; +// Zoom range. 1 is the whole track fitted to the panel; there is nothing below +// it to show, so it is the floor rather than a soft default. 5 is the ceiling +// because peaks.json carries 1500 points per stem: past roughly 5x a typical +// panel asks for more bars than there are samples behind them, and the extra +// bars repeat their neighbours instead of revealing anything. +const WAVE_ZOOM_MIN = 1; +export const WAVE_ZOOM_MAX = 5; +// One wheel notch. Multiplicative, so a notch covers the same proportion of the +// range at 1x as at 4x; linear steps feel fast at the bottom and stuck at the top. +const WAVE_ZOOM_STEP = 1.18; // Below this visible width the waveform stops compressing to fit and instead // keeps a minimum size, overflowing horizontally so .wave-scroll can scroll. const WAVE_MIN_WIDTH = 720; @@ -72,8 +83,32 @@ function setPlayheadTime(sec) { // Spacing of the timeline's labelled ticks. Shared by the ruler above the // lanes and the one on the footer waveform: the two strips are the same width // and start at the same x, so a time has to land at the same place in both. -function tickStep(durationSec) { - return durationSec < 90 ? 15 : durationSec < 300 ? 30 : 60; +// Label spacing the ruler will not go below, comfortably wider than a "10:00" +// label so neighbours never crowd each other. +const MIN_TICK_PX = 110; +const TICK_LADDER = [1, 2, 5, 10, 15, 30, 60, 120, 300]; + +// `contentWidthPx` is the width the ticks will actually occupy. Omitted (the +// footer strip, which always shows the whole track) the step is the plain +// duration-based one, which is also what 1x has always used. +// The step the ruler was last built with. buildRuler mutates elements inside +// .wave-scroll, which is the element the resize observer watches, so rebuilding +// unconditionally from that callback can re-trigger it. Comparing against this +// makes the rebuild idempotent: once the ruler matches the width, it settles. +let _rulerStep = 0; + +function tickStep(durationSec, contentWidthPx = 0) { + const base = durationSec < 90 ? 15 : durationSec < 300 ? 30 : 60; + // Zoom is the only thing that subdivides it. Spreading the same handful of + // ticks across five screen widths would make the ruler less useful the + // further in you went, which is backwards. + if (waveZoom <= 1 || !contentWidthPx || !durationSec) return base; + const pxPerSec = contentWidthPx / durationSec; + for (const step of TICK_LADDER) { + if (step > base) break; + if (step * pxPerSec >= MIN_TICK_PX) return step; + } + return base; } export function buildRuler(durationSec) { @@ -87,7 +122,10 @@ export function buildRuler(durationSec) { rulerTime.appendChild(marker); if (!durationSec || durationSec <= 0) return; - const step = tickStep(durationSec); + // The ruler is width: calc(100% * var(--zoom)), so its own box already is the + // zoomed width; no need to recompute it here. + const step = tickStep(durationSec, rulerTime.getBoundingClientRect().width); + _rulerStep = step; for (let t = 0; t <= durationSec; t += step) { const leftPct = (t / durationSec) * 100; const tick = document.createElement("div"); @@ -494,8 +532,10 @@ export function applyWaveZoom() { if (multitrack && totalDuration > 0 && waveScroll) { const baseWidth = waveScroll.clientWidth; if (baseWidth > 0) { - // Fit to the visible width, but never compress below WAVE_MIN_WIDTH. - const contentWidth = Math.max(baseWidth, WAVE_MIN_WIDTH); + // Two separate reasons the content can be wider than the viewport, and + // they multiply rather than compete: the user's zoom, and the floor that + // stops the whole track compressing into a sliver on a narrow window. + const contentWidth = Math.max(baseWidth * waveZoom, WAVE_MIN_WIDTH); const zoom = contentWidth / baseWidth; // Widen the container via --zoom FIRST. Then, after the browser has // reflowed it, zoom WaveSurfer to fit the container's *actual* width. @@ -508,29 +548,117 @@ export function applyWaveZoom() { if (!multitrack || totalDuration <= 0) return; const w = multitrackContainer?.clientWidth || contentWidth; try { multitrack.zoom(w / totalDuration); } catch { /* ignore -- pre-canplay */ } + // Redraw the SVG bars against the width the reflow actually produced. + // The bars are 1 viewBox unit each, so leaving the old count in place + // would stretch every bar by the zoom factor: same waveform, fatter + // strokes. Redrawing keeps them 3px wide and spends the extra width on + // detail instead, which is the whole point of zooming in. + overviewRerenderFn?.(); syncRulerScroll(); }); } } } +/** + * Set the zoom, keeping the time under `anchorClientX` where it is. + * + * Without the anchor the view jumps to wherever scrollLeft happened to be, and + * zooming toward a specific bar becomes a game of chase-the-scrollbar. + */ +export function setWaveZoomLevel(next, anchorClientX = null) { + const clamped = Math.min(WAVE_ZOOM_MAX, Math.max(WAVE_ZOOM_MIN, next)); + if (Math.abs(clamped - waveZoom) < 1e-4) return false; + // Which content pixel the anchor is on, before anything moves. + const rect = waveScroll?.getBoundingClientRect(); + const offsetX = anchorClientX !== null && rect + ? Math.min(rect.width, Math.max(0, anchorClientX - rect.left)) + : (waveScroll ? waveScroll.clientWidth / 2 : 0); + const contentX = (waveScroll?.scrollLeft ?? 0) + offsetX; + // Measured, not derived from the zoom ratio. WAVE_MIN_WIDTH floors the + // content width, so on a window narrower than 720px a zoom step can widen the + // content by less than its own factor -- or not at all -- and scaling by the + // ratio would slide the anchor out from under the pointer. + const beforeWidth = waveCanvas?.getBoundingClientRect().width || 0; + + setWaveZoom(clamped); + applyWaveZoom(); + const afterWidth = waveCanvas?.getBoundingClientRect().width || beforeWidth; + const growth = beforeWidth > 0 ? afterWidth / beforeWidth : 1; + // Everything positioned against the timeline is laid out again at the new + // width: the ruler because its ticks are now the wrong distance apart for the + // detail on screen, the playhead and the loop region because buildRuler + // rebuilds the elements they live in. + buildRuler(totalDuration); + // buildRuler re-creates the marker element, so it comes back at 0. Put it + // back where the transport actually is -- but only if something can say; + // defaulting to 0 would yank the playhead to the start of the track. + const now = (audioEngine ?? multitrack)?.getCurrentTime?.(); + if (typeof now === "number") updatePlayheadMarker(now); + updateLoopRegionVisual(); + + if (waveScroll) { + // The same content pixel after the widening, minus where it sits in the + // viewport, is the scroll offset that leaves it under the pointer. + const target = contentX * growth - offsetX; + waveScroll.scrollLeft = Math.max(0, target); + syncRulerScroll(); + } + return true; +} + +export function resetWaveZoom() { + return setWaveZoomLevel(WAVE_ZOOM_MIN); +} + function wireZoomButtons() { if (waveScroll) { let rafId = null; const ro = new ResizeObserver(() => { if (!multitrack || totalDuration <= 0) return; if (rafId) cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(() => { rafId = null; applyWaveZoom(); }); + rafId = requestAnimationFrame(() => { + rafId = null; + applyWaveZoom(); + // Tick spacing is chosen from the content width, so a resize can leave + // the ruler at the density the old width called for. Rebuild only when + // the step it would pick has actually changed: buildRuler writes inside + // the observed element, so rebuilding every time would feed the + // observer its own output. + const next = tickStep(totalDuration, rulerTime?.getBoundingClientRect().width || 0); + if (next !== _rulerStep) { + buildRuler(totalDuration); + updateLoopRegionVisual(); + } + }); }); ro.observe(waveScroll); } if (waveScroll) { waveScroll.addEventListener("wheel", (e) => { - if (waveScroll.scrollWidth <= waveScroll.clientWidth) return; - if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + if (totalDuration <= 0) return; + // Shift is the pan gesture, and a trackpad's horizontal axis reports as + // deltaX with no modifier. Both mean "move along the track", so neither + // should change the zoom. + if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + if (waveScroll.scrollWidth <= waveScroll.clientWidth) return; e.preventDefault(); - waveScroll.scrollLeft += e.deltaY; + // Whichever axis the gesture actually carried. Shift-wheel puts it on + // deltaY, a trackpad swipe on deltaX, and shift plus a swipe on deltaX + // with deltaY at zero. + waveScroll.scrollLeft += Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + syncRulerScroll(); + return; } + if (!e.deltaY) return; + // deltaMode 1 is lines and 2 is pages; both deliver far smaller numbers + // than pixels, so normalise to notches rather than scaling by deltaY. + const notches = Math.max(1, Math.min(3, Math.round(Math.abs(e.deltaY) / 100) || 1)); + const factor = WAVE_ZOOM_STEP ** (e.deltaY < 0 ? notches : -notches); + const changed = setWaveZoomLevel(waveZoom * factor, e.clientX); + // Only swallow the event when it did something. At either end of the + // range the page should still get its scroll rather than feel dead. + if (changed) e.preventDefault(); }, { passive: false }); waveScroll.addEventListener("scroll", syncRulerScroll, { passive: true }); } diff --git a/tests/e2e/zoom.spec.mjs b/tests/e2e/zoom.spec.mjs new file mode 100644 index 00000000..a58b77ba --- /dev/null +++ b/tests/e2e/zoom.spec.mjs @@ -0,0 +1,284 @@ +// Scroll-wheel zoom on the mixer waveforms. +// +// The thing worth guarding here is not that a number changes: it is that the +// bars keep their width. The overview is an SVG whose viewBox width is the bar +// COUNT, so widening it without redrawing stretches every bar by the zoom +// factor. That looks like a zoom at a glance and is really just a fatter +// version of the same picture, which is exactly the failure this feature exists +// to avoid. Every assertion about "art" below is measuring that. + +import { test, expect } from "@playwright/test"; +import { openStudio, JOB_ID } from "./helpers.mjs"; + +const wheel = (page, dy, x = 900, y = 400) => + page.mouse.move(x, y).then(() => page.mouse.wheel(0, dy)); + +async function zoomState(page) { + return page.evaluate(() => { + const scroller = document.querySelector(".wave-scroll"); + const canvas = document.querySelector(".wave-canvas"); + const svg = document.querySelector(".stem-waveform-row[data-stem] .stem-waveform-svg"); + const rects = svg ? svg.querySelectorAll("rect") : []; + const box = svg ? svg.getBoundingClientRect() : null; + return { + zoomVar: parseFloat( + getComputedStyle(document.getElementById("lanes")).getPropertyValue("--zoom"), + ) || 1, + viewport: Math.round(scroller.clientWidth), + content: Math.round(canvas.getBoundingClientRect().width), + scrollLeft: Math.round(scroller.scrollLeft), + bars: rects.length, + // One bar occupies one viewBox unit, so its on-screen width is the + // rendered svg width divided by the bar count. That is the number that + // must not move when the zoom does. + barSlotPx: box && rects.length ? +(box.width / rects.length).toFixed(3) : null, + ticks: document.querySelectorAll("#ruler-time .tick").length, + tickLabels: [...document.querySelectorAll("#ruler-time .tick-label")].slice(0, 3).map((e) => e.textContent), + loopDisabled: document.getElementById("t-loop").disabled, + loopStartDisabled: document.getElementById("t-loop-start").disabled, + }; + }); +} + +// The loop bounds as the app holds them, not as they are drawn: the region is +// positioned in percentages, so reading the element back would only prove the +// percentages agree with themselves. +// The lane body is under the wave-loading overlay until the waveforms have +// rendered, and a drag that lands on the overlay is swallowed. Measured on the +// fixture: without this wait a 1x lane drag arms the loop 3 times in 6. Nothing +// to do with zoom, but every test that drags on the lanes has to wait for it. +const lanesReady = (page) => + page.waitForFunction( + () => document.getElementById("waveLoadingOverlay")?.classList.contains("hidden") !== false, + null, + { timeout: 20000 }, + ); + +const loopBounds = (page) => + page.evaluate(() => { + const el = document.getElementById("loop-region"); + const parent = el.parentElement.getBoundingClientRect(); + const box = el.getBoundingClientRect(); + return { + start: +((box.left - parent.left) / parent.width).toFixed(4), + end: +((box.right - parent.left) / parent.width).toFixed(4), + }; + }); + +// Deliberately wide: the bar-count maths only has room to prove itself when the +// panel can hold a few hundred bars. +test.use({ viewport: { width: 1600, height: 900 } }); + +test.describe("waveform zoom", () => { + test("a track opens fitted, with the loop tools available", async ({ page }) => { + await openStudio(page, { tauri: true }); + const s = await zoomState(page); + expect(s.zoomVar).toBeCloseTo(1, 2); + expect(s.content).toBe(s.viewport); + expect(s.loopDisabled).toBe(false); + expect(s.loopStartDisabled).toBe(false); + }); + + test("scrolling up zooms in and scrolling down comes back", async ({ page }) => { + await openStudio(page, { tauri: true }); + const start = await zoomState(page); + + await wheel(page, -300); + const zoomed = await zoomState(page); + expect(zoomed.zoomVar).toBeGreaterThan(start.zoomVar); + expect(zoomed.content).toBeGreaterThan(start.content); + + await wheel(page, 300); + const back = await zoomState(page); + expect(back.zoomVar).toBeCloseTo(start.zoomVar, 2); + expect(back.content).toBe(start.content); + }); + + test("the bars keep their width; zooming buys detail, not fatter bars", async ({ page }) => { + await openStudio(page, { tauri: true }); + const base = await zoomState(page); + expect(base.bars).toBeGreaterThan(50); + + for (let i = 0; i < 12; i++) await wheel(page, -240); + const zoomed = await zoomState(page); + + // The proof: same pixels per bar, more bars, wider content. + expect(zoomed.barSlotPx).toBeCloseTo(base.barSlotPx, 1); + expect(zoomed.bars).toBeGreaterThan(base.bars * 2); + expect(zoomed.content).toBeGreaterThan(base.content * 2); + // Bar count tracks content width, which is what "same art" means here. + expect(zoomed.bars / base.bars).toBeCloseTo(zoomed.content / base.content, 1); + }); + + test("zoom stops at 5x and never goes below the fitted view", async ({ page }) => { + await openStudio(page, { tauri: true }); + const fitted = await zoomState(page); + + for (let i = 0; i < 40; i++) await wheel(page, -240); + const maxed = await zoomState(page); + expect(maxed.content / fitted.content).toBeCloseTo(5, 1); + + for (let i = 0; i < 60; i++) await wheel(page, 240); + const floored = await zoomState(page); + expect(floored.content).toBe(fitted.content); + expect(floored.scrollLeft).toBe(0); + }); + + test("the pointer stays over the same moment in the track", async ({ page }) => { + await openStudio(page, { tauri: true }); + const rect = await page.locator(".wave-scroll").boundingBox(); + const anchorX = rect.x + rect.width * 0.75; + + const before = await zoomState(page); + const timeUnderPointer = (before.scrollLeft + (anchorX - rect.x)) / before.content; + + for (let i = 0; i < 8; i++) await wheel(page, -240, anchorX, rect.y + 60); + const after = await zoomState(page); + const nowUnderPointer = (after.scrollLeft + (anchorX - rect.x)) / after.content; + + // Within half a percent of the track: the anchor holds, so zooming toward a + // bar does not turn into chasing the scrollbar. + expect(Math.abs(nowUnderPointer - timeUnderPointer)).toBeLessThan(0.005); + }); + + test("a loop survives zooming in and out untouched", async ({ page }) => { + await openStudio(page, { tauri: true }); + + // The ruler, not the lane body: the fixture keeps its loading overlay over + // the lanes, which would swallow the drag and pass this test for the wrong + // reason. The ruler is the canonical loop surface anyway. + const ruler = await page.locator("#ruler-time").boundingBox(); + const dragRuler = async (fromFrac, toFrac) => { + const y = ruler.y + ruler.height / 2; + await page.mouse.move(ruler.x + ruler.width * fromFrac, y); + await page.mouse.down(); + await page.mouse.move(ruler.x + ruler.width * toFrac, y, { steps: 8 }); + await page.mouse.up(); + }; + + await dragRuler(0.2, 0.6); + await expect(page.locator("#t-loop")).toHaveClass(/active/); + const armed = await page.locator("#loop-region").getAttribute("style"); + const bounds = await loopBounds(page); + expect(bounds.end).toBeGreaterThan(bounds.start); + + // Zoom is a view change, not an edit. Nothing about the loop may move. + for (let i = 0; i < 12; i++) await wheel(page, -240); + await expect(page.locator("#t-loop")).toHaveClass(/active/); + await expect(page.locator("#loop-region")).not.toHaveClass(/hidden/); + expect(await loopBounds(page)).toEqual(bounds); + expect((await zoomState(page)).loopDisabled).toBe(false); + + for (let i = 0; i < 30; i++) await wheel(page, 240); + expect(await loopBounds(page)).toEqual(bounds); + // Same percentages, so the region lands back on exactly the same span. + expect(await page.locator("#loop-region").getAttribute("style")).toBe(armed); + await expect(page.locator("#t-loop")).toHaveClass(/active/); + }); + + test("a loop can be marked while zoomed in", async ({ page }) => { + await openStudio(page, { tauri: true }); + await lanesReady(page); + for (let i = 0; i < 12; i++) await wheel(page, -240); + const zoomed = await zoomState(page); + expect(zoomed.content).toBeGreaterThan(zoomed.viewport); + expect(zoomed.loopDisabled).toBe(false); + expect(zoomed.loopStartDisabled).toBe(false); + + // Drag across the visible slice. The ruler is translated by scrollLeft, so + // its box is the whole zoomed timeline and a drag inside the viewport marks + // the times actually under the pointer -- which is the thing that has to + // hold once loops can be made at any zoom. + const ruler = await page.locator("#ruler-time").boundingBox(); + const view = await page.locator(".wave-scroll").boundingBox(); + const y = view.y + 10; + const fromX = view.x + view.width * 0.3; + const toX = view.x + view.width * 0.7; + await page.mouse.move(fromX, y); + await page.mouse.down(); + await page.mouse.move(toX, y, { steps: 10 }); + await page.mouse.up(); + + await expect(page.locator("#t-loop")).toHaveClass(/active/); + const bounds = await loopBounds(page); + + // Where the pointer actually was, as a fraction of the whole timeline, + // taken from the ruler's own box: it is translated by scrollLeft, so its + // left edge is off screen and this is the only honest reference. + const expectStart = (fromX - ruler.x) / ruler.width; + const expectEnd = (toX - ruler.x) / ruler.width; + expect(bounds.start).toBeCloseTo(expectStart, 2); + expect(bounds.end).toBeCloseTo(expectEnd, 2); + // A drag across part of a zoomed view marks a slice, not the whole track. + expect(bounds.end - bounds.start).toBeLessThan(0.2); + }); + + // WAVE_MIN_WIDTH floors the content width at 720px, so on a narrow window the + // first zoom step can widen the content by less than its own factor, or not at + // all. Deriving the scroll correction from the zoom ratio rather than the + // measured width slides the anchor out from under the pointer exactly here. + // 1150px leaves the wave area 460px wide, where the floor is active. + test("the anchor holds where the minimum wave width floors the growth", async ({ page }) => { + await page.setViewportSize({ width: 1150, height: 800 }); + await openStudio(page, { tauri: true }); + const rect = await page.locator(".wave-scroll").boundingBox(); + const anchorX = rect.x + rect.width * 0.6; + + const before = await zoomState(page); + // The floor is genuinely in play: the content is already wider than the + // viewport at 1x, so the first notch cannot widen it proportionally. + expect(before.content).toBeGreaterThan(before.viewport); + + const held = (s) => (s.scrollLeft + (anchorX - rect.x)) / s.content; + const target = held(before); + + for (let i = 0; i < 3; i++) await wheel(page, -240, anchorX, rect.y + 40); + const after = await zoomState(page); + + expect(after.content).toBeGreaterThan(before.content); + expect(Math.abs(held(after) - target)).toBeLessThan(0.01); + }); + + test("the ruler gets finer as you zoom, and the footer strip does not", async ({ page }) => { + await openStudio(page, { tauri: true }); + const base = await zoomState(page); + const footerBefore = await page.locator("#footer-wave-ticks .tick").count(); + + for (let i = 0; i < 40; i++) await wheel(page, -240); + const zoomed = await zoomState(page); + + // Same ticks spread over five screen widths would be a worse ruler the + // further in you went. More of them, at a finer step. + expect(zoomed.ticks).toBeGreaterThan(base.ticks); + expect(zoomed.tickLabels[1]).not.toBe(base.tickLabels[1]); + + // The footer strip always shows the whole track, so its ticks must not move. + expect(await page.locator("#footer-wave-ticks .tick").count()).toBe(footerBefore); + }); + + test("shift-scroll pans instead of zooming", async ({ page }) => { + await openStudio(page, { tauri: true }); + for (let i = 0; i < 10; i++) await wheel(page, -240); + const zoomed = await zoomState(page); + + await page.keyboard.down("Shift"); + await wheel(page, 200); + await page.keyboard.up("Shift"); + const panned = await zoomState(page); + + expect(panned.content).toBe(zoomed.content); + expect(panned.scrollLeft).not.toBe(zoomed.scrollLeft); + }); + + test("opening another track returns to the fitted view", async ({ page }) => { + await openStudio(page, { tauri: true }); + for (let i = 0; i < 10; i++) await wheel(page, -240); + expect((await zoomState(page)).zoomVar).toBeGreaterThan(1); + + await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click(); + await page.waitForTimeout(1500); + const reopened = await zoomState(page); + expect(reopened.zoomVar).toBeCloseTo(1, 2); + expect(reopened.loopDisabled).toBe(false); + }); +});