Skip to content

Commit e329bb3

Browse files
committed
File viewer: right-click menu and shift-click extend
- Shift-click extends the current selection: the existing anchor stays put, the focus moves to the clicked position. Without a current selection, shift-click is treated like a plain click (anchor = focus). Pure `extendSelection()` helper added to `selection.svelte.ts` with 4 boundary tests (no selection, normal extend, shrink, direction-flip). - Custom in-app context menu (`ViewerContextMenu.svelte`): floats at the right-click point, offers Copy (disabled when no selection) and Select all. Closes on outside click, window blur, Escape, or after an action. Keyboard nav with ArrowDown / ArrowUp; first item is focused on mount. Styled with design tokens: `--color-bg-secondary` background, `--shadow-md`, `--radius-md`, accent-subtle hover. 2 tier-3 a11y tests cover the with-selection and no-selection states. - `contextmenu` handler on `.file-content` calls `preventDefault()` first so the macOS native menu doesn't stack over ours, then renders our menu at the click position. - The `copy` event interception (M2) already routes any copy gesture, so the menu's Copy item delegates to the same `handleCopyShortcut` the keyboard takes. - Playwright spec: ⌘A select all, right-click → assert menu visible → click Copy → assert clipboard.
1 parent 2bed9fc commit e329bb3

6 files changed

Lines changed: 340 additions & 2 deletions

