@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,124 @@
1
+ import { ref, watch, onMounted, onBeforeUnmount, type Ref } from "vue"
2
+ import { useCore } from "../core"
3
+ import { activeWordRange } from "../utils/wordRange"
4
+
5
+ const SCROLL_KEYS = new Set([
6
+ "ArrowUp",
7
+ "ArrowDown",
8
+ "PageUp",
9
+ "PageDown",
10
+ "Home",
11
+ "End",
12
+ " ", // Space
13
+ ])
14
+
15
+ export function useFollowPlayback(
16
+ scrollContainer: Readonly<Ref<HTMLElement | null | undefined>>,
17
+ ) {
18
+ const core = useCore()
19
+ const isFollowing = ref(true)
20
+ const prefersReducedMotion = window.matchMedia(
21
+ "(prefers-reduced-motion: reduce)",
22
+ ).matches
23
+
24
+ function scrollToActive() {
25
+ const container = scrollContainer.value
26
+ if (!container || !isFollowing.value) return
27
+
28
+ const behavior: ScrollBehavior = prefersReducedMotion ? "instant" : "smooth"
29
+
30
+ // Editor: the active word is located as a character Range (the plain-text
31
+ // doc has no per-word element) — center its rect in the container.
32
+ const wordId = core.audio?.activeWordId.value
33
+ if (wordId) {
34
+ const range = activeWordRange(container, core, wordId)
35
+ const rect = range?.getBoundingClientRect()
36
+ if (rect && (rect.height > 0 || rect.width > 0)) {
37
+ const containerRect = container.getBoundingClientRect()
38
+ const delta =
39
+ rect.top + rect.height / 2 - (containerRect.top + container.clientHeight / 2)
40
+ container.scrollBy({ top: delta, behavior })
41
+ return
42
+ }
43
+ }
44
+
45
+ const turnId = core.audio?.activeTurnId.value
46
+ const target =
47
+ // Non-editor list view still tags the active word this way.
48
+ container.querySelector<HTMLElement>("[data-word-active]") ??
49
+ (turnId
50
+ ? container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`)
51
+ : null)
52
+ if (!target) return
53
+ target.scrollIntoView({ behavior, block: "center" })
54
+ }
55
+
56
+ // Follow the active word (works with the editor + word timestamps)
57
+ watch(
58
+ () => core.audio?.activeWordId.value,
59
+ (id) => {
60
+ if (id) scrollToActive()
61
+ },
62
+ { flush: "post" },
63
+ )
64
+
65
+ // Fallback: at least follow the turn (no editor or no word timestamps)
66
+ watch(
67
+ () => core.audio?.activeTurnId.value,
68
+ (id) => {
69
+ if (id) scrollToActive()
70
+ },
71
+ { flush: "post" },
72
+ )
73
+
74
+ // Re-enable follow when playback starts
75
+ watch(
76
+ () => core.audio?.isPlaying.value,
77
+ (playing) => {
78
+ if (playing) isFollowing.value = true
79
+ },
80
+ )
81
+
82
+ function onManualScroll() {
83
+ isFollowing.value = false
84
+ }
85
+
86
+ function checkKeyDownIsScroll(e: KeyboardEvent) {
87
+ if (SCROLL_KEYS.has(e.key)) {
88
+ onManualScroll()
89
+ }
90
+ }
91
+
92
+ function setupScrollListener(handler: (e: Event) => void) {
93
+ const el = scrollContainer.value
94
+ if (!el) return
95
+ el.addEventListener("wheel", handler, { passive: true })
96
+ el.addEventListener("touchstart", handler, { passive: true })
97
+ el.addEventListener("pointerdown", handler, { passive: true })
98
+ el.addEventListener("keydown", checkKeyDownIsScroll)
99
+ }
100
+
101
+ function downScrollListener(handler: (e: Event) => void) {
102
+ const el = scrollContainer.value
103
+ if (!el) return
104
+ el.removeEventListener("wheel", handler)
105
+ el.removeEventListener("touchstart", handler)
106
+ el.removeEventListener("pointerdown", handler)
107
+ el.removeEventListener("keydown", checkKeyDownIsScroll)
108
+ }
109
+
110
+ onMounted(() => {
111
+ setupScrollListener(onManualScroll)
112
+ })
113
+
114
+ onBeforeUnmount(() => {
115
+ downScrollListener(onManualScroll)
116
+ })
117
+
118
+ function resumeFollow() {
119
+ isFollowing.value = true
120
+ scrollToActive()
121
+ }
122
+
123
+ return { isFollowing, resumeFollow }
124
+ }
@@ -0,0 +1,24 @@
1
+ import { ref, onMounted, onBeforeUnmount } from 'vue'
2
+
3
+ const MOBILE_BREAKPOINT = '(max-width: 767px)'
4
+
5
+ export function useIsMobile() {
6
+ const isMobile = ref(false)
7
+ let mql: MediaQueryList | null = null
8
+
9
+ function onChange(e: MediaQueryListEvent) {
10
+ isMobile.value = e.matches
11
+ }
12
+
13
+ onMounted(() => {
14
+ mql = window.matchMedia(MOBILE_BREAKPOINT)
15
+ isMobile.value = mql.matches
16
+ mql.addEventListener('change', onChange)
17
+ })
18
+
19
+ onBeforeUnmount(() => {
20
+ mql?.removeEventListener('change', onChange)
21
+ })
22
+
23
+ return { isMobile }
24
+ }
@@ -0,0 +1,169 @@
1
+ import {
2
+ shallowReactive,
3
+ computed,
4
+ watch,
5
+ onMounted,
6
+ onBeforeUnmount,
7
+ provide,
8
+ inject,
9
+ type Ref,
10
+ type ComputedRef,
11
+ type InjectionKey,
12
+ } from "vue"
13
+ import type { Turn, Speaker } from "../types/editor"
14
+ import type { Core } from "../core/types"
15
+ import { formatTime } from "../utils/time"
16
+
17
+ export interface TurnSelection {
18
+ readonly count: ComputedRef<number>
19
+ readonly hasSelection: ComputedRef<boolean>
20
+ isSelected(turnId: string): boolean
21
+ toggle(turnId: string): void
22
+ selectRange(turnId: string): void
23
+ clear(): void
24
+ copyText(): Promise<void>
25
+ copyWithMetadata(): Promise<void>
26
+ }
27
+
28
+ const turnSelectionKey: InjectionKey<TurnSelection> = Symbol("turnSelection")
29
+
30
+ function getTurnText(turn: Turn): string {
31
+ if (turn.words.length > 0) {
32
+ return turn.words.map((w) => w.text).join(" ")
33
+ }
34
+ return turn.text ?? ""
35
+ }
36
+
37
+ export function provideTurnSelection(
38
+ turns: Ref<Turn[]> | ComputedRef<Turn[]>,
39
+ speakers: Map<string, Speaker>,
40
+ core: Core,
41
+ ): TurnSelection {
42
+ const selectedIds = shallowReactive(new Map<string, true>())
43
+ let lastToggledId: string | null = null
44
+
45
+ const count = computed(() => selectedIds.size)
46
+ const hasSelection = computed(() => selectedIds.size > 0)
47
+
48
+ function isSelected(turnId: string): boolean {
49
+ return selectedIds.has(turnId)
50
+ }
51
+
52
+ // Selection mode (multi-turn copy) and the editor don't coexist: with the
53
+ // transcriptionEditor plugin loaded, clicks belong to editing (text) and
54
+ // speaker assignment (header) — selection never starts.
55
+ function isSelectionDisabled(): boolean {
56
+ return core.transcriptionEditor !== undefined
57
+ }
58
+
59
+ function toggle(turnId: string) {
60
+ if (isSelectionDisabled()) return
61
+ if (selectedIds.has(turnId)) {
62
+ selectedIds.delete(turnId)
63
+ } else {
64
+ selectedIds.set(turnId, true)
65
+ }
66
+ lastToggledId = turnId
67
+ }
68
+
69
+ function selectRange(turnId: string) {
70
+ if (isSelectionDisabled()) return
71
+ if (lastToggledId === null) {
72
+ toggle(turnId)
73
+ return
74
+ }
75
+ const ids = turns.value.map((t) => t.id)
76
+ const fromIndex = ids.indexOf(lastToggledId)
77
+ const toIndex = ids.indexOf(turnId)
78
+ if (fromIndex === -1 || toIndex === -1) {
79
+ toggle(turnId)
80
+ return
81
+ }
82
+ const start = Math.min(fromIndex, toIndex)
83
+ const end = Math.max(fromIndex, toIndex)
84
+ for (let i = start; i <= end; i++) {
85
+ const id = ids[i]
86
+ if (id != null) selectedIds.set(id, true)
87
+ }
88
+ }
89
+
90
+ function clear() {
91
+ selectedIds.clear()
92
+ lastToggledId = null
93
+ }
94
+
95
+ async function copyText(): Promise<void> {
96
+ const selected = turns.value.filter((t) => selectedIds.has(t.id))
97
+ const text = selected.map(getTurnText).join("\n\n")
98
+ await navigator.clipboard.writeText(text)
99
+ }
100
+
101
+ async function copyWithMetadata(): Promise<void> {
102
+ const selected = turns.value.filter((t) => selectedIds.has(t.id))
103
+ const blocks = selected.map((turn) => {
104
+ const speaker = turn.speakerId
105
+ ? speakers.get(turn.speakerId)
106
+ : undefined
107
+ const name = speaker?.name ?? ""
108
+ const time = turn.startTime != null ? formatTime(turn.startTime) : ""
109
+ const header = [name, time].filter(Boolean).join(" (") + (time ? ")" : "")
110
+ const text = getTurnText(turn)
111
+ return header ? `${header}\n${text}` : text
112
+ })
113
+ await navigator.clipboard.writeText(blocks.join("\n\n"))
114
+ }
115
+
116
+ // Prune stale IDs when turns change
117
+ watch(
118
+ () => turns.value,
119
+ (currentTurns) => {
120
+ if (selectedIds.size === 0) return
121
+ const currentIds = new Set(currentTurns.map((t) => t.id))
122
+ for (const id of [...selectedIds.keys()]) {
123
+ if (!currentIds.has(id)) selectedIds.delete(id)
124
+ }
125
+ },
126
+ )
127
+
128
+ // Clear selection on channel/translation change
129
+ const unsubChannel = core.on("channel:change", clear)
130
+ const unsubTranslation = core.on("translation:change", clear)
131
+
132
+ // Escape to clear
133
+ function onKeydown(e: KeyboardEvent) {
134
+ if (e.key === "Escape" && selectedIds.size > 0) {
135
+ clear()
136
+ }
137
+ }
138
+
139
+ onMounted(() => {
140
+ document.addEventListener("keydown", onKeydown)
141
+ })
142
+
143
+ onBeforeUnmount(() => {
144
+ document.removeEventListener("keydown", onKeydown)
145
+ unsubChannel()
146
+ unsubTranslation()
147
+ })
148
+
149
+ const selection: TurnSelection = {
150
+ count,
151
+ hasSelection,
152
+ isSelected,
153
+ toggle,
154
+ selectRange,
155
+ clear,
156
+ copyText,
157
+ copyWithMetadata,
158
+ }
159
+
160
+ provide(turnSelectionKey, selection)
161
+ return selection
162
+ }
163
+
164
+ export function useTurnSelection(): TurnSelection {
165
+ const sel = inject(turnSelectionKey)
166
+ if (!sel)
167
+ throw new Error("useTurnSelection() requires provideTurnSelection()")
168
+ return sel
169
+ }
@@ -0,0 +1,14 @@
1
+ /** Tableau 10 palette for speakers — single source of truth.
2
+ * 10 hues ordered for max contrast between consecutive speakers. */
3
+ export const SPEAKER_COLORS = [
4
+ "#4E79A7", // bleu
5
+ "#F28E2B", // orange
6
+ "#59A14F", // vert
7
+ "#E15759", // rouge
8
+ "#B07AA1", // violet
9
+ "#76B7B2", // turquoise
10
+ "#FF9DA7", // rose
11
+ "#9C755F", // brun
12
+ "#BAB0AC", // gris chaud
13
+ "#4E4CD2", // indigo
14
+ ] as const
@@ -0,0 +1,162 @@
1
+ import { ref, computed, shallowReactive, type Component } from "vue"
2
+ import type { Channel, EditorDocument } from "../types/editor"
3
+ import type {
4
+ Core,
5
+ CoreOptions,
6
+ CoreEventMap,
7
+ CoreCapabilities,
8
+ CorePlugin,
9
+ TurnEventKey,
10
+ UISlot,
11
+ } from "./types"
12
+ import { createEventBus } from "./modules/eventBus"
13
+ import { createSpeakersStore } from "./stores/speakersStore"
14
+ import { createChannelStore } from "./stores/channelStore"
15
+ import type { ChannelStore } from "./types"
16
+ import { ensureDocumentSpeakers } from "./helpers/ensureDocumentSpeakers"
17
+ import { ensureSpeakersFromTurns } from "./helpers/ensureSpeakersFromTurns"
18
+ import * as utils from "../utils"
19
+
20
+ export function createCore(options: CoreOptions = {}): Core {
21
+ // ── State ──────────────────────────────────────────────────────────
22
+
23
+ const title = ref("")
24
+ const date = ref<string | number | null>(null)
25
+ const activeChannelId = ref(options.activeChannelId ?? "")
26
+ const capabilities = ref<CoreCapabilities>(
27
+ options.capabilities ?? { text: "edit", speakers: "edit" },
28
+ )
29
+
30
+ // ── Event bus ──────────────────────────────────────────────────────
31
+
32
+ const { on, off, emit, clear: clearEvents } = createEventBus<CoreEventMap>()
33
+
34
+ // ── Speakers ───────────────────────────────────────────────────────
35
+
36
+ const speakersInternal = createSpeakersStore(emit)
37
+ const speakers = speakersInternal
38
+
39
+ // ── Channels ───────────────────────────────────────────────────────
40
+
41
+ const channels = shallowReactive(new Map<string, ChannelStore>())
42
+
43
+ // ── Plugin UI ──────────────────────────────────────────────────────
44
+
45
+ const components = shallowReactive<Partial<Record<UISlot, Component>>>({})
46
+
47
+ const activeChannel = computed<ChannelStore | undefined>(() =>
48
+ channels.get(activeChannelId.value) ?? [...channels.values()][0],
49
+ )
50
+
51
+ // ── Scoped events ─────────────────────────────────────────────────
52
+
53
+ function onActiveTranslation<K extends TurnEventKey>(
54
+ event: K,
55
+ handler: (payload: CoreEventMap[K]) => void,
56
+ ): () => void {
57
+ return on(event, (payload) => {
58
+ const channel = activeChannel.value
59
+ if (channel && payload.translationId === channel.activeTranslation.value.id) {
60
+ handler(payload)
61
+ }
62
+ })
63
+ }
64
+
65
+ // ── Build from document ────────────────────────────────────────────
66
+
67
+ function buildFromDocument(doc: EditorDocument): void {
68
+ title.value = doc.title
69
+ date.value = doc.date ?? null
70
+ speakersInternal.clear()
71
+ for (const channel of channels.values()) channel.dispose()
72
+ channels.clear()
73
+
74
+ for (const spkRef of ensureDocumentSpeakers(doc)) {
75
+ speakers.ensure(spkRef.id, spkRef.name)
76
+ }
77
+
78
+ for (const ch of doc.channels) {
79
+ channels.set(ch.id, createChannelStore(ch, emit, on, speakers.ensure))
80
+ }
81
+
82
+ if (channels.size > 0 && !channels.has(activeChannelId.value)) {
83
+ activeChannelId.value = channels.keys().next().value!
84
+ }
85
+ }
86
+
87
+ // ── Document ───────────────────────────────────────────────────────
88
+
89
+ function setDocument(doc: EditorDocument): void {
90
+ utils.validateEditorDocument(doc)
91
+ buildFromDocument(doc)
92
+ emit("document:change", undefined as never)
93
+ }
94
+
95
+ // ── Channel ────────────────────────────────────────────────────────
96
+
97
+ function setActiveChannel(channelId: string): void {
98
+ if (channelId === activeChannelId.value) return
99
+ activeChannelId.value = channelId
100
+ emit("channel:change", { channelId })
101
+ }
102
+
103
+ function setChannel(channelId: string, channel: Channel): void {
104
+ if (!channels.has(channelId)) return
105
+
106
+ for (const translation of channel.translations) {
107
+ ensureSpeakersFromTurns(translation.turns, speakers.ensure)
108
+ }
109
+
110
+ channels.get(channelId)?.dispose()
111
+ channels.set(channelId, createChannelStore(channel, emit, on, speakers.ensure))
112
+ emit("channel:sync", { channelId })
113
+ }
114
+
115
+ // ── Plugins ────────────────────────────────────────────────────────
116
+
117
+ const cleanups: Array<() => void> = []
118
+
119
+ function use(plugin: CorePlugin): void {
120
+ if (plugin.components) Object.assign(components, plugin.components)
121
+ const cleanup = plugin.install(core)
122
+ if (cleanup) cleanups.push(cleanup)
123
+ }
124
+
125
+ function destroy(): void {
126
+ emit("destroy", undefined as never)
127
+ cleanups.forEach((fn) => fn())
128
+ cleanups.length = 0
129
+ for (const channel of channels.values()) channel.dispose()
130
+ clearEvents()
131
+ }
132
+
133
+ // ── Initial document ──────────────────────────────────────────────
134
+
135
+ if (options.document) {
136
+ buildFromDocument(options.document)
137
+ }
138
+
139
+ // ── Assemble ──────────────────────────────────────────────────────
140
+
141
+ const core: Core = {
142
+ title,
143
+ date,
144
+ activeChannelId,
145
+ capabilities,
146
+ speakers,
147
+ channels,
148
+ activeChannel,
149
+ components,
150
+ onActiveTranslation,
151
+ setDocument,
152
+ setActiveChannel,
153
+ setChannel,
154
+ on,
155
+ off,
156
+ emit,
157
+ use,
158
+ destroy,
159
+ }
160
+
161
+ return core
162
+ }
@@ -0,0 +1,11 @@
1
+ import type { Speaker } from "../../types/editor"
2
+ import { SPEAKER_COLORS } from "../../constants/speakers"
3
+
4
+ export function addSpeaker(
5
+ speakers: Map<string, Speaker>,
6
+ id: string,
7
+ name: string,
8
+ ): Speaker {
9
+ const color = SPEAKER_COLORS[speakers.size % SPEAKER_COLORS.length]!
10
+ return { id, name, color }
11
+ }
@@ -0,0 +1,9 @@
1
+ import type { Core } from "../types"
2
+
3
+ /** Number of turns assigned to a speaker in the active translation — what the
4
+ * user currently sees (merge dialog, speaker menus). */
5
+ export function countTurnsForSpeaker(core: Core, speakerId: string): number {
6
+ const translation = core.activeChannel.value?.activeTranslation.value
7
+ if (!translation) return 0
8
+ return translation.turns.value.filter((t) => t.speakerId === speakerId).length
9
+ }
@@ -0,0 +1,16 @@
1
+ import type { Core } from "../types"
2
+ import { addSpeaker } from "./addSpeaker"
3
+ import { switchTurnSpeaker } from "./switchTurnSpeaker"
4
+
5
+ export function createSpeakerAndAssign(
6
+ core: Core,
7
+ turnId: string,
8
+ name: string,
9
+ ): string | null {
10
+ const trimmed = name.trim()
11
+ if (!trimmed) return null
12
+ const speaker = addSpeaker(core.speakers.all, crypto.randomUUID(), trimmed)
13
+ core.speakers.updateOrCreate(speaker)
14
+ switchTurnSpeaker(core, turnId, speaker.id)
15
+ return speaker.id
16
+ }
@@ -0,0 +1,26 @@
1
+ import type { EditorDocument } from "../../types/editor"
2
+
3
+ export function ensureDocumentSpeakers(
4
+ doc: EditorDocument,
5
+ ): { id: string; name: string }[] {
6
+ const seen = new Set<string>()
7
+ const refs: { id: string; name: string }[] = []
8
+
9
+ for (const [id, speaker] of doc.speakers) {
10
+ seen.add(id)
11
+ refs.push({ id, name: speaker.name })
12
+ }
13
+
14
+ for (const channel of doc.channels) {
15
+ for (const translation of channel.translations) {
16
+ for (const turn of translation.turns) {
17
+ if (turn.speakerId && !seen.has(turn.speakerId)) {
18
+ seen.add(turn.speakerId)
19
+ refs.push({ id: turn.speakerId, name: turn.speakerId })
20
+ }
21
+ }
22
+ }
23
+ }
24
+
25
+ return refs
26
+ }
@@ -0,0 +1,12 @@
1
+ import type { Speaker } from "../../types/editor"
2
+ import { addSpeaker } from "./addSpeaker"
3
+
4
+ export function ensureSpeaker(
5
+ speakers: Map<string, Speaker>,
6
+ speakerId: string | null,
7
+ name?: string,
8
+ ): Speaker | null {
9
+ if (!speakerId) return null
10
+ if (speakers.has(speakerId)) return null
11
+ return addSpeaker(speakers, speakerId, name ?? speakerId)
12
+ }
@@ -0,0 +1,14 @@
1
+ import type { Turn } from "../../types/editor"
2
+
3
+ export function ensureSpeakersFromTurns(
4
+ turns: Turn[],
5
+ ensure: (speakerId: string | null, name?: string) => void,
6
+ ): void {
7
+ const seen = new Set<string>()
8
+ for (const turn of turns) {
9
+ if (turn.speakerId && !seen.has(turn.speakerId)) {
10
+ seen.add(turn.speakerId)
11
+ ensure(turn.speakerId)
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,5 @@
1
+ import type { Turn } from "../../types/editor"
2
+
3
+ export function findTurnIndex(turns: Turn[], turnId: string): number {
4
+ return turns.findIndex((t) => t.id === turnId)
5
+ }
@@ -0,0 +1,15 @@
1
+ export { addSpeaker } from "./addSpeaker"
2
+ export { ensureSpeaker } from "./ensureSpeaker"
3
+ export { speakerEquals } from "./speakerEquals"
4
+ export { findTurnIndex } from "./findTurnIndex"
5
+ export { insertTurn } from "./insertTurn"
6
+ export { patchTurn } from "./patchTurn"
7
+ export { removeTurn } from "./removeTurn"
8
+ export { updateTurnWords } from "./updateTurnWords"
9
+ export { ensureDocumentSpeakers } from "./ensureDocumentSpeakers"
10
+ export { ensureSpeakersFromTurns } from "./ensureSpeakersFromTurns"
11
+ export { renameSpeaker } from "./renameSpeaker"
12
+ export { switchTurnSpeaker } from "./switchTurnSpeaker"
13
+ export { createSpeakerAndAssign } from "./createSpeakerAndAssign"
14
+ export { mergeSpeakers } from "./mergeSpeakers"
15
+ export { countTurnsForSpeaker } from "./countTurnsForSpeaker"
@@ -0,0 +1,5 @@
1
+ import type { Turn } from "../../types/editor"
2
+
3
+ export function insertTurn(turns: Turn[], turn: Turn): Turn[] {
4
+ return [...turns, turn]
5
+ }
@@ -0,0 +1,29 @@
1
+ import type { Core } from "../types"
2
+
3
+ /** Reassign every turn of the merged-away speaker to the surviving one, then
4
+ * drop the merged-away speaker — in that order, so no turn ever references a
5
+ * removed speaker. */
6
+ export function mergeSpeakers(
7
+ core: Core,
8
+ fromSpeakerId: string,
9
+ toSpeakerId: string,
10
+ ): void {
11
+ if (fromSpeakerId === toSpeakerId) return
12
+ if (
13
+ !core.speakers.all.has(fromSpeakerId) ||
14
+ !core.speakers.all.has(toSpeakerId)
15
+ ) {
16
+ return
17
+ }
18
+
19
+ for (const channel of core.channels.values()) {
20
+ for (const translation of channel.translations.values()) {
21
+ for (const turn of translation.turns.value) {
22
+ if (turn.speakerId === fromSpeakerId) {
23
+ translation.updateTurn(turn.id, { speakerId: toSpeakerId })
24
+ }
25
+ }
26
+ }
27
+ }
28
+ core.speakers.delete(fromSpeakerId)
29
+ }
@@ -0,0 +1,15 @@
1
+ import type { Turn } from "../../types/editor"
2
+ import { findTurnIndex } from "./findTurnIndex"
3
+
4
+ export function patchTurn(
5
+ turns: Turn[],
6
+ turnId: string,
7
+ patch: Partial<Turn>,
8
+ ): { turns: Turn[]; updated: Turn } | null {
9
+ const idx = findTurnIndex(turns, turnId)
10
+ if (idx === -1) return null
11
+ const updated = { ...turns[idx]!, ...patch, id: turnId }
12
+ const next = turns.slice()
13
+ next[idx] = updated
14
+ return { turns: next, updated }
15
+ }
@@ -0,0 +1,5 @@
1
+ import type { Turn } from "../../types/editor"
2
+
3
+ export function prependTurns(existing: Turn[], newTurns: Turn[]): Turn[] {
4
+ return [...newTurns, ...existing]
5
+ }
@@ -0,0 +1,8 @@
1
+ import type { Turn } from "../../types/editor"
2
+ import { findTurnIndex } from "./findTurnIndex"
3
+
4
+ export function removeTurn(turns: Turn[], turnId: string): Turn[] | null {
5
+ const idx = findTurnIndex(turns, turnId)
6
+ if (idx === -1) return null
7
+ return turns.filter((_, i) => i !== idx)
8
+ }