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:
217
src/lib/components/VirtualSegmentList.svelte
Normal file
217
src/lib/components/VirtualSegmentList.svelte
Normal 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>
|
||||||
28
src/lib/utils/accessibilityTypography.js
Normal file
28
src/lib/utils/accessibilityTypography.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const PRETEXT_FONT_FAMILIES = {
|
||||||
|
lexend: "'Lexend'",
|
||||||
|
atkinson: "'Atkinson Hyperlegible Next'",
|
||||||
|
opendyslexic: "'OpenDyslexic'",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pretextFontFamily(fontFamily = "lexend") {
|
||||||
|
return PRETEXT_FONT_FAMILIES[fontFamily] || PRETEXT_FONT_FAMILIES.lexend;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pretextFontShorthand(accessibility = {}, sizePx = 16) {
|
||||||
|
return `${sizePx}px ${pretextFontFamily(accessibility.fontFamily)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bodyPretextLineHeight(accessibility = {}, sizePx = 16) {
|
||||||
|
const ratio = accessibility.lineHeight || 1.5;
|
||||||
|
return Math.round(sizePx * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transcriptPretextFont(accessibility = {}) {
|
||||||
|
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
|
||||||
|
return pretextFontShorthand(accessibility, sizePx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transcriptPretextLineHeight(accessibility = {}, ratio = 1.85) {
|
||||||
|
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
|
||||||
|
return Math.round(sizePx * ratio);
|
||||||
|
}
|
||||||
127
src/lib/utils/textMeasure.js
Normal file
127
src/lib/utils/textMeasure.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
prepare,
|
||||||
|
layout,
|
||||||
|
prepareWithSegments,
|
||||||
|
layoutWithLines,
|
||||||
|
} from '@chenglou/pretext';
|
||||||
|
|
||||||
|
// Cache prepared text to avoid re-measuring.
|
||||||
|
// prepare() is expensive, layout() is the cheap hot path.
|
||||||
|
const prepareCache = new Map();
|
||||||
|
const segmentedPrepareCache = new Map();
|
||||||
|
const MAX_CACHE_ENTRIES = 400;
|
||||||
|
|
||||||
|
function touch(cache, key, value) {
|
||||||
|
if (cache.has(key)) cache.delete(key);
|
||||||
|
cache.set(key, value);
|
||||||
|
if (cache.size > MAX_CACHE_ENTRIES) {
|
||||||
|
const oldestKey = cache.keys().next().value;
|
||||||
|
cache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKey(text, font, options = {}) {
|
||||||
|
return `${text}\0${font}\0${JSON.stringify(options)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrepared(cache, factory, text, font, options = {}) {
|
||||||
|
const key = cacheKey(text, font, options);
|
||||||
|
if (cache.has(key)) {
|
||||||
|
const cached = cache.get(key);
|
||||||
|
return touch(cache, key, cached);
|
||||||
|
}
|
||||||
|
return touch(cache, key, factory(text, font, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareText(text, font, options = {}) {
|
||||||
|
return getPrepared(prepareCache, prepare, text, font, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareTextWithSegments(text, font, options = {}) {
|
||||||
|
return getPrepared(
|
||||||
|
segmentedPrepareCache,
|
||||||
|
prepareWithSegments,
|
||||||
|
text,
|
||||||
|
font,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the rendered height of text without DOM reflow.
|
||||||
|
* Caches the prepare() result per unique text+font combination.
|
||||||
|
*
|
||||||
|
* @param {string} text - The text content to measure
|
||||||
|
* @param {string} font - CSS font shorthand (e.g. "16px Lexend")
|
||||||
|
* @param {number} maxWidth - Container width in pixels
|
||||||
|
* @param {number} lineHeight - Line height in pixels
|
||||||
|
* @param {object} [options] - Additional options (e.g. { whiteSpace: 'pre-wrap' })
|
||||||
|
* @returns {{ height: number, lineCount: number }}
|
||||||
|
*/
|
||||||
|
export function measureTextHeight(text, font, maxWidth, lineHeight, options = {}) {
|
||||||
|
const prepared = prepareText(text, font, options);
|
||||||
|
return layout(prepared, maxWidth, lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure height using pre-wrap mode (textarea-like behaviour).
|
||||||
|
* Spaces, tabs, and hard breaks are preserved.
|
||||||
|
*/
|
||||||
|
export function measurePreWrap(text, font, maxWidth, lineHeight) {
|
||||||
|
return measureTextHeight(text, font, maxWidth, lineHeight, { whiteSpace: 'pre-wrap' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return full line layout information for custom rendering or clipping.
|
||||||
|
*/
|
||||||
|
export function layoutTextLines(text, font, maxWidth, lineHeight, options = {}) {
|
||||||
|
const prepared = prepareTextWithSegments(text, font, options);
|
||||||
|
return layoutWithLines(prepared, maxWidth, lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp text to a maximum number of laid-out lines without DOM reads.
|
||||||
|
*/
|
||||||
|
export function clampTextLines(
|
||||||
|
text,
|
||||||
|
font,
|
||||||
|
maxWidth,
|
||||||
|
lineHeight,
|
||||||
|
maxLines,
|
||||||
|
options = {},
|
||||||
|
) {
|
||||||
|
const result = layoutTextLines(text, font, maxWidth, lineHeight, options);
|
||||||
|
const visibleLines = result.lines.slice(0, maxLines);
|
||||||
|
const didTruncate = result.lineCount > maxLines;
|
||||||
|
if (!didTruncate) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
visibleLineCount: result.lineCount,
|
||||||
|
text,
|
||||||
|
didTruncate: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedLines = visibleLines.map((line) => line.text);
|
||||||
|
const lastLine = trimmedLines[trimmedLines.length - 1] || "";
|
||||||
|
trimmedLines[trimmedLines.length - 1] =
|
||||||
|
`${lastLine.replace(/\s+$/g, "")}\u2026`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
visibleLineCount: visibleLines.length,
|
||||||
|
text: trimmedLines.join("\n"),
|
||||||
|
height: visibleLines.length * lineHeight,
|
||||||
|
didTruncate: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all cached measurements.
|
||||||
|
* Call when font family, size, or line height changes.
|
||||||
|
*/
|
||||||
|
export function invalidateCache() {
|
||||||
|
prepareCache.clear();
|
||||||
|
segmentedPrepareCache.clear();
|
||||||
|
}
|
||||||
49
src/lib/utils/virtualList.js
Normal file
49
src/lib/utils/virtualList.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export function buildCumulativeOffsets(heights) {
|
||||||
|
const offsets = new Array(heights.length + 1);
|
||||||
|
offsets[0] = 0;
|
||||||
|
for (let i = 0; i < heights.length; i++) {
|
||||||
|
offsets[i + 1] = offsets[i] + heights[i];
|
||||||
|
}
|
||||||
|
return offsets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findVisibleRange(
|
||||||
|
cumulativeOffsets,
|
||||||
|
itemCount,
|
||||||
|
scrollTop,
|
||||||
|
viewportHeight,
|
||||||
|
buffer = 4,
|
||||||
|
) {
|
||||||
|
if (!cumulativeOffsets.length || itemCount === 0 || viewportHeight <= 0) {
|
||||||
|
return { start: 0, end: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let lo = 0;
|
||||||
|
let hi = itemCount - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
|
if (cumulativeOffsets[mid + 1] < scrollTop) {
|
||||||
|
lo = mid + 1;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const start = Math.max(0, lo - buffer);
|
||||||
|
|
||||||
|
const endScroll = scrollTop + viewportHeight;
|
||||||
|
lo = start;
|
||||||
|
hi = itemCount - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
|
if (cumulativeOffsets[mid] < endScroll) {
|
||||||
|
lo = mid + 1;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end: Math.min(itemCount, lo + buffer),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user