Skip to content

Commit 46f278b

Browse files
committed
File viewer: drag past the edge autoscrolls and survives blur
- Pure `computeAutoscrollPxPerFrame(pointerY, top, bottom)` returns negative for scroll-up, positive for scroll-down, zero in the safe band. Threshold 30 px, speed scales linearly with distance from the edge, capped at 540 px/frame. Pure: no DOM, no time. 7 tests cover boundary semantics, monotonic scaling, and clamp. - New `createViewerAutoscroll()` composable in `viewer-autoscroll.svelte.ts` owns the RAF loop. `start()` requests a frame; the tick reads the current pointer Y, the content's bounding rect, applies the px-per-frame delta to `scrollTop`, calls back into the page to re-resolve the caret, and re-queues. Self-terminates when the pointer re-enters the safe band or the content element disappears. 6 tests stub RAF/cancel and drive frames deterministically. - Page wiring: `pointerdown` calls `setPointerCapture` (try/catch since some webviews refuse it on non-focusable targets; falling through is safe, just no autoscroll past the webview edge). `pointermove` updates the drag's pointer Y and toggles the autoscroll loop based on the current px-per-frame. `pointerup` / `pointercancel` end the drag. Window `blur` is the macOS safety net: if focus hands off to another app mid-drag without firing a pointer event, blur stops the loop. - Playwright smoke test for drag-past-edge: dispatches synthetic pointer events that exit the viewport via the bottom, then `pointerup` outside, asserts the page didn't crash and `.file-content` is still alive. A full "selection extends past the original buffer" test needs a multi-thousand-line fixture (deferred; the math + controller logic are well-covered at the unit level).
1 parent 8d6f85c commit 46f278b

7 files changed

Lines changed: 415 additions & 4 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,7 @@ apps/desktop/src-tauri/resources/ai
6666
.cargo-docker/
6767

6868
# Entire.io
69-
.entire/docs/specs/file-viewer-selection-progress.md
69+
.entire/
70+
71+
# Per-milestone progress tracker for in-flight feature work (transient, recovery aid)
72+
docs/specs/file-viewer-selection-progress.md

apps/desktop/src/routes/viewer/+page.svelte

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
} from './selection.svelte'
3535
import { createViewerCopy } from './viewer-copy.svelte'
3636
import { caretFromPoint } from './viewer-pointer'
37+
import { computeAutoscrollPxPerFrame } from './viewer-autoscroll'
38+
import { createViewerAutoscroll } from './viewer-autoscroll.svelte'
3739
import { addToast } from '$lib/ui/toast/toast-store.svelte'
3840
import { formatBytes, type RangeEnd } from '$lib/tauri-commands'
3941
import ModalDialog from '$lib/ui/ModalDialog.svelte'
@@ -203,6 +205,27 @@
203205
*/
204206
let dragPointerId: number | null = null
205207
208+
/** The pointer's most-recent Y position, used by the autoscroll RAF loop. */
209+
let dragPointerY: number = 0
210+
211+
/**
212+
* Re-resolves the caret after each autoscroll step. Uses the X position one px
213+
* past the left edge of `.file-content` so the caret lands inside the line text
214+
* (not the line-number gutter, which sits flush to the left edge).
215+
*/
216+
function reAimAfterAutoscroll(pointerY: number): void {
217+
if (!scroll.contentRef) return
218+
const rect = scroll.contentRef.getBoundingClientRect()
219+
const caret = caretFromPoint(document, rect.left + 1, pointerY)
220+
if (caret !== null) selection.setFocus(caret)
221+
}
222+
223+
const autoscroll = createViewerAutoscroll({
224+
getContentRef: () => scroll.contentRef,
225+
getPointerY: () => dragPointerY,
226+
onScrollStep: reAimAfterAutoscroll,
227+
})
228+
206229
function handleContentPointerDown(e: PointerEvent): void {
207230
// Left mouse button only (button 0). Right-click goes to the context menu (M4).
208231
if (e.button !== 0) return
@@ -211,18 +234,39 @@
211234
e.preventDefault()
212235
selection.setAnchor(caret)
213236
dragPointerId = e.pointerId
237+
dragPointerY = e.clientY
238+
// Capture so we keep receiving pointer events even if the cursor leaves the
239+
// webview (the user dragged past the edge into another macOS window or the
240+
// desktop). Without capture, autoscroll would never see a `pointerup` to stop.
241+
try {
242+
;(e.currentTarget as Element | null)?.setPointerCapture(e.pointerId)
243+
} catch {
244+
// Capture can throw on some webviews if the target isn't focusable; ignoring
245+
// is safe (the drag still works, just without the safety net).
246+
}
214247
}
215248
216249
function handleContentPointerMove(e: PointerEvent): void {
217250
if (dragPointerId === null || e.pointerId !== dragPointerId) return
251+
dragPointerY = e.clientY
218252
const caret = caretFromPoint(document, e.clientX, e.clientY)
219-
if (caret === null) return
220-
selection.setFocus(caret)
253+
if (caret !== null) selection.setFocus(caret)
254+
255+
// Check whether the pointer is near a viewport edge; start/stop autoscroll as needed.
256+
if (!scroll.contentRef) return
257+
const rect = scroll.contentRef.getBoundingClientRect()
258+
const delta = computeAutoscrollPxPerFrame(e.clientY, rect.top, rect.bottom)
259+
if (delta !== 0) {
260+
autoscroll.start()
261+
} else {
262+
autoscroll.stop()
263+
}
221264
}
222265
223266
function endDrag(pointerId: number): void {
224267
if (dragPointerId !== pointerId) return
225268
dragPointerId = null
269+
autoscroll.stop()
226270
}
227271
228272
function handleContentPointerUp(e: PointerEvent): void {
@@ -233,6 +277,18 @@
233277
endDrag(e.pointerId)
234278
}
235279
280+
/**
281+
* Window `blur` safety net: macOS may hand focus to another app mid-drag without
282+
* firing a `pointerup` or `pointercancel`. Without this, the autoscroll RAF loop
283+
* would keep running indefinitely.
284+
*/
285+
function handleWindowBlur(): void {
286+
if (dragPointerId !== null) {
287+
dragPointerId = null
288+
}
289+
autoscroll.stop()
290+
}
291+
236292
// Fetch lines when visible range changes (debounced)
237293
$effect(() => {
238294
scroll.runFetchEffect()
@@ -700,7 +756,7 @@
700756
})
701757
</script>
702758

