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 @@
- + setZoom(parseInt((e.target as HTMLInputElement).value, 10))} title={t().status.zoom} /> - +
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); + }); +});