File tree

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

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import { caretFromPoint } from './viewer-pointer'
3737
import { computeAutoscrollPxPerFrame } from './viewer-autoscroll'
3838
import { createViewerAutoscroll } from './viewer-autoscroll.svelte'
39+
import ViewerContextMenu from './ViewerContextMenu.svelte'
3940
import { addToast } from '$lib/ui/toast/toast-store.svelte'
4041
import { formatBytes, type RangeEnd } from '$lib/tauri-commands'
4142
import ModalDialog from '$lib/ui/ModalDialog.svelte'
@@ -205,6 +206,9 @@
205206
*/
206207
let dragPointerId: number | null = null
207208
209+
/** Position of the in-app context menu while it's open, or `null`. */
210+
let contextMenuPos = $state<{ x: number; y: number } | null>(null)
211+
208212
/** The pointer's most-recent Y position, used by the autoscroll RAF loop. */
209213
let dragPointerY: number = 0
210214
@@ -227,12 +231,20 @@
227231
})
228232
229233
function handleContentPointerDown(e: PointerEvent): void {
230-
// Left mouse button only (button 0). Right-click goes to the context menu (M4).
234+
// Left mouse button only (button 0). Right-click goes to the context menu.
231235
if (e.button !== 0) return
232236
const caret = caretFromPoint(document, e.clientX, e.clientY)
233237
if (caret === null) return
234238
e.preventDefault()
235-
selection.setAnchor(caret)
239+
240+
// Shift-click extends the existing selection from its anchor to the clicked
241+
// position. If there's no current selection, treat shift-click as a plain click.
242+
if (e.shiftKey && selection.selection !== null) {
243+
selection.setFocus(caret)
244+
} else {
245+
selection.setAnchor(caret)
246+
}
247+
236248
dragPointerId = e.pointerId
237249
dragPointerY = e.clientY
238250
// Capture so we keep receiving pointer events even if the cursor leaves the
@@ -277,6 +289,16 @@
277289
endDrag(e.pointerId)
278290
}
279291
292+
function handleContentContextMenu(e: MouseEvent): void {
293+
// Suppress the native OS context menu so our in-app one wins.
294+
e.preventDefault()
295+
contextMenuPos = { x: e.clientX, y: e.clientY }
296+
}
297+
298+
function closeContextMenu(): void {
299+
contextMenuPos = null
300+
}
301+
280302
/**
281303
* Window `blur` safety net: macOS may hand focus to another app mid-drag without
282304
* firing a `pointerup` or `pointercancel`. Without this, the autoscroll RAF loop
@@ -902,6 +924,7 @@
902924
onpointermove={handleContentPointerMove}
903925
onpointerup={handleContentPointerUp}
904926
onpointercancel={handleContentPointerCancel}
927+
oncontextmenu={handleContentContextMenu}
905928
>
906929
<div
907930
class="scroll-spacer"
@@ -972,6 +995,19 @@
972995
</div>
973996
</main>
974997

998+
{#if contextMenuPos !== null}
999+
<ViewerContextMenu
1000+
x={contextMenuPos.x}
1001+
y={contextMenuPos.y}
1002+
hasSelection={selection.selection !== null}
1003+
onCopy={() => {
1004+
void handleCopyShortcut()
1005+
}}
1006+
onSelectAll={handleSelectAllShortcut}
1007+
onClose={closeContextMenu}
1008+
/>
1009+
{/if}
1010+
9751011
{#if copyConfirmBytes !== null}
9761012
{@const confirmBytes = copyConfirmBytes}
9771013
<ModalDialog
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it } from 'vitest'
2+
import { mount, tick } from 'svelte'
3+
4+
import ViewerContextMenu from './ViewerContextMenu.svelte'
5+
import { expectNoA11yViolations } from '$lib/test-a11y'
6+
7+
describe('ViewerContextMenu a11y', () => {
8+
it('default state (selection present) has no a11y violations', async () => {
9+
const target = document.createElement('div')
10+
document.body.appendChild(target)
11+
mount(ViewerContextMenu, {
12+
target,
13+
props: {
14+
x: 50,
15+
y: 50,
16+
hasSelection: true,
17+
onCopy: () => {},
18+
onSelectAll: () => {},
19+
onClose: () => {},
20+
},
21+
})
22+
await tick()
23+
await expectNoA11yViolations(target)
24+
})
25+
26+
it('no-selection state (Copy disabled) has no a11y violations', async () => {
27+
const target = document.createElement('div')
28+
document.body.appendChild(target)
29+
mount(ViewerContextMenu, {
30+
target,
31+
props: {
32+
x: 50,
33+
y: 50,
34+
hasSelection: false,
35+
onCopy: () => {},
36+
onSelectAll: () => {},
37+
onClose: () => {},
38+
},
39+
})
40+
await tick()
41+
await expectNoA11yViolations(target)
42+
})
43+
})
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<!--
2+
Minimal context menu for the viewer's `.file-content`. Floats at the right-click
3+
position; offers Copy when there's a selection, and Select all otherwise. Closes on
4+
outside click, Escape, blur, or after an action runs.
5+
6+
Why an in-app HTML menu instead of the OS-native one (`showContextMenu`): the OS
7+
menu would interrupt the gesture (it pops up over the webview), and tying a single
8+
Copy action to the heavyweight IPC isn't worth it. The two items here are pure
9+
UI-level actions; the gesture stays inside the webview.
10+
-->
11+
12+
<script lang="ts">
13+
import { onMount, tick } from 'svelte'
14+
15+
interface Props {
16+
/** Viewport-relative coordinates of the right-click. */
17+
x: number
18+
y: number
19+
/** Whether a non-empty selection currently exists. Controls which item is enabled. */
20+
hasSelection: boolean
21+
onCopy: () => void
22+
onSelectAll: () => void
23+
onClose: () => void
24+
}
25+
26+
const { x, y, hasSelection, onCopy, onSelectAll, onClose }: Props = $props()
27+
28+
let menuRef: HTMLDivElement | undefined = $state()
29+
/** Which item the keyboard has focused (0 = Copy, 1 = Select all). */
30+
let focusedIndex = $state(0)
31+
let firstItemRef: HTMLButtonElement | undefined = $state()
32+
let secondItemRef: HTMLButtonElement | undefined = $state()
33+
34+
onMount(() => {
35+
// Move focus into the menu so Escape/Enter/arrows route here without the user
36+
// having to mouse over an item first.
37+
void tick().then(() => {
38+
firstItemRef?.focus()
39+
})
40+
})
41+
42+
function handleKey(e: KeyboardEvent): void {
43+
if (e.key === 'Escape') {
44+
e.preventDefault()
45+
onClose()
46+
return
47+
}
48+
if (e.key === 'ArrowDown') {
49+
e.preventDefault()
50+
focusedIndex = (focusedIndex + 1) % 2
51+
;(focusedIndex === 0 ? firstItemRef : secondItemRef)?.focus()
52+
return
53+
}
54+
if (e.key === 'ArrowUp') {
55+
e.preventDefault()
56+
focusedIndex = (focusedIndex + 1) % 2
57+
;(focusedIndex === 0 ? firstItemRef : secondItemRef)?.focus()
58+
}
59+
}
60+
61+
function handleOutsideClick(e: MouseEvent): void {
62+
if (menuRef && e.target instanceof Node && !menuRef.contains(e.target)) {
63+
onClose()
64+
}
65+
}
66+
67+
function chooseCopy(): void {
68+
onCopy()
69+
onClose()
70+
}
71+
72+
function chooseSelectAll(): void {
73+
onSelectAll()
74+
onClose()
75+
}
76+
</script>
77+
78+
<svelte:window onmousedown={handleOutsideClick} onblur={onClose} onkeydown={handleKey} />
79+
80+
<div
81+
bind:this={menuRef}
82+
role="menu"
83+
aria-label="Viewer actions"
84+
class="viewer-context-menu"
85+
style="left: {x}px; top: {y}px"
86+
>
87+
<button
88+
bind:this={firstItemRef}
89+
type="button"
90+
role="menuitem"
91+
class="menu-item"
92+
disabled={!hasSelection}
93+
onclick={chooseCopy}
94+
>
95+
Copy
96+
<span class="shortcut">⌘C</span>
97+
</button>
98+
<button
99+
bind:this={secondItemRef}
100+
type="button"
101+
role="menuitem"
102+
class="menu-item"
103+
onclick={chooseSelectAll}
104+
>
105+
Select all
106+
<span class="shortcut">⌘A</span>
107+
</button>
108+
</div>
109+
110+
<style>
111+
.viewer-context-menu {
112+
position: fixed;
113+
z-index: var(--z-dropdown);
114+
min-width: 160px;
115+
padding: var(--spacing-xs);
116+
background: var(--color-bg-secondary);
117+
border: 1px solid var(--color-border);
118+
border-radius: var(--radius-md);
119+
box-shadow: var(--shadow-md);
120+
font-size: var(--font-size-sm);
121+
}
122+
123+
.menu-item {
124+
display: flex;
125+
align-items: center;
126+
justify-content: space-between;
127+
width: 100%;
128+
padding: var(--spacing-xs) var(--spacing-sm);
129+
border: none;
130+
background: transparent;
131+
color: var(--color-text-primary);
132+
font-size: var(--font-size-sm);
133+
text-align: left;
134+
border-radius: var(--radius-sm);
135+
transition: background var(--transition-fast);
136+
}
137+
138+
.menu-item:hover:not(:disabled),
139+
.menu-item:focus-visible:not(:disabled) {
140+
background: var(--color-accent-subtle);
141+
outline: none;
142+
}
143+
144+
.menu-item:disabled {
145+
color: var(--color-text-tertiary);
146+
opacity: 0.6;
147+
}
148+
149+
.shortcut {
150+
color: var(--color-text-tertiary);
151+
font-family: var(--font-mono);
152+
font-size: var(--font-size-xs);
153+
margin-left: var(--spacing-md);
154+
}
155+
</style>

