@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,275 @@
1
+ <script setup lang="ts">
2
+ import { Button } from "@linto-ai/transcript-ui-ui"
3
+ import {
4
+ computed,
5
+ useTemplateRef,
6
+ onMounted,
7
+ onBeforeUnmount,
8
+ watch,
9
+ nextTick,
10
+ } from "vue"
11
+ import { useStickToBottom } from "vue-stick-to-bottom"
12
+ import TranscriptionTurn from "./TranscriptionTurn.vue"
13
+ import TranscriptionEmpty from "./TranscriptionEmpty.vue"
14
+ import { useCore } from "../core"
15
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
16
+ import { useFollowPlayback } from "../composables/useFollowPlayback"
17
+ import { throttle } from "../utils"
18
+ import type { Turn, Speaker } from "../types/editor"
19
+ const props = defineProps<{
20
+ turns: Turn[]
21
+ speakers: Map<string, Speaker>
22
+ }>()
23
+
24
+ const { t } = useI18n()
25
+ const core = useCore()
26
+ const scrollContainerRef = useTemplateRef<HTMLElement>("scrollContainer")
27
+
28
+ const partialTurn = computed(() => {
29
+ const text = core.live?.partial.value ?? null
30
+ if (text === null) return null
31
+ return {
32
+ id: "__partial__",
33
+ speakerId: null,
34
+ text,
35
+ words: [],
36
+ language:
37
+ core.activeChannel.value?.activeTranslation.value.languages[0] ?? "",
38
+ startTime: undefined,
39
+ endTime: undefined,
40
+ } as Turn
41
+ })
42
+
43
+ const hasLiveUpdate = computed(() => core.live?.hasLiveUpdate.value ?? false)
44
+ const isPlaying = computed(() => core.audio?.isPlaying.value ?? false)
45
+
46
+ const activeTranslation = computed(
47
+ () => core.activeChannel.value?.activeTranslation.value,
48
+ )
49
+ const activeChannel = computed(() => core.activeChannel.value)
50
+ const isLoadingHistory = computed(
51
+ () => activeChannel.value?.isLoadingHistory.value ?? false,
52
+ )
53
+ const hasMoreHistory = computed(
54
+ () => activeChannel.value?.hasMoreHistory.value ?? false,
55
+ )
56
+
57
+ // ── Follow playback ────────────────────────────────────────────────────
58
+
59
+ const { isFollowing, resumeFollow } = useFollowPlayback(scrollContainerRef)
60
+
61
+ // ── Stick to bottom (live only) ────────────────────────────────────────
62
+
63
+ const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom()
64
+
65
+ onMounted(() => {
66
+ // Stick-to-bottom is a LIVE feature (follow the incoming feed) — the
67
+ // editor must open at the top, not scrolled to the end.
68
+ if (!core.live) return
69
+ scrollRef.value = scrollContainerRef.value
70
+ contentRef.value =
71
+ scrollContainerRef.value?.querySelector(".turns-container") ?? null
72
+ })
73
+
74
+ const showResumeButton = computed(
75
+ () =>
76
+ (!isFollowing.value && isPlaying.value) ||
77
+ (!isAtBottom.value && hasLiveUpdate.value),
78
+ )
79
+
80
+ function onResumeClick() {
81
+ if (isPlaying.value) {
82
+ resumeFollow()
83
+ } else {
84
+ scrollToBottom()
85
+ }
86
+ }
87
+
88
+ // ── Scroll top detection ────────────────────────────────────────────────
89
+
90
+ const emitScrollTop = throttle(() => {
91
+ const channel = activeChannel.value
92
+ if (!channel?.hasMoreHistory.value) return
93
+ if (channel.isLoadingHistory.value) return
94
+ if (props.turns.length === 0) return
95
+ const translation = activeTranslation.value
96
+ if (!translation) return
97
+ core.emit("scroll:top", { translationId: translation.id })
98
+ }, 500)
99
+
100
+ function onScrollTop() {
101
+ const el = scrollContainerRef.value
102
+ if (!el) return
103
+ if (el.scrollTop < 100) {
104
+ emitScrollTop()
105
+ }
106
+ }
107
+
108
+ watch(
109
+ () => props.turns,
110
+ (newTurns, oldTurns) => {
111
+ const newLen = newTurns.length
112
+ const oldLen = oldTurns.length
113
+ if (
114
+ newLen > oldLen &&
115
+ !isAtBottom.value &&
116
+ newTurns[0]?.id != oldTurns[0]?.id
117
+ ) {
118
+ const added = newLen - oldLen
119
+ const anchorId = props.turns[added]?.id
120
+ if (!anchorId || !scrollRef.value) return
121
+
122
+ nextTick(() => {
123
+ const el = scrollRef.value?.querySelector(
124
+ `[data-turn-id="${anchorId}"]`,
125
+ )
126
+ el?.scrollIntoView({ block: "start", behavior: "instant" })
127
+ })
128
+ }
129
+ },
130
+ { flush: "pre" },
131
+ )
132
+
133
+ onMounted(() => {
134
+ scrollContainerRef.value?.addEventListener("scroll", onScrollTop, {
135
+ passive: true,
136
+ })
137
+ })
138
+
139
+ onBeforeUnmount(() => {
140
+ scrollContainerRef.value?.removeEventListener("scroll", onScrollTop)
141
+ })
142
+ </script>
143
+
144
+ <template>
145
+ <article class="transcription-panel">
146
+ <div ref="scrollContainer" class="scroll-container">
147
+ <div class="turns-container">
148
+ <div v-if="isLoadingHistory" class="history-loading" role="status">
149
+ <progress />
150
+ </div>
151
+ <div v-if="!hasMoreHistory && turns.length > 0" class="history-start">
152
+ {{ t("transcription.historyStart") }}
153
+ </div>
154
+ <TranscriptionEmpty
155
+ v-if="turns.length === 0 && !isLoadingHistory && !partialTurn"
156
+ class="transcription-empty" />
157
+ <TranscriptionTurn
158
+ v-for="(turn, i) in turns"
159
+ v-memo="[
160
+ turn,
161
+ speakers.get(turn.speakerId ?? ''),
162
+ hasLiveUpdate && !partialTurn && i === turns.length - 1,
163
+ turns[i - 1]?.id,
164
+ ]"
165
+ :data-turn-id="turn.id"
166
+ :key="turn.id"
167
+ :turn="turn"
168
+ :speaker="turn.speakerId ? speakers.get(turn.speakerId) : undefined"
169
+ :live="hasLiveUpdate && !partialTurn && i === turns.length - 1"
170
+ :previous-turn-id="turns[i - 1]?.id" />
171
+ <TranscriptionTurn
172
+ v-if="partialTurn"
173
+ key="__partial__"
174
+ :turn="partialTurn"
175
+ partial />
176
+ </div>
177
+
178
+ <Transition name="fade-slide">
179
+ <Button
180
+ v-if="showResumeButton"
181
+ size="sm"
182
+ icon="arrow-down"
183
+ class="resume-scroll-btn"
184
+ :aria-label="t('transcription.resumeScroll')"
185
+ @click="onResumeClick">
186
+ {{ t("transcription.resumeScroll") }}
187
+ </Button>
188
+ </Transition>
189
+ </div>
190
+ </article>
191
+ </template>
192
+
193
+ <style scoped>
194
+ .transcription-panel {
195
+ min-height: 0;
196
+ overflow: hidden;
197
+ background-color: var(--color-surface);
198
+ }
199
+
200
+ .scroll-container {
201
+ height: 100%;
202
+ overflow: auto;
203
+ position: relative;
204
+ }
205
+
206
+ .turns-container {
207
+ max-width: 80ch;
208
+ margin-inline: auto;
209
+ padding: var(--spacing-lg);
210
+ }
211
+
212
+ .turns-container:has(.transcription-empty) {
213
+ display: flex;
214
+ flex-direction: column;
215
+ min-height: 100%;
216
+ }
217
+
218
+ .history-loading {
219
+ text-align: center;
220
+ padding: var(--spacing-md);
221
+ }
222
+
223
+ .history-loading progress {
224
+ width: 120px;
225
+ }
226
+
227
+ .history-start {
228
+ text-align: center;
229
+ padding: var(--spacing-md);
230
+ color: var(--color-text-muted);
231
+ font-size: var(--font-size-sm);
232
+ }
233
+
234
+ /* Resume scroll button */
235
+ .resume-scroll-btn {
236
+ position: sticky;
237
+ bottom: var(--spacing-lg);
238
+ left: 50%;
239
+ translate: -50% 0;
240
+ z-index: var(--z-sticky);
241
+ /* No backdrop-filter: this button is sticky inside the tall scroll
242
+ container, where a backdrop-filter makes WebRender allocate a render
243
+ target spanning the whole scroll height — multi-GB on a long transcript. */
244
+ background: white !important;
245
+ border: 1px solid var(--color-border);
246
+ box-shadow: var(--shadow-sm);
247
+ }
248
+
249
+ /* Transition */
250
+ .fade-slide-enter-active,
251
+ .fade-slide-leave-active {
252
+ transition:
253
+ opacity 200ms ease,
254
+ translate 200ms ease;
255
+ }
256
+
257
+ .fade-slide-enter-from,
258
+ .fade-slide-leave-to {
259
+ opacity: 0;
260
+ translate: -50% 8px;
261
+ }
262
+
263
+ @media (prefers-reduced-motion: reduce) {
264
+ .fade-slide-enter-active,
265
+ .fade-slide-leave-active {
266
+ transition: none;
267
+ }
268
+ }
269
+
270
+ @media (max-width: 767px) {
271
+ .turns-container {
272
+ padding: var(--spacing-md);
273
+ }
274
+ }
275
+ </style>
@@ -0,0 +1,407 @@
1
+ <script setup lang="ts">
2
+ import { Button, EditorCheckbox, UserAvatar, TurnTextEditor } from "@linto-ai/transcript-ui-ui"
3
+ import { computed, ref, useTemplateRef } from "vue"
4
+ import SpeakerLabel from "./SpeakerLabel.vue"
5
+ import MergeTurnsButton from "./molecules/MergeTurnsButton.vue"
6
+ import SpeakerPopover from "./molecules/SpeakerPopover.vue"
7
+ import { useCore } from "../core"
8
+ import { useTurnSelection } from "../composables/useTurnSelection"
9
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
10
+ import * as utils from "../utils"
11
+ import { computeTurnPlainText } from "../utils/computeTurnPlainText"
12
+ import { computeCaretOffsetFromPoint } from "../utils/computeCaretOffsetFromPoint"
13
+ import { findWordAtOffset } from "../utils/findWordAtOffset"
14
+ import type { Turn, Speaker } from "../types/editor"
15
+
16
+ const props = defineProps<{
17
+ turn: Turn
18
+ speaker?: Speaker
19
+ partial?: boolean
20
+ live?: boolean
21
+ /** Id of the preceding turn — hosts the merge control above this turn. */
22
+ previousTurnId?: string
23
+ }>()
24
+
25
+ const core = useCore()
26
+ const selection = useTurnSelection()
27
+ const { t } = useI18n()
28
+
29
+ const hasWords = computed(() => props.turn.words.length > 0)
30
+
31
+ const activeWordId = computed(() => {
32
+ if (!core.audio?.src.value || !hasWords.value) return null
33
+ const time = core.audio.currentTime.value
34
+ const { startTime, endTime, words } = props.turn
35
+ if (startTime == null || endTime == null) return null
36
+ if (time < startTime || time > endTime) return null
37
+ return utils.findActiveWord(words, time)
38
+ })
39
+
40
+ const isTurnActive = computed(() => {
41
+ if (!core.audio?.src.value) return false
42
+ if (props.turn.startTime == null || props.turn.endTime == null) return false
43
+ if (utils.hasWordTimestamps(props.turn.words)) return false
44
+ const time = core.audio.currentTime.value
45
+ return time >= props.turn.startTime && time <= props.turn.endTime
46
+ })
47
+
48
+ const speakerColor = computed(() => props.speaker?.color ?? "transparent")
49
+
50
+ const isSelected = computed(() => selection.isSelected(props.turn.id))
51
+
52
+ const checkboxLabel = computed(() => {
53
+ const name = props.speaker?.name ?? ""
54
+ const key = isSelected.value ? "selection.deselect" : "selection.select"
55
+ return t(key).replace("{name}", name)
56
+ })
57
+
58
+ // ── Per-turn text editing (transcriptionEditor plugin) ────────────────
59
+
60
+ const canEditText = computed(
61
+ () =>
62
+ core.transcriptionEditor !== undefined &&
63
+ core.capabilities.value.text === "edit" &&
64
+ !props.partial &&
65
+ !props.live,
66
+ )
67
+
68
+ const isEditing = computed(
69
+ () => core.transcriptionEditor?.editingTurnId.value === props.turn.id,
70
+ )
71
+
72
+ // Lock held by someone else: the plugin stores own locks too, but the turn
73
+ // being edited HERE renders the editor instead — so any visible lock is
74
+ // "another session" by construction (including the same user's other tab).
75
+ const turnLock = computed(() =>
76
+ isEditing.value
77
+ ? undefined
78
+ : core.transcriptionEditor?.getTurnLock(props.turn.id),
79
+ )
80
+
81
+ const lockedByLabel = computed(() =>
82
+ turnLock.value
83
+ ? t("transcription.lockedBy").replace("{name}", turnLock.value.userName)
84
+ : "",
85
+ )
86
+
87
+ const isTextInteractive = computed(() => canEditText.value && !turnLock.value)
88
+
89
+ const plainText = computed(() => computeTurnPlainText(props.turn))
90
+
91
+ const editorRef = useTemplateRef<InstanceType<typeof TurnTextEditor>>("editor")
92
+
93
+ function onTextClick(event: MouseEvent) {
94
+ const container = event.currentTarget as HTMLElement
95
+ const offset = computeCaretOffsetFromPoint(
96
+ container,
97
+ event.clientX,
98
+ event.clientY,
99
+ )
100
+
101
+ seekToClickedWord(offset)
102
+
103
+ if (!isTextInteractive.value) return
104
+ void core.transcriptionEditor!.beginEdit(
105
+ props.turn.id,
106
+ offset ?? plainText.value.length,
107
+ )
108
+ }
109
+
110
+ // Clicking a word cues the audio there, paused — a reading feature, active
111
+ // in every mode (viewer included). Untimed word (freshly edited zone) or
112
+ // text-only turn: fall back to the turn start; no time at all: no seek.
113
+ function seekToClickedWord(offset: number | null) {
114
+ if (!core.audio) return
115
+ const word =
116
+ offset !== null ? findWordAtOffset(props.turn.words, offset) : undefined
117
+ const time = word?.startTime ?? props.turn.startTime
118
+ if (time == null) return
119
+ core.audio.seekTo(time)
120
+ core.audio.pause()
121
+ }
122
+
123
+ function onTextKeydown(event: KeyboardEvent) {
124
+ if (!isTextInteractive.value || event.key !== "Enter") return
125
+ event.preventDefault()
126
+ void core.transcriptionEditor!.beginEdit(props.turn.id, 0)
127
+ }
128
+
129
+ function onEditorSave(text: string) {
130
+ core.transcriptionEditor!.saveTurn(text)
131
+ }
132
+
133
+ function onEditorCancel() {
134
+ core.transcriptionEditor!.cancelEdit()
135
+ }
136
+
137
+ function onEditorSplit(text: string, offset: number) {
138
+ core.transcriptionEditor!.splitTurn(text, offset)
139
+ }
140
+
141
+ // Header buttons: mousedown is prevented in the template so the editable
142
+ // keeps focus — no blur-save racing ahead of the explicit action.
143
+ function onValidateClick() {
144
+ core.transcriptionEditor!.saveTurn(
145
+ editorRef.value?.getText() ?? plainText.value,
146
+ )
147
+ }
148
+
149
+ function onCancelClick() {
150
+ core.transcriptionEditor!.cancelEdit()
151
+ }
152
+
153
+ // ── Speaker assignment (SpeakerPopover, mounted lazily on first click:
154
+ // hundreds of turns must not each carry a popover instance) ────────────
155
+
156
+ const canEditSpeakers = computed(
157
+ () =>
158
+ core.transcriptionEditor !== undefined &&
159
+ core.capabilities.value.speakers === "edit" &&
160
+ !props.partial &&
161
+ !props.live,
162
+ )
163
+
164
+ const speakerPopoverMounted = ref(false)
165
+
166
+ function onSpeakerClick() {
167
+ speakerPopoverMounted.value = true
168
+ }
169
+
170
+ function onHeaderClick(event: MouseEvent) {
171
+ // Selecting the turn being edited makes no sense — the header hosts the
172
+ // save/cancel actions while editing.
173
+ if (isEditing.value) return
174
+ if (event.shiftKey) {
175
+ selection.selectRange(props.turn.id)
176
+ } else {
177
+ selection.toggle(props.turn.id)
178
+ }
179
+ }
180
+
181
+ function onCheckboxChange(event: MouseEvent) {
182
+ if (event.shiftKey) {
183
+ selection.selectRange(props.turn.id)
184
+ } else {
185
+ selection.toggle(props.turn.id)
186
+ }
187
+ }
188
+ </script>
189
+
190
+ <template>
191
+ <section
192
+ class="turn"
193
+ :class="{
194
+ 'turn--active': isTurnActive,
195
+ 'turn--partial': partial,
196
+ 'turn--selected': isSelected,
197
+ }"
198
+ :data-turn-active="isTurnActive || partial || live || undefined"
199
+ :style="{ '--speaker-color': speakerColor }"
200
+ :aria-selected="selection.hasSelection.value ? isSelected : undefined">
201
+ <MergeTurnsButton
202
+ v-if="previousTurnId && !partial && !live"
203
+ :first-turn-id="previousTurnId"
204
+ :second-turn-id="turn.id" />
205
+ <div v-if="!partial" class="turn-header" @click="onHeaderClick">
206
+ <EditorCheckbox
207
+ v-if="selection.hasSelection.value"
208
+ :model-value="isSelected"
209
+ :aria-label="checkboxLabel"
210
+ @click.stop="onCheckboxChange" />
211
+ <SpeakerPopover
212
+ v-if="speakerPopoverMounted"
213
+ :turn-id="turn.id"
214
+ :current-speaker-id="turn.speakerId"
215
+ initial-open>
216
+ <SpeakerLabel
217
+ :speaker="speaker"
218
+ :start-time="turn.startTime"
219
+ :start-date="turn.startDate"
220
+ :language="turn.language"
221
+ interactive />
222
+ </SpeakerPopover>
223
+ <button
224
+ v-else-if="canEditSpeakers"
225
+ type="button"
226
+ class="speaker-trigger"
227
+ @click.stop="onSpeakerClick">
228
+ <SpeakerLabel
229
+ :speaker="speaker"
230
+ :start-time="turn.startTime"
231
+ :start-date="turn.startDate"
232
+ :language="turn.language"
233
+ interactive />
234
+ </button>
235
+ <SpeakerLabel
236
+ v-else
237
+ :speaker="speaker"
238
+ :start-time="turn.startTime"
239
+ :start-date="turn.startDate"
240
+ :language="turn.language" />
241
+ <div v-if="isEditing || turnLock" class="turn-edit-actions">
242
+ <template v-if="isEditing">
243
+ <Button
244
+ size="sm"
245
+ variant="tertiary"
246
+ icon="x"
247
+ :aria-label="t('transcription.cancelEdit')"
248
+ @mousedown.prevent
249
+ @click.stop="onCancelClick" />
250
+ <Button
251
+ size="sm"
252
+ variant="primary"
253
+ icon="check"
254
+ :aria-label="t('transcription.saveEdit')"
255
+ @mousedown.prevent
256
+ @click.stop="onValidateClick" />
257
+ </template>
258
+ <UserAvatar
259
+ v-else
260
+ :name="turnLock!.userName"
261
+ :label="lockedByLabel"
262
+ @click.stop />
263
+ </div>
264
+ </div>
265
+ <TurnTextEditor
266
+ v-if="isEditing"
267
+ ref="editor"
268
+ :text="plainText"
269
+ :caret-offset="core.transcriptionEditor?.editingCaretOffset.value"
270
+ class="turn-text"
271
+ @save="onEditorSave"
272
+ @cancel="onEditorCancel"
273
+ @split="onEditorSplit" />
274
+ <p
275
+ v-else
276
+ class="turn-text"
277
+ :class="{ 'turn-text--editable': isTextInteractive }"
278
+ :role="isTextInteractive ? 'button' : undefined"
279
+ :tabindex="isTextInteractive ? 0 : undefined"
280
+ :aria-label="isTextInteractive ? t('transcription.editTurn') : undefined"
281
+ :aria-disabled="canEditText && turnLock ? true : undefined"
282
+ @click="onTextClick"
283
+ @keydown="onTextKeydown">
284
+ <template v-if="hasWords">
285
+ <template v-for="(word, i) in turn.words" :key="word.id">
286
+ <span
287
+ :class="{ 'word--active': word.id === activeWordId }"
288
+ :data-word-active="word.id === activeWordId || undefined"
289
+ >{{ word.text }}</span
290
+ >{{ i < turn.words.length - 1 ? " " : "" }}
291
+ </template>
292
+ </template>
293
+ <template v-else-if="turn.text">{{ turn.text }}</template>
294
+ </p>
295
+ </section>
296
+ </template>
297
+
298
+ <style scoped>
299
+ .turn {
300
+ padding: var(--spacing-sm) var(--spacing-lg);
301
+ }
302
+
303
+ .turn-header {
304
+ display: flex;
305
+ align-items: center;
306
+ gap: var(--spacing-sm);
307
+ cursor: pointer;
308
+ user-select: none;
309
+ border-radius: var(--radius-sm);
310
+ padding: var(--spacing-xxs) 0;
311
+ /* Reserve the edit-actions height (Button sm) so entering/leaving edit
312
+ mode never shifts the layout. */
313
+ min-height: 36px;
314
+ }
315
+
316
+ .turn-edit-actions {
317
+ margin-left: auto;
318
+ display: flex;
319
+ gap: var(--spacing-xs);
320
+ }
321
+
322
+ /* Same reset as the popover's own trigger: the label IS the button. */
323
+ .speaker-trigger {
324
+ all: unset;
325
+ cursor: pointer;
326
+ display: inline-flex;
327
+ align-items: center;
328
+ border-radius: var(--radius-sm);
329
+ }
330
+
331
+ .speaker-trigger:focus-visible {
332
+ outline: 2px solid var(--color-primary);
333
+ outline-offset: 2px;
334
+ }
335
+
336
+ .turn:has(.turn-header:hover) {
337
+ background-color: var(--color-surface-hover);
338
+ }
339
+
340
+ .turn-text {
341
+ margin-top: var(--spacing-xs);
342
+ font-size: var(--font-size-base);
343
+ line-height: var(--line-height);
344
+ color: var(--color-text-primary);
345
+ }
346
+
347
+ .turn-text--editable {
348
+ cursor: text;
349
+ }
350
+
351
+ .turn-text--editable:focus-visible {
352
+ outline: 2px solid var(--color-primary);
353
+ border-radius: var(--radius-sm);
354
+ }
355
+
356
+ .turn--selected {
357
+ background-color: color-mix(in srgb, var(--color-primary) 8%, transparent);
358
+ border-left: 3px solid var(--color-primary);
359
+ padding-left: calc(var(--spacing-lg) - 3px);
360
+ }
361
+
362
+ .turn--active:not(.turn--selected) {
363
+ border-left: 3px solid var(--speaker-color);
364
+ background-color: color-mix(in srgb, var(--speaker-color) 8%, transparent);
365
+ padding-left: calc(var(--spacing-lg) - 3px);
366
+ }
367
+
368
+ .word--active {
369
+ text-decoration: underline;
370
+ text-decoration-color: var(--color-primary);
371
+ text-decoration-thickness: 2px;
372
+ text-underline-offset: 3px;
373
+ color: var(--color-primary);
374
+ }
375
+
376
+ .turn--partial .turn-text {
377
+ font-style: italic;
378
+ color: var(--color-text-muted);
379
+ animation: partial-fade-in 200ms ease;
380
+ }
381
+
382
+ @keyframes partial-fade-in {
383
+ from {
384
+ opacity: 0;
385
+ }
386
+ to {
387
+ opacity: 1;
388
+ }
389
+ }
390
+
391
+ @media (prefers-reduced-motion: reduce) {
392
+ .turn--partial .turn-text {
393
+ animation: none;
394
+ }
395
+ }
396
+
397
+ @media (max-width: 767px) {
398
+ .turn {
399
+ padding: var(--spacing-sm) var(--spacing-md);
400
+ }
401
+
402
+ .turn--selected,
403
+ .turn--active:not(.turn--selected) {
404
+ padding-left: calc(var(--spacing-md) - 3px);
405
+ }
406
+ }
407
+ </style>