feat: touchpad and keyboard scale document instad of browser ui
This commit is contained in:
1 parent
ca4fbd422b
commit
7cc9a36d18
4 files changed
+129
-22
No files matched your search
+28
-6
@@ -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>
|
||||
|
||||
@@ -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;` : ''}>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user