feat: touchpad and keyboard scale document instad of browser ui

This commit is contained in:
Steffen Becker committed 2026-07-31 18:37:17 +02:00
1 parent ca4fbd422b
commit 7cc9a36d18
4 files changed
+129 -22

No files matched your search

+28 -6
View File
@@ -19,6 +19,7 @@
import { getColorDebug } from './lib/utils/colorDebug';
import { resetHistoryLog } from './lib/utils/historyLog.svelte';
import { countText, type TextStats } from './lib/utils/wordCount';
import { clampZoom, wheelZoomFactor, MIN_ZOOM, MAX_ZOOM } from './lib/utils/zoom';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme';
import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins';
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
@@ -102,7 +103,7 @@
let themeOpen = $state(false);
let toolbarExpanded = $state(loadToolbarExpanded());
let showFormattingMarks = $state(loadFormattingMarks());
let zoom = $state(Math.max(20, Math.min(300, parseInt(localStorage.getItem('odf-editor-zoom') ?? '100', 10))));
let zoom = $state(clampZoom(parseInt(localStorage.getItem('odf-editor-zoom') ?? '100', 10)));
let pageMargins: PageMargins = $state(loadPageMargins());
let pageOrientation: Orientation = $state(loadOrientation());
let pageFormat: PageFormat = $state(loadPageFormat());
@@ -204,7 +205,7 @@
});
function setZoom(value: number) {
zoom = Math.max(20, Math.min(300, value));
zoom = clampZoom(value);
localStorage.setItem('odf-editor-zoom', String(zoom));
}
@@ -265,6 +266,13 @@
// Wheel over the toolbar scrolls it horizontally (it has no vertical scroll), so a
// trackpad/mouse can pan to the hidden buttons without grabbing the scrollbar.
function onToolbarWheel(e: WheelEvent) {
// A zoom gesture, not a pan: zoom the document rather than let the browser scale
// the whole app. No pointer anchor here — the document isn't under the cursor.
if (e.ctrlKey) {
e.preventDefault();
setZoom(zoom * wheelZoomFactor(e.deltaY, e.deltaMode));
return;
}
if (tbOverflow <= 0) return;
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (delta === 0) return;
@@ -682,6 +690,19 @@
e.preventDefault();
openFind('replace');
}
// Ctrl/Cmd +/−/0 → zoom the document, not the browser window.
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
if (e.key === '+' || e.key === '=' || e.code === 'NumpadAdd') {
e.preventDefault();
setZoom(zoom + 10);
} else if (e.key === '-' || e.code === 'NumpadSubtract') {
e.preventDefault();
setZoom(zoom - 10);
} else if (e.key === '0' || e.code === 'Numpad0') {
e.preventDefault();
setZoom(100);
}
}
// Escape closes the bar (when it isn't handled inside an input).
if (e.key === 'Escape' && findOpen) closeFind();
}
@@ -974,6 +995,7 @@
bind:hfTick
{hfDistances}
{zoom}
onZoom={setZoom}
{showFormattingMarks}
{pageMargins}
orientation={pageOrientation}
@@ -1026,18 +1048,18 @@
</div>
<div class="sb-right">
<div class="zoom-controls">
<button class="zoom-btn" onclick={() => setZoom(zoom - 10)} disabled={zoom <= 20} title={t().status.zoomOut}>−</button>
<button class="zoom-btn" onclick={() => setZoom(zoom - 10)} disabled={zoom <= MIN_ZOOM} title={t().status.zoomOut}>−</button>
<input
type="range"
class="zoom-slider"
min="20"
max="300"
min={MIN_ZOOM}
max={MAX_ZOOM}
step="1"
value={zoom}
oninput={(e) => setZoom(parseInt((e.target as HTMLInputElement).value, 10))}
title={t().status.zoom}
/>
<button class="zoom-btn" onclick={() => setZoom(zoom + 10)} disabled={zoom >= 300} title={t().status.zoomIn}>+</button>
<button class="zoom-btn" onclick={() => setZoom(zoom + 10)} disabled={zoom >= MAX_ZOOM} title={t().status.zoomIn}>+</button>
<button class="zoom-pct" onclick={() => setZoom(100)} title={t().status.resetZoom}>{zoom}%</button>
</div>
</div>
+43 -16
View File
@@ -23,6 +23,7 @@
import { DEFAULT_HF_DISTANCES, hfIsEmpty, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
import { wheelZoomFactor } from '../utils/zoom';
import { styleCss } from '../styles/styleSheet';
import { styleSheet } from '../styles/sheet.svelte';
import { t } from '../i18n/i18n.svelte';
@@ -33,7 +34,7 @@
let {
editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1),
zoom = 100, showFormattingMarks = false, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
zoom = 100, onZoom, showFormattingMarks = false, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
pageFormat = 'A4',
headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES,
headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false,
@@ -41,6 +42,7 @@
hfEditor = $bindable(null), hfActive = $bindable(null), hfTick = $bindable(0),
}: {
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number;
onZoom?: (zoom: number) => void;
showFormattingMarks?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat;
headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances;
headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean;
@@ -480,35 +482,60 @@
});
});
// Preserve the top-of-viewport anchor across zoom changes.
// Ctrl+wheel — a touchpad two-finger zoom fires exactly this — zooms the document
// instead of the browser. `wheel` on a plain element is non-passive, so the
// preventDefault sticks. The pointer is the zoom anchor.
function onWheel(e: WheelEvent) {
if (!e.ctrlKey || !onZoom) return;
e.preventDefault();
pendingAnchor = { x: e.clientX, y: e.clientY };
onZoom(zoom * wheelZoomFactor(e.deltaY, e.deltaMode));
}
// The point held fixed across a zoom change: the pointer for a wheel zoom, else
// (slider, buttons, keyboard) the top of the viewport.
let prevZoom = -1;
let pendingAnchorDocY: number | null = null;
let pendingAnchor: { x: number; y: number } | null = null;
let pendingAnchorDoc: { x: number; y: number; screenX: number; screenY: number } | null = null;
$effect.pre(() => {
const z = appliedZoom;
if (prevZoom < 0 || !editorContainer || !element || z === prevZoom) {
// The scaled footprint drives the horizontal centering, so it has to reach the DOM
// in the same flush as the transform — the post-effect measures against it.
// Untracked so only a zoom change runs the anchor logic (onPageCount recomputes too).
untrack(recomputeScaledSize);
if (prevZoom < 0 || !editorContainer || !paperEl || z === prevZoom) {
prevZoom = z;
pendingAnchor = null;
return;
}
const editorRect = editorContainer.getBoundingClientRect();
const paperRect = element.getBoundingClientRect();
pendingAnchorDocY = (editorRect.top - paperRect.top) / (prevZoom / 100);
const paperRect = paperEl.getBoundingClientRect();
const screenX = pendingAnchor ? pendingAnchor.x : paperRect.left;
const screenY = pendingAnchor ? pendingAnchor.y : editorRect.top;
const s = prevZoom / 100;
pendingAnchorDoc = {
x: (screenX - paperRect.left) / s,
y: (screenY - paperRect.top) / s,
screenX, screenY,
};
pendingAnchor = null;
prevZoom = z;
});
$effect(() => {
appliedZoom; // track to fire after the pre-effect / DOM update
// Re-reserve the scaled scroll footprint for the new zoom (layout itself is frozen).
recomputeScaledSize();
// Zoom changes the table's rendered position — re-place the floating toolbar.
scheduleTableUi();
if (pendingAnchorDocY === null || !editorContainer || !element) return;
const docY = pendingAnchorDocY;
pendingAnchorDocY = null;
const editorRect = editorContainer.getBoundingClientRect();
const paperRect = element.getBoundingClientRect();
const targetScreenY = paperRect.top + docY * (appliedZoom / 100);
editorContainer.scrollTop += targetScreenY - editorRect.top;
if (pendingAnchorDoc === null || !editorContainer || !paperEl) return;
const anchor = pendingAnchorDoc;
pendingAnchorDoc = null;
const paperRect = paperEl.getBoundingClientRect();
const s = appliedZoom / 100;
// Without horizontal overflow .paper-scaler stays centred and the scrollLeft write
// is a no-op — the whole page is visible, so there is nothing to keep in view.
editorContainer.scrollLeft += paperRect.left + anchor.x * s - anchor.screenX;
editorContainer.scrollTop += paperRect.top + anchor.y * s - anchor.screenY;
});
function updateCurrentPage() {
@@ -733,7 +760,7 @@
});
</script>
<div class="editor" bind:this={editorContainer}>
<div class="editor" bind:this={editorContainer} onwheel={onWheel}>
<!-- Reserves the scaled scroll footprint; the transform on .paper reserves none.
Before the first measure (size 0) it's left unsized so .paper isn't clipped. -->
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledWidth}px; height: ${scaledHeight}px;` : ''}>
+23
View File
@@ -0,0 +1,23 @@
// Document zoom (the CSS scale on .paper, Editor.svelte) — bounds and the
// wheel-gesture → scale-factor conversion.
export const MIN_ZOOM = 20;
export const MAX_ZOOM = 300;
export function clampZoom(value: number): number {
return Math.round(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value)));
}
// Gesture feel, tune here: sensitivity per wheel unit and the per-event cap that
// keeps one mouse notch (deltaY ±100) from jumping several hundred percent.
const SENSITIVITY = 1 / 200;
const DELTA_CAP = 30;
const LINE_HEIGHT_PX = 16; // Firefox reports wheel deltas in lines (deltaMode 1)
// Multiplicative, so a step feels the same at 30% as at 250% — a touchpad
// two-finger zoom is a scale gesture, and the browser derives deltaY from it.
export function wheelZoomFactor(deltaY: number, deltaMode = 0): number {
const px = deltaMode === 1 ? deltaY * LINE_HEIGHT_PX : deltaY;
const capped = Math.max(-DELTA_CAP, Math.min(DELTA_CAP, px));
return Math.exp(-capped * SENSITIVITY);
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { clampZoom, wheelZoomFactor, MIN_ZOOM, MAX_ZOOM } from '../../src/lib/utils/zoom';
// Ctrl+wheel / touchpad two-finger zoom feeds wheelZoomFactor. The deltas differ wildly
// per device and browser, so the conversion has to stay bounded and correctly signed.
describe('wheelZoomFactor', () => {
it('zooms in on a negative delta, out on a positive one', () => {
expect(wheelZoomFactor(-10)).toBeGreaterThan(1);
expect(wheelZoomFactor(10)).toBeLessThan(1);
expect(wheelZoomFactor(0)).toBe(1);
});
it('keeps one mouse notch to a moderate step', () => {
const step = wheelZoomFactor(-100); // Chrome, deltaMode 0
expect(step).toBeGreaterThan(1.05);
expect(step).toBeLessThan(1.5);
});
it("lands Firefox's line-mode notch in the same range", () => {
expect(wheelZoomFactor(-3, 1)).toBeCloseTo(wheelZoomFactor(-100), 5);
});
it('scales small touchpad deltas finely', () => {
expect(wheelZoomFactor(-2)).toBeLessThan(wheelZoomFactor(-20));
expect(wheelZoomFactor(-2)).toBeGreaterThan(1);
});
});
describe('clampZoom', () => {
it('clamps to the slider range and rounds to whole percent', () => {
expect(clampZoom(5)).toBe(MIN_ZOOM);
expect(clampZoom(1000)).toBe(MAX_ZOOM);
expect(clampZoom(104.4)).toBe(104);
});
});