apps/desktop/src/routes/viewer/selection.svelte.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ export function makeSelectAll(totalLines: number, lastLineLength: number): Selec
138138
}
139139
}
140140

141+
/**
142+
* Shift-click extension: returns a new selection that runs from the current selection's
143+
* anchor (or `point` if there's no current selection) to `point`. Caller-owned
144+
* `anchor` is preserved; only the focus changes. This is the gesture-correct shape:
145+
* the user clicked a new endpoint; the anchor (where the original gesture started)
146+
* stays put.
147+
*
148+
* Pure: no DOM, no state. The composable just sets `selection = extendSelection(...)`.
149+
*/
150+
export function extendSelection(current: Selection | null, point: LineOffset): Selection {
151+
if (current === null) {
152+
return { anchor: point, focus: point }
153+
}
154+
return { anchor: current.anchor, focus: point }
155+
}
156+
141157
/**
142158
* Estimates the UTF-8 byte length of the selected range, given a per-line byte
143159
* length lookup and per-line UTF-16 length lookup. Used by the copy flow to

apps/desktop/src/routes/viewer/selection.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'
33
import {
44
compareLineOffset,
55
estimateSelectionBytes,
6+
extendSelection,
67
getLineSegmentBounds,
78
isEmpty,
89
isLineInRange,
@@ -169,6 +170,40 @@ describe('getLineSegmentBounds', () => {
169170
})
170171
})
171172

173+
describe('extendSelection (shift-click)', () => {
174+
it('no current selection: anchor = focus = point', () => {
175+
const point = { line: 5, offset: 2 }
176+
expect(extendSelection(null, point)).toEqual({ anchor: point, focus: point })
177+
})
178+
179+
it('preserves existing anchor, moves focus to the new point', () => {
180+
const current: Selection = { anchor: { line: 2, offset: 3 }, focus: { line: 5, offset: 7 } }
181+
const newPoint = { line: 8, offset: 1 }
182+
expect(extendSelection(current, newPoint)).toEqual({
183+
anchor: { line: 2, offset: 3 },
184+
focus: { line: 8, offset: 1 },
185+
})
186+
})
187+
188+
it('can shrink the selection (new focus before the anchor)', () => {
189+
const current: Selection = { anchor: { line: 5, offset: 0 }, focus: { line: 10, offset: 0 } }
190+
const newPoint = { line: 7, offset: 2 }
191+
expect(extendSelection(current, newPoint)).toEqual({
192+
anchor: { line: 5, offset: 0 },
193+
focus: { line: 7, offset: 2 },
194+
})
195+
})
196+
197+
it('can flip the selection direction (new focus before the original anchor)', () => {
198+
const current: Selection = { anchor: { line: 5, offset: 0 }, focus: { line: 10, offset: 0 } }
199+
const newPoint = { line: 2, offset: 0 }
200+
expect(extendSelection(current, newPoint)).toEqual({
201+
anchor: { line: 5, offset: 0 },
202+
focus: { line: 2, offset: 0 },
203+
})
204+
})
205+
})
206+
172207
describe('makeSelectAll', () => {
173208
it('returns null for 0-line files', () => {
174209
expect(makeSelectAll(0, 0)).toBeNull()

0 commit comments

Comments
 (0)