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), + }; +}