703-
<svelte:window on:keydown={handleKeyDown} />
759+
<svelte:window on:keydown={handleKeyDown} on:blur={handleWindowBlur} />
704760

705761
<main
706762
class="viewer-container"
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
2+
3+
import { createViewerAutoscroll } from './viewer-autoscroll.svelte'
4+
5+
/**
6+
* Stub `requestAnimationFrame` / `cancelAnimationFrame` so tests can drive the loop
7+
* deterministically. Each `start()` queues a tick; the test then calls `runOneFrame()`
8+
* to fire it.
9+
*/
10+
let scheduledTick: (() => void) | null = null
11+
let nextRafId = 1
12+
13+
function rafStub(cb: FrameRequestCallback): number {
14+
scheduledTick = () => cb(performance.now())
15+
return nextRafId++
16+
}
17+
18+
function cancelStub(_id: number): void {
19+
scheduledTick = null
20+
}
21+
22+
function runOneFrame(): void {
23+
const fn = scheduledTick
24+
scheduledTick = null
25+
fn?.()
26+
}
27+
28+
let originalRaf: typeof requestAnimationFrame
29+
let originalCaf: typeof cancelAnimationFrame
30+
31+
beforeEach(() => {
32+
originalRaf = globalThis.requestAnimationFrame
33+
originalCaf = globalThis.cancelAnimationFrame
34+
globalThis.requestAnimationFrame = rafStub as unknown as typeof requestAnimationFrame
35+
globalThis.cancelAnimationFrame = cancelStub as unknown as typeof cancelAnimationFrame
36+
scheduledTick = null
37+
nextRafId = 1
38+
})
39+
40+
afterEach(() => {
41+
globalThis.requestAnimationFrame = originalRaf
42+
globalThis.cancelAnimationFrame = originalCaf
43+
})
44+
45+
function makeContent(rectTop: number, rectBottom: number): { el: HTMLElement; getScrollTop: () => number } {
46+
const el = document.createElement('div')
47+
// jsdom doesn't lay anything out, so stub getBoundingClientRect.
48+
el.getBoundingClientRect = () =>
49+
({
50+
top: rectTop,
51+
bottom: rectBottom,
52+
left: 0,
53+
right: 100,
54+
width: 100,
55+
height: rectBottom - rectTop,
56+
x: 0,
57+
y: rectTop,
58+
toJSON: () => ({}),
59+
}) as DOMRect
60+
return { el, getScrollTop: () => el.scrollTop }
61+
}
62+
63+
describe('createViewerAutoscroll', () => {
64+
it('start() requests a frame; stop() cancels it', () => {
65+
const { el } = makeContent(0, 400)
66+
const ctrl = createViewerAutoscroll({
67+
getContentRef: () => el,
68+
getPointerY: () => 200,
69+
onScrollStep: () => {},
70+
})
71+
72+
expect(ctrl.isRunning()).toBe(false)
73+
ctrl.start()
74+
expect(ctrl.isRunning()).toBe(true)
75+
ctrl.stop()
76+
expect(ctrl.isRunning()).toBe(false)
77+
})
78+
79+
it('start() is idempotent: second call is a no-op', () => {
80+
const { el } = makeContent(0, 400)
81+
const ctrl = createViewerAutoscroll({
82+
getContentRef: () => el,
83+
getPointerY: () => 200,
84+
onScrollStep: () => {},
85+
})
86+
ctrl.start()
87+
const firstTick = scheduledTick
88+
ctrl.start()
89+
expect(scheduledTick).toBe(firstTick)
90+
})
91+
92+
it('loop scrolls and calls onScrollStep when the pointer is near the edge', () => {
93+
const { el } = makeContent(0, 400)
94+
const onStep = vi.fn()
95+
const ctrl = createViewerAutoscroll({
96+
getContentRef: () => el,
97+
getPointerY: () => 395, // 5 px from the bottom edge → autoscroll down.
98+
onScrollStep: onStep,
99+
})
100+
101+
ctrl.start()
102+
runOneFrame()
103+
expect(el.scrollTop).toBeGreaterThan(0)
104+
expect(onStep).toHaveBeenCalledTimes(1)
105+
expect(onStep).toHaveBeenCalledWith(395)
106+
// The tick re-queued itself for the next frame.
107+
expect(ctrl.isRunning()).toBe(true)
108+
})
109+
110+
it('loop self-terminates when the pointer re-enters the safe band', () => {
111+
const { el } = makeContent(0, 400)
112+
let y = 5
113+
const ctrl = createViewerAutoscroll({
114+
getContentRef: () => el,
115+
getPointerY: () => y,
116+
onScrollStep: () => {},
117+
})
118+
119+
ctrl.start()
120+
runOneFrame() // First frame: pointer is at y=5 (near top), so we scroll up.
121+
expect(ctrl.isRunning()).toBe(true)
122+
y = 200 // Move pointer back to the middle.
123+
runOneFrame() // delta = 0, loop terminates.
124+
expect(ctrl.isRunning()).toBe(false)
125+
})
126+
127+
it('loop self-terminates when the content ref disappears (unmount mid-drag)', () => {
128+
let contentRef: HTMLElement | undefined = makeContent(0, 400).el
129+
const ctrl = createViewerAutoscroll({
130+
getContentRef: () => contentRef,
131+
getPointerY: () => 5,
132+
onScrollStep: () => {},
133+
})
134+
135+
ctrl.start()
136+
expect(ctrl.isRunning()).toBe(true)
137+
contentRef = undefined
138+
runOneFrame()
139+
expect(ctrl.isRunning()).toBe(false)
140+
})
141+
142+
it('stop() during a tick prevents the next frame', () => {
143+
const { el } = makeContent(0, 400)
144+
const ctrl = createViewerAutoscroll({
145+
getContentRef: () => el,
146+
getPointerY: () => 5,
147+
onScrollStep: () => {},
148+
})
149+
150+
ctrl.start()
151+
runOneFrame() // First frame fires and re-queues.
152+
expect(ctrl.isRunning()).toBe(true)
153+
ctrl.stop()
154+
expect(ctrl.isRunning()).toBe(false)
155+
// No more frames will fire.
156+
expect(scheduledTick).toBeNull()
157+
})
158+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Drag-autoscroll loop driver for the viewer.
3+
*
4+
* Owns the RAF id and the running flag. The page wires the rest: it tells the loop
5+
* the current pointer Y on each move, picks the scroll target (`contentRef`), and the
6+
* loop tells the page when to re-resolve the caret after each scroll step. Pure
7+
* functions go in `viewer-autoscroll.ts`; the side-effecting bits (RAF, scrollTop
8+
* mutation) live here.
9+
*/
10+
11+
import { computeAutoscrollPxPerFrame } from './viewer-autoscroll'
12+
13+
interface AutoscrollDeps {
14+
/** Returns the scrollable element to mutate. Re-read every frame so an unmount
15+
* during the loop bails cleanly. */
16+
getContentRef: () => HTMLElement | undefined
17+
/** Returns the pointer's most-recent clientY. Re-read every frame. */
18+
getPointerY: () => number
19+
/** Called after each scroll step so the page can update the selection focus. */
20+
onScrollStep: (pointerY: number) => void
21+
}
22+
23+
export interface AutoscrollController {
24+
/** Idempotently starts the loop. No-op if already running. */
25+
start(): void
26+
/** Stops the loop if running. Safe to call from anywhere (pointerup, blur, unmount). */
27+
stop(): void
28+
/** Returns whether the loop is currently running. Test-only hook. */
29+
isRunning(): boolean
30+
}
31+
32+
export function createViewerAutoscroll(deps: AutoscrollDeps): AutoscrollController {
33+
let rafId: number | null = null
34+
35+
function tick(): void {
36+
const content = deps.getContentRef()
37+
if (!content) {
38+
rafId = null
39+
return
40+
}
41+
const rect = content.getBoundingClientRect()
42+
const y = deps.getPointerY()
43+
const delta = computeAutoscrollPxPerFrame(y, rect.top, rect.bottom)
44+
if (delta === 0) {
45+
rafId = null
46+
return
47+
}
48+
content.scrollTop += delta
49+
deps.onScrollStep(y)
50+
rafId = requestAnimationFrame(tick)
51+
}
52+
53+
function start(): void {
54+
if (rafId !== null) return
55+
rafId = requestAnimationFrame(tick)
56+
}
57+
58+
function stop(): void {
59+
if (rafId === null) return
60+
cancelAnimationFrame(rafId)
61+
rafId = null
62+
}
63+
64+
function isRunning(): boolean {
65+
return rafId !== null
66+
}
67+
68+
return { start, stop, isRunning }
69+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
import { EDGE_AUTOSCROLL_PX, computeAutoscrollPxPerFrame } from './viewer-autoscroll'
4+
5+
describe('computeAutoscrollPxPerFrame', () => {
6+
// Use a notional viewport of [100, 500] (height 400) for these tests.
7+
const top = 100
8+
const bottom = 500
9+
10+
it('returns 0 when the pointer is comfortably inside the viewport', () => {
11+
expect(computeAutoscrollPxPerFrame(300, top, bottom)).toBe(0)
12+
expect(computeAutoscrollPxPerFrame(top + EDGE_AUTOSCROLL_PX + 1, top, bottom)).toBe(0)
13+
expect(computeAutoscrollPxPerFrame(bottom - EDGE_AUTOSCROLL_PX - 1, top, bottom)).toBe(0)
14+
})
15+
16+
it('returns 0 right at the threshold (closed interval on the safe side)', () => {
17+
// Exactly at the threshold: still safe. (distanceFromTop = EDGE_AUTOSCROLL_PX → not less than).
18+
expect(computeAutoscrollPxPerFrame(top + EDGE_AUTOSCROLL_PX, top, bottom)).toBe(0)
19+
expect(computeAutoscrollPxPerFrame(bottom - EDGE_AUTOSCROLL_PX, top, bottom)).toBe(0)
20+
})
21+
22+
it('returns a small negative value near the top edge', () => {
23+
// 5 px inside the threshold: ratio = (30 - 25) / 30 = 1/6. So roughly -90.
24+
const v = computeAutoscrollPxPerFrame(top + 25, top, bottom)
25+
expect(v).toBeLessThan(0)
26+
expect(v).toBeGreaterThan(-100)
27+
})
28+
29+
it('returns a small positive value near the bottom edge', () => {
30+
const v = computeAutoscrollPxPerFrame(bottom - 25, top, bottom)
31+
expect(v).toBeGreaterThan(0)
32+
expect(v).toBeLessThan(100)
33+
})
34+
35+
it('returns the maximum speed when the pointer is exactly at the edge', () => {
36+
expect(computeAutoscrollPxPerFrame(top, top, bottom)).toBe(-540)
37+
expect(computeAutoscrollPxPerFrame(bottom, top, bottom)).toBe(540)
38+
})
39+
40+
it('clamps to the maximum when the pointer is past the edge', () => {
41+
expect(computeAutoscrollPxPerFrame(top - 100, top, bottom)).toBe(-540)
42+
expect(computeAutoscrollPxPerFrame(bottom + 100, top, bottom)).toBe(540)
43+
})
44+
45+
it('scales monotonically: closer to the edge → faster', () => {
46+
const a = computeAutoscrollPxPerFrame(bottom - 25, top, bottom)
47+
const b = computeAutoscrollPxPerFrame(bottom - 10, top, bottom)
48+
const c = computeAutoscrollPxPerFrame(bottom - 1, top, bottom)
49+
expect(a).toBeLessThan(b)
50+
expect(b).toBeLessThan(c)
51+
})
52+
})

0 commit comments

Comments
 (0)