diff --git a/src/App.tsx b/src/App.tsx index a28b62f..a820019 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -71,6 +71,7 @@ import { useLocalStorage } from '@/hooks/useLocalStorage' import { useDebouncedValue } from '@/hooks/useDebouncedValue' import { useMediaQuery } from '@/hooks/useMediaQuery' import { useDocumentLibrary } from '@/hooks/useDocumentLibrary' +import { useScrollSync } from '@/hooks/useScrollSync' import { DEFAULT_CONFIG, MARGIN_PRESETS, STORAGE_KEYS } from '@/lib/constants' import { countHeadings } from '@/lib/utils' @@ -172,7 +173,7 @@ export default function App({ lock }: AppProps) { const [saving, setSaving] = useState(false) const [editorView, setEditorView] = useState(null) - const [editorScroll, setEditorScroll] = useState(undefined) + const [previewScrollEl, setPreviewScrollEl] = useState(null) const previewRef = useRef(null) const fileInputRef = useRef(null) const mainRef = useRef(null) @@ -203,6 +204,17 @@ export default function App({ lock }: AppProps) { const showPreview = effectiveView !== 'editor' const isSplit = showEditor && showPreview && isDesktop + // Anchor-based scroll sync between the editor and preview (split view only). + const [syncEnabled, setSyncEnabled] = useLocalStorage('scripto:scroll-sync', true) + const getDoc = useCallback(() => previewRef.current?.getDocElement() ?? null, []) + useScrollSync({ + view: editorView, + previewScroll: previewScrollEl, + getPreviewDoc: getDoc, + bodyLineOffset: parsed.bodyLineOffset, + enabled: syncEnabled && isSplit, + }) + const startResize = useCallback( (event: React.MouseEvent) => { event.preventDefault() @@ -301,7 +313,6 @@ export default function App({ lock }: AppProps) { [library, t], ) - const getDoc = useCallback(() => previewRef.current?.getDocElement() ?? null, []) const handleEditorError = useCallback((message: string) => toast.error(message), []) const ensureDoc = useCallback((): HTMLElement | null => { @@ -893,7 +904,6 @@ export default function App({ lock }: AppProps) { resolvedTheme={resolvedTheme} direction={effectiveConfig.direction === 'auto' ? ui.dir : effectiveConfig.direction} onReady={setEditorView} - onScrollFraction={showPreview ? setEditorScroll : undefined} onActivity={bumpActivity} slashAi={handleAiAction} onSlashHelp={openFormattingHelp} @@ -949,7 +959,9 @@ export default function App({ lock }: AppProps) { content={parsed.body} config={effectiveConfig} resolvedTheme={resolvedTheme} - scrollFraction={editorScroll} + onScrollElement={setPreviewScrollEl} + syncEnabled={isSplit ? syncEnabled : undefined} + onToggleSync={isSplit ? () => setSyncEnabled((v) => !v) : undefined} /> )} diff --git a/src/components/editor/MarkdownEditor.tsx b/src/components/editor/MarkdownEditor.tsx index 9a05018..74666ba 100644 --- a/src/components/editor/MarkdownEditor.tsx +++ b/src/components/editor/MarkdownEditor.tsx @@ -23,7 +23,6 @@ interface MarkdownEditorProps { onReady?: (view: EditorView) => void wordWrap?: boolean direction?: TextDirection - onScrollFraction?: (fraction: number) => void /** Fires on any selection / geometry / scroll change so overlays can reposition. */ onActivity?: () => void /** Enables the `/` slash menu, routing AI items to this handler. */ @@ -115,7 +114,6 @@ export function MarkdownEditor({ onReady, wordWrap = true, direction = 'auto', - onScrollFraction, onActivity, slashAi, onSlashHelp, @@ -129,7 +127,6 @@ export function MarkdownEditor({ const callbacks = useRef({ onChange, onReady, - onScrollFraction, onActivity, slashAi, onSlashHelp, @@ -140,7 +137,6 @@ export function MarkdownEditor({ callbacks.current = { onChange, onReady, - onScrollFraction, onActivity, slashAi, onSlashHelp, @@ -226,13 +222,8 @@ export function MarkdownEditor({ base.push( EditorView.domEventHandlers({ - scroll: (_event, view) => { + scroll: () => { callbacks.current.onActivity?.() - const notify = callbacks.current.onScrollFraction - if (!notify) return - const el = view.scrollDOM - const max = el.scrollHeight - el.clientHeight - notify(max > 0 ? el.scrollTop / max : 0) }, paste: (event, view) => { const files = imageFiles(event.clipboardData?.files) diff --git a/src/components/preview/Preview.tsx b/src/components/preview/Preview.tsx index 433671f..fe6b692 100644 --- a/src/components/preview/Preview.tsx +++ b/src/components/preview/Preview.tsx @@ -8,7 +8,7 @@ import { useState, type CSSProperties, } from 'react' -import { Eye, EyeOff, Minus, Plus } from 'lucide-react' +import { Eye, EyeOff, Link2, Link2Off, Minus, Plus } from 'lucide-react' import { MarkdownRenderer } from '@/markdown/MarkdownRenderer' import { ErrorBoundary } from '@/components/ErrorBoundary' import { useLanguage } from '@/i18n' @@ -25,15 +25,17 @@ import type { PdfConfig, ResolvedTheme, TocEntry } from '@/types' export interface PreviewHandle { /** The live document element — cloned by the PDF engine for export. */ getDocElement: () => HTMLElement | null - scrollToFraction: (fraction: number) => void } interface PreviewProps { content: string config: PdfConfig resolvedTheme: ResolvedTheme - scrollFraction?: number - onScrollFraction?: (fraction: number) => void + /** Reports the scroll container up (for scroll sync); null on unmount. */ + onScrollElement?: (el: HTMLElement | null) => void + /** Scroll-sync toggle — rendered only when provided (i.e. in split view). */ + syncEnabled?: boolean + onToggleSync?: () => void } const pageCard = 'rounded-xl bg-surface shadow-sm ring-1 ring-border/60' @@ -41,13 +43,12 @@ const pageCard = 'rounded-xl bg-surface shadow-sm ring-1 ring-border/60' /** The live, scrollable rendered document. Shows the same cover + contents pages * the PDF will, so the preview matches the export. */ export const Preview = forwardRef(function Preview( - { content, config, resolvedTheme, scrollFraction, onScrollFraction }, + { content, config, resolvedTheme, onScrollElement, syncEnabled, onToggleSync }, ref, ) { const { t, lang } = useLanguage() const scrollRef = useRef(null) const docRef = useRef(null) - const syncingRef = useRef(false) const [progress, setProgress] = useState(0) const [zoom, setZoom] = useState(1) const zoomRef = useRef(1) @@ -59,23 +60,14 @@ export const Preview = forwardRef(function Preview( useImperativeHandle(ref, () => ({ getDocElement: () => docRef.current, - scrollToFraction: (fraction) => { - const el = scrollRef.current - if (!el) return - const max = el.scrollHeight - el.clientHeight - el.scrollTop = max * fraction - }, })) + // Report the scroll container to the parent so the scroll-sync hook can wire + // native listeners to it. useEffect(() => { - const el = scrollRef.current - if (!el || scrollFraction === undefined) return - syncingRef.current = true - const max = el.scrollHeight - el.clientHeight - el.scrollTop = max * scrollFraction - const id = window.setTimeout(() => (syncingRef.current = false), 60) - return () => window.clearTimeout(id) - }, [scrollFraction]) + onScrollElement?.(scrollRef.current) + return () => onScrollElement?.(null) + }, [onScrollElement]) // Pinch-to-zoom the preview on touch devices (native non-passive listeners so // the gesture can preventDefault the browser's own page zoom). @@ -161,10 +153,7 @@ export const Preview = forwardRef(function Preview( onScroll={(e) => { const el = e.currentTarget const max = el.scrollHeight - el.clientHeight - const fraction = max > 0 ? el.scrollTop / max : 0 - setProgress(fraction) - if (syncingRef.current || !onScrollFraction) return - onScrollFraction(fraction) + setProgress(max > 0 ? el.scrollTop / max : 0) }} >
(function Preview(
+ {/* Scroll-sync toggle — only in split view (when the editor is visible). */} + {onToggleSync && ( + + )} + {/* Floating zoom control — most useful on touch / small screens. */}