@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,39 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { FormInput } from "@linto-ai/transcript-ui-ui"
|
|
3
|
+
import { computed } from "vue"
|
|
4
|
+
import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
5
|
+
import { buildTranslationItems } from "../utils/intl"
|
|
6
|
+
import type { TranslationInfo } from "../core/types"
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
translations: TranslationInfo[]
|
|
10
|
+
selectedTranslationId: string
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{
|
|
14
|
+
"update:selectedTranslationId": [id: string]
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const { t, locale } = useI18n()
|
|
18
|
+
|
|
19
|
+
const options = computed(() =>
|
|
20
|
+
buildTranslationItems(
|
|
21
|
+
props.translations,
|
|
22
|
+
locale.value,
|
|
23
|
+
t("sidebar.originalLanguage"),
|
|
24
|
+
t("language.wildcard"),
|
|
25
|
+
t("sidebar.bilingual"),
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const field = computed(() => ({ label: t("sidebar.translationSelectLabel") }))
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<FormInput
|
|
34
|
+
select
|
|
35
|
+
:field="field"
|
|
36
|
+
:options="options"
|
|
37
|
+
:model-value="selectedTranslationId"
|
|
38
|
+
@update:model-value="emit('update:selectedTranslationId', $event)" />
|
|
39
|
+
</template>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { DocumentArticle, type DownloadFormat, DownloadMenu } from "@linto-ai/transcript-ui-ui"
|
|
3
|
+
import { computed } from "vue"
|
|
4
|
+
import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
5
|
+
import { useCore } from "../core"
|
|
6
|
+
import * as utils from "../utils"
|
|
7
|
+
|
|
8
|
+
const core = useCore()
|
|
9
|
+
const { locale } = useI18n()
|
|
10
|
+
|
|
11
|
+
const formats: DownloadFormat[] = [
|
|
12
|
+
{ format: "docx", labelKey: "format.docx" },
|
|
13
|
+
{ format: "pdf", labelKey: "format.pdf" },
|
|
14
|
+
{ format: "txt", labelKey: "format.txt" },
|
|
15
|
+
{ format: "json", labelKey: "format.json" },
|
|
16
|
+
{ format: "whisperx", labelKey: "format.whisperx" },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const turns = computed(
|
|
20
|
+
() => core.activeChannel.value?.activeTranslation.value.turns.value ?? [],
|
|
21
|
+
)
|
|
22
|
+
const speakers = core.speakers.all
|
|
23
|
+
|
|
24
|
+
const title = computed(() => core.title.value)
|
|
25
|
+
|
|
26
|
+
const displayNames = computed(
|
|
27
|
+
() => new Intl.DisplayNames([locale.value], { type: "language" }),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
function speakerName(id: string | null): string {
|
|
31
|
+
if (id == null) return ""
|
|
32
|
+
return speakers.get(id)?.name ?? id
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function languageName(code: string): string {
|
|
36
|
+
if (!code) return ""
|
|
37
|
+
return displayNames.value.of(code) ?? code
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function turnText(turn: {
|
|
41
|
+
text: string | null
|
|
42
|
+
words: { text: string }[]
|
|
43
|
+
}): string {
|
|
44
|
+
if (turn.text != null) return turn.text
|
|
45
|
+
return turn.words.map((w) => w.text).join(" ")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function onExport(format?: string): void {
|
|
49
|
+
if (!format) return
|
|
50
|
+
core.emit("verbatim:export", { format })
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<section class="verbatim-panel">
|
|
56
|
+
<DocumentArticle :formats="formats" @export="onExport">
|
|
57
|
+
<template #toolbar-right>
|
|
58
|
+
<DownloadMenu :formats="formats" @select="onExport" />
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<article class="verbatim-panel__content">
|
|
62
|
+
<header class="verbatim-panel__header">
|
|
63
|
+
<h1 class="verbatim-panel__doc-title">{{ title }}</h1>
|
|
64
|
+
</header>
|
|
65
|
+
|
|
66
|
+
<ul class="verbatim-panel__turns">
|
|
67
|
+
<li v-for="turn in turns" :key="turn.id" class="verbatim-panel__turn">
|
|
68
|
+
<header class="verbatim-panel__turn-header">
|
|
69
|
+
<strong class="verbatim-panel__speaker-name">
|
|
70
|
+
{{ speakerName(turn.speakerId) }}
|
|
71
|
+
</strong>
|
|
72
|
+
<span v-if="turn.startTime != null" class="verbatim-panel__meta">
|
|
73
|
+
<span class="verbatim-panel__sep" aria-hidden="true">·</span>
|
|
74
|
+
<time>{{ utils.formatTime(turn.startTime) }}</time>
|
|
75
|
+
</span>
|
|
76
|
+
<span v-if="turn.language" class="verbatim-panel__meta">
|
|
77
|
+
<span class="verbatim-panel__sep" aria-hidden="true">·</span>
|
|
78
|
+
{{ languageName(turn.language) }}
|
|
79
|
+
</span>
|
|
80
|
+
</header>
|
|
81
|
+
<p class="verbatim-panel__text">{{ turnText(turn) }}</p>
|
|
82
|
+
</li>
|
|
83
|
+
</ul>
|
|
84
|
+
</article>
|
|
85
|
+
</DocumentArticle>
|
|
86
|
+
</section>
|
|
87
|
+
</template>
|
|
88
|
+
|
|
89
|
+
<style scoped>
|
|
90
|
+
.verbatim-panel {
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
min-width: 0;
|
|
94
|
+
min-height: 0;
|
|
95
|
+
overflow-y: auto;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.verbatim-panel__content {
|
|
99
|
+
padding: var(--spacing-md) var(--spacing-lg);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.verbatim-panel__header {
|
|
103
|
+
margin-bottom: var(--spacing-lg);
|
|
104
|
+
padding-bottom: var(--spacing-md);
|
|
105
|
+
border-bottom: 1px solid var(--color-border);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.verbatim-panel__doc-title {
|
|
109
|
+
font-size: var(--font-size-xl);
|
|
110
|
+
font-weight: 700;
|
|
111
|
+
margin: 0;
|
|
112
|
+
color: var(--color-text-primary);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.verbatim-panel__turns {
|
|
116
|
+
list-style: none;
|
|
117
|
+
margin: 0;
|
|
118
|
+
padding: 0;
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
gap: var(--spacing-lg);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.verbatim-panel__turn {
|
|
125
|
+
display: block;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.verbatim-panel__turn-header {
|
|
129
|
+
margin: 0 0 var(--spacing-xs);
|
|
130
|
+
font-size: var(--font-size-base);
|
|
131
|
+
line-height: 1.4;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.verbatim-panel__speaker-name {
|
|
135
|
+
font-weight: 700;
|
|
136
|
+
color: var(--color-text-primary);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.verbatim-panel__meta {
|
|
140
|
+
color: var(--color-text-muted);
|
|
141
|
+
font-weight: 400;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.verbatim-panel__sep {
|
|
145
|
+
margin: 0 0.35em;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.verbatim-panel__text {
|
|
149
|
+
margin: 0;
|
|
150
|
+
font-size: var(--font-size-base);
|
|
151
|
+
line-height: 1.6;
|
|
152
|
+
color: var(--color-text-primary);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@media (max-width: 767px) {
|
|
156
|
+
.verbatim-panel {
|
|
157
|
+
padding: var(--spacing-md);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
</style>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Button, FormInput } from "@linto-ai/transcript-ui-ui"
|
|
3
|
+
import { ref, computed, watch, useTemplateRef } from "vue"
|
|
4
|
+
import { useCore } from "../../core"
|
|
5
|
+
import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
6
|
+
import { countTurnsForSpeaker, mergeSpeakers } from "../../core/helpers"
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
open: boolean
|
|
10
|
+
fromSpeakerId: string | null
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{
|
|
14
|
+
"update:open": [value: boolean]
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const core = useCore()
|
|
18
|
+
const { t } = useI18n()
|
|
19
|
+
|
|
20
|
+
const dialogRef = useTemplateRef<HTMLDialogElement>("dialog")
|
|
21
|
+
const targetId = ref<string>("")
|
|
22
|
+
|
|
23
|
+
const fromSpeaker = computed(() =>
|
|
24
|
+
props.fromSpeakerId ? core.speakers.all.get(props.fromSpeakerId) : undefined,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const candidates = computed(() =>
|
|
28
|
+
Array.from(core.speakers.all.values()).filter(
|
|
29
|
+
(s) => s.id !== props.fromSpeakerId,
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const targetOptions = computed(() =>
|
|
34
|
+
candidates.value.map((c) => ({ value: c.id, label: c.name })),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const targetField = computed(() => ({
|
|
38
|
+
label: t("mergeDialog.targetLabel"),
|
|
39
|
+
required: true,
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
const affectedCount = computed(() => {
|
|
43
|
+
if (!props.fromSpeakerId) return 0
|
|
44
|
+
return countTurnsForSpeaker(core, props.fromSpeakerId)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
watch(
|
|
48
|
+
() => props.open,
|
|
49
|
+
(open) => {
|
|
50
|
+
if (open) {
|
|
51
|
+
targetId.value = candidates.value[0]?.id ?? ""
|
|
52
|
+
dialogRef.value?.showModal()
|
|
53
|
+
} else {
|
|
54
|
+
dialogRef.value?.close()
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
function onClose(): void {
|
|
60
|
+
emit("update:open", false)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onConfirm(): void {
|
|
64
|
+
if (!props.fromSpeakerId || !targetId.value) return
|
|
65
|
+
// Through the plugin when present (server round-trip, applied at the
|
|
66
|
+
// broadcast); direct store helper otherwise (plugin-less embeds).
|
|
67
|
+
if (core.transcriptionEditor) {
|
|
68
|
+
core.transcriptionEditor.replaceSpeaker(props.fromSpeakerId, targetId.value)
|
|
69
|
+
} else {
|
|
70
|
+
mergeSpeakers(core, props.fromSpeakerId, targetId.value)
|
|
71
|
+
}
|
|
72
|
+
emit("update:open", false)
|
|
73
|
+
}
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<dialog
|
|
78
|
+
ref="dialog"
|
|
79
|
+
class="merge-dialog"
|
|
80
|
+
@close="onClose"
|
|
81
|
+
@cancel.prevent="onClose">
|
|
82
|
+
<form v-if="fromSpeaker" class="merge-dialog-form" @submit.prevent="onConfirm">
|
|
83
|
+
<h2 class="merge-dialog-title">
|
|
84
|
+
{{ t('mergeDialog.title') }}
|
|
85
|
+
</h2>
|
|
86
|
+
<p class="merge-dialog-description">
|
|
87
|
+
<strong>{{ fromSpeaker.name }}</strong> · {{ affectedCount }}
|
|
88
|
+
{{ t('mergeDialog.turnsAffected') }}
|
|
89
|
+
</p>
|
|
90
|
+
<FormInput
|
|
91
|
+
select
|
|
92
|
+
:field="targetField"
|
|
93
|
+
:options="targetOptions"
|
|
94
|
+
v-model="targetId" />
|
|
95
|
+
<div class="merge-dialog-actions">
|
|
96
|
+
<Button variant="tertiary" type="button" @click="onClose">
|
|
97
|
+
{{ t('mergeDialog.cancel') }}
|
|
98
|
+
</Button>
|
|
99
|
+
<Button variant="primary" type="submit" :disabled="!targetId">
|
|
100
|
+
{{ t('mergeDialog.confirm') }}
|
|
101
|
+
</Button>
|
|
102
|
+
</div>
|
|
103
|
+
</form>
|
|
104
|
+
</dialog>
|
|
105
|
+
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<style scoped>
|
|
109
|
+
.merge-dialog {
|
|
110
|
+
margin: auto;
|
|
111
|
+
max-width: 420px;
|
|
112
|
+
width: 90vw;
|
|
113
|
+
padding: var(--spacing-lg);
|
|
114
|
+
background-color: var(--color-surface);
|
|
115
|
+
border: 1px solid var(--color-border);
|
|
116
|
+
border-radius: var(--radius-md);
|
|
117
|
+
color: var(--color-text-primary);
|
|
118
|
+
box-shadow: 0 16px 48px color-mix(in srgb, var(--color-text-primary) 20%, transparent);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.merge-dialog::backdrop {
|
|
122
|
+
/* No backdrop-filter: a full-viewport backdrop blur is a large WebRender
|
|
123
|
+
render target; the dim background alone is enough. */
|
|
124
|
+
background-color: color-mix(in srgb, var(--color-text-primary) 35%, transparent);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.merge-dialog-form {
|
|
128
|
+
display: flex;
|
|
129
|
+
flex-direction: column;
|
|
130
|
+
gap: var(--spacing-md);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.merge-dialog-title {
|
|
134
|
+
margin: 0;
|
|
135
|
+
font-size: var(--font-size-lg);
|
|
136
|
+
font-weight: 600;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.merge-dialog-description {
|
|
140
|
+
margin: 0;
|
|
141
|
+
font-size: var(--font-size-sm);
|
|
142
|
+
color: var(--color-text-secondary);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.merge-dialog-actions {
|
|
146
|
+
display: flex;
|
|
147
|
+
justify-content: flex-end;
|
|
148
|
+
gap: var(--spacing-sm);
|
|
149
|
+
}
|
|
150
|
+
</style>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Button } from "@linto-ai/transcript-ui-ui"
|
|
3
|
+
import { computed } from "vue"
|
|
4
|
+
import { useCore } from "../../core"
|
|
5
|
+
import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
6
|
+
|
|
7
|
+
// Always-visible merge control between two adjacent turns. A lock on either
|
|
8
|
+
// side DISABLES it (the layout must not jump, and the lock badge tells why);
|
|
9
|
+
// the server ack stays the authority on races.
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
firstTurnId: string
|
|
12
|
+
secondTurnId: string
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const core = useCore()
|
|
16
|
+
const { t } = useI18n()
|
|
17
|
+
|
|
18
|
+
const canShow = computed(
|
|
19
|
+
() =>
|
|
20
|
+
core.transcriptionEditor !== undefined &&
|
|
21
|
+
core.capabilities.value.text === "edit",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const isDisabled = computed(
|
|
25
|
+
() =>
|
|
26
|
+
!!core.transcriptionEditor?.getTurnLock(props.firstTurnId) ||
|
|
27
|
+
!!core.transcriptionEditor?.getTurnLock(props.secondTurnId),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
function onMergeClick() {
|
|
31
|
+
core.transcriptionEditor!.mergeTurns(props.firstTurnId, props.secondTurnId)
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div v-if="canShow" class="merge-turns">
|
|
37
|
+
<Button
|
|
38
|
+
size="sm"
|
|
39
|
+
variant="inverse"
|
|
40
|
+
icon="merge"
|
|
41
|
+
:disabled="isDisabled"
|
|
42
|
+
:aria-label="t('transcription.mergeTurns')"
|
|
43
|
+
@click.stop="onMergeClick" />
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<style scoped>
|
|
48
|
+
.merge-turns {
|
|
49
|
+
display: flex;
|
|
50
|
+
justify-content: center;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Compact: the control sits in the gap between turns without inflating it. */
|
|
54
|
+
.merge-turns :deep(.editor-btn) {
|
|
55
|
+
--btn-height: 22px;
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { PopoverList, SpeakerIndicator, Button, FormInput, type FormField } from "@linto-ai/transcript-ui-ui"
|
|
3
|
+
import { ref, computed, nextTick, useTemplateRef, watch } from "vue"
|
|
4
|
+
import { useCore } from "../../core"
|
|
5
|
+
import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
6
|
+
import { switchTurnSpeaker, createSpeakerAndAssign } from "../../core/helpers"
|
|
7
|
+
import type { Speaker } from "../../types/editor"
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
turnId: string
|
|
11
|
+
currentSpeakerId: string | null
|
|
12
|
+
// When the popover is mounted lazily on click, open it immediately.
|
|
13
|
+
initialOpen?: boolean
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const core = useCore()
|
|
17
|
+
const { t } = useI18n()
|
|
18
|
+
|
|
19
|
+
const isOpen = ref(props.initialOpen ?? false)
|
|
20
|
+
const isCreatingNew = ref(false)
|
|
21
|
+
const newName = ref("")
|
|
22
|
+
const newInputRef = useTemplateRef<{ focus: () => void }>("newInput")
|
|
23
|
+
|
|
24
|
+
const speakers = computed(() => Array.from(core.speakers.all.values()))
|
|
25
|
+
|
|
26
|
+
const newSpeakerField = computed<FormField>(() => ({
|
|
27
|
+
placeholder: t("speakerPopover.newSpeakerPlaceholder"),
|
|
28
|
+
customParams: { "aria-label": t("speakerPopover.newSpeaker") },
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
watch(isOpen, (open) => {
|
|
32
|
+
if (!open) {
|
|
33
|
+
isCreatingNew.value = false
|
|
34
|
+
newName.value = ""
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
async function startCreatingNew(): Promise<void> {
|
|
39
|
+
isCreatingNew.value = true
|
|
40
|
+
newName.value = ""
|
|
41
|
+
await nextTick()
|
|
42
|
+
newInputRef.value?.focus()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function onPickExisting(speaker: Speaker): void {
|
|
46
|
+
if (speaker.id !== props.currentSpeakerId) {
|
|
47
|
+
// Through the plugin when present (server round-trip, applied at the
|
|
48
|
+
// broadcast); direct store helper otherwise (plugin-less embeds).
|
|
49
|
+
if (core.transcriptionEditor) {
|
|
50
|
+
core.transcriptionEditor.updateTurnSpeaker(props.turnId, {
|
|
51
|
+
speakerId: speaker.id,
|
|
52
|
+
})
|
|
53
|
+
} else {
|
|
54
|
+
switchTurnSpeaker(core, props.turnId, speaker.id)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
isOpen.value = false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function submitNew(): void {
|
|
61
|
+
const trimmed = newName.value.trim()
|
|
62
|
+
if (!trimmed) {
|
|
63
|
+
isCreatingNew.value = false
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (core.transcriptionEditor) {
|
|
67
|
+
core.transcriptionEditor.updateTurnSpeaker(props.turnId, {
|
|
68
|
+
speakerName: trimmed,
|
|
69
|
+
})
|
|
70
|
+
} else {
|
|
71
|
+
createSpeakerAndAssign(core, props.turnId, trimmed)
|
|
72
|
+
}
|
|
73
|
+
isOpen.value = false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function onNewKeydown(e: KeyboardEvent): void {
|
|
77
|
+
// Prevent DropdownMenu typeahead / keyboard nav from hijacking the input.
|
|
78
|
+
// Enter / Escape are handled by FormInput's withConfirmation.
|
|
79
|
+
e.stopPropagation()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function onCancelNew(): void {
|
|
83
|
+
isCreatingNew.value = false
|
|
84
|
+
}
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<template>
|
|
88
|
+
<PopoverList
|
|
89
|
+
v-model:open="isOpen"
|
|
90
|
+
:items="speakers"
|
|
91
|
+
:item-key="(s) => s.id"
|
|
92
|
+
:is-current="(s) => s.id === currentSpeakerId"
|
|
93
|
+
@select="onPickExisting">
|
|
94
|
+
<template #trigger>
|
|
95
|
+
<button type="button" class="speaker-popover-trigger">
|
|
96
|
+
<slot />
|
|
97
|
+
</button>
|
|
98
|
+
</template>
|
|
99
|
+
<template #item="{ item }">
|
|
100
|
+
<SpeakerIndicator :color="item.color" />
|
|
101
|
+
<span class="speaker-popover-name">{{ item.name }}</span>
|
|
102
|
+
</template>
|
|
103
|
+
<template #footer>
|
|
104
|
+
<Button
|
|
105
|
+
v-if="!isCreatingNew"
|
|
106
|
+
icon="user-plus"
|
|
107
|
+
variant="transparent"
|
|
108
|
+
block
|
|
109
|
+
@click="startCreatingNew">
|
|
110
|
+
{{ t('speakerPopover.newSpeaker') }}
|
|
111
|
+
</Button>
|
|
112
|
+
<FormInput
|
|
113
|
+
v-else
|
|
114
|
+
ref="newInput"
|
|
115
|
+
v-model="newName"
|
|
116
|
+
:field="newSpeakerField"
|
|
117
|
+
size="sm"
|
|
118
|
+
full-width
|
|
119
|
+
with-confirmation
|
|
120
|
+
@keydown="onNewKeydown"
|
|
121
|
+
@on-confirm="submitNew"
|
|
122
|
+
@on-cancel="onCancelNew" />
|
|
123
|
+
</template>
|
|
124
|
+
</PopoverList>
|
|
125
|
+
</template>
|
|
126
|
+
|
|
127
|
+
<style scoped>
|
|
128
|
+
.speaker-popover-trigger {
|
|
129
|
+
all: unset;
|
|
130
|
+
cursor: pointer;
|
|
131
|
+
display: inline-flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
border-radius: var(--radius-sm);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.speaker-popover-trigger:focus-visible {
|
|
137
|
+
outline: 2px solid var(--color-primary);
|
|
138
|
+
outline-offset: 2px;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.speaker-popover-name {
|
|
142
|
+
overflow: hidden;
|
|
143
|
+
text-overflow: ellipsis;
|
|
144
|
+
white-space: nowrap;
|
|
145
|
+
}
|
|
146
|
+
</style>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ref, onScopeDispose, type Ref } from "vue"
|
|
2
|
+
import type { Core } from "../core/types"
|
|
3
|
+
|
|
4
|
+
// Clear the overlay after this long even if no document ever arrives, so a
|
|
5
|
+
// failed load can't leave the editor masked forever.
|
|
6
|
+
const READY_TIMEOUT_MS = 20000
|
|
7
|
+
|
|
8
|
+
export interface EditorReady {
|
|
9
|
+
/** True while the editor is loading its first (or a freshly reloaded) document. */
|
|
10
|
+
isLoading: Ref<boolean>
|
|
11
|
+
/** Non-null when the editor failed to load. */
|
|
12
|
+
error: Ref<string | null>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Owns the editor's loading state so embedders don't have to track it.
|
|
17
|
+
*
|
|
18
|
+
* The overlay shows until a document is loaded (channels populated). Loading a
|
|
19
|
+
* new document (`document:change`) resolves it immediately — setDocument is
|
|
20
|
+
* synchronous — so the reset only matters for the initial mount, where the
|
|
21
|
+
* host may set the document long after the component tree is up. A timeout
|
|
22
|
+
* guarantees the overlay is eventually cleared.
|
|
23
|
+
*/
|
|
24
|
+
export function useEditorReady(core: Core): EditorReady {
|
|
25
|
+
const isLoading = ref(true)
|
|
26
|
+
const error = ref<string | null>(null)
|
|
27
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
28
|
+
|
|
29
|
+
function clearTimer(): void {
|
|
30
|
+
if (timer !== undefined) {
|
|
31
|
+
clearTimeout(timer)
|
|
32
|
+
timer = undefined
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function settle(): void {
|
|
37
|
+
isLoading.value = false
|
|
38
|
+
clearTimer()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function reset(): void {
|
|
42
|
+
error.value = null
|
|
43
|
+
if (core.channels.size > 0) {
|
|
44
|
+
settle()
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
isLoading.value = true
|
|
48
|
+
clearTimer()
|
|
49
|
+
timer = setTimeout(settle, READY_TIMEOUT_MS)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const offDocChange = core.on("document:change", reset)
|
|
53
|
+
|
|
54
|
+
reset()
|
|
55
|
+
|
|
56
|
+
onScopeDispose(() => {
|
|
57
|
+
offDocChange()
|
|
58
|
+
clearTimer()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
return { isLoading, error }
|
|
62
|
+
}
|