|
9 | 9 | const LINE_HEIGHT = 18 |
10 | 10 | const BUFFER_LINES = 50 |
11 | 11 | const FETCH_BATCH = 500 |
| 12 | + // WebKit caps element height at ~2^25 px (33.5M). Stay well below to avoid scroll cutoff. |
| 13 | + const MAX_SCROLL_HEIGHT = 30_000_000 |
12 | 14 | const SEARCH_POLL_INTERVAL = 100 |
13 | 15 | // Must match INDEXING_TIMEOUT_SECS in src-tauri/src/file_viewer/session.rs |
14 | 16 | const INDEXING_TIMEOUT_SECS = 5 |
|
28 | 30 | viewerSetupMenu, |
29 | 31 | viewerSetWordWrap, |
30 | 32 | type ViewerSearchMatch, |
31 | | - type BackendCapabilities, |
32 | 33 | } from '$lib/tauri-commands' |
33 | 34 | import { getCurrentWindow } from '@tauri-apps/api/window' |
34 | 35 | import { listen, type UnlistenFn } from '@tauri-apps/api/event' |
|
47 | 48 | let loading = $state(true) |
48 | 49 | let sessionId = $state('') |
49 | 50 | let backendType = $state<'fullLoad' | 'byteSeek' | 'lineIndex'>('fullLoad') |
50 | | - let capabilities = $state<BackendCapabilities | null>(null) |
51 | 51 | let isIndexing = $state(false) |
52 | 52 |
|
53 | 53 | // Line cache: lineNumber -> text (sparse - may have gaps) |
|
68 | 68 | let avgWrappedLineHeight = $state(LINE_HEIGHT) |
69 | 69 | const effectiveLineHeight = $derived(wordWrap ? avgWrappedLineHeight : LINE_HEIGHT) |
70 | 70 |
|
| 71 | + // Scroll compression: when total content height exceeds browser limits, scale down |
| 72 | + // the scroll coordinate system. Each scroll pixel then covers more than one real line. |
| 73 | + // scrollLineHeight = pixels per line in scroll space (may be < effectiveLineHeight for huge files). |
| 74 | + const scrollScale = $derived.by(() => { |
| 75 | + const fullHeight = estimatedTotalLines() * effectiveLineHeight |
| 76 | + return fullHeight > MAX_SCROLL_HEIGHT ? MAX_SCROLL_HEIGHT / fullHeight : 1 |
| 77 | + }) |
| 78 | + const scrollLineHeight = $derived(effectiveLineHeight * scrollScale) |
| 79 | +
|
71 | 80 | // Derived: which lines are visible |
72 | | - const visibleFrom = $derived(Math.max(0, Math.floor(scrollTop / effectiveLineHeight) - BUFFER_LINES)) |
| 81 | + const visibleFrom = $derived(Math.max(0, Math.floor(scrollTop / scrollLineHeight) - BUFFER_LINES)) |
73 | 82 | const visibleTo = $derived( |
74 | | - Math.min(estimatedTotalLines(), Math.ceil((scrollTop + viewportHeight) / effectiveLineHeight) + BUFFER_LINES), |
| 83 | + Math.min(estimatedTotalLines(), Math.ceil((scrollTop + viewportHeight) / scrollLineHeight) + BUFFER_LINES), |
75 | 84 | ) |
76 | 85 | const visibleLines = $derived(getVisibleLines()) |
77 | 86 | const gutterWidth = $derived(String(estimatedTotalLines()).length) |
|
201 | 210 | } |
202 | 211 | }) |
203 | 212 |
|
204 | | - // Compensate scroll position when effectiveLineHeight changes (toggle or measurement update). |
205 | | - // Proportional adjustment keeps the same line visible: ratio * scrollTop preserves the line |
206 | | - // at the top of the viewport, and ON ratio * OFF ratio = 1.0 so there's zero cumulative drift. |
207 | | - let prevEffectiveLineHeight = LINE_HEIGHT |
| 213 | + // Compensate scroll position when scrollLineHeight changes (word wrap toggle, measurement |
| 214 | + // update, or scroll scale change from totalLines update). Ratio adjustment preserves the |
| 215 | + // line at the top of the viewport. |
| 216 | + let prevScrollLineHeight = LINE_HEIGHT |
208 | 217 | $effect(() => { |
209 | | - const newHeight = effectiveLineHeight |
210 | | - if (!contentRef || prevEffectiveLineHeight === newHeight) { |
211 | | - prevEffectiveLineHeight = newHeight |
| 218 | + const newSLH = scrollLineHeight |
| 219 | + if (!contentRef || prevScrollLineHeight === newSLH) { |
| 220 | + prevScrollLineHeight = newSLH |
212 | 221 | return |
213 | 222 | } |
214 | | - const ratio = newHeight / prevEffectiveLineHeight |
| 223 | + const ratio = newSLH / prevScrollLineHeight |
215 | 224 | contentRef.scrollTop = Math.round(contentRef.scrollTop * ratio) |
216 | | - prevEffectiveLineHeight = newHeight |
| 225 | + prevScrollLineHeight = newSLH |
217 | 226 | }) |
218 | 227 |
|
219 | 228 | function scheduleFetch(from: number, to: number) { |
|
234 | 243 | totalLines = newTotal |
235 | 244 | return |
236 | 245 | } |
237 | | - // Calculate current scroll fraction before updating |
238 | | - const oldHeight = oldEstimate * effectiveLineHeight |
| 246 | + // Calculate current scroll fraction before updating (use capped heights for accuracy) |
| 247 | + const oldHeight = Math.min(oldEstimate * effectiveLineHeight, MAX_SCROLL_HEIGHT) |
239 | 248 | const scrollFraction = contentRef.scrollTop / oldHeight |
240 | 249 | log.debug('totalLines changed: {oldEstimate} -> {newTotal}, preserving scroll fraction {fraction}', { |
241 | 250 | oldEstimate, |
|
244 | 253 | }) |
245 | 254 | totalLines = newTotal |
246 | 255 | // Restore scroll fraction after DOM updates using rAF to run after browser's scroll clamping |
247 | | - const newHeight = newTotal * effectiveLineHeight |
| 256 | + const newHeight = Math.min(newTotal * effectiveLineHeight, MAX_SCROLL_HEIGHT) |
248 | 257 | const newScrollTop = Math.round(scrollFraction * newHeight) |
249 | 258 | const ref = contentRef // Capture reference for rAF callback |
250 | 259 | requestAnimationFrame(() => { |
|
465 | 474 | // Convert via byte offset: (byteOffset / totalBytes) * estimatedTotalLines |
466 | 475 | targetLine = totalBytes > 0 ? (match.byteOffset / totalBytes) * estimatedTotalLines() : match.line |
467 | 476 | } |
468 | | - const targetScroll = targetLine * effectiveLineHeight - viewportHeight / 2 |
469 | | - const finalScroll = Math.max(0, targetScroll) |
470 | | - contentRef.scrollTop = finalScroll |
| 477 | + const targetScroll = targetLine * scrollLineHeight - viewportHeight / 2 |
| 478 | + contentRef.scrollTop = Math.max(0, targetScroll) |
471 | 479 | } |
472 | 480 |
|
473 | 481 | // Debounce search input |
|
529 | 537 |
|
530 | 538 | function scrollByLines(lines: number) { |
531 | 539 | if (contentRef) { |
532 | | - contentRef.scrollTop = Math.max(0, contentRef.scrollTop + lines * effectiveLineHeight) |
| 540 | + contentRef.scrollTop = Math.max(0, contentRef.scrollTop + lines * scrollLineHeight) |
533 | 541 | } |
534 | 542 | } |
535 | 543 |
|
536 | 544 | function scrollByPages(pages: number) { |
537 | 545 | if (contentRef) { |
538 | | - const pageSize = contentRef.clientHeight - effectiveLineHeight // Overlap by 1 line |
539 | | - contentRef.scrollTop = Math.max(0, contentRef.scrollTop + pages * pageSize) |
| 546 | + // Advance by one screenful of lines (minus 1 for overlap context) |
| 547 | + const linesPerPage = Math.floor(contentRef.clientHeight / effectiveLineHeight) - 1 |
| 548 | + contentRef.scrollTop = Math.max(0, contentRef.scrollTop + pages * linesPerPage * scrollLineHeight) |
540 | 549 | } |
541 | 550 | } |
542 | 551 |
|
|
748 | 757 | totalLines = result.totalLines |
749 | 758 | estimatedLines = result.estimatedTotalLines |
750 | 759 | backendType = result.backendType |
751 | | - capabilities = result.capabilities |
752 | 760 | isIndexing = result.isIndexing |
753 | 761 |
|
754 | 762 | log.debug( |
|
889 | 897 | > |
890 | 898 | <div |
891 | 899 | class="scroll-spacer" |
892 | | - style="height: {estimatedTotalLines() * effectiveLineHeight}px; min-width: {wordWrap |
893 | | - ? 0 |
894 | | - : contentWidth}px" |
| 900 | + style="height: {estimatedTotalLines() * scrollLineHeight}px; min-width: {wordWrap ? 0 : contentWidth}px" |
895 | 901 | > |
896 | 902 | <div |
897 | 903 | class="lines-container" |
898 | 904 | bind:this={linesContainerRef} |
899 | | - style="transform: translateY({visibleFrom * effectiveLineHeight}px)" |
| 905 | + style="transform: translateY({visibleFrom * scrollLineHeight}px)" |
900 | 906 | > |
901 | 907 | {#each visibleLines as { lineNumber, text } (lineNumber)} |
902 | 908 | <div class="line" data-line={lineNumber}> |
|
0 commit comments