screen: add fit-to-window toggle

Adds a 'fit' toolbar toggle on the /screen VNC viewer. When on (the
default), the canvas is CSS-scaled down to fit the browser viewport
preserving aspect ratio — no more scrolling a desktop larger than the
window. When off, the canvas renders at native resolution with scroll.
The choice persists in localStorage.

The canvas's intrinsic resolution is never touched — only its display
size. sendPointer now rescales client coordinates by the canvas
display-vs-intrinsic ratio so clicks stay accurate in fit mode (this
also fixes a latent off-by-scale bug). Toolbar buttons share a .tbtn
class for consistent styling.

Issue #133 (fit toggle now; dynamic desktop resize tracked separately).
This commit is contained in:
iris 2026-05-20 22:56:19 +02:00
parent 1f52746bd9
commit 305a32220b

View file

@ -30,6 +30,12 @@ html, body { height: 100%; background: var(--base); color: var(--text);
} }
#toolbar a { color: var(--blue); text-decoration: none; font-size: 0.85rem; } #toolbar a { color: var(--blue); text-decoration: none; font-size: 0.85rem; }
#toolbar a:hover { text-decoration: underline; } #toolbar a:hover { text-decoration: underline; }
.tbtn {
padding: 0.15rem 0.5rem; font-size: 0.72rem; font-family: inherit;
background: var(--surface0); color: var(--subtext0);
border: 1px solid var(--surface1); border-radius: 4px; cursor: pointer;
}
.tbtn.active { color: var(--green); border-color: var(--green); }
#status { margin-left: auto; font-size: 0.75rem; color: var(--subtext0); } #status { margin-left: auto; font-size: 0.75rem; color: var(--subtext0); }
#status.connected { color: var(--green); } #status.connected { color: var(--green); }
#status.error { color: var(--red); } #status.error { color: var(--red); }
@ -50,7 +56,12 @@ html, body { height: 100%; background: var(--base); color: var(--text);
width: 100%; height: calc(100% - 36px); overflow: auto; width: 100%; height: calc(100% - 36px); overflow: auto;
background: var(--crust); background: var(--crust);
} }
/* Fit mode: centre the canvas and let CSS scale it down to the
viewport (intrinsic resolution unchanged — see canvas.fit). */
#canvas-wrap.fit { align-items: center; overflow: hidden; }
canvas { display: block; cursor: default; } canvas { display: block; cursor: default; }
/* max-* on a replaced element shrinks it preserving aspect ratio. */
canvas.fit { max-width: 100%; max-height: 100%; }
#msg { #msg {
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
background: var(--surface0); color: var(--yellow); border-radius: 6px; background: var(--surface0); color: var(--yellow); border-radius: 6px;
@ -64,10 +75,8 @@ canvas { display: block; cursor: default; }
<div id="toolbar"> <div id="toolbar">
<strong>🖥 screen</strong> <strong>🖥 screen</strong>
<a href="/" title="back to agent page">← agent</a> <a href="/" title="back to agent page">← agent</a>
<button id="debug-toggle" title="Toggle RFB debug log" <button id="fit-toggle" class="tbtn" title="Toggle fit-to-window scaling">⤢ fit</button>
style="margin-left:0.5rem;padding:0.15rem 0.5rem;font-size:0.72rem; <button id="debug-toggle" class="tbtn" title="Toggle RFB debug log">debug</button>
background:var(--surface0);color:var(--subtext0);border:1px solid var(--surface1);
border-radius:4px;cursor:pointer;">debug</button>
<span id="status">connecting…</span> <span id="status">connecting…</span>
</div> </div>
<div id="canvas-wrap"><canvas id="c"></canvas></div> <div id="canvas-wrap"><canvas id="c"></canvas></div>
@ -92,15 +101,36 @@ canvas { display: block; cursor: default; }
const msg = document.getElementById('msg'); const msg = document.getElementById('msg');
const debugLog = document.getElementById('debug-log'); const debugLog = document.getElementById('debug-log');
const debugBtn = document.getElementById('debug-toggle'); const debugBtn = document.getElementById('debug-toggle');
const fitBtn = document.getElementById('fit-toggle');
const canvasWrap = document.getElementById('canvas-wrap');
// --- Debug log --- // --- Debug log ---
let debugVisible = false; let debugVisible = false;
debugBtn.addEventListener('click', () => { debugBtn.addEventListener('click', () => {
debugVisible = !debugVisible; debugVisible = !debugVisible;
debugLog.style.display = debugVisible ? 'block' : 'none'; debugLog.style.display = debugVisible ? 'block' : 'none';
debugBtn.style.color = debugVisible ? 'var(--green)' : 'var(--subtext0)'; debugBtn.classList.toggle('active', debugVisible);
}); });
// --- Fit-to-window toggle ---
// Scales the canvas down via CSS so the whole desktop is visible
// without scrolling. The canvas's intrinsic resolution is untouched;
// only its displayed size changes (see canvas.fit / #canvas-wrap.fit).
// Pointer coordinates are rescaled in sendPointer to stay accurate.
// The choice is persisted in localStorage; default is fit-on.
let fitMode = localStorage.getItem('screen-fit') !== 'off';
function applyFitMode() {
canvas.classList.toggle('fit', fitMode);
canvasWrap.classList.toggle('fit', fitMode);
fitBtn.classList.toggle('active', fitMode);
}
fitBtn.addEventListener('click', () => {
fitMode = !fitMode;
localStorage.setItem('screen-fit', fitMode ? 'on' : 'off');
applyFitMode();
});
applyFitMode();
function hex(bytes) { function hex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join(' '); return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join(' ');
} }
@ -570,8 +600,12 @@ canvas { display: block; cursor: default; }
function sendPointer(ev) { function sendPointer(ev) {
const r = canvas.getBoundingClientRect(); const r = canvas.getBoundingClientRect();
const x = Math.max(0, Math.min(fbW-1, ev.clientX - r.left)); // In fit mode the canvas is CSS-scaled, so the rendered rect differs
const y = Math.max(0, Math.min(fbH-1, ev.clientY - r.top)); // from the intrinsic resolution — map client coords back to fb pixels.
const sx = r.width ? canvas.width / r.width : 1;
const sy = r.height ? canvas.height / r.height : 1;
const x = Math.max(0, Math.min(fbW-1, Math.round((ev.clientX - r.left) * sx)));
const y = Math.max(0, Math.min(fbH-1, Math.round((ev.clientY - r.top) * sy)));
let mask = 0; let mask = 0;
if (ev.buttons & 1) mask |= 1; if (ev.buttons & 1) mask |= 1;
if (ev.buttons & 4) mask |= 2; if (ev.buttons & 4) mask |= 2;