diff --git a/src/App.svelte b/src/App.svelte index a979245..dae3f5b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -14,6 +14,12 @@ let themeMode: ThemeMode = $state(loadTheme()); let themeOpen = $state(false); let toolbarExpanded = $state(loadToolbarExpanded()); + let zoom = $state(Math.max(50, Math.min(200, parseInt(localStorage.getItem('odf-editor-zoom') ?? '100', 10)))); + + function setZoom(value: number) { + zoom = Math.max(50, Math.min(200, value)); + localStorage.setItem('odf-editor-zoom', String(zoom)); + } function selectTheme(m: ThemeMode) { themeMode = m; @@ -113,9 +119,24 @@ {#if toolbarExpanded} {/if} - +
Page {currentPage} of {numPages} +
+ + setZoom(parseInt((e.target as HTMLInputElement).value, 10))} + title="Zoom" + /> + + +
@@ -265,4 +286,99 @@ user-select: none; z-index: 50; } + + .zoom-controls { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + } + + .zoom-btn { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + background: transparent; + color: var(--color-text-muted); + font-size: 14px; + line-height: 1; + cursor: pointer; + border-radius: 2px; + transition: color 0.1s, background 0.1s; + } + + .zoom-btn:hover:not(:disabled) { + color: var(--color-text); + background: var(--color-btn-hover); + } + + .zoom-btn:disabled { + opacity: 0.35; + cursor: not-allowed; + } + + .zoom-slider { + -webkit-appearance: none; + appearance: none; + width: 80px; + height: 3px; + border-radius: 2px; + background: var(--color-border); + outline: none; + cursor: pointer; + } + + .zoom-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-text-muted); + cursor: pointer; + transition: background 0.1s; + } + + .zoom-slider:hover::-webkit-slider-thumb, + .zoom-slider:focus::-webkit-slider-thumb { + background: var(--color-primary); + } + + .zoom-slider::-moz-range-thumb { + width: 10px; + height: 10px; + border: none; + border-radius: 50%; + background: var(--color-text-muted); + cursor: pointer; + transition: background 0.1s; + } + + .zoom-slider:hover::-moz-range-thumb, + .zoom-slider:focus::-moz-range-thumb { + background: var(--color-primary); + } + + .zoom-pct { + min-width: 36px; + padding: 0 3px; + border: none; + background: transparent; + color: var(--color-text-muted); + font-family: var(--font-sans); + font-size: 0.75rem; + text-align: right; + cursor: pointer; + border-radius: 2px; + transition: color 0.1s, background 0.1s; + } + + .zoom-pct:hover { + color: var(--color-text); + background: var(--color-btn-hover); + } diff --git a/src/lib/editor/Editor.svelte b/src/lib/editor/Editor.svelte index 0d1d47f..d9e847f 100644 --- a/src/lib/editor/Editor.svelte +++ b/src/lib/editor/Editor.svelte @@ -5,8 +5,8 @@ import { saveDocument, loadDocument } from '../storage/autosave'; import '../../styles/editor.css'; - let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1) }: { - editor: Editor | null; tick: number; currentPage: number; numPages: number; + let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), zoom = 100 }: { + editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; } = $props(); let element: HTMLDivElement; @@ -20,15 +20,17 @@ const editorRect = editorContainer.getBoundingClientRect(); const tiptapRect = tiptap.getBoundingClientRect(); + // getBoundingClientRect and coordsAtPos return zoomed viewport pixels; + // divide by zoom factor to convert to document coordinates before comparing with CYCLE. + const zoomFactor = zoom / 100; - // If the cursor is visible in the editor viewport, show its page if (editor) { try { const coords = editor.view.coordsAtPos(editor.state.selection.head); const cursorMidY = (coords.top + coords.bottom) / 2; if (cursorMidY >= editorRect.top && cursorMidY <= editorRect.bottom) { - const cursorInTiptap = cursorMidY - tiptapRect.top; - currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInTiptap) / CYCLE) + 1)); + const cursorInDoc = (cursorMidY - tiptapRect.top) / zoomFactor; + currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / CYCLE) + 1)); return; } } catch { @@ -36,9 +38,8 @@ } } - // Cursor not visible — use the top of the visible scroll area - const visibleTopInTiptap = editorRect.top - tiptapRect.top; - currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, visibleTopInTiptap) / CYCLE) + 1)); + const visibleTopInDoc = (editorRect.top - tiptapRect.top) / zoomFactor; + currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, visibleTopInDoc) / CYCLE) + 1)); } function onPageCount(e: Event) { @@ -64,8 +65,8 @@ if (!tiptap) return; try { const coords = e.view.coordsAtPos(e.state.selection.head); - const cursorInTiptap = ((coords.top + coords.bottom) / 2) - tiptap.getBoundingClientRect().top; - currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInTiptap) / CYCLE) + 1)); + const cursorInDoc = ((coords.top + coords.bottom) / 2 - tiptap.getBoundingClientRect().top) / (zoom / 100); + currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / CYCLE) + 1)); } catch { /* ignore */ } }, onUpdate: ({ editor: e }) => { @@ -85,5 +86,5 @@
-
+