fix(frontend): commit missing utility modules (P0 — imports resolved to nothing)

textMeasure.js, virtualList.js, and accessibilityTypography.js were
added in the 2026/04/04 session alongside VirtualSegmentList.svelte but
never committed. All four are imported from DictationPage, HistoryPage,
SettingsPage, and the viewer — a fresh clone had unresolvable imports.

textMeasure.js: pretext-backed text measurement with LRU cache.
  measureTextHeight, measurePreWrap, clampTextLines, invalidateCache.
virtualList.js: binary-search visible range for variable-height virtual
  lists. buildCumulativeOffsets + findVisibleRange.
accessibilityTypography.js: pretext font/line-height helpers that read
  the user's accessibility preference store (Lexend / Atkinson /
  OpenDyslexic mapping).
VirtualSegmentList.svelte: virtualised transcript segment renderer used
  by the viewer page; depends on all three utils above.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 09:03:17 +01:00
parent cca79dec4c
commit f384641097
4 changed files with 421 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
<script>
import { measureTextHeight } from "$lib/utils/textMeasure.js";
import { buildCumulativeOffsets, findVisibleRange } from "$lib/utils/virtualList.js";
import { getPreferences } from "$lib/stores/preferences.svelte.js";
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
// Props
let {
segments = [],
activeSegmentIdx = -1,
matchingSegments = new Set(),
showTimestamps = true,
editingIdx = -1,
editingText = $bindable(""),
searchQuery = "",
formatTime = (t) => String(t),
onSeekToTime = () => {},
onStartEditing = () => {},
onFinishEditing = () => {},
onToggleStar = () => {},
onCopySegment = () => {},
onDeleteSegment = () => {},
highlightText = (t) => t,
} = $props();
// Internal state
let containerEl = $state(null);
let containerHeight = $state(0);
let containerWidth = $state(0);
let scrollTop = $state(0);
let hasSelection = $state(false);
const prefs = getPreferences();
// Segment height measurement
const SEGMENT_PADDING = 20; // py-2 (16px) + gap-1 (4px)
const BUFFER_COUNT = 5;
const SELECTION_BUFFER = 10;
let textFont = $derived(pretextFontShorthand(prefs.accessibility, 13));
let textLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 13));
// Pre-calculate heights for all segments
let segmentHeights = $derived.by(() => {
if (!segments.length || containerWidth <= 0) return [];
// Available width for text: container - star (26px) - timestamp (48px if shown) - padding (24px) - actions (0, hidden)
const timestampWidth = showTimestamps ? 48 : 0;
const textWidth = containerWidth - 26 - timestampWidth - 24;
if (textWidth <= 0) return segments.map(() => 40);
return segments.map(seg => {
const text = seg.text?.trim() || "";
if (!text) return SEGMENT_PADDING + textLineHeight;
const { height } = measureTextHeight(text, textFont, textWidth, textLineHeight);
return height + SEGMENT_PADDING;
});
});
// Cumulative offsets for O(1) lookup
let cumulativeOffsets = $derived(buildCumulativeOffsets(segmentHeights));
let totalHeight = $derived(
cumulativeOffsets.length > 0 ? cumulativeOffsets[cumulativeOffsets.length - 1] : 0
);
// Determine visible range
let visibleRange = $derived.by(() => {
const buffer = hasSelection ? SELECTION_BUFFER : BUFFER_COUNT;
return findVisibleRange(
cumulativeOffsets,
segments.length,
scrollTop,
containerHeight,
buffer,
);
});
// Visible segment slice with positioning
let visibleItems = $derived.by(() => {
const items = [];
for (let i = visibleRange.start; i < visibleRange.end; i++) {
items.push({
seg: segments[i],
idx: segments[i]._idx,
top: cumulativeOffsets[i],
height: segmentHeights[i],
});
}
return items;
});
// Scroll to a specific segment index (used for active segment tracking + search)
export function scrollToSegment(idx) {
if (!containerEl || idx < 0 || idx >= segments.length) return;
const segTop = cumulativeOffsets[idx];
const segHeight = segmentHeights[idx];
const viewCenter = containerHeight / 2;
containerEl.scrollTop = segTop - viewCenter + segHeight / 2;
}
function onScroll(e) {
scrollTop = e.target.scrollTop;
// Track selection state for expanded buffer
hasSelection = !document.getSelection()?.isCollapsed;
}
// ResizeObserver for container dimensions
$effect(() => {
if (!containerEl) return;
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
containerHeight = entry.contentRect.height;
containerWidth = entry.contentRect.width;
}
});
ro.observe(containerEl);
return () => ro.disconnect();
});
// Auto-scroll to active segment during playback
$effect(() => {
if (activeSegmentIdx >= 0) {
// Find the index in our segments array that matches
const arrayIdx = segments.findIndex(s => s._idx === activeSegmentIdx);
if (arrayIdx >= 0) {
const segTop = cumulativeOffsets[arrayIdx];
const segBottom = segTop + segmentHeights[arrayIdx];
// Only scroll if the active segment is out of view
if (segTop < scrollTop || segBottom > scrollTop + containerHeight) {
scrollToSegment(arrayIdx);
}
}
}
});
</script>
<div
bind:this={containerEl}
class="flex-1 overflow-y-auto px-5 py-3 min-h-0"
onscroll={onScroll}
>
<div class="relative" style="height: {totalHeight}px">
{#each visibleItems as { seg, idx, top, height } (idx)}
<div
class="absolute left-0 right-0 group flex gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors
{activeSegmentIdx === idx
? 'bg-accent/10 border-l-2 border-accent'
: matchingSegments.has(idx)
? 'bg-warning/5 border-l-2 border-warning/40'
: 'border-l-2 border-transparent hover:bg-hover'}"
style="top: {top}px"
onclick={() => onSeekToTime(seg.start)}
ondblclick={() => onStartEditing(idx)}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === "Enter" && editingIdx !== idx) onSeekToTime(seg.start); }}
>
<!-- Star -->
<button
class="mt-0.5 flex-shrink-0 {seg.starred ? 'text-accent' : 'text-text-tertiary opacity-0 group-hover:opacity-100'}"
onclick={(e) => { e.stopPropagation(); onToggleStar(idx); }}
aria-label={seg.starred ? "Unstar" : "Star"}
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill={seg.starred ? "currentColor" : "none"} stroke="currentColor" stroke-width="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
<!-- Timestamp -->
{#if showTimestamps}
<span class="text-[10px] text-text-tertiary tabular-nums flex-shrink-0 mt-0.5 min-w-[36px]">
{formatTime(seg.start)}
</span>
{/if}
<!-- Text (editable or display) -->
{#if editingIdx === idx}
<textarea
class="flex-1 bg-bg-input border border-accent rounded-lg px-2 py-1 text-[13px] text-text
resize-none focus:outline-none min-h-[40px]"
style="line-height: {textLineHeight}px"
bind:value={editingText}
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onFinishEditing(); } if (e.key === "Escape") { onFinishEditing(); } }}
onblur={onFinishEditing}
onclick={(e) => e.stopPropagation()}
data-no-transition
></textarea>
{:else}
<p class="text-[13px] text-text flex-1" style="line-height: {textLineHeight}px">
{#if searchQuery.trim()}
{@html highlightText(seg.text.trim())}
{:else}
{seg.text.trim()}
{/if}
</p>
{/if}
<!-- Actions (hover reveal) -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 flex-shrink-0">
<button
class="text-text-tertiary hover:text-accent"
onclick={(e) => { e.stopPropagation(); onCopySegment(idx); }}
title="Copy segment"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
<button
class="text-text-tertiary hover:text-danger"
onclick={(e) => { e.stopPropagation(); onDeleteSegment(idx); }}
title="Delete segment"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
{/each}
</div>
</div>