@linto-ai/transcript-ui-core 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +46 -0
  3. package/package.json +46 -0
  4. package/src/adapters/apiAdapter.ts +44 -0
  5. package/src/adapters/mapApiTurns.ts +32 -0
  6. package/src/adapters/test/mapApiTurns.test.ts +73 -0
  7. package/src/adapters/whisperXAdapter.ts +73 -0
  8. package/src/components/ChannelSelector.vue +31 -0
  9. package/src/components/EditorErrorOverlay.vue +55 -0
  10. package/src/components/EditorLoadingOverlay.vue +52 -0
  11. package/src/components/Header.vue +138 -0
  12. package/src/components/Layout.vue +221 -0
  13. package/src/components/MicrophoneIllustration.vue +28 -0
  14. package/src/components/SelectionActionBar.vue +86 -0
  15. package/src/components/SidebarDrawer.vue +32 -0
  16. package/src/components/SpeakerLabel.vue +105 -0
  17. package/src/components/SpeakerSidebar.vue +455 -0
  18. package/src/components/TabBar.constants.ts +2 -0
  19. package/src/components/TabBar.vue +52 -0
  20. package/src/components/TranscriptUI.vue +68 -0
  21. package/src/components/TranscriptionEmpty.vue +39 -0
  22. package/src/components/TranscriptionPanel.vue +275 -0
  23. package/src/components/TranscriptionTurn.vue +407 -0
  24. package/src/components/TranslationSelector.vue +39 -0
  25. package/src/components/VerbatimPanel.vue +160 -0
  26. package/src/components/molecules/MergeDialog.vue +150 -0
  27. package/src/components/molecules/MergeTurnsButton.vue +57 -0
  28. package/src/components/molecules/SpeakerPopover.vue +146 -0
  29. package/src/composables/useEditorReady.ts +62 -0
  30. package/src/composables/useFollowPlayback.ts +124 -0
  31. package/src/composables/useIsMobile.ts +24 -0
  32. package/src/composables/useTurnSelection.ts +169 -0
  33. package/src/constants/speakers.ts +14 -0
  34. package/src/core/createCore.ts +162 -0
  35. package/src/core/helpers/addSpeaker.ts +11 -0
  36. package/src/core/helpers/countTurnsForSpeaker.ts +9 -0
  37. package/src/core/helpers/createSpeakerAndAssign.ts +16 -0
  38. package/src/core/helpers/ensureDocumentSpeakers.ts +26 -0
  39. package/src/core/helpers/ensureSpeaker.ts +12 -0
  40. package/src/core/helpers/ensureSpeakersFromTurns.ts +14 -0
  41. package/src/core/helpers/findTurnIndex.ts +5 -0
  42. package/src/core/helpers/index.ts +15 -0
  43. package/src/core/helpers/insertTurn.ts +5 -0
  44. package/src/core/helpers/mergeSpeakers.ts +29 -0
  45. package/src/core/helpers/patchTurn.ts +15 -0
  46. package/src/core/helpers/prependTurns.ts +5 -0
  47. package/src/core/helpers/removeTurn.ts +8 -0
  48. package/src/core/helpers/renameSpeaker.ts +12 -0
  49. package/src/core/helpers/speakerEquals.ts +6 -0
  50. package/src/core/helpers/switchTurnSpeaker.ts +17 -0
  51. package/src/core/helpers/test/countTurnsForSpeaker.test.ts +16 -0
  52. package/src/core/helpers/test/createSpeakerAndAssign.test.ts +22 -0
  53. package/src/core/helpers/test/makeTestCore.ts +40 -0
  54. package/src/core/helpers/test/mergeSpeakers.test.ts +24 -0
  55. package/src/core/helpers/test/renameSpeaker.test.ts +19 -0
  56. package/src/core/helpers/test/switchTurnSpeaker.test.ts +27 -0
  57. package/src/core/helpers/updateTurnWords.ts +22 -0
  58. package/src/core/index.ts +47 -0
  59. package/src/core/modules/eventBus.ts +43 -0
  60. package/src/core/stores/channelStore.ts +100 -0
  61. package/src/core/stores/crossTranslationStore.ts +141 -0
  62. package/src/core/stores/index.ts +7 -0
  63. package/src/core/stores/speakersStore.ts +53 -0
  64. package/src/core/stores/translationStore.ts +133 -0
  65. package/src/core/types.ts +602 -0
  66. package/src/core/useCore.ts +16 -0
  67. package/src/index.ts +88 -0
  68. package/src/types/api.ts +52 -0
  69. package/src/types/editor.ts +65 -0
  70. package/src/types/whisperx.ts +22 -0
  71. package/src/utils/color.ts +7 -0
  72. package/src/utils/computeCaretOffsetFromPoint.ts +56 -0
  73. package/src/utils/computeTurnPlainText.ts +9 -0
  74. package/src/utils/extractLangCode.ts +7 -0
  75. package/src/utils/findWordAtOffset.ts +16 -0
  76. package/src/utils/index.ts +19 -0
  77. package/src/utils/intl.ts +47 -0
  78. package/src/utils/isSameLanguage.ts +13 -0
  79. package/src/utils/test/computeTurnPlainText.test.ts +34 -0
  80. package/src/utils/test/findWordAtOffset.test.ts +25 -0
  81. package/src/utils/throttle.ts +23 -0
  82. package/src/utils/time.ts +95 -0
  83. package/src/utils/tokenize.ts +34 -0
  84. package/src/utils/tts.ts +49 -0
  85. package/src/utils/turnWords/carryWordTimes.ts +37 -0
  86. package/src/utils/turnWords/index.ts +6 -0
  87. package/src/utils/turnWords/layoutWords.ts +41 -0
  88. package/src/utils/turnWords/parseWordId.ts +13 -0
  89. package/src/utils/turnWords/test/carryWordTimes.test.ts +47 -0
  90. package/src/utils/turnWords/test/layoutWords.test.ts +54 -0
  91. package/src/utils/turnWords/test/parseWordId.test.ts +24 -0
  92. package/src/utils/turnWords/test/wordId.test.ts +9 -0
  93. package/src/utils/turnWords/test/wordsFromApi.test.ts +20 -0
  94. package/src/utils/turnWords/test/wordsFromText.test.ts +17 -0
  95. package/src/utils/turnWords/wordId.ts +9 -0
  96. package/src/utils/turnWords/wordsFromApi.ts +16 -0
  97. package/src/utils/turnWords/wordsFromText.ts +14 -0
  98. package/src/utils/validateDocument.ts +80 -0
  99. package/src/utils/waveform.ts +64 -0
  100. package/src/utils/wordRange.ts +67 -0
  101. package/src/utils/words.ts +45 -0
