@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,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
+ }