refactor(frontend): migrate JS modules to TypeScript

Wholesale JS -> TS migration of the frontend — stores, utils, actions,
and all Svelte component scripts adopt type annotations. Compile-time
surfaces (app.d.ts, lib/types/) added for shared DTO types.

Build plumbing:
  - package.json: dev:frontend script that runs svelte-kit sync first
  - tauri.conf.json: beforeDevCommand points at dev:frontend
  - run.sh: dropped the sed-hack that temporarily blanked beforeDevCommand;
    now relies on npm run dev:frontend to avoid double-Vite
  - jsconfig.json: allowImportingTsExtensions

Preserves all Group 1 behaviour:
  - page.svelte.ts keeps loadHistory / loadTasks Tauri-first, no
    localStorage; saveTranscriptMeta + mapTranscriptRow + mapTaskRow
    intact; update_task_cmd and update_transcript_meta_cmd invocations
    carry the correct payload shape.
  - Toasts, preferences stores typed without behaviour change.
  - Viewer still routes segment edits through saveTranscriptMeta; the
    Task 1.5 TODO markers are gone.

taskExtractor.ts is functionally improved during the migration:
  - multi-task matches in the same sentence
  - list-style shopping-verb expansion (get bread, milk, and cheese)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 20:05:54 +01:00
parent 6605266587
commit d6bf9ed245
48 changed files with 1693 additions and 1156 deletions

View File

@@ -1,28 +0,0 @@
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);
}

View File

@@ -0,0 +1,41 @@
import type { AccessibilityPreferences, FontFamily } from "$lib/types/app";
const PRETEXT_FONT_FAMILIES = {
lexend: "'Lexend'",
atkinson: "'Atkinson Hyperlegible Next'",
opendyslexic: "'OpenDyslexic'",
} satisfies Record<FontFamily, string>;
export function pretextFontFamily(fontFamily: FontFamily = "lexend"): string {
return PRETEXT_FONT_FAMILIES[fontFamily] || PRETEXT_FONT_FAMILIES.lexend;
}
export function pretextFontShorthand(
accessibility: Partial<AccessibilityPreferences> = {},
sizePx = 16,
): string {
return `${sizePx}px ${pretextFontFamily(accessibility.fontFamily)}`;
}
export function bodyPretextLineHeight(
accessibility: Partial<AccessibilityPreferences> = {},
sizePx = 16,
): number {
const ratio = accessibility.lineHeight || 1.5;
return Math.round(sizePx * ratio);
}
export function transcriptPretextFont(
accessibility: Partial<AccessibilityPreferences> = {},
): string {
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
return pretextFontShorthand(accessibility, sizePx);
}
export function transcriptPretextLineHeight(
accessibility: Partial<AccessibilityPreferences> = {},
ratio = 1.85,
): number {
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
return Math.round(sizePx * ratio);
}

8
src/lib/utils/errors.ts Normal file
View File

@@ -0,0 +1,8 @@
export function errorMessage(error: unknown): string {
if (typeof error === "string") return error;
if (error && typeof error === "object" && "message" in error) {
const message = (error as { message?: unknown }).message;
if (typeof message === "string") return message;
}
return String(error);
}

View File

