Skip to content

Commit b6542e7

Browse files
committed
File viewer: copy dialog UX and select-all sizing fix
- `isWholeFileSelection` short-circuits the per-line byte estimator so ⌘A on a multi-MB file routes to the right size tier. The line cache only contains scrolled lines, so the old walk bailed to null on ⌘A and the page fell into the unknown-size confirm dialog instead of the proper refuse dialog for >100 MB files. `totalBytes` from `viewer_open` is the exact answer. - `Button` gains an `autoFocus` prop that focuses the underlying element after a `requestAnimationFrame` so it lands after `ModalDialog`'s post-`tick()` overlay focus. The copy confirm and refuse dialogs use it on the primary action, so Enter triggers Copy (or Save as file…) by default. - `ModalDialog` × close button gets `tabindex="-1"` so Tab cycles only the action buttons. Keyboard users start on the primary action, not on ×. - Copy dialogs get `containerStyle="max-width: 480px"` per the design-system Dialogs spec, and a `.copy-dialog-body-wrap` with the standard `0 24px 24px` padding so text and buttons no longer hit the dialog edge.
1 parent b183c6c commit b6542e7

5 files changed

Lines changed: 158 additions & 35 deletions

File tree

apps/desktop/src/lib/ui/Button.svelte

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import type { Snippet } from 'svelte'
2+
import { onMount, type Snippet } from 'svelte'
33
44
type Variant = 'primary' | 'secondary' | 'danger'
55
type Size = 'regular' | 'mini'
@@ -11,6 +11,12 @@
1111
type?: 'button' | 'submit'
1212
onclick?: (e: MouseEvent) => void
1313
'aria-label'?: string
14+
/**
15+
* Focus this button after mount. Uses `requestAnimationFrame` so it lands
16+
* after a parent `ModalDialog`'s post-`tick()` overlay focus, which would
17+
* otherwise win and steal focus to the scrim.
18+
*/
19+
autoFocus?: boolean
1420
children: Snippet
1521
}
1622
@@ -21,11 +27,27 @@
2127
type = 'button',
2228
onclick,
2329
'aria-label': ariaLabel,
30+
autoFocus = false,
2431
children,
2532
}: Props = $props()
33+
34+
let buttonEl: HTMLButtonElement | undefined
35+
onMount(() => {
36+
if (!autoFocus) return
37+
requestAnimationFrame(() => {
38+
buttonEl?.focus()
39+
})
40+
})
2641
</script>
2742

28-
<button {type} class="btn btn-{variant} btn-{size}" {disabled} {onclick} aria-label={ariaLabel}>
43+
<button
44+
bind:this={buttonEl}
45+
{type}
46+
class="btn btn-{variant} btn-{size}"
47+
{disabled}
48+
{onclick}
49+
aria-label={ariaLabel}
50+
>
2951
{@render children()}
3052
</button>
3153

