diff --git a/src/App.svelte b/src/App.svelte
index d70d1e1..fc5024e 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -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 @@
diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte
index 52bf02a..d0f7da1 100644
--- a/src/lib/components/Editor.svelte
+++ b/src/lib/components/Editor.svelte
@@ -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 @@
});
-
+
diff --git a/src/lib/utils/zoom.ts b/src/lib/utils/zoom.ts
new file mode 100644
index 0000000..2a6987a
--- /dev/null
+++ b/src/lib/utils/zoom.ts
@@ -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);
+}
diff --git a/tests/unit/zoom.test.ts b/tests/unit/zoom.test.ts
new file mode 100644
index 0000000..e4d7bbe
--- /dev/null
+++ b/tests/unit/zoom.test.ts
@@ -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);
+ });
+});