@@ -1,4 +1,7 @@
import { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
import type { Segment } from "$lib/types/app";
type ExportFormat = "txt" | "md" | "csv" | "html" | "srt" | "vtt";
/**
* Export transcript in various formats.
@@ -7,7 +10,11 @@ import { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
* @param {string} format - "txt" | "md" | "csv" | "html" | "srt" | "vtt"
* @returns {string}
*/
export function exportTranscript(text, segments, format) {
export function exportTranscript(
text: string,
segments: Segment[],
format: ExportFormat,
): string {
switch (format) {
case "srt":
return toSRT(segments);
@@ -24,7 +31,7 @@ export function exportTranscript(text, segments, format) {
}
}
function toSRT(segments) {
function toSRT(segments: Segment[]): string {
if (!segments || segments.length === 0) return "";
return segments
.map((seg, i) => {
@@ -33,7 +40,7 @@ function toSRT(segments) {
.join("\n");
}
function toVTT(segments) {
function toVTT(segments: Segment[]): string {
if (!segments || segments.length === 0) return "";
let out = "WEBVTT\n\n";
out += segments
@@ -44,7 +51,7 @@ function toVTT(segments) {
return out;
}
function toCSV(segments) {
function toCSV(segments: Segment[]): string {
if (!segments || segments.length === 0) return "Start,End,Text\n";
let csv = "Start,End,Text\n";
for (const seg of segments) {
@@ -54,11 +61,11 @@ function toCSV(segments) {
return csv;
}
function escapeHtml(str) {
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function toHTML(text, segments) {
function toHTML(text: string, segments: Segment[]): string {
let html = `<!DOCTYPE html>
<html lang="en">
<head>
@@ -95,7 +102,7 @@ function toHTML(text, segments) {
return html;
}
function toMarkdown(text, segments) {
function toMarkdown(text: string, segments: Segment[]): string {
let md = `# Transcription\n\n`;
md += `**Date:** ${new Date().toLocaleDateString("en-GB")}\n`;
if (segments && segments.length > 0) {

View File

@@ -25,12 +25,12 @@ const WORD_BUCKETS = [
{ max: Infinity, tag: "words:very-long" },
];
function durationTag(seconds) {
function durationTag(seconds: number): string | null {
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return DURATION_BUCKETS.find((b) => seconds < b.max)?.tag ?? null;
}
function wordCountTag(text) {
function wordCountTag(text: string): string | null {
if (!text || typeof text !== "string") return null;
const count = text.trim().split(/\s+/).filter(Boolean).length;
return WORD_BUCKETS.find((b) => count < b.max)?.tag ?? null;
@@ -39,7 +39,7 @@ function wordCountTag(text) {
// Resolve time-of-day bucket from an ISO date or a legacy string like
// "19/04/2026, 11:37:23". Thresholds are fixed and local to the user's
// machine — hour 6-11 morning, 12-17 afternoon, 18-21 evening, else night.
function timeOfDayTag(dateStr) {
function timeOfDayTag(dateStr: string): string | null {
if (!dateStr) return null;
let ts = Date.parse(dateStr);
if (Number.isNaN(ts)) {
@@ -62,7 +62,7 @@ function timeOfDayTag(dateStr) {
return "time:night";
}
function sourceTag(source) {
function sourceTag(source: string): string | null {
if (!source) return null;
const s = String(source).toLowerCase();
if (s.includes("file")) return "source:file";
@@ -76,17 +76,17 @@ function sourceTag(source) {
// information and add cognitive load without improving retrieval. The
// function is kept as a hook for one future AI-derived content tag
// (`topic:*`) once kon-llm wires up real llama-cpp-2 in Phase 3.
export function deriveAutoTags(_item) {
export function deriveAutoTags(_item: TranscriptEntry): string[] {
return [];
}
export function normaliseTag(raw) {
export function normaliseTag(raw: string): string {
return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-");
}
// Build the flat frontmatter object that represents a transcript's metadata.
// Shown in the expanded History row and serialised when exporting to .md.
export function buildFrontmatter(item) {
export function buildFrontmatter(item: TranscriptEntry | null) {
if (!item) return {};
const auto = deriveAutoTags(item);
const manual = Array.isArray(item.manualTags) ? item.manualTags : [];
@@ -105,7 +105,7 @@ export function buildFrontmatter(item) {
// Escape a YAML scalar. Keeps things simple — quote if it contains any
// character that would otherwise need escaping in plain scalars.
function yamlScalar(value) {
function yamlScalar(value: unknown): string {
if (value === null || value === undefined) return "null";
if (typeof value === "number" || typeof value === "boolean") return String(value);
const s = String(value);
@@ -114,7 +114,7 @@ function yamlScalar(value) {
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
export function serialiseFrontmatter(fm) {
export function serialiseFrontmatter(fm: Record<string, unknown>): string {
const lines = ["---"];
for (const [key, value] of Object.entries(fm)) {
if (Array.isArray(value)) {
@@ -133,10 +133,11 @@ export function serialiseFrontmatter(fm) {
}
// Produce an Obsidian-flavoured markdown document for a transcript.
export function buildMarkdown(item) {
export function buildMarkdown(item: TranscriptEntry): string {
const fm = buildFrontmatter(item);
const header = serialiseFrontmatter(fm);
const title = fm.title || "Transcript";
const body = item?.text || "";
return `${header}\n\n# ${title}\n\n${body}\n`;
}
import type { TranscriptEntry } from "$lib/types/app";

View File

@@ -18,7 +18,17 @@
import { invoke } from '@tauri-apps/api/core';
import { hasTauriRuntime } from './runtime.js';
let cached = null;
interface OsInfo {
os: string;
arch: string;
family: string;
usesCmd: boolean;
isWayland: boolean;
customHotkeyBackend: boolean;
primaryModifierLabel: string;
}
let cached: OsInfo | null = null;
const FALLBACK_BROWSER_INFO = {
os: detectBrowserOs(),
@@ -52,14 +62,14 @@ function detectBrowserFamily() {
/** Fetch OS info from the Tauri backend. Idempotent call multiple times,
* only the first does the round-trip. Browser-preview mode returns the
* navigator-detected fallback. */
export async function loadOsInfo() {
export async function loadOsInfo(): Promise<OsInfo> {
if (cached) return cached;
if (!hasTauriRuntime()) {
cached = FALLBACK_BROWSER_INFO;
return cached;
}
try {
cached = await invoke('get_os_info');
cached = await invoke<OsInfo>("get_os_info");
} catch (err) {
console.warn('loadOsInfo: get_os_info failed, using browser fallback', err);
cached = FALLBACK_BROWSER_INFO;
@@ -70,31 +80,31 @@ export async function loadOsInfo() {
/** Synchronous accessor. Returns null if loadOsInfo has not been awaited
* yet. Components that need the value at first render should await
* loadOsInfo() in onMount before reading. */
export function osInfo() {
export function osInfo(): OsInfo | null {
return cached;
}
export function isMac() {
export function isMac(): boolean {
return cached?.os === 'macos';
}
export function isWindows() {
export function isWindows(): boolean {
return cached?.os === 'windows';
}
export function isLinux() {
export function isLinux(): boolean {
return cached?.os === 'linux';
}
/** Localised label for the primary keyboard modifier. "Cmd" on macOS,
* "Ctrl" everywhere else. Use in hotkey display strings:
* `${modKeyLabel()}+Shift+R` */
export function modKeyLabel() {
export function modKeyLabel(): string {
return cached?.primaryModifierLabel ?? 'Ctrl';
}
/** True if the current Linux session is Wayland. False on Windows/macOS.
* Useful for Settings Audio "PipeWire detected" status display. */
export function isWayland() {
export function isWayland(): boolean {
return !!cached?.isWayland;
}

View File

@@ -11,7 +11,7 @@
// behaviour for browser-preview mode.
export function hasTauriRuntime() {
if (typeof window === 'undefined') return false;
if (typeof window === "undefined") return false;
if (window.__TAURI_INTERNALS__) return true;
if (window.isTauri === true) return true;
return false;

8
src/lib/utils/storage.ts Normal file
View File

@@ -0,0 +1,8 @@
export function parseStoredJson<T>(raw: string | null): T | null {
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}

View File

@@ -1,127 +0,0 @@
/**
* Rule-based task extraction from transcripts.
* Identifies action items from natural speech patterns.
*/
// Action verbs that typically start task sentences
const ACTION_VERBS = [
"call", "email", "send", "write", "build", "fix", "update", "check",
"review", "schedule", "book", "create", "set up", "follow up", "organise",
"prepare", "draft", "submit", "cancel", "confirm", "arrange", "order",
"buy", "get", "find", "look into", "research", "test", "deploy", "push",
"move", "add", "remove", "delete", "install", "configure", "contact",
"message", "tell", "ask", "invite", "remind", "finish", "complete",
"start", "begin", "plan", "design", "implement", "refactor",
];
// Phrases that signal a task
const TASK_PHRASES = [
"need to", "needs to", "should", "must", "have to", "has to",
"want to", "going to", "gonna", "got to", "gotta",
"don't forget to", "remember to", "make sure to", "make sure we",
"let's", "we should", "i should", "we need to", "i need to",
"we have to", "i have to", "we must", "i must",
];
// Explicit markers (user intentionally marks a task)
const EXPLICIT_MARKERS = [
"action:", "action item:", "todo:", "task:", "to do:",
"action item", "follow up:", "follow-up:",
];
/**
* Extract tasks from a transcript string.
* Returns an array of { text, confidence } objects.
*/
export function extractTasks(transcript) {
if (!transcript || !transcript.trim()) return [];
const tasks = [];
const seen = new Set();
// Split into sentences (rough but effective for speech)
const sentences = splitSentences(transcript);
for (const sentence of sentences) {
const trimmed = sentence.trim();
if (trimmed.length < 5) continue;
const lower = trimmed.toLowerCase();
let matched = false;
let confidence = 0;
// Check explicit markers (highest confidence)
for (const marker of EXPLICIT_MARKERS) {
if (lower.startsWith(marker) || lower.includes(marker)) {
const taskText = extractAfterMarker(trimmed, marker);
if (taskText && taskText.length > 3) {
addTask(tasks, seen, capitalise(taskText), 0.95);
matched = true;
break;
}
}
}
if (matched) continue;
// Check task phrases (high confidence)
for (const phrase of TASK_PHRASES) {
const idx = lower.indexOf(phrase);
if (idx >= 0) {
const taskText = extractTaskFromPhrase(trimmed, phrase, idx);
if (taskText && taskText.length > 3) {
addTask(tasks, seen, capitalise(taskText), 0.8);
matched = true;
break;
}
}
}
if (matched) continue;
// Check if sentence starts with an action verb (medium confidence)
const firstWord = lower.split(/\s+/)[0];
const firstTwo = lower.split(/\s+/).slice(0, 2).join(" ");
if (ACTION_VERBS.includes(firstWord) || ACTION_VERBS.includes(firstTwo)) {
addTask(tasks, seen, capitalise(trimmed), 0.6);
}
}
return tasks;
}
function splitSentences(text) {
// Split on sentence-ending punctuation, newlines, or common speech breaks
return text
.split(/(?<=[.!?])\s+|\n+/)
.flatMap((s) => s.split(/(?:,\s*(?:and\s+)?)?(?=(?:then|also|plus)\s)/i))
.filter((s) => s.trim().length > 0);
}
function extractAfterMarker(sentence, marker) {
const lower = sentence.toLowerCase();
const idx = lower.indexOf(marker);
if (idx < 0) return null;
return sentence.slice(idx + marker.length).trim().replace(/^[:\-–—]\s*/, "");
}
function extractTaskFromPhrase(sentence, phrase, idx) {
// Extract "need to X" → "X"
const afterPhrase = sentence.slice(idx + phrase.length).trim();
if (afterPhrase) return afterPhrase;
return null;
}
function capitalise(text) {
if (!text) return text;
// Clean up leading punctuation/whitespace
const cleaned = text.replace(/^[,;:\-–—\s]+/, "").trim();
if (!cleaned) return text;
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
function addTask(tasks, seen, text, confidence) {
// Remove trailing punctuation for dedup
const key = text.toLowerCase().replace(/[.!?,;:]+$/, "").trim();
if (seen.has(key)) return;
seen.add(key);
tasks.push({ text: text.replace(/[.!?]+$/, "").trim(), confidence });
}

View File

@@ -0,0 +1,224 @@
/**
* Rule-based task extraction from transcripts.
* Identifies action items from natural speech patterns.
*/
// Action verbs that typically start task sentences
const ACTION_VERBS = [
"call", "email", "send", "write", "build", "fix", "update", "check",
"review", "schedule", "book", "create", "set up", "follow up", "organise",
"prepare", "draft", "submit", "cancel", "confirm", "arrange", "order",
"buy", "get", "find", "look into", "research", "test", "deploy", "push",
"move", "add", "remove", "delete", "install", "configure", "contact",
"message", "tell", "ask", "invite", "remind", "finish", "complete",
"start", "begin", "plan", "design", "implement", "refactor",
];
// Phrases that signal a task
const TASK_PHRASES = [
"need to", "needs to", "should", "must", "have to", "has to",
"want to", "going to", "gonna", "got to", "gotta",
"don't forget to", "remember to", "make sure to", "make sure we",
"let's", "we should", "i should", "we need to", "i need to",
"we have to", "i have to", "we must", "i must",
];
// Explicit markers (user intentionally marks a task)
const EXPLICIT_MARKERS = [
"action:", "action item:", "todo:", "task:", "to do:",
"action item", "follow up:", "follow-up:",
];
const SORTED_TASK_PHRASES = [...TASK_PHRASES].sort((a, b) => b.length - a.length);
const LISTABLE_VERBS = ["get", "buy", "pick up", "order"];
const LISTABLE_VERB_PATTERN = new RegExp(`^(${LISTABLE_VERBS.join("|")})\\s+(.+)$`, "i");
/**
* Extract tasks from a transcript string.
* Returns an array of { text, confidence } objects.
*/
export function extractTasks(transcript: string): ExtractedTask[] {
if (!transcript || !transcript.trim()) return [];
const tasks: ExtractedTask[] = [];
const seen = new Set<string>();
// Split into sentences (rough but effective for speech)
const sentences = splitSentences(transcript);
for (const sentence of sentences) {
const trimmed = sentence.trim();
if (trimmed.length < 5) continue;
const lower = trimmed.toLowerCase();
let matched = false;
// Check explicit markers (highest confidence)
for (const marker of EXPLICIT_MARKERS) {
if (lower.startsWith(marker) || lower.includes(marker)) {
const taskText = extractAfterMarker(trimmed, marker);
if (taskText && taskText.length > 3) {
addTask(tasks, seen, capitalise(taskText), 0.95);
matched = true;
break;
}
}
}
if (matched) continue;
// Check task phrases (high confidence)
const phraseMatches = findPhraseMatches(lower);
if (phraseMatches.length > 0) {
for (let i = 0; i < phraseMatches.length; i += 1) {
const match = phraseMatches[i];
const nextMatch = phraseMatches[i + 1] ?? null;
const taskCandidates = extractTaskCandidatesFromPhrase(trimmed, match, nextMatch);
for (const candidate of taskCandidates) {
if (candidate.length > 3) {
addTask(tasks, seen, capitalise(candidate), 0.8);
matched = true;
}
}
}
}
if (matched) continue;
// Check if sentence starts with an action verb (medium confidence)
const firstWord = lower.split(/\s+/)[0];
const firstTwo = lower.split(/\s+/).slice(0, 2).join(" ");
if (ACTION_VERBS.includes(firstWord) || ACTION_VERBS.includes(firstTwo)) {
addTask(tasks, seen, capitalise(trimmed), 0.6);
}
}
return tasks;
}
function splitSentences(text: string): string[] {
// Split on sentence-ending punctuation, newlines, or common speech breaks
return text
.split(/(?<=[.!?])\s+|\n+/)
.flatMap((s) => s.split(/(?:,\s*(?:and\s+)?)?(?=(?:then|also|plus)\s)/i))
.filter((s) => s.trim().length > 0);
}
function extractAfterMarker(sentence: string, marker: string): string | null {
const lower = sentence.toLowerCase();
const idx = lower.indexOf(marker);
if (idx < 0) return null;
const extracted = sentence.slice(idx + marker.length).trim().replace(/^[:\-–—]\s*/, "");
return cleanTaskText(extracted);
}
function findPhraseMatches(lower: string): PhraseMatch[] {
const matches: PhraseMatch[] = [];
for (let i = 0; i < lower.length; i += 1) {
for (const phrase of SORTED_TASK_PHRASES) {
if (!lower.startsWith(phrase, i)) continue;
if (!hasPhraseBoundary(lower, i, phrase.length)) continue;
matches.push({ index: i, phrase });
i += phrase.length - 1;
break;
}
}
return matches;
}
function hasPhraseBoundary(text: string, index: number, length: number): boolean {
const before = index === 0 ? "" : text[index - 1];
const after = index + length >= text.length ? "" : text[index + length];
return isBoundaryChar(before) && isBoundaryChar(after);
}
function isBoundaryChar(char: string): boolean {
return !char || /[^a-z0-9]/i.test(char);
}
function extractTaskCandidatesFromPhrase(
sentence: string,
match: PhraseMatch,
nextMatch: PhraseMatch | null,
): string[] {
const end = nextMatch?.index ?? sentence.length;
const afterPhrase = sentence.slice(match.index + match.phrase.length, end).trim();
const cleaned = cleanTaskText(afterPhrase);
if (!cleaned) return [];
return expandListTask(cleaned);
}
function cleanTaskText(text: string): string {
return text
.replace(/^[,;:\-–—\s]+/, "")
.replace(/\s+/g, " ")
.replace(/[.!?]+$/, "")
.trim();
}
function expandListTask(taskText: string): string[] {
const match = taskText.match(LISTABLE_VERB_PATTERN);
if (!match) return [taskText];
const [, verb, remainder] = match;
if (!/(?:,|&|\band\b)/i.test(remainder)) {
return [taskText];
}
const items = remainder
.split(/\s*(?:,|&|\band\b)\s*/i)
.map((item) => cleanTaskText(item))
.filter(Boolean);
if (
items.length < 2
|| items.some((item) => item.split(/\s+/).length > 4)
|| items.some((item) => containsActionLanguage(item))
) {
return [taskText];
}
return items.map((item) => `${verb} ${item}`);
}
function containsActionLanguage(text: string): boolean {
const lower = text.toLowerCase();
const words = lower.split(/\s+/);
const firstWord = words[0] ?? "";
const firstTwo = words.slice(0, 2).join(" ");
return (
ACTION_VERBS.includes(firstWord)
|| ACTION_VERBS.includes(firstTwo)
|| TASK_PHRASES.some((phrase) => lower.includes(phrase))
);
}
function capitalise(text: string): string {
if (!text) return text;
// Clean up leading punctuation/whitespace
const cleaned = text.replace(/^[,;:\-–—\s]+/, "").trim();
if (!cleaned) return text;
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
function addTask(
tasks: ExtractedTask[],
seen: Set<string>,
text: string,
confidence: number,
) {
// Remove trailing punctuation for dedup
const key = text.toLowerCase().replace(/[.!?,;:]+$/, "").trim();
if (seen.has(key)) return;
seen.add(key);
tasks.push({ text: text.replace(/[.!?]+$/, "").trim(), confidence });
}
interface ExtractedTask {
text: string;
confidence: number;
}
interface PhraseMatch {
index: number;
phrase: string;
}

View File

@@ -3,7 +3,19 @@ import {
layout,
prepareWithSegments,
layoutWithLines,
} from '@chenglou/pretext';
} from "@chenglou/pretext";
type LayoutOptions = Record<string, unknown>;
type PreparedFactory = (
text: string,
font: string,
options?: LayoutOptions,
) => unknown;
type ClampResult = ReturnType<typeof layoutWithLines> & {
visibleLineCount: number;
text: string;
didTruncate: boolean;
};
// Cache prepared text to avoid re-measuring.
// prepare() is expensive, layout() is the cheap hot path.
@@ -11,34 +23,44 @@ const prepareCache = new Map();
const segmentedPrepareCache = new Map();
const MAX_CACHE_ENTRIES = 400;
function touch(cache, key, value) {
function touch<T>(cache: Map<string, T>, key: string, value: T): T {
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);
if (typeof oldestKey === "string") cache.delete(oldestKey);
}
return value;
}
function cacheKey(text, font, options = {}) {
function cacheKey(text: string, font: string, options: LayoutOptions = {}): string {
return `${text}\0${font}\0${JSON.stringify(options)}`;
}
function getPrepared(cache, factory, text, font, options = {}) {
function getPrepared(
cache: Map<string, unknown>,
factory: PreparedFactory,
text: string,
font: string,
options: LayoutOptions = {},
): unknown {
const key = cacheKey(text, font, options);
if (cache.has(key)) {
const cached = cache.get(key);
return touch(cache, key, cached);
if (cached !== undefined) return touch(cache, key, cached);
}
return touch(cache, key, factory(text, font, options));
}
export function prepareText(text, font, options = {}) {
export function prepareText(text: string, font: string, options: LayoutOptions = {}): unknown {
return getPrepared(prepareCache, prepare, text, font, options);
}
export function prepareTextWithSegments(text, font, options = {}) {
export function prepareTextWithSegments(
text: string,
font: string,
options: LayoutOptions = {},
): unknown {
return getPrepared(
segmentedPrepareCache,
prepareWithSegments,
@@ -59,7 +81,13 @@ export function prepareTextWithSegments(text, font, options = {}) {
* @param {object} [options] - Additional options (e.g. { whiteSpace: 'pre-wrap' })
* @returns {{ height: number, lineCount: number }}
*/
export function measureTextHeight(text, font, maxWidth, lineHeight, options = {}) {
export function measureTextHeight(
text: string,
font: string,
maxWidth: number,
lineHeight: number,
options: LayoutOptions = {},
) {
const prepared = prepareText(text, font, options);
return layout(prepared, maxWidth, lineHeight);
}
@@ -68,14 +96,25 @@ export function measureTextHeight(text, font, maxWidth, lineHeight, options = {}
* 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' });
export function measurePreWrap(
text: string,
font: string,
maxWidth: number,
lineHeight: number,
) {
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 = {}) {
export function layoutTextLines(
text: string,
font: string,
maxWidth: number,
lineHeight: number,
options: LayoutOptions = {},
) {
const prepared = prepareTextWithSegments(text, font, options);
return layoutWithLines(prepared, maxWidth, lineHeight);
}
@@ -84,13 +123,13 @@ export function layoutTextLines(text, font, maxWidth, lineHeight, options = {})
* Clamp text to a maximum number of laid-out lines without DOM reads.
*/
export function clampTextLines(
text,
font,
maxWidth,
lineHeight,
maxLines,
options = {},
) {
text: string,
font: string,
maxWidth: number,
lineHeight: number,
maxLines: number,
options: LayoutOptions = {},
): ClampResult {
const result = layoutTextLines(text, font, maxWidth, lineHeight, options);
const visibleLines = result.lines.slice(0, maxLines);
const didTruncate = result.lineCount > maxLines;

View File

@@ -1,10 +1,10 @@
/** Pad number to 2 digits */
export function pad(n) {
export function pad(n: number): string {
return n.toString().padStart(2, "0");
}
/** Format seconds as M:SS (e.g. 1:05) */
export function formatTime(seconds) {
export function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60).toString().padStart(2, "0");
@@ -12,7 +12,7 @@ export function formatTime(seconds) {
}
/** Format seconds as human duration (e.g. 2m 30s) */
export function formatDuration(seconds) {
export function formatDuration(seconds: number): string {
if (!seconds) return "";
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
@@ -20,7 +20,7 @@ export function formatDuration(seconds) {
}
/** Format ISO timestamp as D Mon HH:MM (e.g. 15 Mar 18:11) */
export function formatTimestamp(iso) {
export function formatTimestamp(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }) +
@@ -28,7 +28,7 @@ export function formatTimestamp(iso) {
}
/** Format seconds as SRT timestamp (HH:MM:SS,mmm) */
export function formatTimeSRT(seconds) {
export function formatTimeSRT(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
@@ -37,7 +37,7 @@ export function formatTimeSRT(seconds) {
}
/** Format seconds as VTT timestamp (HH:MM:SS.mmm) */
export function formatTimeVTT(seconds) {
export function formatTimeVTT(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);

View File

@@ -1,4 +1,4 @@
export function buildCumulativeOffsets(heights) {
export function buildCumulativeOffsets(heights: number[]): number[] {
const offsets = new Array(heights.length + 1);
offsets[0] = 0;
for (let i = 0; i < heights.length; i++) {
@@ -8,10 +8,10 @@ export function buildCumulativeOffsets(heights) {
}
export function findVisibleRange(
cumulativeOffsets,
itemCount,
scrollTop,
viewportHeight,
cumulativeOffsets: number[],
itemCount: number,
scrollTop: number,
viewportHeight: number,
buffer = 4,
) {
if (!cumulativeOffsets.length || itemCount === 0 || viewportHeight <= 0) {