Skip to content

Commit 8d6f85c

Browse files
committed
File viewer: mouse-drag selects across nested marks
- New `viewer-pointer.ts` with pure `caretFromPoint(doc, x, y)` that resolves a viewport-relative point to a `LineOffset`. Walks up to the `[data-line]` ancestor, then sums sibling text-node lengths across nested `<mark>` spans (which the search highlighter adds) to compute the flat UTF-16 offset. Uses `caretPositionFromPoint` by default; falls back to `caretRangeFromPoint` for older WebKit. - Pointer event handlers on `.file-content` (`pointerdown` / `pointermove` / `pointerup` / `pointercancel`). `pointerdown` sets the selection anchor from the caret; `pointermove` updates the focus while a drag is active; `pointerup` / `pointercancel` end the drag. No autoscroll yet (M3b). - 15 Vitest tests cover the pure helpers: plain text, single mark, multiple marks, caret in the trailing text after a mark, surrogate-pair offsets, element-node caret (boundary index), `caretRangeFromPoint` fallback, multi-digit line numbers, out-of-bounds caret -> null. - Playwright spec: ⌘A + ⌘C copies the whole 1024-byte fixture and `navigator.clipboard.readText()` confirms the bytes landed. Drag-within-viewport spec dispatches synthetic pointer events at known x positions on line 0, runs ⌘C, asserts the clipboard contains a non-empty 'A'-only substring. - `routes/viewer/CLAUDE.md` updated with the three new files (`viewer-pointer`, `viewer-copy`, `viewer-copy.svelte`).
1 parent 1e06182 commit 8d6f85c

5 files changed

Lines changed: 479 additions & 0 deletions

