@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.
- package/LICENSE +661 -0
- package/README.md +46 -0
- package/package.json +46 -0
- package/src/adapters/apiAdapter.ts +44 -0
- package/src/adapters/mapApiTurns.ts +32 -0
- package/src/adapters/test/mapApiTurns.test.ts +73 -0
- package/src/adapters/whisperXAdapter.ts +73 -0
- package/src/components/ChannelSelector.vue +31 -0
- package/src/components/EditorErrorOverlay.vue +55 -0
- package/src/components/EditorLoadingOverlay.vue +52 -0
- package/src/components/Header.vue +138 -0
- package/src/components/Layout.vue +221 -0
- package/src/components/MicrophoneIllustration.vue +28 -0
- package/src/components/SelectionActionBar.vue +86 -0
- package/src/components/SidebarDrawer.vue +32 -0
- package/src/components/SpeakerLabel.vue +105 -0
- package/src/components/SpeakerSidebar.vue +455 -0
- package/src/components/TabBar.constants.ts +2 -0
- package/src/components/TabBar.vue +52 -0
- package/src/components/TranscriptUI.vue +68 -0
- package/src/components/TranscriptionEmpty.vue +39 -0
- package/src/components/TranscriptionPanel.vue +275 -0
- package/src/components/TranscriptionTurn.vue +407 -0
- package/src/components/TranslationSelector.vue +39 -0
- package/src/components/VerbatimPanel.vue +160 -0
- package/src/components/molecules/MergeDialog.vue +150 -0
- package/src/components/molecules/MergeTurnsButton.vue +57 -0
- package/src/components/molecules/SpeakerPopover.vue +146 -0
- package/src/composables/useEditorReady.ts +62 -0
- package/src/composables/useFollowPlayback.ts +124 -0
- package/src/composables/useIsMobile.ts +24 -0
- package/src/composables/useTurnSelection.ts +169 -0
- package/src/constants/speakers.ts +14 -0
- package/src/core/createCore.ts +162 -0
- package/src/core/helpers/addSpeaker.ts +11 -0
- package/src/core/helpers/countTurnsForSpeaker.ts +9 -0
- package/src/core/helpers/createSpeakerAndAssign.ts +16 -0
- package/src/core/helpers/ensureDocumentSpeakers.ts +26 -0
- package/src/core/helpers/ensureSpeaker.ts +12 -0
- package/src/core/helpers/ensureSpeakersFromTurns.ts +14 -0
- package/src/core/helpers/findTurnIndex.ts +5 -0
- package/src/core/helpers/index.ts +15 -0
- package/src/core/helpers/insertTurn.ts +5 -0
- package/src/core/helpers/mergeSpeakers.ts +29 -0
- package/src/core/helpers/patchTurn.ts +15 -0
- package/src/core/helpers/prependTurns.ts +5 -0
- package/src/core/helpers/removeTurn.ts +8 -0
- package/src/core/helpers/renameSpeaker.ts +12 -0
- package/src/core/helpers/speakerEquals.ts +6 -0
- package/src/core/helpers/switchTurnSpeaker.ts +17 -0
- package/src/core/helpers/test/countTurnsForSpeaker.test.ts +16 -0
- package/src/core/helpers/test/createSpeakerAndAssign.test.ts +22 -0
- package/src/core/helpers/test/makeTestCore.ts +40 -0
- package/src/core/helpers/test/mergeSpeakers.test.ts +24 -0
- package/src/core/helpers/test/renameSpeaker.test.ts +19 -0
- package/src/core/helpers/test/switchTurnSpeaker.test.ts +27 -0
- package/src/core/helpers/updateTurnWords.ts +22 -0
- package/src/core/index.ts +47 -0
- package/src/core/modules/eventBus.ts +43 -0
- package/src/core/stores/channelStore.ts +100 -0
- package/src/core/stores/crossTranslationStore.ts +141 -0
- package/src/core/stores/index.ts +7 -0
- package/src/core/stores/speakersStore.ts +53 -0
- package/src/core/stores/translationStore.ts +133 -0
- package/src/core/types.ts +602 -0
- package/src/core/useCore.ts +16 -0
- package/src/index.ts +88 -0
- package/src/types/api.ts +52 -0
- package/src/types/editor.ts +65 -0
- package/src/types/whisperx.ts +22 -0
- package/src/utils/color.ts +7 -0
- package/src/utils/computeCaretOffsetFromPoint.ts +56 -0
- package/src/utils/computeTurnPlainText.ts +9 -0
- package/src/utils/extractLangCode.ts +7 -0
- package/src/utils/findWordAtOffset.ts +16 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/intl.ts +47 -0
- package/src/utils/isSameLanguage.ts +13 -0
- package/src/utils/test/computeTurnPlainText.test.ts +34 -0
- package/src/utils/test/findWordAtOffset.test.ts +25 -0
- package/src/utils/throttle.ts +23 -0
- package/src/utils/time.ts +95 -0
- package/src/utils/tokenize.ts +34 -0
- package/src/utils/tts.ts +49 -0
- package/src/utils/turnWords/carryWordTimes.ts +37 -0
- package/src/utils/turnWords/index.ts +6 -0
- package/src/utils/turnWords/layoutWords.ts +41 -0
- package/src/utils/turnWords/parseWordId.ts +13 -0
- package/src/utils/turnWords/test/carryWordTimes.test.ts +47 -0
- package/src/utils/turnWords/test/layoutWords.test.ts +54 -0
- package/src/utils/turnWords/test/parseWordId.test.ts +24 -0
- package/src/utils/turnWords/test/wordId.test.ts +9 -0
- package/src/utils/turnWords/test/wordsFromApi.test.ts +20 -0
- package/src/utils/turnWords/test/wordsFromText.test.ts +17 -0
- package/src/utils/turnWords/wordId.ts +9 -0
- package/src/utils/turnWords/wordsFromApi.ts +16 -0
- package/src/utils/turnWords/wordsFromText.ts +14 -0
- package/src/utils/validateDocument.ts +80 -0
- package/src/utils/waveform.ts +64 -0
- package/src/utils/wordRange.ts +67 -0
- 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>
|