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(() => {