@@ -0,0 +1,13 @@
1
+ /** Inverse of wordId: split a `turnId#index` key back into its parts. */
2
+ export function parseWordId(
3
+ id: string,
4
+ ): { turnId: string; index: number } | null {
5
+ const sep = id.lastIndexOf("#")
6
+ if (sep <= 0) return null
7
+ const raw = id.slice(sep + 1)
8
+ // Number("") is 0 — an empty index must not parse as word 0.
9
+ if (raw === "") return null
10
+ const index = Number(raw)
11
+ if (!Number.isInteger(index) || index < 0) return null
12
+ return { turnId: id.slice(0, sep), index }
13
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { carryWordTimes } from "../carryWordTimes"
3
+ import { wordsFromText } from "../wordsFromText"
4
+ import { layoutWords } from "../layoutWords"
5
+
6
+ const prev = layoutWords("turn-1", [
7
+ { text: "Bonjour", startTime: 0, endTime: 0.8 },
8
+ { text: "tout", startTime: 0.9, endTime: 1.1 },
9
+ { text: "le", startTime: 1.2, endTime: 1.3 },
10
+ { text: "monde", startTime: 1.4, endTime: 1.8 },
11
+ ])
12
+
13
+ describe("carryWordTimes", () => {
14
+ it("keeps every timing when the text is unchanged", () => {
15
+ const next = carryWordTimes(
16
+ wordsFromText("turn-1", "Bonjour tout le monde"),
17
+ prev,
18
+ )
19
+ expect(next.map((w) => w.startTime)).toEqual([0, 0.9, 1.2, 1.4])
20
+ })
21
+
22
+ it("keeps prefix/suffix timings and leaves the edited middle untimed", () => {
23
+ const next = carryWordTimes(
24
+ wordsFromText("turn-1", "Bonjour tous le monde"),
25
+ prev,
26
+ )
27
+ expect(next[0]?.startTime).toBe(0)
28
+ expect(next[1]?.startTime).toBeUndefined()
29
+ expect(next[2]?.startTime).toBe(1.2)
30
+ expect(next[3]?.startTime).toBe(1.4)
31
+ })
32
+
33
+ it("anchors the suffix by position from the end on insertion", () => {
34
+ const next = carryWordTimes(
35
+ wordsFromText("turn-1", "Bonjour vraiment tout le monde"),
36
+ prev,
37
+ )
38
+ expect(next.map((w) => w.startTime)).toEqual([0, undefined, 0.9, 1.2, 1.4])
39
+ })
40
+
41
+ it("carries what survives a deletion", () => {
42
+ const next = carryWordTimes(wordsFromText("turn-1", "Bonjour le monde"), prev)
43
+ expect(next[0]?.startTime).toBe(0)
44
+ expect(next[1]?.startTime).toBe(1.2)
45
+ expect(next[2]?.startTime).toBe(1.4)
46
+ })
47
+ })
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { layoutWords } from "../layoutWords"
3
+
4
+ describe("layoutWords", () => {
5
+ it("lays timed words out with single-space offsets", () => {
6
+ const words = layoutWords("turn-1", [
7
+ { text: "Bonjour", startTime: 0, endTime: 0.8 },
8
+ { text: "tout", startTime: 0.9, endTime: 1.1 },
9
+ ])
10
+ expect(words).toEqual([
11
+ {
12
+ id: "turn-1#0",
13
+ text: "Bonjour",
14
+ charStart: 0,
15
+ charEnd: 7,
16
+ startTime: 0,
17
+ endTime: 0.8,
18
+ },
19
+ {
20
+ id: "turn-1#1",
21
+ text: "tout",
22
+ charStart: 8,
23
+ charEnd: 12,
24
+ startTime: 0.9,
25
+ endTime: 1.1,
26
+ },
27
+ ])
28
+ })
29
+
30
+ it("splits a source word with internal whitespace, each part keeping its timing", () => {
31
+ const words = layoutWords("turn-1", [
32
+ { text: "l'enfant ?", startTime: 2, endTime: 3 },
33
+ ])
34
+ expect(words.map((w) => w.text)).toEqual(["l'enfant", "?"])
35
+ expect(words[0]).toMatchObject({ charStart: 0, charEnd: 8, startTime: 2 })
36
+ expect(words[1]).toMatchObject({ charStart: 9, charEnd: 10, endTime: 3 })
37
+ })
38
+
39
+ it("skips silence placeholders (empty/whitespace-only words)", () => {
40
+ const words = layoutWords("turn-1", [
41
+ { text: "", startTime: 0, endTime: 1 },
42
+ { text: " " },
43
+ { text: "mot", startTime: 1, endTime: 2 },
44
+ ])
45
+ expect(words.map((w) => w.text)).toEqual(["mot"])
46
+ expect(words[0]?.charStart).toBe(0)
47
+ })
48
+
49
+ it("omits absent timing fields instead of writing undefined", () => {
50
+ const [word] = layoutWords("turn-1", [{ text: "mot" }])
51
+ expect(word && "startTime" in word).toBe(false)
52
+ expect(word && "endTime" in word).toBe(false)
53
+ })
54
+ })
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { parseWordId } from "../parseWordId"
3
+ import { wordId } from "../wordId"
4
+
5
+ describe("parseWordId", () => {
6
+ it("round-trips wordId", () => {
7
+ expect(parseWordId(wordId("turn-1", 4))).toEqual({
8
+ turnId: "turn-1",
9
+ index: 4,
10
+ })
11
+ })
12
+
13
+ it("splits on the LAST separator (turn ids may contain '#')", () => {
14
+ expect(parseWordId("a#b#7")).toEqual({ turnId: "a#b", index: 7 })
15
+ })
16
+
17
+ it("rejects malformed ids", () => {
18
+ expect(parseWordId("no-separator")).toBeNull()
19
+ expect(parseWordId("#3")).toBeNull()
20
+ expect(parseWordId("turn-1#")).toBeNull()
21
+ expect(parseWordId("turn-1#-2")).toBeNull()
22
+ expect(parseWordId("turn-1#1.5")).toBeNull()
23
+ })
24
+ })
@@ -0,0 +1,9 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { wordId } from "../wordId"
3
+
4
+ describe("wordId", () => {
5
+ it("derives the positional key", () => {
6
+ expect(wordId("turn-1", 0)).toBe("turn-1#0")
7
+ expect(wordId("turn-1", 12)).toBe("turn-1#12")
8
+ })
9
+ })
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { wordsFromApi } from "../wordsFromApi"
3
+
4
+ describe("wordsFromApi", () => {
5
+ it("maps API fields (word/stime/etime/confidence) onto store words", () => {
6
+ const words = wordsFromApi("turn-1", [
7
+ { wid: "w1", word: "Bonjour", stime: 0, etime: 0.8, confidence: 0.9 },
8
+ { wid: "w2", word: "tout" },
9
+ ])
10
+ expect(words[0]).toMatchObject({
11
+ id: "turn-1#0",
12
+ text: "Bonjour",
13
+ startTime: 0,
14
+ endTime: 0.8,
15
+ confidence: 0.9,
16
+ })
17
+ expect(words[1]).toMatchObject({ id: "turn-1#1", text: "tout" })
18
+ expect(words[1] && "startTime" in words[1]).toBe(false)
19
+ })
20
+ })
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { wordsFromText } from "../wordsFromText"
3
+
4
+ describe("wordsFromText", () => {
5
+ it("derives positional words with offsets from plain text", () => {
6
+ expect(wordsFromText("turn-1", "Bonjour tout le")).toEqual([
7
+ { id: "turn-1#0", text: "Bonjour", charStart: 0, charEnd: 7 },
8
+ { id: "turn-1#1", text: "tout", charStart: 8, charEnd: 12 },
9
+ { id: "turn-1#2", text: "le", charStart: 13, charEnd: 15 },
10
+ ])
11
+ })
12
+
13
+ it("returns no words for empty or whitespace-only text", () => {
14
+ expect(wordsFromText("turn-1", "")).toEqual([])
15
+ expect(wordsFromText("turn-1", " ")).toEqual([])
16
+ })
17
+ })
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Word identity is positional now that the document is plain text: a word IS
3
+ * the i-th whitespace-delimited token of its turn. Word.id is derived
4
+ * (`turnId#index`) — an opaque, recomputable key for consumers (karaoke,
5
+ * follow-playback), never persisted and never on the wire.
6
+ */
7
+ export function wordId(turnId: string, index: number): string {
8
+ return `${turnId}#${index}`
9
+ }
@@ -0,0 +1,16 @@
1
+ import { layoutWords } from "./layoutWords"
2
+ import type { Word } from "../../types/editor"
3
+ import type { ApiWord } from "../../types/api"
4
+
5
+ /** Build the word list from an API turn payload (see layoutWords). */
6
+ export function wordsFromApi(turnId: string, apiWords: ApiWord[]): Word[] {
7
+ return layoutWords(
8
+ turnId,
9
+ apiWords.map((w) => ({
10
+ text: w.word ?? "",
11
+ ...(w.stime !== undefined && { startTime: w.stime }),
12
+ ...(w.etime !== undefined && { endTime: w.etime }),
13
+ ...(w.confidence !== undefined && { confidence: w.confidence }),
14
+ })),
15
+ )
16
+ }
@@ -0,0 +1,14 @@
1
+ import { tokenize } from "../tokenize"
2
+ import { wordId } from "./wordId"
3
+ import type { Word } from "../../types/editor"
4
+
5
+ /** Derive the word list from a turn's plain text (no timestamps — those are
6
+ * carried over or broadcast by the server). */
7
+ export function wordsFromText(turnId: string, text: string): Word[] {
8
+ return tokenize(text).map((t, i) => ({
9
+ id: wordId(turnId, i),
10
+ text: t.text,
11
+ charStart: t.charStart,
12
+ charEnd: t.charEnd,
13
+ }))
14
+ }
@@ -0,0 +1,80 @@
1
+ import type { EditorDocument } from "../types/editor"
2
+
3
+ export class DocumentValidationError extends Error {
4
+ path: string
5
+ constructor(path: string, message: string) {
6
+ super(`${path}: ${message}`)
7
+ this.name = "DocumentValidationError"
8
+ this.path = path
9
+ }
10
+ }
11
+
12
+ export function validateEditorDocument(doc: unknown): asserts doc is EditorDocument {
13
+ if (doc == null || typeof doc !== "object") {
14
+ throw new DocumentValidationError("document", "must be a non-null object")
15
+ }
16
+
17
+ const d = doc as Record<string, unknown>
18
+
19
+ if (typeof d.title !== "string") {
20
+ throw new DocumentValidationError("document.title", "must be a string")
21
+ }
22
+
23
+ if (!(d.speakers instanceof Map)) {
24
+ throw new DocumentValidationError("document.speakers", "must be a Map")
25
+ }
26
+
27
+ if (!Array.isArray(d.channels)) {
28
+ throw new DocumentValidationError("document.channels", "must be an array")
29
+ }
30
+
31
+ for (let ci = 0; ci < d.channels.length; ci++) {
32
+ const ch = d.channels[ci] as Record<string, unknown>
33
+ const cp = `channels[${ci}]`
34
+
35
+ if (ch == null || typeof ch !== "object") {
36
+ throw new DocumentValidationError(cp, "must be a non-null object")
37
+ }
38
+
39
+ if (typeof ch.id !== "string") {
40
+ throw new DocumentValidationError(`${cp}.id`, "must be a string")
41
+ }
42
+
43
+ if (typeof ch.name !== "string") {
44
+ throw new DocumentValidationError(`${cp}.name`, "must be a string")
45
+ }
46
+
47
+ if (typeof ch.duration !== "number") {
48
+ throw new DocumentValidationError(`${cp}.duration`, "must be a number")
49
+ }
50
+
51
+ if (!Array.isArray(ch.translations)) {
52
+ throw new DocumentValidationError(`${cp}.translations`, "must be an array")
53
+ }
54
+
55
+ for (let ti = 0; ti < ch.translations.length; ti++) {
56
+ const tr = ch.translations[ti] as Record<string, unknown>
57
+ const tp = `${cp}.translations[${ti}]`
58
+
59
+ if (tr == null || typeof tr !== "object") {
60
+ throw new DocumentValidationError(tp, "must be a non-null object")
61
+ }
62
+
63
+ if (typeof tr.id !== "string") {
64
+ throw new DocumentValidationError(`${tp}.id`, "must be a string")
65
+ }
66
+
67
+ if (!Array.isArray(tr.languages)) {
68
+ throw new DocumentValidationError(`${tp}.languages`, "must be an array")
69
+ }
70
+
71
+ if (typeof tr.isSource !== "boolean") {
72
+ throw new DocumentValidationError(`${tp}.isSource`, "must be a boolean")
73
+ }
74
+
75
+ if (!Array.isArray(tr.turns)) {
76
+ throw new DocumentValidationError(`${tp}.turns`, "must be an array")
77
+ }
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Normalizes raw waveform values (any scale, e.g. 16-bit ints from the
3
+ * audiowaveform format) to [-1, 1] floats as expected by WaveSurfer.
4
+ * Scales against the 98th percentile (values above it clip at 1) so a few
5
+ * isolated loud peaks don't flatten the rest of the waveform.
6
+ */
7
+ export function normalizePeaks(data: number[]): Float32Array {
8
+ const peaks = new Float32Array(data.length)
9
+ if (data.length === 0) return peaks
10
+
11
+ const sorted = data.map(Math.abs).sort((a, b) => a - b)
12
+ const reference = sorted[Math.floor((sorted.length - 1) * 0.98)] ?? 0
13
+ if (reference === 0) return peaks
14
+
15
+ // Gamma > 1 expands the contrast: average speech stays mid-height while
16
+ // near-silence drops to a flat line instead of a thin permanent bar.
17
+ const gamma = 1.5
18
+ for (let i = 0; i < data.length; i++) {
19
+ const value = (data[i] ?? 0) / reference
20
+ const clipped = Math.max(-1, Math.min(1, value))
21
+ peaks[i] = Math.sign(clipped) * Math.abs(clipped) ** gamma
22
+ }
23
+ return peaks
24
+ }
25
+
26
+ export function renderWaveform(
27
+ channels: (number[] | Float32Array)[],
28
+ ctx: CanvasRenderingContext2D,
29
+ ): void {
30
+ const { width, height } = ctx.canvas
31
+ const channel = channels[0]!
32
+ const scale = channel.length / width
33
+ const step = 0.5
34
+
35
+ ctx.translate(0, height / 2)
36
+ ctx.strokeStyle = ctx.fillStyle
37
+ ctx.beginPath()
38
+
39
+ for (let i = 0; i < width; i += step * 2) {
40
+ // Average all values that fall into this pixel bucket: on long audios
41
+ // (several data points per pixel) point-sampling picks loud windows too
42
+ // often and the waveform turns into a solid mass.
43
+ const start = Math.floor(i * scale)
44
+ const end = Math.max(start + 1, Math.floor((i + step * 2) * scale))
45
+ let sum = 0
46
+ for (let j = start; j < end; j++) sum += Math.abs(channel[j] ?? 0)
47
+ const value = sum / (end - start)
48
+ let x = i
49
+ let y = value * (height / 2)
50
+
51
+ ctx.moveTo(x, 0)
52
+ ctx.lineTo(x, y)
53
+ ctx.lineTo(x + step, 0)
54
+
55
+ x = x + step
56
+ y = -y
57
+ ctx.moveTo(x, 0)
58
+ ctx.lineTo(x, y)
59
+ ctx.lineTo(x + step, 0)
60
+ }
61
+
62
+ ctx.stroke()
63
+ ctx.closePath()
64
+ }
@@ -0,0 +1,67 @@
1
+ import type { Core } from "../core/types"
2
+ import type { Turn } from "../types/editor"
3
+ import { parseWordId } from "./turnWords"
4
+
5
+ /**
6
+ * Locate a word in the rendered DOM as a character Range — the shared anchor
7
+ * for the karaoke highlight (CSS Custom Highlight API) and follow-playback
8
+ * scrolling. Nothing is ever inserted into the content: a Range is a
9
+ * read-only view over the existing text nodes.
10
+ */
11
+
12
+ /** Resolve the active word id (`turnId#index`) into a DOM Range under `root`
13
+ * (the editor DOM or any container holding the rendered turns). */
14
+ export function activeWordRange(
15
+ root: ParentNode,
16
+ core: Core,
17
+ wordId: string,
18
+ ): Range | null {
19
+ const parsed = parseWordId(wordId)
20
+ if (!parsed) return null
21
+ const translation = core.activeChannel.value?.activeTranslation.value
22
+ const turn = translation?.turns.value.find((t) => t.id === parsed.turnId)
23
+ if (!turn) return null
24
+ return findWordRange(root, turn, parsed.index)
25
+ }
26
+
27
+ export function findWordRange(
28
+ root: ParentNode,
29
+ turn: Turn,
30
+ index: number,
31
+ ): Range | null {
32
+ const word = turn.words[index]
33
+ if (!word || word.charStart == null || word.charEnd == null) return null
34
+
35
+ const container = root.querySelector(
36
+ `[data-turn-id="${cssEscape(turn.id)}"] .turn-text`,
37
+ )
38
+ if (!container) return null
39
+
40
+ // Map the char offsets onto the container's text nodes. The store offsets
41
+ // come from tokenizing the same text PM renders, so a full walk always
42
+ // lands — unless an edit is mid-flight, in which case bail (null).
43
+ const range = document.createRange()
44
+ let offset = 0
45
+ let startSet = false
46
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT)
47
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
48
+ const len = node.nodeValue?.length ?? 0
49
+ if (!startSet && word.charStart < offset + len) {
50
+ range.setStart(node, word.charStart - offset)
51
+ startSet = true
52
+ }
53
+ if (word.charEnd <= offset + len) {
54
+ if (!startSet) return null
55
+ range.setEnd(node, word.charEnd - offset)
56
+ return range
57
+ }
58
+ offset += len
59
+ }
60
+ return null
61
+ }
62
+
63
+ function cssEscape(value: string): string {
64
+ return typeof CSS !== "undefined" && CSS.escape
65
+ ? CSS.escape(value)
66
+ : value.replace(/["\\]/g, "\\$&")
67
+ }
@@ -0,0 +1,45 @@
1
+ import type { Word } from "../types/editor"
2
+
3
+ /** True if at least one word carries a timestamp. Robust to sparse timing:
4
+ * freshly typed words (and split/merge products) may have no timestamp yet,
5
+ * and they can sit anywhere in the list — not just at index 0. */
6
+ export function hasWordTimestamps(words: Word[]): boolean {
7
+ return words.some((w) => w.startTime != null)
8
+ }
9
+
10
+ /** First defined word start in the list (skips untimed leading words). */
11
+ export function firstWordStart(words: Word[]): number | undefined {
12
+ for (const w of words) if (w.startTime != null) return w.startTime
13
+ return undefined
14
+ }
15
+
16
+ /** Last defined word end in the list (skips untimed trailing words). */
17
+ export function lastWordEnd(words: Word[]): number | undefined {
18
+ for (let i = words.length - 1; i >= 0; i--) {
19
+ const e = words[i]!.endTime
20
+ if (e != null) return e
21
+ }
22
+ return undefined
23
+ }
24
+
25
+ /**
26
+ * Margin in seconds around a word's [startTime, endTime] interval.
27
+ * Avoids highlight "holes" during inter-word micro-gaps (silences,
28
+ * punctuation). Beyond this margin no word is active.
29
+ */
30
+ const ACTIVE_WORD_MARGIN = 1
31
+
32
+ /**
33
+ * Returns the first word whose [startTime - margin, endTime] interval contains
34
+ * `time`, or null when none does. Words without timestamps are skipped (a typed
35
+ * word with no timing yet is never "active").
36
+ */
37
+ export function findActiveWord(words: Word[], time: number): string | null {
38
+ for (const word of words) {
39
+ if (word.startTime == null || word.endTime == null) continue
40
+ if (word.startTime - ACTIVE_WORD_MARGIN <= time && time <= word.endTime) {
41
+ return word.id
42
+ }
43
+ }
44
+ return null
45
+ }