apps/desktop/src/lib/ui/ModalDialog.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,13 @@
131131
>
132132
<div class="modal-dialog" class:dragging={isDragging} style={dialogStyle}>
133133
{#if onclose}
134-
<button class="modal-close-button" onclick={onclose} aria-label="Close">×</button>
134+
<!--
135+
tabindex=-1 keeps the × out of the tab cycle. The dialog's action buttons
136+
should be the only tab stops; × is a mouse / Escape-key affordance. Without
137+
this, Tab from the overlay lands on × first, which surprises keyboard users
138+
expecting the primary or first action to be the entry point.
139+
-->
140+
<button class="modal-close-button" onclick={onclose} aria-label="Close" tabindex="-1">×</button>
135141
{/if}
136142
<!-- svelte-ignore a11y_no_static_element_interactions -->
137143
<div class="dialog-title-bar" class:draggable onmousedown={handleTitleMouseDown}>

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

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
describeSelectionForAt,
3232
estimateSelectionBytes,
3333
getLineSegmentBounds,
34+
isWholeFileSelection,
3435
normaliseSelection,
3536
} from './selection.svelte'
3637
import { createViewerCopy } from './viewer-copy.svelte'
@@ -182,8 +183,18 @@
182183
* the "unknown size" branch and confirm before reading).
183184
*/
184185
function estimateCurrentSelectionBytes(): number | null {
186+
const sel = selection.selection
187+
if (sel === null) return 0
188+
// Whole-file shortcut: ⌘A on a multi-MB file selects lines the user never scrolled
189+
// through, so the line cache can't service the per-line walk. `totalBytes` is the
190+
// exact answer (it's the file size from `viewer_open`) and avoids the bail-to-null
191+
// that would otherwise route to the "unknown size" confirm dialog instead of the
192+
// correct refuse / confirm tier.
193+
if (isWholeFileSelection(sel, totalLines)) {
194+
return totalBytes
195+
}
185196
return estimateSelectionBytes(
186-
selection.selection,
197+
sel,
187198
(n) => {
188199
const txt = scroll.lineCache.get(n)
189200
if (txt === undefined) return null
@@ -1111,6 +1122,7 @@
11111122
dialogId="viewer-copy-confirm"
11121123
titleId="viewer-copy-confirm-title"
11131124
onclose={cancelCopyConfirm}
1125+
containerStyle="max-width: 480px"
11141126
>
11151127
{#snippet title()}
11161128
<h2 id="viewer-copy-confirm-title" class="copy-dialog-title">
@@ -1121,23 +1133,26 @@
11211133
{/if}
11221134
</h2>
11231135
{/snippet}
1124-
<p class="copy-dialog-body">
1125-
Large pastes can slow down other apps. Try search (⌘F) to narrow it down.
1126-
</p>
1127-
<div class="copy-dialog-actions">
1128-
<Button variant="secondary" onclick={cancelCopyConfirm}>Cancel</Button>
1129-
<Button
1130-
variant="secondary"
1131-
onclick={() => {
1132-
void handleSaveAs()
1133-
}}>Save as file…</Button
1134-
>
1135-
<Button
1136-
variant="primary"
1137-
onclick={() => {
1138-
if (copyConfirmProceed) void copyConfirmProceed()
1139-
}}>Copy</Button
1140-
>
1136+
<div class="copy-dialog-body-wrap">
1137+
<p class="copy-dialog-body">
1138+
Large pastes can slow down other apps. Try search (⌘F) to narrow it down.
1139+
</p>
1140+
<div class="copy-dialog-actions">
1141+
<Button variant="secondary" onclick={cancelCopyConfirm}>Cancel</Button>
1142+
<Button
1143+
variant="secondary"
1144+
onclick={() => {
1145+
void handleSaveAs()
1146+
}}>Save as file…</Button
1147+
>
1148+
<Button
1149+
variant="primary"
1150+
autoFocus
1151+
onclick={() => {
1152+
if (copyConfirmProceed) void copyConfirmProceed()
1153+
}}>Copy</Button
1154+
>
1155+
</div>
11411156
</div>
11421157
</ModalDialog>
11431158
{/if}
@@ -1148,24 +1163,28 @@
11481163
dialogId="viewer-copy-refuse"
11491164
titleId="viewer-copy-refuse-title"
11501165
onclose={dismissCopyRefuse}
1166+
containerStyle="max-width: 480px"
11511167
>
11521168
{#snippet title()}
11531169
<h2 id="viewer-copy-refuse-title" class="copy-dialog-title">
11541170
Copy {formatBytes(refuseBytes)} to the clipboard?
11551171
</h2>
11561172
{/snippet}
1157-
<p class="copy-dialog-body">
1158-
That's larger than the 100 MB clipboard limit. Try search (⌘F) to find what you need, or save the
1159-
selection as a file.
1160-
</p>
1161-
<div class="copy-dialog-actions">
1162-
<Button variant="secondary" onclick={dismissCopyRefuse}>Cancel</Button>
1163-
<Button
1164-
variant="primary"
1165-
onclick={() => {
1166-
void handleSaveAs()
1167-
}}>Save as file…</Button
1168-
>
1173+
<div class="copy-dialog-body-wrap">
1174+
<p class="copy-dialog-body">
1175+
That's larger than the 100 MB clipboard limit. Try search (⌘F) to find what you need, or save the
1176+
selection as a file.
1177+
</p>
1178+
<div class="copy-dialog-actions">
1179+
<Button variant="secondary" onclick={dismissCopyRefuse}>Cancel</Button>
1180+
<Button
1181+
variant="primary"
1182+
autoFocus
1183+
onclick={() => {
1184+
void handleSaveAs()
1185+
}}>Save as file…</Button
1186+
>
1187+
</div>
11691188
</div>
11701189
</ModalDialog>
11711190
{/if}
@@ -1542,17 +1561,21 @@
15421561
margin: 0;
15431562
}
15441563
1564+
/* Matches the AlertDialog body wrapper: design-system § Dialogs body padding 0 24px 24px. */
1565+
.copy-dialog-body-wrap {
1566+
padding: 0 var(--spacing-xl) var(--spacing-xl);
1567+
}
1568+
15451569
.copy-dialog-body {
15461570
font-size: var(--font-size-md);
15471571
line-height: 1.4;
15481572
color: var(--color-text-secondary);
1549-
margin-top: var(--spacing-md);
1573+
margin: 0 0 var(--spacing-xl);
15501574
}
15511575
15521576
.copy-dialog-actions {
15531577
display: flex;
15541578
gap: var(--spacing-md);
15551579
justify-content: flex-end;
1556-
margin-top: var(--spacing-xl);
15571580
}
15581581
</style>

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

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

141+
/**
142+
* Returns `true` if the selection covers the whole file. Used by the copy flow to
143+
* short-circuit the per-line byte estimator with the known file size: walking lines
144+
* fails for ⌘A on large files because the line cache only contains lines the user
145+
* has scrolled past, but the file size is known at viewer-open time.
146+
*
147+
* Matches three cases:
148+
* 1. ByteSeek-no-index sentinel: `end.line === Number.MAX_SAFE_INTEGER`.
149+
* 2. Known total lines: `end.line >= totalLines - 1` (the last line is included).
150+
* 3. `start === (0, 0)` is required in all cases.
151+
*
152+
* Reversed selections (anchor below focus) normalise first.
153+
*/
154+
export function isWholeFileSelection(sel: Selection | null, totalLines: number | null): boolean {
155+
if (sel === null) return false
156+
const { start, end } = normaliseSelection(sel)
157+
if (start.line !== 0 || start.offset !== 0) return false
158+
if (end.line === Number.MAX_SAFE_INTEGER) return true
159+
if (totalLines !== null && end.line >= totalLines - 1) return true
160+
return false
161+
}
162+
141163
/**
142164
* Maximum number of intermediate lines the AT (VoiceOver) announcement loop walks
143165
* before falling back to a generic "extends past visible content" message. Caps the

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getLineSegmentBounds,
99
isEmpty,
1010
isLineInRange,
11+
isWholeFileSelection,
1112
lineOffsetEquals,
1213
makeSelectAll,
1314
MAX_ANNOUNCE_LINES,
@@ -233,6 +234,55 @@ describe('makeSelectAll', () => {
233234
})
234235
})
235236

237+
describe('isWholeFileSelection', () => {
238+
it('matches the output of makeSelectAll', () => {
239+
expect(isWholeFileSelection(makeSelectAll(100, 50), 100)).toBe(true)
240+
})
241+
242+
it('matches the ByteSeek-no-index sentinel (focus.line = MAX_SAFE_INTEGER)', () => {
243+
const sel: Selection = {
244+
anchor: { line: 0, offset: 0 },
245+
focus: { line: Number.MAX_SAFE_INTEGER, offset: 0 },
246+
}
247+
expect(isWholeFileSelection(sel, null)).toBe(true)
248+
expect(isWholeFileSelection(sel, 50)).toBe(true)
249+
})
250+
251+
it('rejects non-zero start line', () => {
252+
const sel: Selection = { anchor: { line: 1, offset: 0 }, focus: { line: 99, offset: 0 } }
253+
expect(isWholeFileSelection(sel, 100)).toBe(false)
254+
})
255+
256+
it('rejects non-zero start offset', () => {
257+
const sel: Selection = { anchor: { line: 0, offset: 1 }, focus: { line: 99, offset: 5 } }
258+
expect(isWholeFileSelection(sel, 100)).toBe(false)
259+
})
260+
261+
it('rejects end before the last line', () => {
262+
const sel: Selection = { anchor: { line: 0, offset: 0 }, focus: { line: 50, offset: 0 } }
263+
expect(isWholeFileSelection(sel, 100)).toBe(false)
264+
})
265+
266+
it('treats end at last-line-start as whole-file (over-include is fine for tier classification)', () => {
267+
const sel: Selection = { anchor: { line: 0, offset: 0 }, focus: { line: 99, offset: 0 } }
268+
expect(isWholeFileSelection(sel, 100)).toBe(true)
269+
})
270+
271+
it('normalises reversed selections', () => {
272+
const reversed: Selection = { anchor: { line: 99, offset: 50 }, focus: { line: 0, offset: 0 } }
273+
expect(isWholeFileSelection(reversed, 100)).toBe(true)
274+
})
275+
276+
it('returns false for null selection', () => {
277+
expect(isWholeFileSelection(null, 100)).toBe(false)
278+
})
279+
280+
it('without totalLines and without sentinel, never matches', () => {
281+
const sel: Selection = { anchor: { line: 0, offset: 0 }, focus: { line: 99, offset: 5 } }
282+
expect(isWholeFileSelection(sel, null)).toBe(false)
283+
})
284+
})
285+
236286
describe('estimateSelectionBytes', () => {
237287
// Helper that builds fixed byte / UTF-16 lookups.
238288
function makeLookups(lines: { bytes: number; utf16: number }[]) {

0 commit comments

Comments
 (0)