File tree

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
normaliseSelection,
3434
} from './selection.svelte'
3535
import { createViewerCopy } from './viewer-copy.svelte'
36+
import { caretFromPoint } from './viewer-pointer'
3637
import { addToast } from '$lib/ui/toast/toast-store.svelte'
3738
import { formatBytes, type RangeEnd } from '$lib/tauri-commands'
3839
import ModalDialog from '$lib/ui/ModalDialog.svelte'
@@ -194,6 +195,44 @@
194195
/** Whether the > 100 MiB refuse dialog is showing. */
195196
let copyRefuseBytes = $state<number | null>(null)
196197
198+
/**
199+
* Whether a pointer drag is currently in progress. Tracks `pointerId` so we only
200+
* react to moves from the same pointer that started the gesture (multi-touch is a
201+
* future concern; today the viewer is a mouse-only surface but the type is
202+
* correct).
203+
*/
204+
let dragPointerId: number | null = null
205+
206+
function handleContentPointerDown(e: PointerEvent): void {
207+
// Left mouse button only (button 0). Right-click goes to the context menu (M4).
208+
if (e.button !== 0) return
209+
const caret = caretFromPoint(document, e.clientX, e.clientY)
210+
if (caret === null) return
211+
e.preventDefault()
212+
selection.setAnchor(caret)
213+
dragPointerId = e.pointerId
214+
}
215+
216+
function handleContentPointerMove(e: PointerEvent): void {
217+
if (dragPointerId === null || e.pointerId !== dragPointerId) return
218+
const caret = caretFromPoint(document, e.clientX, e.clientY)
219+
if (caret === null) return
220+
selection.setFocus(caret)
221+
}
222+
223+
function endDrag(pointerId: number): void {
224+
if (dragPointerId !== pointerId) return
225+
dragPointerId = null
226+
}
227+
228+
function handleContentPointerUp(e: PointerEvent): void {
229+
endDrag(e.pointerId)
230+
}
231+
232+
function handleContentPointerCancel(e: PointerEvent): void {
233+
endDrag(e.pointerId)
234+
}
235+
197236
// Fetch lines when visible range changes (debounced)
198237
$effect(() => {
199238
scroll.runFetchEffect()
@@ -803,6 +842,10 @@
803842
aria-label="File content: {fileName}"
804843
bind:this={scroll.contentRef}
805844
onscroll={scroll.handleScroll}
845+
onpointerdown={handleContentPointerDown}
846+
onpointermove={handleContentPointerMove}
847+
onpointerup={handleContentPointerUp}
848+
onpointercancel={handleContentPointerCancel}
806849
>
807850
<div
808851
class="scroll-spacer"

apps/desktop/src/routes/viewer/CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ The file viewer opens files in a separate Tauri window with virtual scrolling an
1515
| `viewer-keyboard.ts` | Pure helpers `handleNavigationKey` / `handleToggleKey` mapping keys to scroll calls |
1616
| `selection.svelte.ts` | Selection model: state + pure helpers (normalise, in-range, segment bounds, byte estimator) |
1717
| `line-segments.ts` | Pure shared segmenter: merges search matches + selection bounds into render spans |
18+
| `viewer-pointer.ts` | Pure caret-from-point math: `(x, y)` -> `LineOffset` with surrogate-safe sibling-offset sum |
19+
| `viewer-copy.ts` | Pure three-band copy policy (silent / confirm / refuse) and threshold constants |
20+
| `viewer-copy.svelte.ts` | Copy composable: state + busy flag + per-call read_id + cancel plumbing |
1821

1922
## Architecture
2023

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
3+
import { caretFromPoint, findLineAncestor, findLineTextNode, sumOffsetWithin } from './viewer-pointer'
4+
5+
/**
6+
* Sets up a viewer-shaped DOM fragment with one or more lines. Each line gets a
7+
* `[data-line]` element with `.line-text` inside. Returns the root element and a
8+
* helper to find the `.line-text` of line `n`.
9+
*/
10+
function buildLineDom(linesInnerHtml: string[]): { root: HTMLElement; getLineText: (n: number) => HTMLElement } {
11+
const root = document.createElement('div')
12+
for (let i = 0; i < linesInnerHtml.length; i++) {
13+
const line = document.createElement('div')
14+
line.className = 'line'
15+
line.setAttribute('data-line', String(i))
16+
const lineText = document.createElement('span')
17+
lineText.className = 'line-text'
18+
lineText.innerHTML = linesInnerHtml[i]
19+
line.appendChild(lineText)
20+
root.appendChild(line)
21+
}
22+
document.body.appendChild(root)
23+
return {
24+
root,
25+
getLineText: (n) => root.querySelectorAll('.line-text')[n] as HTMLElement,
26+
}
27+
}
28+
29+
beforeEach(() => {
30+
document.body.innerHTML = ''
31+
})
32+
33+
describe('findLineAncestor', () => {
34+
it('finds the [data-line] ancestor when started from a text node', () => {
35+
const { root } = buildLineDom(['hello world'])
36+
const textNode = root.querySelector('.line-text')!.firstChild!
37+
const line = findLineAncestor(textNode)
38+
expect(line).not.toBeNull()
39+
expect(line!.getAttribute('data-line')).toBe('0')
40+
})
41+
42+
it('finds the [data-line] when started from a nested mark', () => {
43+
const { root } = buildLineDom(['hel<mark>lo</mark> world'])
44+
const markText = root.querySelector('mark')!.firstChild!
45+
const line = findLineAncestor(markText)
46+
expect(line).not.toBeNull()
47+
expect(line!.getAttribute('data-line')).toBe('0')
48+
})
49+
50+
it('returns null for nodes outside any [data-line]', () => {
51+
const stray = document.createElement('div')
52+
document.body.appendChild(stray)
53+
expect(findLineAncestor(stray)).toBeNull()
54+
})
55+
})
56+
57+
describe('findLineTextNode', () => {
58+
it('finds the .line-text inside a line node', () => {
59+
const { root } = buildLineDom(['hello'])
60+
const line = root.querySelector('[data-line="0"]') as HTMLElement
61+
expect(findLineTextNode(line)).toBe(line.querySelector('.line-text'))
62+
})
63+
})
64+
65+
describe('sumOffsetWithin', () => {
66+
it('plain text node: returns the caretOffset directly', () => {
67+
const { getLineText } = buildLineDom(['hello world'])
68+
const lineText = getLineText(0)
69+
const text = lineText.firstChild!
70+
expect(sumOffsetWithin(lineText, text, 6)).toBe(6)
71+
})
72+
73+
it('caret inside a nested <mark>: sums preceding sibling text + caretOffset', () => {
74+
// "hello <mark>world</mark>"
75+
const { getLineText } = buildLineDom(['hello <mark>world</mark>'])
76+
const lineText = getLineText(0)
77+
const markText = lineText.querySelector('mark')!.firstChild!
78+
expect(sumOffsetWithin(lineText, markText, 2)).toBe(6 + 2) // "hello " (6) + "wo" (2)
79+
})
80+
81+
it('caret after the <mark>: sums everything before plus offset in the trailing text', () => {
82+
// "foo <mark>bar</mark> baz"
83+
const { getLineText } = buildLineDom(['foo <mark>bar</mark> baz'])
84+
const lineText = getLineText(0)
85+
// The trailing " baz" text node is the last text node.
86+
const walker = document.createTreeWalker(lineText, NodeFilter.SHOW_TEXT)
87+
let last: Node | null = null
88+
let n: Node | null = walker.nextNode()
89+
while (n !== null) {
90+
last = n
91+
n = walker.nextNode()
92+
}
93+
expect(last!.nodeValue).toBe(' baz')
94+
// Offset 2 in " baz": "foo " (4) + "bar" (3) + " b" (2) = 9
95+
expect(sumOffsetWithin(lineText, last!, 2)).toBe(9)
96+
})
97+
98+
it('multiple marks in a row: sums correctly', () => {
99+
const { getLineText } = buildLineDom(['<mark>aaa</mark><mark>bbb</mark>ccc'])
100+
const lineText = getLineText(0)
101+
const walker = document.createTreeWalker(lineText, NodeFilter.SHOW_TEXT)
102+
const firstMark = walker.nextNode()! // "aaa"
103+
const secondMark = walker.nextNode()! // "bbb"
104+
const trailing = walker.nextNode()! // "ccc"
105+
expect(sumOffsetWithin(lineText, firstMark, 2)).toBe(2)
106+
expect(sumOffsetWithin(lineText, secondMark, 0)).toBe(3)
107+
expect(sumOffsetWithin(lineText, secondMark, 2)).toBe(5)
108+
expect(sumOffsetWithin(lineText, trailing, 1)).toBe(7)
109+
})
110+
111+
it('UTF-16 surrogate pair: each text node treats the emoji as 2 UTF-16 units', () => {
112+
// "👋hi" - emoji is 2 UTF-16 units in the text node's nodeValue.
113+
const { getLineText } = buildLineDom(['👋hi'])
114+
const lineText = getLineText(0)
115+
const text = lineText.firstChild!
116+
expect(sumOffsetWithin(lineText, text, 2)).toBe(2) // end of emoji, before 'h'
117+
expect(sumOffsetWithin(lineText, text, 3)).toBe(3) // end of 'h'
118+
})
119+
120+
it('returns null when caretNode is outside lineTextRoot', () => {
121+
const { getLineText, root } = buildLineDom(['hello'])
122+
const lineText = getLineText(0)
123+
const stray = document.createElement('div')
124+
root.appendChild(stray)
125+
expect(sumOffsetWithin(lineText, stray, 0)).toBeNull()
126+
})
127+
128+
it('caret on an element node (selected via boundary index)', () => {
129+
const { getLineText } = buildLineDom(['a<span>bc</span>d'])
130+
const lineText = getLineText(0)
131+
// Caret on lineText with offset 1 means "boundary at child 1" = before the <span>.
132+
expect(sumOffsetWithin(lineText, lineText, 1)).toBe(1) // "a" before the span
133+
// Offset 2 = after the span, before "d": "a" + "bc" = 3.
134+
expect(sumOffsetWithin(lineText, lineText, 2)).toBe(3)
135+
})
136+
})
137+
138+
describe('caretFromPoint', () => {
139+
it('returns null when caretPositionFromPoint resolves outside a [data-line]', () => {
140+
// Make a doc that returns body for any (x, y).
141+
const fakeDoc = {
142+
caretPositionFromPoint: () => ({ offsetNode: document.body, offset: 0 }),
143+
} as unknown as Document
144+
expect(caretFromPoint(fakeDoc, 0, 0)).toBeNull()
145+
})
146+
147+
it('integrates: finds line + offset from a caret inside a nested mark', () => {
148+
const { root } = buildLineDom(['<mark>foo</mark>bar'])
149+
const markText = root.querySelector('mark')!.firstChild!
150+
const fakeDoc = {
151+
caretPositionFromPoint: () => ({ offsetNode: markText, offset: 1 }),
152+
} as unknown as Document
153+
expect(caretFromPoint(fakeDoc, 0, 0)).toEqual({ line: 0, offset: 1 })
154+
})
155+
156+
it('uses caretRangeFromPoint fallback when caretPositionFromPoint is unavailable', () => {
157+
const { root } = buildLineDom(['hello'])
158+
const text = root.querySelector('.line-text')!.firstChild!
159+
const fakeDoc = {
160+
caretRangeFromPoint: () => ({ startContainer: text, startOffset: 3 }),
161+
} as unknown as Document
162+
expect(caretFromPoint(fakeDoc, 0, 0)).toEqual({ line: 0, offset: 3 })
163+
})
164+
165+
it('parses multi-digit line numbers from data-line', () => {
166+
// Build a few lines so the 42nd one renders with data-line="42".
167+
const lines: string[] = []
168+
for (let i = 0; i < 43; i++) lines.push(`line ${String(i)}`)
169+
const { root } = buildLineDom(lines)
170+
const line42 = root.querySelector('[data-line="42"]')!.querySelector('.line-text')!.firstChild!
171+
const fakeDoc = {
172+
caretPositionFromPoint: () => ({ offsetNode: line42, offset: 2 }),
173+
} as unknown as Document
174+
expect(caretFromPoint(fakeDoc, 0, 0)).toEqual({ line: 42, offset: 2 })
175+
})
176+
})

0 commit comments

Comments
 (0)