From cb891ac540ce7f4fe71d8a7a12ceedc68c902003 Mon Sep 17 00:00:00 2001 From: = <=> Date: Sun, 19 Jul 2026 16:08:15 +0300 Subject: [PATCH 1/6] feat: use mousewheel ovet the monitor to zoom the composition --- app.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- index.html | 1 + style.css | 9 ++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index af0868a..eccdd10 100644 --- a/app.js +++ b/app.js @@ -153,6 +153,8 @@ const state = { connected: false, exporting: false, rendering: false, // fast (server/ffmpeg) export in progress guides: false, // safe-area overlay on the monitor + viewZoom: 1, // program-monitor display zoom (1 = fit stage) + viewPan: { x: 0, y: 0 }, // px offset while zoomed (zoom-to-cursor / pan) ffmpeg: false, // server reports ffmpeg available dirtyTimeline: true, gesture: false, workAreaPlay: false, // when true, play + Home/End stay inside IN/OUT @@ -221,8 +223,8 @@ const els = { exportOverlay: $("exportOverlay"), exportProgress: $("exportProgress"), exportTitle: $("exportTitle"), exportNote: $("exportNote"), projectName: $("projectName"), monitorRes: $("monitorRes"), - aspectSel: $("aspectSel"), btnGuides: $("btnGuides"), safeOverlay: $("safeOverlay"), - btnSpeed: $("btnSpeed"), + aspectSel: $("aspectSel"), btnGuides: $("btnGuides"), btnZoom100: $("btnZoom100"), + safeOverlay: $("safeOverlay"), btnSpeed: $("btnSpeed"), monitorStage: $("monitorStage"), exportSetup: $("exportSetup"), engineFast: $("engineFast"), engineRealtime: $("engineRealtime"), }; @@ -3307,6 +3309,7 @@ function pickClipAt(pt, W, H) { let canvasDrag = null, canvasDidMove = false; els.preview.style.touchAction = "none"; els.preview.addEventListener("pointerdown", (e) => { + if (e.altKey || e.button === 1) return; // leave to monitor pan const W = els.preview.width, H = els.preview.height, pt = canvasPt(e); const cur = getClip(state.selId); canvasDrag = null; @@ -4388,6 +4391,79 @@ els.btnGuides.addEventListener("click", () => { els.btnGuides.classList.toggle("on", state.guides); els.safeOverlay.classList.toggle("hidden", !state.guides); }); +/* ── Program-monitor view zoom (wheel) + 100% reset ── + Zooms the CSS-scaled canvas inside the clipped stage. Pointer mapping via + getBoundingClientRect already includes the transform, so canvas edits stay accurate. */ +const VIEW_ZOOM_MIN = 1, VIEW_ZOOM_MAX = 6; +function applyMonitorView() { + const z = state.viewZoom, pan = state.viewPan; + const zoomed = z > 1.001; + if (!zoomed) { + state.viewZoom = 1; + state.viewPan = { x: 0, y: 0 }; + els.preview.style.transform = ""; + } else { + els.preview.style.transform = `translate(${pan.x}px, ${pan.y}px) scale(${z})`; + } + els.btnZoom100.classList.toggle("hidden", !zoomed); + els.monitorStage.classList.toggle("is-zoomed", zoomed); + updateSafeOverlay(); +} +function resetMonitorView() { + state.viewZoom = 1; + state.viewPan = { x: 0, y: 0 }; + applyMonitorView(); +} +els.btnZoom100.addEventListener("click", resetMonitorView); +els.monitorStage.addEventListener("wheel", (e) => { + e.preventDefault(); + const stage = els.monitorStage.getBoundingClientRect(); + const mx = e.clientX - stage.left - stage.width / 2; + const my = e.clientY - stage.top - stage.height / 2; + const oldZ = state.viewZoom; + const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; + const next = clamp(+(oldZ * factor).toFixed(3), VIEW_ZOOM_MIN, VIEW_ZOOM_MAX); + if (Math.abs(next - oldZ) < 1e-4) { + if (next <= VIEW_ZOOM_MIN) resetMonitorView(); + return; + } + const pan = state.viewPan; + state.viewPan = { + x: mx - (mx - pan.x) * (next / oldZ), + y: my - (my - pan.y) * (next / oldZ), + }; + state.viewZoom = next; + if (next <= VIEW_ZOOM_MIN) resetMonitorView(); + else applyMonitorView(); +}, { passive: false }); +/* Pan while zoomed: middle mouse, or Alt+drag on the stage (not on a clip drag). */ +let viewPanDrag = null; +els.monitorStage.addEventListener("pointerdown", (e) => { + if (state.viewZoom <= 1.001) return; + if (e.button === 1 || (e.button === 0 && e.altKey)) { + viewPanDrag = { x: e.clientX, y: e.clientY, panX: state.viewPan.x, panY: state.viewPan.y }; + els.monitorStage.classList.add("is-panning"); + els.monitorStage.setPointerCapture(e.pointerId); + e.preventDefault(); + } +}); +els.monitorStage.addEventListener("pointermove", (e) => { + if (!viewPanDrag) return; + state.viewPan = { + x: viewPanDrag.panX + (e.clientX - viewPanDrag.x), + y: viewPanDrag.panY + (e.clientY - viewPanDrag.y), + }; + applyMonitorView(); +}); +function endViewPan(e) { + if (!viewPanDrag) return; + viewPanDrag = null; + els.monitorStage.classList.remove("is-panning"); + try { els.monitorStage.releasePointerCapture(e.pointerId); } catch { } +} +els.monitorStage.addEventListener("pointerup", endViewPan); +els.monitorStage.addEventListener("pointercancel", endViewPan); +els.monitorStage.addEventListener("auxclick", (e) => { if (e.button === 1) e.preventDefault(); }); /* Keep the guide overlay glued to the (letterboxed, CSS-scaled) canvas */ function updateSafeOverlay() { if (!state.guides) return; diff --git a/index.html b/index.html index 48763d3..70d9a80 100644 --- a/index.html +++ b/index.html @@ -66,6 +66,7 @@ + 1280 × 720 · 30fps diff --git a/style.css b/style.css index 51c6e8e..67e8832 100644 --- a/style.css +++ b/style.css @@ -559,6 +559,15 @@ body.track-size-s .clip.selected { max-height: 100%; display: block; background: #000; + transform-origin: center center; + will-change: transform; +} + +.monitor-stage.is-zoomed { + cursor: grab; +} +.monitor-stage.is-zoomed.is-panning { + cursor: grabbing; } /* ── Per-track VU (RMS / LUFS / Peak) ── */ From 2b5af2638de86554f73624a533f4f0cb3db74085 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 20 Jul 2026 10:09:46 +0300 Subject: [PATCH 2/6] feat: restyle zoom reset button --- index.html | 7 +++++-- style.css | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 70d9a80..b5a8b95 100644 --- a/index.html +++ b/index.html @@ -62,11 +62,14 @@
-
Program Monitor +
+ + Program Monitor + + - 1280 × 720 · 30fps
diff --git a/style.css b/style.css index 67e8832..392206a 100644 --- a/style.css +++ b/style.css @@ -365,6 +365,19 @@ body.track-size-s .clip.selected { border-bottom: 1px solid var(--border); } +.panel-head-title { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.panel-head-title .btn { + text-transform: none; + letter-spacing: normal; + font-weight: 600; +} + .panel-head-actions { display: flex; gap: 6px; From 63878619e7d3f0782b23d09c3b9109cbb53a0ce1 Mon Sep 17 00:00:00 2001 From: = <=> Date: Fri, 24 Jul 2026 17:25:28 +0300 Subject: [PATCH 3/6] fix: fix CodeRabbit findings, clmpa max zoom to 2x real pixels --- app.js | 57 ++++++++++++++++++++++++++++++++++++++++++++++-------- index.html | 2 +- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/app.js b/app.js index eccdd10..5c7c972 100644 --- a/app.js +++ b/app.js @@ -4391,10 +4391,36 @@ els.btnGuides.addEventListener("click", () => { els.btnGuides.classList.toggle("on", state.guides); els.safeOverlay.classList.toggle("hidden", !state.guides); }); -/* ── Program-monitor view zoom (wheel) + 100% reset ── +/* ── Program-monitor view zoom (wheel) + fit reset ── Zooms the CSS-scaled canvas inside the clipped stage. Pointer mapping via - getBoundingClientRect already includes the transform, so canvas edits stay accurate. */ -const VIEW_ZOOM_MIN = 1, VIEW_ZOOM_MAX = 6; + getBoundingClientRect already includes the transform, so canvas edits stay accurate. + Max zoom = VIEW_PIXEL_MAX screen CSS pixels per canvas pixel (not a fixed ×fit). */ +const VIEW_ZOOM_MIN = 1; +const VIEW_PIXEL_MAX = 2; +function maxViewZoom() { + const layoutW = els.preview.offsetWidth; + const pxW = project.width || els.preview.width; + if (!layoutW || !pxW) return VIEW_ZOOM_MIN; + return Math.max(VIEW_ZOOM_MIN, VIEW_PIXEL_MAX * pxW / layoutW); +} +/** Keep pan so the scaled canvas still covers the stage (can't drag it fully off-screen). */ +function clampViewPan(stageRect) { + const z = state.viewZoom; + if (z <= 1.001) { + state.viewPan = { x: 0, y: 0 }; + return; + } + const stage = stageRect || els.monitorStage.getBoundingClientRect(); + // offsetWidth/Height are layout size (ignore CSS transform); scaled size = layout × zoom + const cw = els.preview.offsetWidth, ch = els.preview.offsetHeight; + if (!cw || !ch || !stage.width || !stage.height) return; + const maxX = Math.max(0, (cw * z - stage.width) / 2); + const maxY = Math.max(0, (ch * z - stage.height) / 2); + state.viewPan = { + x: clamp(state.viewPan.x, -maxX, maxX), + y: clamp(state.viewPan.y, -maxY, maxY), + }; +} function applyMonitorView() { const z = state.viewZoom, pan = state.viewPan; const zoomed = z > 1.001; @@ -4409,10 +4435,23 @@ function applyMonitorView() { els.monitorStage.classList.toggle("is-zoomed", zoomed); updateSafeOverlay(); } +let monitorViewRaf = 0; +/** Coalesce repeated zoom/pan updates into one paint (updateSafeOverlay ≤ once/frame). */ +function scheduleMonitorView() { + if (monitorViewRaf) return; + monitorViewRaf = requestAnimationFrame(() => { + monitorViewRaf = 0; + applyMonitorView(); + }); +} function resetMonitorView() { state.viewZoom = 1; state.viewPan = { x: 0, y: 0 }; - applyMonitorView(); + if (monitorViewRaf) { + cancelAnimationFrame(monitorViewRaf); + monitorViewRaf = 0; + } + applyMonitorView(); // immediate — don't wait a frame to clear zoom } els.btnZoom100.addEventListener("click", resetMonitorView); els.monitorStage.addEventListener("wheel", (e) => { @@ -4422,19 +4461,20 @@ els.monitorStage.addEventListener("wheel", (e) => { const my = e.clientY - stage.top - stage.height / 2; const oldZ = state.viewZoom; const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; - const next = clamp(+(oldZ * factor).toFixed(3), VIEW_ZOOM_MIN, VIEW_ZOOM_MAX); + const next = clamp(+(oldZ * factor).toFixed(3), VIEW_ZOOM_MIN, maxViewZoom()); if (Math.abs(next - oldZ) < 1e-4) { if (next <= VIEW_ZOOM_MIN) resetMonitorView(); return; } const pan = state.viewPan; + state.viewZoom = next; state.viewPan = { x: mx - (mx - pan.x) * (next / oldZ), y: my - (my - pan.y) * (next / oldZ), }; - state.viewZoom = next; + clampViewPan(stage); if (next <= VIEW_ZOOM_MIN) resetMonitorView(); - else applyMonitorView(); + else scheduleMonitorView(); }, { passive: false }); /* Pan while zoomed: middle mouse, or Alt+drag on the stage (not on a clip drag). */ let viewPanDrag = null; @@ -4453,7 +4493,8 @@ els.monitorStage.addEventListener("pointermove", (e) => { x: viewPanDrag.panX + (e.clientX - viewPanDrag.x), y: viewPanDrag.panY + (e.clientY - viewPanDrag.y), }; - applyMonitorView(); + clampViewPan(); + scheduleMonitorView(); }); function endViewPan(e) { if (!viewPanDrag) return; diff --git a/index.html b/index.html index b5a8b95..2300634 100644 --- a/index.html +++ b/index.html @@ -65,7 +65,7 @@
Program Monitor - + From 4197ab2faddd3b0cd8afd71afa55b39030486e92 Mon Sep 17 00:00:00 2001 From: = <=> Date: Fri, 24 Jul 2026 17:36:53 +0300 Subject: [PATCH 4/6] feat: add scrollbars when monitor is zomed in --- app.js | 183 ++++++++++++++++++++++++++++++++++------------------- index.html | 16 +++-- style.css | 58 +++++++++++++---- 3 files changed, 173 insertions(+), 84 deletions(-) diff --git a/app.js b/app.js index 5c7c972..18f3863 100644 --- a/app.js +++ b/app.js @@ -154,7 +154,6 @@ const state = { rendering: false, // fast (server/ffmpeg) export in progress guides: false, // safe-area overlay on the monitor viewZoom: 1, // program-monitor display zoom (1 = fit stage) - viewPan: { x: 0, y: 0 }, // px offset while zoomed (zoom-to-cursor / pan) ffmpeg: false, // server reports ffmpeg available dirtyTimeline: true, gesture: false, workAreaPlay: false, // when true, play + Home/End stay inside IN/OUT @@ -225,7 +224,8 @@ const els = { projectName: $("projectName"), monitorRes: $("monitorRes"), aspectSel: $("aspectSel"), btnGuides: $("btnGuides"), btnZoom100: $("btnZoom100"), safeOverlay: $("safeOverlay"), btnSpeed: $("btnSpeed"), - monitorStage: $("monitorStage"), + monitorStage: $("monitorStage"), monitorScroll: $("monitorScroll"), + monitorZoomInner: $("monitorZoomInner"), exportSetup: $("exportSetup"), engineFast: $("engineFast"), engineRealtime: $("engineRealtime"), }; const ctx2d = els.preview.getContext("2d"); @@ -4390,49 +4390,67 @@ els.btnGuides.addEventListener("click", () => { state.guides = !state.guides; els.btnGuides.classList.toggle("on", state.guides); els.safeOverlay.classList.toggle("hidden", !state.guides); + if (state.guides) updateSafeOverlay(); }); /* ── Program-monitor view zoom (wheel) + fit reset ── - Zooms the CSS-scaled canvas inside the clipped stage. Pointer mapping via - getBoundingClientRect already includes the transform, so canvas edits stay accurate. - Max zoom = VIEW_PIXEL_MAX screen CSS pixels per canvas pixel (not a fixed ×fit). */ + Zoom enlarges the canvas layout size inside a scrollport (native scrollbars), + not a CSS transform — overflow stays reachable. Pointer mapping via + getBoundingClientRect still tracks the visible canvas. + Max zoom = VIEW_PIXEL_MAX screen CSS pixels per canvas pixel. */ const VIEW_ZOOM_MIN = 1; const VIEW_PIXEL_MAX = 2; +let monitorFitCache = null; // {w,h} fit size captured at zoom start (stable while zoomed) +let monitorViewPad = { x: 0, y: 0 }; // content padding so any canvas point can sit under the cursor +function measureMonitorFit() { + const sw = els.monitorStage.clientWidth, sh = els.monitorStage.clientHeight; + const pw = project.width || els.preview.width || 1; + const ph = project.height || els.preview.height || 1; + const s = Math.min(sw / pw, sh / ph); + return { w: pw * s, h: ph * s }; +} +function monitorFitSize() { + if (monitorFitCache) return monitorFitCache; + const w = els.preview.offsetWidth, h = els.preview.offsetHeight; + if (w > 0 && h > 0 && state.viewZoom <= 1.001) return { w, h }; + return measureMonitorFit(); +} function maxViewZoom() { - const layoutW = els.preview.offsetWidth; + const { w } = monitorFitSize(); const pxW = project.width || els.preview.width; - if (!layoutW || !pxW) return VIEW_ZOOM_MIN; - return Math.max(VIEW_ZOOM_MIN, VIEW_PIXEL_MAX * pxW / layoutW); -} -/** Keep pan so the scaled canvas still covers the stage (can't drag it fully off-screen). */ -function clampViewPan(stageRect) { - const z = state.viewZoom; - if (z <= 1.001) { - state.viewPan = { x: 0, y: 0 }; - return; - } - const stage = stageRect || els.monitorStage.getBoundingClientRect(); - // offsetWidth/Height are layout size (ignore CSS transform); scaled size = layout × zoom - const cw = els.preview.offsetWidth, ch = els.preview.offsetHeight; - if (!cw || !ch || !stage.width || !stage.height) return; - const maxX = Math.max(0, (cw * z - stage.width) / 2); - const maxY = Math.max(0, (ch * z - stage.height) / 2); - state.viewPan = { - x: clamp(state.viewPan.x, -maxX, maxX), - y: clamp(state.viewPan.y, -maxY, maxY), - }; + if (!w || !pxW) return VIEW_ZOOM_MIN; + return Math.max(VIEW_ZOOM_MIN, VIEW_PIXEL_MAX * pxW / w); } function applyMonitorView() { - const z = state.viewZoom, pan = state.viewPan; + const z = state.viewZoom; const zoomed = z > 1.001; + const scroll = els.monitorScroll; + const inner = els.monitorZoomInner; if (!zoomed) { state.viewZoom = 1; - state.viewPan = { x: 0, y: 0 }; - els.preview.style.transform = ""; + monitorFitCache = null; + monitorViewPad = { x: 0, y: 0 }; + els.preview.style.width = ""; + els.preview.style.height = ""; + if (inner) inner.style.padding = ""; + scroll.scrollLeft = 0; + scroll.scrollTop = 0; } else { - els.preview.style.transform = `translate(${pan.x}px, ${pan.y}px) scale(${z})`; + if (!monitorFitCache) monitorFitCache = monitorFitSize(); + const fit = monitorFitCache; + els.preview.style.width = (fit.w * z) + "px"; + els.preview.style.height = (fit.h * z) + "px"; + // Pad by the stage size so scrollLeft can be "negative" relative to the canvas + // (needed when zooming from a centered fit letterbox without jumping). + if (!monitorViewPad.x && !monitorViewPad.y) { + monitorViewPad = { + x: Math.ceil(els.monitorStage.clientWidth || scroll.clientWidth || 0), + y: Math.ceil(els.monitorStage.clientHeight || scroll.clientHeight || 0), + }; + } + if (inner) inner.style.padding = `${monitorViewPad.y}px ${monitorViewPad.x}px`; } els.btnZoom100.classList.toggle("hidden", !zoomed); - els.monitorStage.classList.toggle("is-zoomed", zoomed); + scroll.classList.toggle("is-zoomed", zoomed); updateSafeOverlay(); } let monitorViewRaf = 0; @@ -4446,7 +4464,8 @@ function scheduleMonitorView() { } function resetMonitorView() { state.viewZoom = 1; - state.viewPan = { x: 0, y: 0 }; + monitorFitCache = null; + monitorViewPad = { x: 0, y: 0 }; if (monitorViewRaf) { cancelAnimationFrame(monitorViewRaf); monitorViewRaf = 0; @@ -4454,65 +4473,97 @@ function resetMonitorView() { applyMonitorView(); // immediate — don't wait a frame to clear zoom } els.btnZoom100.addEventListener("click", resetMonitorView); -els.monitorStage.addEventListener("wheel", (e) => { +els.monitorScroll.addEventListener("wheel", (e) => { e.preventDefault(); - const stage = els.monitorStage.getBoundingClientRect(); - const mx = e.clientX - stage.left - stage.width / 2; - const my = e.clientY - stage.top - stage.height / 2; + const scroll = els.monitorScroll; + const rect = scroll.getBoundingClientRect(); + const ox = e.clientX - rect.left; + const oy = e.clientY - rect.top; const oldZ = state.viewZoom; + if (oldZ <= 1.001) { + monitorFitCache = { + w: els.preview.offsetWidth || measureMonitorFit().w, + h: els.preview.offsetHeight || measureMonitorFit().h, + }; + } + const fit = monitorFitSize(); const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; const next = clamp(+(oldZ * factor).toFixed(3), VIEW_ZOOM_MIN, maxViewZoom()); if (Math.abs(next - oldZ) < 1e-4) { if (next <= VIEW_ZOOM_MIN) resetMonitorView(); return; } - const pan = state.viewPan; + // Fraction of the canvas under the cursor (works for centered fit and scrolled zoom). + const cv = els.preview.getBoundingClientRect(); + const relX = cv.width > 0 ? (e.clientX - cv.left) / cv.width : 0.5; + const relY = cv.height > 0 ? (e.clientY - cv.top) / cv.height : 0.5; state.viewZoom = next; - state.viewPan = { - x: mx - (mx - pan.x) * (next / oldZ), - y: my - (my - pan.y) * (next / oldZ), - }; - clampViewPan(stage); - if (next <= VIEW_ZOOM_MIN) resetMonitorView(); - else scheduleMonitorView(); + if (next <= VIEW_ZOOM_MIN) { + resetMonitorView(); + return; + } + applyMonitorView(); + void scroll.scrollWidth; // ensure padding/size are laid out before assigning scroll + const pad = monitorViewPad; + scroll.scrollLeft = pad.x + relX * fit.w * next - ox; + scroll.scrollTop = pad.y + relY * fit.h * next - oy; }, { passive: false }); -/* Pan while zoomed: middle mouse, or Alt+drag on the stage (not on a clip drag). */ +/* Pan while zoomed: middle mouse, or Alt+drag — drives native scroll position. */ let viewPanDrag = null; -els.monitorStage.addEventListener("pointerdown", (e) => { +els.monitorScroll.addEventListener("pointerdown", (e) => { if (state.viewZoom <= 1.001) return; if (e.button === 1 || (e.button === 0 && e.altKey)) { - viewPanDrag = { x: e.clientX, y: e.clientY, panX: state.viewPan.x, panY: state.viewPan.y }; - els.monitorStage.classList.add("is-panning"); - els.monitorStage.setPointerCapture(e.pointerId); + const scroll = els.monitorScroll; + viewPanDrag = { x: e.clientX, y: e.clientY, sl: scroll.scrollLeft, st: scroll.scrollTop }; + scroll.classList.add("is-panning"); + scroll.setPointerCapture(e.pointerId); e.preventDefault(); } }); -els.monitorStage.addEventListener("pointermove", (e) => { +els.monitorScroll.addEventListener("pointermove", (e) => { if (!viewPanDrag) return; - state.viewPan = { - x: viewPanDrag.panX + (e.clientX - viewPanDrag.x), - y: viewPanDrag.panY + (e.clientY - viewPanDrag.y), - }; - clampViewPan(); - scheduleMonitorView(); + const scroll = els.monitorScroll; + scroll.scrollLeft = viewPanDrag.sl - (e.clientX - viewPanDrag.x); + scroll.scrollTop = viewPanDrag.st - (e.clientY - viewPanDrag.y); }); function endViewPan(e) { if (!viewPanDrag) return; viewPanDrag = null; - els.monitorStage.classList.remove("is-panning"); - try { els.monitorStage.releasePointerCapture(e.pointerId); } catch { } + els.monitorScroll.classList.remove("is-panning"); + try { els.monitorScroll.releasePointerCapture(e.pointerId); } catch { } +} +els.monitorScroll.addEventListener("pointerup", endViewPan); +els.monitorScroll.addEventListener("pointercancel", endViewPan); +els.monitorScroll.addEventListener("auxclick", (e) => { if (e.button === 1) e.preventDefault(); }); +els.monitorScroll.addEventListener("scroll", () => { + if (state.guides) updateSafeOverlay(); +}); +if (typeof ResizeObserver !== "undefined") { + new ResizeObserver(() => { + if (state.viewZoom <= 1.001) { + monitorFitCache = null; + if (state.guides) updateSafeOverlay(); + return; + } + const scroll = els.monitorScroll; + const relX = scroll.scrollWidth > 0 ? scroll.scrollLeft / scroll.scrollWidth : 0; + const relY = scroll.scrollHeight > 0 ? scroll.scrollTop / scroll.scrollHeight : 0; + monitorFitCache = measureMonitorFit(); + if (state.viewZoom > maxViewZoom()) state.viewZoom = maxViewZoom(); + applyMonitorView(); + scroll.scrollLeft = relX * scroll.scrollWidth; + scroll.scrollTop = relY * scroll.scrollHeight; + }).observe(els.monitorStage); } -els.monitorStage.addEventListener("pointerup", endViewPan); -els.monitorStage.addEventListener("pointercancel", endViewPan); -els.monitorStage.addEventListener("auxclick", (e) => { if (e.button === 1) e.preventDefault(); }); -/* Keep the guide overlay glued to the (letterboxed, CSS-scaled) canvas */ +/* Keep the guide overlay glued to the canvas inside .monitor-zoom-inner */ function updateSafeOverlay() { if (!state.guides) return; - const stage = els.monitorStage.getBoundingClientRect(); - const cv = els.preview.getBoundingClientRect(); + const cv = els.preview; const o = els.safeOverlay.style; - o.left = (cv.left - stage.left) + "px"; o.top = (cv.top - stage.top) + "px"; - o.width = cv.width + "px"; o.height = cv.height + "px"; + o.left = cv.offsetLeft + "px"; + o.top = cv.offsetTop + "px"; + o.width = cv.offsetWidth + "px"; + o.height = cv.offsetHeight + "px"; els.safeOverlay.classList.toggle("vertical", project.height > project.width); } diff --git a/index.html b/index.html index 2300634..db0cf2f 100644 --- a/index.html +++ b/index.html @@ -74,12 +74,16 @@
- - diff --git a/style.css b/style.css index 392206a..cfba42c 100644 --- a/style.css +++ b/style.css @@ -557,8 +557,7 @@ body.track-size-s .clip.selected { .monitor-stage { flex: 1; display: flex; - align-items: center; - justify-content: center; + flex-direction: column; background: #000; min-height: 0; margin: 8px; @@ -567,26 +566,61 @@ body.track-size-s .clip.selected { position: relative; } +.monitor-scroll { + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.monitor-scroll.is-zoomed { + display: block; + overflow: auto; + cursor: grab; +} + +.monitor-scroll.is-zoomed.is-panning { + cursor: grabbing; +} + +.monitor-zoom-inner { + position: relative; + line-height: 0; + flex-shrink: 0; +} + +/* Fit mode: fill the scrollport so #preview max-% resolves against the stage */ +.monitor-scroll:not(.is-zoomed) .monitor-zoom-inner { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.monitor-scroll.is-zoomed .monitor-zoom-inner { + display: inline-block; +} + #preview { max-width: 100%; max-height: 100%; display: block; background: #000; - transform-origin: center center; - will-change: transform; } -.monitor-stage.is-zoomed { - cursor: grab; -} -.monitor-stage.is-zoomed.is-panning { - cursor: grabbing; +.monitor-scroll.is-zoomed #preview { + max-width: none; + max-height: none; } /* ── Per-track VU (RMS / LUFS / Peak) ── */ .vu-meter { position: absolute; - right: 2px; + right: 8px; bottom: 10px; display: flex; flex-direction: column; @@ -602,7 +636,7 @@ body.track-size-s .clip.selected { display: block; width: 100%; margin: 0; - padding: 2px 0 2px; + padding: 2px 0 2px 14px; border: 0; background: transparent; color: #c8c8d0; @@ -616,7 +650,7 @@ body.track-size-s .clip.selected { .vu-channels { display: flex; align-items: flex-start; - gap: 4px; + gap: 2px; } .vu-scale { position: relative; From 301e53c890eb2c89c0560755125c29d379cc9e63 Mon Sep 17 00:00:00 2001 From: = <=> Date: Fri, 24 Jul 2026 17:41:24 +0300 Subject: [PATCH 5/6] feat: update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 130be41..62db57b 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ same time. - Press Alt+t to add an in/out transition based on the playhead position over the selected clip. The last used transition is remembered as the default. Drag the overlay triangle to adjust duration; Delete clears the focused transition. - Real decoded audio waveforms on clips - Canvas aspect presets (16:9, 9:16 reels, 4:5, 1:1) + safe-area guides +- **Program Monitor zoom** — mouse-wheel over the preview zooms the composition + toward the cursor (fit → up to **2 screen pixels per canvas pixel**). Magnified + view uses **native scrollbars** so overflow stays reachable; middle-click or + Alt+drag pans. The **Fit** button (shown while zoomed) resets to the + fit-to-stage baseline - Preview playback speed — shuttle the monitor through 1×/1.5×/2×/4× with **J**/**K**/**L** (from a stop J/L start playback; while playing L steps faster and J slower, K toggles play/pause and resets to 1×); affects the From fbcd69a9af040796d3ddb095e530473a1c06d2b0 Mon Sep 17 00:00:00 2001 From: = <=> Date: Fri, 24 Jul 2026 17:59:16 +0300 Subject: [PATCH 6/6] feat: fix CodeRabbit (valid) findings --- app.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/app.js b/app.js index 18f3863..15c551c 100644 --- a/app.js +++ b/app.js @@ -4454,18 +4454,24 @@ function applyMonitorView() { updateSafeOverlay(); } let monitorViewRaf = 0; +let monitorViewAfter = null; /** Coalesce repeated zoom/pan updates into one paint (updateSafeOverlay ≤ once/frame). */ -function scheduleMonitorView() { +function scheduleMonitorView(after) { + if (after) monitorViewAfter = after; if (monitorViewRaf) return; monitorViewRaf = requestAnimationFrame(() => { monitorViewRaf = 0; + const fn = monitorViewAfter; + monitorViewAfter = null; applyMonitorView(); + if (fn) fn(); }); } function resetMonitorView() { state.viewZoom = 1; monitorFitCache = null; monitorViewPad = { x: 0, y: 0 }; + monitorViewAfter = null; if (monitorViewRaf) { cancelAnimationFrame(monitorViewRaf); monitorViewRaf = 0; @@ -4502,11 +4508,12 @@ els.monitorScroll.addEventListener("wheel", (e) => { resetMonitorView(); return; } - applyMonitorView(); - void scroll.scrollWidth; // ensure padding/size are laid out before assigning scroll - const pad = monitorViewPad; - scroll.scrollLeft = pad.x + relX * fit.w * next - ox; - scroll.scrollTop = pad.y + relY * fit.h * next - oy; + scheduleMonitorView(() => { + void scroll.scrollWidth; // ensure padding/size are laid out before assigning scroll + const pad = monitorViewPad; + scroll.scrollLeft = pad.x + relX * fit.w * next - ox; + scroll.scrollTop = pad.y + relY * fit.h * next - oy; + }); }, { passive: false }); /* Pan while zoomed: middle mouse, or Alt+drag — drives native scroll position. */ let viewPanDrag = null; @@ -4537,6 +4544,7 @@ els.monitorScroll.addEventListener("pointercancel", endViewPan); els.monitorScroll.addEventListener("auxclick", (e) => { if (e.button === 1) e.preventDefault(); }); els.monitorScroll.addEventListener("scroll", () => { if (state.guides) updateSafeOverlay(); + scheduleMonitorView(); }); if (typeof ResizeObserver !== "undefined") { new ResizeObserver(() => {