From f384641097baf093479ce0d9c6840eef45161ae3 Mon Sep 17 00:00:00 2001 From: Jake Date: Sat, 18 Apr 2026 09:03:17 +0100 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20commit=20missing=20utility=20m?= =?UTF-8?q?odules=20(P0=20=E2=80=94=20imports=20resolved=20to=20nothing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/components/VirtualSegmentList.svelte | 217 +++++++++++++++++++ src/lib/utils/accessibilityTypography.js | 28 +++ src/lib/utils/textMeasure.js | 127 +++++++++++ src/lib/utils/virtualList.js | 49 +++++ 4 files changed, 421 insertions(+) create mode 100644 src/lib/components/VirtualSegmentList.svelte create mode 100644 src/lib/utils/accessibilityTypography.js create mode 100644 src/lib/utils/textMeasure.js create mode 100644 src/lib/utils/virtualList.js diff --git a/src/lib/components/VirtualSegmentList.svelte b/src/lib/components/VirtualSegmentList.svelte new file mode 100644 index 0000000..8cb6a31 --- /dev/null +++ b/src/lib/components/VirtualSegmentList.svelte @@ -0,0 +1,217 @@ + + +
+
+ {#each visibleItems as { seg, idx, top, height } (idx)} +
onSeekToTime(seg.start)} + ondblclick={() => onStartEditing(idx)} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === "Enter" && editingIdx !== idx) onSeekToTime(seg.start); }} + > + + + + {#if showTimestamps} + + {formatTime(seg.start)} + + {/if} + + {#if editingIdx === idx} + + {:else} +

+ {#if searchQuery.trim()} + {@html highlightText(seg.text.trim())} + {:else} + {seg.text.trim()} + {/if} +

+ {/if} + +
+ + +
+
+ {/each} +
+
diff --git a/src/lib/utils/accessibilityTypography.js b/src/lib/utils/accessibilityTypography.js new file mode 100644 index 0000000..798a1b5 --- /dev/null +++ b/src/lib/utils/accessibilityTypography.js @@ -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); +} diff --git a/src/lib/utils/textMeasure.js b/src/lib/utils/textMeasure.js new file mode 100644 index 0000000..7e5bfdb --- /dev/null +++ b/src/lib/utils/textMeasure.js @@ -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(); +} diff --git a/src/lib/utils/virtualList.js b/src/lib/utils/virtualList.js new file mode 100644 index 0000000..371dc28 --- /dev/null +++ b/src/lib/utils/virtualList.js @@ -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), + }; +}