From 2d6f909b0e1928ef91aef35c77483f96531e0140 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Fri, 5 Jun 2026 13:13:37 +0530 Subject: [PATCH] feat(renderer): useScrollSync hook (60fps throttling, ratio calc) --- src/renderer/hooks/use-scroll-sync.ts | 28 ++++++++++++++++++++++++ tests/unit/hooks/use-scroll-sync.test.ts | 26 ++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/renderer/hooks/use-scroll-sync.ts create mode 100644 tests/unit/hooks/use-scroll-sync.test.ts diff --git a/src/renderer/hooks/use-scroll-sync.ts b/src/renderer/hooks/use-scroll-sync.ts new file mode 100644 index 0000000..e58b8c4 --- /dev/null +++ b/src/renderer/hooks/use-scroll-sync.ts @@ -0,0 +1,28 @@ +import { useCallback, useRef } from 'react'; + +interface Options { + onEditorScroll?: (ratio: number) => void; + onPreviewScroll?: (ratio: number) => void; +} + +export function useScrollSync(opts: Options) { + const FRAME_MS = 1000 / 60; + const lastTick = useRef(-FRAME_MS); + + const handleEditorScroll = useCallback((evt: React.UIEvent) => { + const target = evt.currentTarget; + const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1); + const now = performance.now(); + if (now - lastTick.current < FRAME_MS) return; + lastTick.current = now; + opts.onEditorScroll?.(ratio); + }, [opts]); + + const handlePreviewScroll = useCallback((evt: React.UIEvent) => { + const target = evt.currentTarget; + const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1); + opts.onPreviewScroll?.(ratio); + }, [opts]); + + return { handleEditorScroll, handlePreviewScroll }; +} diff --git a/tests/unit/hooks/use-scroll-sync.test.ts b/tests/unit/hooks/use-scroll-sync.test.ts new file mode 100644 index 0000000..9b3250a --- /dev/null +++ b/tests/unit/hooks/use-scroll-sync.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useScrollSync } from '@/hooks/use-scroll-sync'; + +describe('useScrollSync', () => { + beforeEach(() => { + vi.useFakeTimers(); + // Mock performance.now() to return 0 and not auto-advance + let time = 0; + vi.spyOn(performance, 'now').mockImplementation(() => time); + }); + + it('throttles editor scroll events to 60fps', () => { + const onScroll = vi.fn(); + const { result } = renderHook(() => useScrollSync({ onEditorScroll: onScroll })); + const mockEvt = { currentTarget: { scrollTop: 100, scrollHeight: 1000, clientHeight: 200 } } as any; + // All calls within the same act() - performance.now() stays at 0 + // First call passes, subsequent calls within FRAME_MS are throttled + act(() => { + result.current.handleEditorScroll(mockEvt); + result.current.handleEditorScroll(mockEvt); + result.current.handleEditorScroll(mockEvt); + }); + expect(onScroll).toHaveBeenCalledTimes(1); + }); +});