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:
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