@linto-ai/transcript-ui-plugin-subtitle 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.
@@ -0,0 +1,66 @@
1
+ import { onMounted, onUnmounted, watch, type ComputedRef, type Ref } from "vue"
2
+ import { useCore } from "@linto-ai/transcript-ui-core"
3
+ import { SubtitleScroller } from "./SubtitleScroller"
4
+
5
+ interface UseSubtitleScrollerOptions {
6
+ canvasRef: Readonly<Ref<HTMLCanvasElement | null>>
7
+ fontSize: Ref<number> | ComputedRef<number>
8
+ lineHeight: Ref<number> | ComputedRef<number>
9
+ }
10
+
11
+ /**
12
+ * Wires a SubtitleScroller to editor events (live partials, turn adds, sync resets).
13
+ * Handles creation on mount and full cleanup on unmount.
14
+ */
15
+ export function useSubtitleScroller(options: UseSubtitleScrollerOptions) {
16
+ const core = useCore()
17
+ let scroller: SubtitleScroller | null = null
18
+
19
+ onMounted(() => {
20
+ if (!options.canvasRef.value) return
21
+ scroller = new SubtitleScroller(options.canvasRef.value, {
22
+ fontSize: options.fontSize.value,
23
+ lineHeight: options.lineHeight.value,
24
+ })
25
+ })
26
+
27
+ watch([options.fontSize, options.lineHeight], ([fs, lh]) => {
28
+ if (!scroller) return
29
+ scroller.setFontSize(fs, lh)
30
+ })
31
+
32
+ watch(
33
+ () => core.live?.partial.value,
34
+ (text) => {
35
+ if (text && scroller) scroller.newPartial(text)
36
+ },
37
+ )
38
+
39
+ const unsubTurnAdd = core.onActiveTranslation("turn:add", ({ turn }) => {
40
+ if (!scroller) return
41
+ const text =
42
+ turn.words.length > 0
43
+ ? turn.words.map((w) => w.text).join(" ")
44
+ : (turn.text ?? "")
45
+ if (text) scroller.newFinal(text)
46
+ })
47
+
48
+ function resetScroller(): void {
49
+ if (!scroller) return
50
+ scroller.resetDrawing()
51
+ scroller.resetAll()
52
+ }
53
+
54
+ const unsubTranslationChange = core.on("translation:change", resetScroller)
55
+ const unsubTranslationSync = core.on("translation:sync", resetScroller)
56
+ const unsubChannelSync = core.on("channel:sync", resetScroller)
57
+
58
+ onUnmounted(() => {
59
+ unsubTurnAdd()
60
+ unsubTranslationChange()
61
+ unsubTranslationSync()
62
+ unsubChannelSync()
63
+ scroller?.dispose()
64
+ scroller = null
65
+ })
66
+ }
@@ -0,0 +1,59 @@
1
+ import { onBeforeUnmount, onMounted, ref, watch } from "vue"
2
+ import type { WatermarkPluginApi } from "@linto-ai/transcript-ui-core"
3
+
4
+ export function useWatermarkCycle(api: WatermarkPluginApi | undefined) {
5
+ const visible = ref(false)
6
+ let showTimer: ReturnType<typeof setTimeout> | null = null
7
+ let hideTimer: ReturnType<typeof setTimeout> | null = null
8
+
9
+ function clearTimers() {
10
+ if (showTimer) {
11
+ clearTimeout(showTimer)
12
+ showTimer = null
13
+ }
14
+ if (hideTimer) {
15
+ clearTimeout(hideTimer)
16
+ hideTimer = null
17
+ }
18
+ }
19
+
20
+ function show() {
21
+ if (!api || !api.display.value) return
22
+ visible.value = true
23
+ if (!api.pinned.value) {
24
+ hideTimer = setTimeout(hide, api.duration.value * 1000)
25
+ }
26
+ }
27
+
28
+ function hide() {
29
+ visible.value = false
30
+ if (!api || !api.display.value || api.pinned.value) return
31
+ showTimer = setTimeout(show, api.frequency.value * 1000)
32
+ }
33
+
34
+ function restart() {
35
+ clearTimers()
36
+ if (!api || !api.display.value) {
37
+ visible.value = false
38
+ return
39
+ }
40
+ if (api.pinned.value) {
41
+ visible.value = true
42
+ return
43
+ }
44
+ visible.value = false
45
+ showTimer = setTimeout(show, api.frequency.value * 1000)
46
+ }
47
+
48
+ if (api) {
49
+ watch(
50
+ [api.display, api.pinned, api.frequency, api.duration],
51
+ restart,
52
+ )
53
+ }
54
+
55
+ onMounted(restart)
56
+ onBeforeUnmount(clearTimers)
57
+
58
+ return { visible }
59
+ }
@@ -0,0 +1,39 @@
1
+ import type { WatermarkToken } from "@linto-ai/transcript-ui-core"
2
+
3
+ export type WatermarkPart =
4
+ | { type: "text"; value: string }
5
+ | { type: "token"; src: string; alt: string }
6
+
7
+ const TOKEN_RE = /\$(\w+)/g
8
+
9
+ export function parseWatermark(
10
+ content: string,
11
+ tokens: Record<string, WatermarkToken>,
12
+ ): WatermarkPart[] {
13
+ const parts: WatermarkPart[] = []
14
+ let lastIndex = 0
15
+ let match: RegExpExecArray | null
16
+
17
+ TOKEN_RE.lastIndex = 0
18
+ while ((match = TOKEN_RE.exec(content)) !== null) {
19
+ if (match.index > lastIndex) {
20
+ parts.push({ type: "text", value: content.slice(lastIndex, match.index) })
21
+ }
22
+
23
+ const name = match[1] ?? ""
24
+ const token = name ? tokens[name] : undefined
25
+ if (token) {
26
+ parts.push({ type: "token", src: token.src, alt: token.alt ?? name })
27
+ } else {
28
+ parts.push({ type: "text", value: match[0] })
29
+ }
30
+
31
+ lastIndex = match.index + match[0].length
32
+ }
33
+
34
+ if (lastIndex < content.length) {
35
+ parts.push({ type: "text", value: content.slice(lastIndex) })
36
+ }
37
+
38
+ return parts
39
+ }
@@ -0,0 +1,200 @@
1
+ import { diffArrays } from "diff"
2
+
3
+ interface SubtitleState {
4
+ previousText: string
5
+ previousIndexes: number[]
6
+ }
7
+
8
+ interface DiffEntry {
9
+ value: string[]
10
+ added?: boolean
11
+ removed?: boolean
12
+ count: number
13
+ }
14
+
15
+ interface ReplacementEntry {
16
+ replaced: true
17
+ removed: boolean
18
+ added: boolean
19
+ countRemoved: number
20
+ countAdded: number
21
+ }
22
+
23
+ type DiffWithReplacements = (DiffEntry | ReplacementEntry)[]
24
+
25
+ export default function splitPartialSubtitles(
26
+ { previousText, previousIndexes: oldCutPositions }: SubtitleState,
27
+ newText: string,
28
+ computeIfTextIsTooLong: (text: string) => boolean,
29
+ computeIfTextOverflows?: (text: string) => boolean,
30
+ ): SubtitleState {
31
+ if (!newText) {
32
+ return { previousText, previousIndexes: oldCutPositions }
33
+ }
34
+
35
+ const previousTextSplitBySpace = previousText.split(" ")
36
+ const newTextSplitBySpace = newText.split(" ")
37
+ const diffList = diffArrays(previousTextSplitBySpace, newTextSplitBySpace, {
38
+ comparator: isSameWord,
39
+ })
40
+
41
+ const diffListWithReplacements = detectReplacements(diffList)
42
+
43
+ const copyCutPositions = [...oldCutPositions]
44
+ let newCutPositions = [...oldCutPositions]
45
+
46
+ let wordIndex = 0
47
+ for (const diff of diffListWithReplacements) {
48
+ do {
49
+ if (wordIndex < copyCutPositions[0]!) break
50
+ } while (copyCutPositions.shift() !== undefined)
51
+
52
+ if (copyCutPositions.length === 0) break
53
+
54
+ if ("replaced" in diff && diff.replaced) {
55
+ newCutPositions = incrementIndexes(
56
+ newCutPositions,
57
+ copyCutPositions[0]!,
58
+ diff.countAdded - diff.countRemoved,
59
+ )
60
+ wordIndex += diff.countRemoved
61
+ } else if ("removed" in diff && diff.removed) {
62
+ const d = diff as DiffEntry
63
+ wordIndex += d.count
64
+ newCutPositions = incrementIndexes(
65
+ newCutPositions,
66
+ copyCutPositions[0]!,
67
+ -d.count,
68
+ )
69
+ } else if ("added" in diff && diff.added) {
70
+ const d = diff as DiffEntry
71
+ newCutPositions = incrementIndexes(
72
+ newCutPositions,
73
+ copyCutPositions[0]!,
74
+ d.count,
75
+ )
76
+ } else {
77
+ const d = diff as DiffEntry
78
+ wordIndex += d.count
79
+ }
80
+ }
81
+
82
+ // The scroller only ever displays the last two lines, so we only keep those
83
+ // two inside the canvas.
84
+ const lineText = (from: number, to?: number): string =>
85
+ newTextSplitBySpace.slice(from, to).join(" ")
86
+
87
+ // Step 1 — keep the second-to-last (first visible) line inside the canvas. If a
88
+ // widened word pushed it off the edge, move the last cut earlier so the
89
+ // overflowing tail flows down into the last line; the head stays in place. We
90
+ // trigger on the overflow threshold but cut at the normal one, leaving a margin
91
+ // before it can overflow again (hysteresis, avoids re-cutting on every partial).
92
+ if (computeIfTextOverflows && newCutPositions.length > 0) {
93
+ const lastIndex = newCutPositions.length - 1
94
+ const lineStart = lastIndex > 0 ? newCutPositions[lastIndex - 1]! : 0
95
+ const secondToLastLine = lineText(lineStart, newCutPositions[lastIndex]!)
96
+
97
+ if (computeIfTextOverflows(secondToLastLine)) {
98
+ const [headCut] = getIndexesWhereToCutText(
99
+ secondToLastLine,
100
+ computeIfTextIsTooLong,
101
+ )
102
+ // headCut is undefined for a single unsplittable word: leave it as is.
103
+ if (headCut !== undefined) newCutPositions[lastIndex] = lineStart + headCut
104
+ }
105
+ }
106
+
107
+ // Step 2 — cut the last line if it is too long, including the words step 1 may
108
+ // have just pushed down. This is the normal live-growth cut.
109
+ const lastLineStart =
110
+ newCutPositions.length > 0 ? newCutPositions[newCutPositions.length - 1]! : 0
111
+ const lastLine = lineText(lastLineStart)
112
+ if (computeIfTextIsTooLong(lastLine)) {
113
+ const cuts = getIndexesWhereToCutText(lastLine, computeIfTextIsTooLong)
114
+ newCutPositions = newCutPositions.concat(cuts.map((i) => i + lastLineStart))
115
+ }
116
+
117
+ return {
118
+ previousIndexes: newCutPositions,
119
+ previousText: newText,
120
+ }
121
+ }
122
+
123
+ function detectReplacements(diffList: DiffEntry[]): DiffWithReplacements {
124
+ const result: DiffWithReplacements = []
125
+
126
+ for (let i = 0; i < diffList.length; i++) {
127
+ const currentDiff = diffList[i]!
128
+ const nextDiff = diffList[i + 1]
129
+
130
+ // A replacement is a removed block immediately followed by an added one.
131
+ const isReplacement = currentDiff.removed && nextDiff?.added
132
+
133
+ if (isReplacement) {
134
+ result.push({
135
+ replaced: true,
136
+ removed: true,
137
+ added: true,
138
+ countRemoved: currentDiff.count,
139
+ countAdded: nextDiff!.count,
140
+ })
141
+ i++ // skip the added block, already merged into this replacement
142
+ } else {
143
+ result.push(currentDiff)
144
+ }
145
+ }
146
+
147
+ return result
148
+ }
149
+
150
+ function incrementIndexes(indexes: number[], from: number, increment: number): number[] {
151
+ return indexes.map((index) => (index >= from ? index + increment : index))
152
+ }
153
+
154
+ export function getIndexesWhereToCutText(
155
+ text: string,
156
+ computeIfTextIsTooLong: (text: string) => boolean,
157
+ ): number[] {
158
+ const splitText = text.split(" ")
159
+ if (!computeIfTextIsTooLong(text) || splitText.length <= 1) {
160
+ return []
161
+ }
162
+
163
+ let i: number
164
+ for (i = 0; i < splitText.length; i++) {
165
+ const currentText = splitText.slice(0, i).join(" ")
166
+ if (computeIfTextIsTooLong(currentText)) break
167
+ }
168
+
169
+ return [i - 1].concat(
170
+ incrementIndexes(
171
+ getIndexesWhereToCutText(
172
+ splitText.slice(i - 1).join(" "),
173
+ computeIfTextIsTooLong,
174
+ ),
175
+ 0,
176
+ i - 1,
177
+ ),
178
+ )
179
+ }
180
+
181
+ function isSameWord(word1: string, word2: string): boolean {
182
+ const w1Normalized = word1
183
+ .toLowerCase()
184
+ .normalize("NFD")
185
+ .replace(/\p{Diacritic}/gu, "")
186
+ const w2Normalized = word2
187
+ .toLowerCase()
188
+ .normalize("NFD")
189
+ .replace(/\p{Diacritic}/gu, "")
190
+
191
+ const shortestLength = Math.min(w1Normalized.length, w2Normalized.length)
192
+
193
+ let numberOfSameChars = 0
194
+ for (let i = 0; i < shortestLength; i++) {
195
+ if (w1Normalized[i] === w2Normalized[i]) numberOfSameChars++
196
+ }
197
+
198
+ const similarity = numberOfSameChars / w1Normalized.length
199
+ return similarity > 0.8
200
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { parseWatermark } from "../parseWatermark"
3
+
4
+ const tokens = {
5
+ linto: { src: "/img/linto.svg", alt: "LinTO" },
6
+ logo: { src: "/img/logo.png" },
7
+ }
8
+
9
+ describe("parseWatermark", () => {
10
+ it("returns a single text part for plain content", () => {
11
+ expect(parseWatermark("Hello world", tokens)).toEqual([
12
+ { type: "text", value: "Hello world" },
13
+ ])
14
+ })
15
+
16
+ it("returns an empty array for empty content", () => {
17
+ expect(parseWatermark("", tokens)).toEqual([])
18
+ })
19
+
20
+ it("replaces a known token with an image part", () => {
21
+ expect(parseWatermark("$linto", tokens)).toEqual([
22
+ { type: "token", src: "/img/linto.svg", alt: "LinTO" },
23
+ ])
24
+ })
25
+
26
+ it("uses the token name as alt when alt is not provided", () => {
27
+ expect(parseWatermark("$logo", tokens)).toEqual([
28
+ { type: "token", src: "/img/logo.png", alt: "logo" },
29
+ ])
30
+ })
31
+
32
+ it("keeps unknown tokens as literal text", () => {
33
+ expect(parseWatermark("Hi $unknown", tokens)).toEqual([
34
+ { type: "text", value: "Hi " },
35
+ { type: "text", value: "$unknown" },
36
+ ])
37
+ })
38
+
39
+ it("handles text around and between tokens", () => {
40
+ expect(
41
+ parseWatermark("Powered by $linto and $logo - thanks", tokens),
42
+ ).toEqual([
43
+ { type: "text", value: "Powered by " },
44
+ { type: "token", src: "/img/linto.svg", alt: "LinTO" },
45
+ { type: "text", value: " and " },
46
+ { type: "token", src: "/img/logo.png", alt: "logo" },
47
+ { type: "text", value: " - thanks" },
48
+ ])
49
+ })
50
+
51
+ it("replaces every occurrence of the same token", () => {
52
+ expect(parseWatermark("$linto vs $linto", tokens)).toEqual([
53
+ { type: "token", src: "/img/linto.svg", alt: "LinTO" },
54
+ { type: "text", value: " vs " },
55
+ { type: "token", src: "/img/linto.svg", alt: "LinTO" },
56
+ ])
57
+ })
58
+
59
+ it("stops the token name at the first non-word character", () => {
60
+ expect(parseWatermark("$linto.svg", tokens)).toEqual([
61
+ { type: "token", src: "/img/linto.svg", alt: "LinTO" },
62
+ { type: "text", value: ".svg" },
63
+ ])
64
+ })
65
+
66
+ it("handles adjacent tokens", () => {
67
+ expect(parseWatermark("$linto$logo", tokens)).toEqual([
68
+ { type: "token", src: "/img/linto.svg", alt: "LinTO" },
69
+ { type: "token", src: "/img/logo.png", alt: "logo" },
70
+ ])
71
+ })
72
+
73
+ it("keeps a lone $ as literal text", () => {
74
+ expect(parseWatermark("price: $ 20", tokens)).toEqual([
75
+ { type: "text", value: "price: $ 20" },
76
+ ])
77
+ })
78
+
79
+ it("keeps numeric-only tokens as literal when unknown", () => {
80
+ expect(parseWatermark("$20", tokens)).toEqual([
81
+ { type: "text", value: "$20" },
82
+ ])
83
+ })
84
+
85
+ it("does not run HTML: angle brackets are kept as-is (rendering escapes them)", () => {
86
+ expect(parseWatermark("<script>$linto", tokens)).toEqual([
87
+ { type: "text", value: "<script>" },
88
+ { type: "token", src: "/img/linto.svg", alt: "LinTO" },
89
+ ])
90
+ })
91
+
92
+ it("works with no tokens registered", () => {
93
+ expect(parseWatermark("$linto hi", {})).toEqual([
94
+ { type: "text", value: "$linto" },
95
+ { type: "text", value: " hi" },
96
+ ])
97
+ })
98
+
99
+ it("is stateless across consecutive calls (no regex lastIndex leak)", () => {
100
+ const first = parseWatermark("$linto", tokens)
101
+ const second = parseWatermark("$linto", tokens)
102
+ expect(first).toEqual(second)
103
+ })
104
+ })
@@ -0,0 +1,180 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import splitPartialSubtitles, {
3
+ getIndexesWhereToCutText,
4
+ } from "../splitPartialSubtitles"
5
+
6
+ // A line is "too long" once it exceeds `maxWords` words. Word-based so the cut
7
+ // positions stay easy to reason about: one cut roughly every `maxWords` words.
8
+ const tooLongAfter = (maxWords: number) => (text: string) =>
9
+ text.split(" ").length > maxWords
10
+
11
+ describe("getIndexesWhereToCutText", () => {
12
+ it("returns no cut when the text fits", () => {
13
+ expect(getIndexesWhereToCutText("a b c", tooLongAfter(3))).toEqual([])
14
+ })
15
+
16
+ it("returns no cut for a single word, even when it is too long", () => {
17
+ expect(getIndexesWhereToCutText("word", (t) => t.length > 1)).toEqual([])
18
+ })
19
+
20
+ it("cuts once when the text is one line too long", () => {
21
+ expect(getIndexesWhereToCutText("a b c d e", tooLongAfter(3))).toEqual([3])
22
+ })
23
+
24
+ it("cuts repeatedly for a very long text", () => {
25
+ expect(getIndexesWhereToCutText("a b c d e f g h", tooLongAfter(3))).toEqual([
26
+ 3, 6,
27
+ ])
28
+ })
29
+ })
30
+
31
+ describe("splitPartialSubtitles", () => {
32
+ const emptyState = { previousText: "", previousIndexes: [] as number[] }
33
+
34
+ it("returns the previous state untouched for empty text", () => {
35
+ const state = { previousText: "a b", previousIndexes: [1] }
36
+ expect(splitPartialSubtitles(state, "", tooLongAfter(3))).toEqual(state)
37
+ })
38
+
39
+ it("produces no cut for a short fresh text", () => {
40
+ expect(splitPartialSubtitles(emptyState, "a b", tooLongAfter(3))).toEqual({
41
+ previousText: "a b",
42
+ previousIndexes: [],
43
+ })
44
+ })
45
+
46
+ it("cuts a long fresh text", () => {
47
+ expect(splitPartialSubtitles(emptyState, "a b c d e", tooLongAfter(3))).toEqual(
48
+ { previousText: "a b c d e", previousIndexes: [3] },
49
+ )
50
+ })
51
+
52
+ it("keeps the earlier cut stable when the text grows", () => {
53
+ const state = { previousText: "a b c d e", previousIndexes: [3] }
54
+ expect(
55
+ splitPartialSubtitles(state, "a b c d e f g", tooLongAfter(3)),
56
+ ).toEqual({ previousText: "a b c d e f g", previousIndexes: [3, 6] })
57
+ })
58
+
59
+ it("does not cut when a word is replaced without overflowing", () => {
60
+ const state = { previousText: "hello world", previousIndexes: [] }
61
+ expect(splitPartialSubtitles(state, "hi world", tooLongAfter(3))).toEqual({
62
+ previousText: "hi world",
63
+ previousIndexes: [],
64
+ })
65
+ })
66
+ })
67
+
68
+ // Two stable lines of three words each, cut at index 3:
69
+ // "mot1 mot2 mot3 | mot4 mot5 mot6"
70
+ // These pin how an edit shifts (or keeps) that cut.
71
+ describe("splitPartialSubtitles — cut shifting on edits", () => {
72
+ const twoLines = {
73
+ previousText: "mot1 mot2 mot3 mot4 mot5 mot6",
74
+ previousIndexes: [3],
75
+ }
76
+
77
+ const indexesAfter = (next: string): number[] =>
78
+ splitPartialSubtitles(twoLines, next, tooLongAfter(3)).previousIndexes
79
+
80
+ it("keeps the cut when line 1 is replaced word-for-word (1 word -> 2, 1 deleted)", () => {
81
+ // mot2 mot3 -> mot2.1 mot2.2 (still 2 words), line 2 stays 3 words
82
+ expect(indexesAfter("mot1 mot2.1 mot2.2 mot4 mot6 mot7")).toEqual([3])
83
+ })
84
+
85
+ it("keeps the cut on a 1:1 replacement plus a tail delete/add", () => {
86
+ expect(indexesAfter("mot1 mot2.1 mot3 mot5 mot6 mot7")).toEqual([3])
87
+ })
88
+
89
+ it("keeps the cut when a word is duplicated across both lines", () => {
90
+ // mot2 -> mot4, so "mot4" now appears in both lines
91
+ expect(indexesAfter("mot1 mot4 mot3 mot4 mot5 mot6")).toEqual([3])
92
+ })
93
+
94
+ it("shifts the cut right when a word is inserted in line 1", () => {
95
+ // KNOWN OVERFLOW BUG: line 1 ends up with 4 words and is NOT re-cut.
96
+ // Expected to change once the two-threshold fix re-validates earlier lines.
97
+ expect(indexesAfter("mot1 inserted mot2 mot3 mot4 mot5 mot6")).toEqual([4])
98
+ })
99
+
100
+ it("shifts the cut left when a word is deleted in line 1", () => {
101
+ expect(indexesAfter("mot1 mot3 mot4 mot5 mot6")).toEqual([2])
102
+ })
103
+
104
+ it("re-cuts the last line when it grows too long", () => {
105
+ expect(indexesAfter("mot1 mot2 mot3 mot4 mot5 mot6 mot7 mot8")).toEqual([
106
+ 3, 6,
107
+ ])
108
+ })
109
+
110
+ it("re-cuts the last line when a word is inserted into line 2", () => {
111
+ expect(indexesAfter("mot1 mot2 mot3 mot4 inserted mot5 mot6")).toEqual([
112
+ 3, 6,
113
+ ])
114
+ })
115
+ })
116
+
117
+ // The overflow predicate is char-based here, standing in for pixel width:
118
+ // tooLong = normal cut threshold (canvas.width - 2 * paddingInline)
119
+ // overflows = canvas edge / overflow (canvas.width - paddingInline)
120
+ // The gap between 11 and 15 is the inline margin a line may spill into.
121
+ describe("splitPartialSubtitles — overflow on the second-to-last line", () => {
122
+ const tooLong = (t: string) => t.length > 11
123
+ const overflows = (t: string) => t.length > 15
124
+
125
+ const indexesAfter = (
126
+ prev: string,
127
+ prevIndexes: number[],
128
+ next: string,
129
+ ): number[] =>
130
+ splitPartialSubtitles(
131
+ { previousText: prev, previousIndexes: prevIndexes },
132
+ next,
133
+ tooLong,
134
+ overflows,
135
+ ).previousIndexes
136
+
137
+ it("pushes the overflowing tail down and keeps the head when the last line has room", () => {
138
+ // "ddd" -> "dddd" makes line 1 leave the canvas; its tail flows down and the
139
+ // head ("aaa bbb ccc") stays in place.
140
+ expect(indexesAfter("aaa bbb ccc ddd z", [4], "aaa bbb ccc dddd z")).toEqual([
141
+ 3,
142
+ ])
143
+ })
144
+
145
+ it("tolerates a line that only spills into the inline margin (no re-cut)", () => {
146
+ // line 1 is 13 chars: past the soft limit (11) but within the hard one (15).
147
+ expect(indexesAfter("aaaa bbbb ccc z", [3], "aaaa bbbb ccc z")).toEqual([3])
148
+ })
149
+
150
+ it("scrolls when the last line is full and cannot absorb the pushed-down word", () => {
151
+ expect(
152
+ indexesAfter("aaa bbb ccc ddd xxx yyy", [4], "aaa bbb ccc dddd xxx yyy"),
153
+ ).toEqual([3, 5])
154
+ })
155
+
156
+ it("leaves an unsplittable wide word in place", () => {
157
+ expect(
158
+ indexesAfter("wordwordwordword z", [1], "wordwordwordword z"),
159
+ ).toEqual([1])
160
+ })
161
+
162
+ it("does nothing when no line overflows", () => {
163
+ expect(indexesAfter("aa bb cc z", [3], "aa bb cc z")).toEqual([3])
164
+ })
165
+
166
+ it("worked example: pushes the head down then re-cuts the rest", () => {
167
+ // "mot2" widens to "mot2222" and "mot7" is appended:
168
+ // mot1 mot2 mot3 | mot4 mot5 mot6
169
+ // -> mot1 mot2222 | mot3 mot4 mot5 | mot6 mot7
170
+ const soft = (t: string) => t.length > 14
171
+ const hard = (t: string) => t.length > 16
172
+ const res = splitPartialSubtitles(
173
+ { previousText: "mot1 mot2 mot3 mot4 mot5 mot6", previousIndexes: [3] },
174
+ "mot1 mot2222 mot3 mot4 mot5 mot6 mot7",
175
+ soft,
176
+ hard,
177
+ )
178
+ expect(res.previousIndexes).toEqual([2, 5])
179
+ })
180
+ })