Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -172,7 +173,7 @@ export default function App({ lock }: AppProps) {
const [saving, setSaving] = useState(false)

const [editorView, setEditorView] = useState<EditorView | null>(null)
const [editorScroll, setEditorScroll] = useState<number | undefined>(undefined)
const [previewScrollEl, setPreviewScrollEl] = useState<HTMLElement | null>(null)
const previewRef = useRef<PreviewHandle>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const mainRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
/>
)}
</section>
Expand Down
11 changes: 1 addition & 10 deletions src/components/editor/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -115,7 +114,6 @@ export function MarkdownEditor({
onReady,
wordWrap = true,
direction = 'auto',
onScrollFraction,
onActivity,
slashAi,
onSlashHelp,
Expand All @@ -129,7 +127,6 @@ export function MarkdownEditor({
const callbacks = useRef({
onChange,
onReady,
onScrollFraction,
onActivity,
slashAi,
onSlashHelp,
Expand All @@ -140,7 +137,6 @@ export function MarkdownEditor({
callbacks.current = {
onChange,
onReady,
onScrollFraction,
onActivity,
slashAi,
onSlashHelp,
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 31 additions & 24 deletions src/components/preview/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,29 +25,30 @@ 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'

/** The live, scrollable rendered document. Shows the same cover + contents pages
* the PDF will, so the preview matches the export. */
export const Preview = forwardRef<PreviewHandle, PreviewProps>(function Preview(
{ content, config, resolvedTheme, scrollFraction, onScrollFraction },
{ content, config, resolvedTheme, onScrollElement, syncEnabled, onToggleSync },
ref,
) {
const { t, lang } = useLanguage()
const scrollRef = useRef<HTMLDivElement>(null)
const docRef = useRef<HTMLDivElement>(null)
const syncingRef = useRef(false)
const [progress, setProgress] = useState(0)
const [zoom, setZoom] = useState(1)
const zoomRef = useRef(1)
Expand All @@ -59,23 +60,14 @@ export const Preview = forwardRef<PreviewHandle, PreviewProps>(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).
Expand Down Expand Up @@ -161,10 +153,7 @@ export const Preview = forwardRef<PreviewHandle, PreviewProps>(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)
}}
>
<div
Expand Down Expand Up @@ -223,6 +212,24 @@ export const Preview = forwardRef<PreviewHandle, PreviewProps>(function Preview(
</div>
</div>

{/* Scroll-sync toggle — only in split view (when the editor is visible). */}
{onToggleSync && (
<button
type="button"
onClick={onToggleSync}
aria-pressed={syncEnabled}
title={syncEnabled ? t('preview.sync.on') : t('preview.sync.off')}
aria-label={syncEnabled ? t('preview.sync.on') : t('preview.sync.off')}
className={`absolute end-3 top-3 z-20 flex h-8 w-8 items-center justify-center rounded-full border shadow-lg backdrop-blur transition-colors ${
syncEnabled
? 'border-primary/30 bg-primary/10 text-primary'
: 'border-border bg-surface/90 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{syncEnabled ? <Link2 size={15} /> : <Link2Off size={15} />}
</button>
)}

{/* Floating zoom control — most useful on touch / small screens. */}
<div className="absolute bottom-3 end-3 z-20 flex items-center gap-0.5 rounded-full border border-border bg-surface/90 p-0.5 shadow-lg backdrop-blur lg:hidden">
<button
Expand Down
Loading
Loading