@linto-ai/transcript-ui-core 0.9.23 → 0.9.24-unstable.202610151359
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/README.md +32 -0
- package/package.json +3 -3
- package/src/components/Header.vue +54 -43
- package/src/components/Layout.vue +27 -8
- package/src/components/MicrophoneIllustration.vue +5 -5
- package/src/components/SpeakerLabel.vue +31 -16
- package/src/components/SpeakerSidebar.vue +74 -54
- package/src/components/SpeechActivityIndicator.vue +94 -0
- package/src/components/TranscriptUI.vue +33 -3
- package/src/components/TranscriptionPanel.vue +24 -3
- package/src/components/TranscriptionTurn.vue +33 -6
- package/src/composables/useFollowPlayback.ts +4 -1
- package/src/composables/useIsMobile.ts +8 -25
- package/src/core/capabilities.ts +18 -0
- package/src/core/createCore.ts +78 -1
- package/src/core/index.ts +7 -0
- package/src/core/modules/viewport.ts +48 -0
- package/src/core/stores/channelStore.ts +4 -1
- package/src/core/stores/test/translationStore.test.ts +35 -0
- package/src/core/stores/translationStore.ts +5 -2
- package/src/core/test/capabilities.test.ts +32 -0
- package/src/core/test/createCore.test.ts +248 -0
- package/src/core/types.ts +131 -6
- package/src/index.ts +9 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
3
|
+
|
|
4
|
+
defineProps<{ active: boolean }>()
|
|
5
|
+
|
|
6
|
+
const { t } = useI18n()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<!-- Always rendered, only hidden when nobody is talking: the panel's
|
|
11
|
+
stick-to-bottom watches .turns-container's height, so an element that
|
|
12
|
+
came and went in here would rescroll the whole transcript at every
|
|
13
|
+
silence. `visibility` keeps the box and drops it from the
|
|
14
|
+
accessibility tree.
|
|
15
|
+
|
|
16
|
+
Not a live region either: it comes back with every pause, and a screen
|
|
17
|
+
reader announcing it each time would talk over the transcript it is
|
|
18
|
+
supposed to accompany. -->
|
|
19
|
+
<p
|
|
20
|
+
class="speech-activity"
|
|
21
|
+
:class="{ 'speech-activity--idle': !active }"
|
|
22
|
+
role="img"
|
|
23
|
+
:aria-label="t('transcription.speaking')">
|
|
24
|
+
<span class="speech-activity-dot"></span>
|
|
25
|
+
<span class="speech-activity-dot"></span>
|
|
26
|
+
<span class="speech-activity-dot"></span>
|
|
27
|
+
</p>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<style scoped>
|
|
31
|
+
/* Typing bubble, as in messaging apps. The box never changes size, only the
|
|
32
|
+
dots move inside it, so the transcript above stays still. */
|
|
33
|
+
.speech-activity {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
gap: 5px;
|
|
37
|
+
width: fit-content;
|
|
38
|
+
margin: var(--spacing-sm) var(--spacing-lg);
|
|
39
|
+
padding: var(--spacing-sm) var(--spacing-md);
|
|
40
|
+
border-radius: var(--radius-lg) var(--radius-lg) var(--radius-lg)
|
|
41
|
+
var(--radius-sm);
|
|
42
|
+
background-color: var(--color-surface-hover);
|
|
43
|
+
transition:
|
|
44
|
+
opacity 0.4s ease,
|
|
45
|
+
visibility 0.4s;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.speech-activity-dot {
|
|
49
|
+
width: 7px;
|
|
50
|
+
height: 7px;
|
|
51
|
+
border-radius: 50%;
|
|
52
|
+
background-color: var(--color-text-muted);
|
|
53
|
+
animation: speech-activity-hop 1.2s infinite ease-in-out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.speech-activity-dot:nth-child(2) {
|
|
57
|
+
animation-delay: 0.15s;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.speech-activity-dot:nth-child(3) {
|
|
61
|
+
animation-delay: 0.3s;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@keyframes speech-activity-hop {
|
|
65
|
+
0%,
|
|
66
|
+
60%,
|
|
67
|
+
100% {
|
|
68
|
+
transform: translateY(0);
|
|
69
|
+
opacity: 0.55;
|
|
70
|
+
}
|
|
71
|
+
30% {
|
|
72
|
+
transform: translateY(-5px);
|
|
73
|
+
opacity: 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.speech-activity--idle {
|
|
78
|
+
opacity: 0;
|
|
79
|
+
visibility: hidden;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@media (prefers-reduced-motion: reduce) {
|
|
83
|
+
.speech-activity-dot {
|
|
84
|
+
animation: none;
|
|
85
|
+
opacity: 0.8;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@media (max-width: 767px) {
|
|
90
|
+
.speech-activity {
|
|
91
|
+
margin-inline: var(--spacing-md);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, watch, onBeforeUnmount } from "vue"
|
|
2
|
+
import { computed, ref, watch, onBeforeUnmount } from "vue"
|
|
3
3
|
import Layout from "./Layout.vue"
|
|
4
4
|
import EditorLoadingOverlay from "./EditorLoadingOverlay.vue"
|
|
5
5
|
import EditorErrorOverlay from "./EditorErrorOverlay.vue"
|
|
@@ -16,11 +16,17 @@ const props = withDefaults(
|
|
|
16
16
|
// tab left — see TabBar.vue) — a live session has no finished verbatim
|
|
17
17
|
// to show, see SessionLiveNG.vue.
|
|
18
18
|
noVerbatim?: boolean
|
|
19
|
+
// Hides the high-contrast switch. The theme it drives is the host's as
|
|
20
|
+
// much as the editor's — the host follows "theme:change" to darken its
|
|
21
|
+
// own chrome — so an app that has no dark values of its own turns the
|
|
22
|
+
// switch off rather than offering a half-dark screen.
|
|
23
|
+
noThemeToggle?: boolean
|
|
19
24
|
}>(),
|
|
20
25
|
{
|
|
21
26
|
locale: "fr",
|
|
22
27
|
noHeader: false,
|
|
23
28
|
noVerbatim: false,
|
|
29
|
+
noThemeToggle: false,
|
|
24
30
|
},
|
|
25
31
|
)
|
|
26
32
|
|
|
@@ -37,6 +43,21 @@ watch(
|
|
|
37
43
|
const core = createCore()
|
|
38
44
|
provideCore(core)
|
|
39
45
|
|
|
46
|
+
// A host cannot theme the editor from outside: the token declarations sit on
|
|
47
|
+
// this very root, which is closer than the custom element, so they win over
|
|
48
|
+
// anything set on <linto-editor>. An inline style is the one thing that
|
|
49
|
+
// outranks them — hence the brand colour travels through core.primaryColor
|
|
50
|
+
// rather than through CSS. The hover shade is derived so the pair stays
|
|
51
|
+
// coherent instead of leaving the editor's own blue behind.
|
|
52
|
+
const brandStyle = computed(() =>
|
|
53
|
+
core.primaryColor.value
|
|
54
|
+
? {
|
|
55
|
+
"--color-primary": core.primaryColor.value,
|
|
56
|
+
"--color-primary-hover": `color-mix(in srgb, ${core.primaryColor.value} 85%, #000)`,
|
|
57
|
+
}
|
|
58
|
+
: undefined,
|
|
59
|
+
)
|
|
60
|
+
|
|
40
61
|
// The editor owns its loading/error overlays: loading stays up until the
|
|
41
62
|
// document is loaded and (in collab mode) the first sync lands; error shows
|
|
42
63
|
// when the load fails non-recoverably. Embedders don't poll or render either.
|
|
@@ -50,11 +71,20 @@ defineExpose({ core })
|
|
|
50
71
|
</script>
|
|
51
72
|
|
|
52
73
|
<template>
|
|
53
|
-
<div
|
|
74
|
+
<div
|
|
75
|
+
class="transcript-ui-root"
|
|
76
|
+
:data-theme="core.theme.value"
|
|
77
|
+
:style="brandStyle">
|
|
54
78
|
<Layout
|
|
55
79
|
v-if="core.channels.size"
|
|
56
80
|
:show-header="!props.noHeader"
|
|
57
|
-
:show-verbatim="!props.noVerbatim"
|
|
81
|
+
:show-verbatim="!props.noVerbatim"
|
|
82
|
+
:show-theme-toggle="!props.noThemeToggle">
|
|
83
|
+
<!-- Declared on the custom element's root component so Vue renders a
|
|
84
|
+
native <slot>: the host's children with slot="header-start" land
|
|
85
|
+
in the header. -->
|
|
86
|
+
<template #header-start><slot name="header-start" /></template>
|
|
87
|
+
</Layout>
|
|
58
88
|
<EditorErrorOverlay v-if="error" :message="error" />
|
|
59
89
|
<EditorLoadingOverlay v-else-if="isLoading" />
|
|
60
90
|
</div>
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { useStickToBottom } from "vue-stick-to-bottom"
|
|
12
12
|
import TranscriptionTurn from "./TranscriptionTurn.vue"
|
|
13
13
|
import TranscriptionEmpty from "./TranscriptionEmpty.vue"
|
|
14
|
+
import SpeechActivityIndicator from "./SpeechActivityIndicator.vue"
|
|
14
15
|
import { useCore } from "../core"
|
|
15
16
|
import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
16
17
|
import { useFollowPlayback } from "../composables/useFollowPlayback"
|
|
@@ -41,6 +42,21 @@ const partialTurn = computed(() => {
|
|
|
41
42
|
})
|
|
42
43
|
|
|
43
44
|
const hasLiveUpdate = computed(() => core.live?.hasLiveUpdate.value ?? false)
|
|
45
|
+
|
|
46
|
+
// Set here rather than on a global stylesheet: the value stays scoped to the
|
|
47
|
+
// reading surface, and the partial turn inherits it like any other.
|
|
48
|
+
const transcriptFontSize = computed(() => `${core.transcriptFontSize.value}px`)
|
|
49
|
+
|
|
50
|
+
// With the partial text hidden, the panel would sit perfectly still between
|
|
51
|
+
// two finalized turns — nothing left to say the session is still listening.
|
|
52
|
+
// The line is kept for the whole session rather than mounted when someone
|
|
53
|
+
// speaks: useStickToBottom watches .turns-container, so anything appearing
|
|
54
|
+
// and disappearing in there drags the transcript with it (the indicator
|
|
55
|
+
// hides in place instead — see SpeechActivityIndicator).
|
|
56
|
+
const showSpeechIndicator = computed(
|
|
57
|
+
() => core.live !== undefined && core.live.partialsVisible.value === false,
|
|
58
|
+
)
|
|
59
|
+
const isSpeechActive = computed(() => core.live?.isSpeechActive.value === true)
|
|
44
60
|
const isPlaying = computed(() => core.audio?.isPlaying.value ?? false)
|
|
45
61
|
|
|
46
62
|
const activeTranslation = computed(
|
|
@@ -142,7 +158,9 @@ onBeforeUnmount(() => {
|
|
|
142
158
|
</script>
|
|
143
159
|
|
|
144
160
|
<template>
|
|
145
|
-
<article
|
|
161
|
+
<article
|
|
162
|
+
class="transcription-panel"
|
|
163
|
+
:style="{ '--transcript-font-size': transcriptFontSize }">
|
|
146
164
|
<div ref="scrollContainer" class="scroll-container">
|
|
147
165
|
<div class="turns-container">
|
|
148
166
|
<div v-if="isLoadingHistory" class="history-loading" role="status">
|
|
@@ -173,6 +191,9 @@ onBeforeUnmount(() => {
|
|
|
173
191
|
key="__partial__"
|
|
174
192
|
:turn="partialTurn"
|
|
175
193
|
partial />
|
|
194
|
+
<SpeechActivityIndicator
|
|
195
|
+
v-if="showSpeechIndicator"
|
|
196
|
+
:active="isSpeechActive" />
|
|
176
197
|
</div>
|
|
177
198
|
|
|
178
199
|
<Transition name="fade-slide">
|
|
@@ -247,11 +268,11 @@ onBeforeUnmount(() => {
|
|
|
247
268
|
align-items: center;
|
|
248
269
|
}
|
|
249
270
|
|
|
250
|
-
.resume-scroll-btn {
|
|
271
|
+
.transcript-ui-root .resume-scroll-btn {
|
|
251
272
|
/* No backdrop-filter: this button is sticky inside the tall scroll
|
|
252
273
|
container, where a backdrop-filter makes WebRender allocate a render
|
|
253
274
|
target spanning the whole scroll height — multi-GB on a long transcript. */
|
|
254
|
-
background:
|
|
275
|
+
background: var(--color-surface) !important;
|
|
255
276
|
border: 1px solid var(--color-border);
|
|
256
277
|
box-shadow: var(--shadow-sm);
|
|
257
278
|
}
|
|
@@ -4,7 +4,7 @@ import { computed, ref, useTemplateRef } from "vue"
|
|
|
4
4
|
import SpeakerLabel from "./SpeakerLabel.vue"
|
|
5
5
|
import MergeTurnsButton from "./molecules/MergeTurnsButton.vue"
|
|
6
6
|
import SpeakerPopover from "./molecules/SpeakerPopover.vue"
|
|
7
|
-
import { useCore } from "../core"
|
|
7
|
+
import { useCore, CROSS_TRANSLATION_ID } from "../core"
|
|
8
8
|
import { useTurnSelection } from "../composables/useTurnSelection"
|
|
9
9
|
import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
10
10
|
import * as utils from "../utils"
|
|
@@ -47,6 +47,15 @@ const isTurnActive = computed(() => {
|
|
|
47
47
|
|
|
48
48
|
const speakerColor = computed(() => props.speaker?.color ?? "transparent")
|
|
49
49
|
|
|
50
|
+
// On a real translation track every turn is in the same language and the
|
|
51
|
+
// selector already names it. The source keeps it — a channel can be
|
|
52
|
+
// multilingual — and so does cross mode, where turns alternate on purpose.
|
|
53
|
+
const showLanguage = computed(() => {
|
|
54
|
+
const active = core.activeChannel.value?.activeTranslation.value
|
|
55
|
+
if (!active) return true
|
|
56
|
+
return active.isSource || active.id === CROSS_TRANSLATION_ID
|
|
57
|
+
})
|
|
58
|
+
|
|
50
59
|
const isSelected = computed(() => selection.isSelected(props.turn.id))
|
|
51
60
|
|
|
52
61
|
const checkboxLabel = computed(() => {
|
|
@@ -78,6 +87,12 @@ const turnLock = computed(() =>
|
|
|
78
87
|
: core.transcriptionEditor?.getTurnLock(props.turn.id),
|
|
79
88
|
)
|
|
80
89
|
|
|
90
|
+
// Both things that can fill the header's action slot — the save/cancel pair
|
|
91
|
+
// while editing, another session's lock avatar otherwise — come from the
|
|
92
|
+
// transcription editor. Without that plugin (a live session) the row can
|
|
93
|
+
// never host anything, so it has no height to reserve.
|
|
94
|
+
const hasEditActions = computed(() => core.transcriptionEditor !== undefined)
|
|
95
|
+
|
|
81
96
|
const lockedByLabel = computed(() =>
|
|
82
97
|
turnLock.value
|
|
83
98
|
? t("transcription.lockedBy").replace("{name}", turnLock.value.userName)
|
|
@@ -202,7 +217,11 @@ function onCheckboxChange(event: MouseEvent) {
|
|
|
202
217
|
v-if="previousTurnId && !partial && !live"
|
|
203
218
|
:first-turn-id="previousTurnId"
|
|
204
219
|
:second-turn-id="turn.id" />
|
|
205
|
-
<div
|
|
220
|
+
<div
|
|
221
|
+
v-if="!partial"
|
|
222
|
+
class="turn-header"
|
|
223
|
+
:class="{ 'turn-header--with-actions': hasEditActions }"
|
|
224
|
+
@click="onHeaderClick">
|
|
206
225
|
<EditorCheckbox
|
|
207
226
|
v-if="selection.hasSelection.value"
|
|
208
227
|
:model-value="isSelected"
|
|
@@ -218,6 +237,7 @@ function onCheckboxChange(event: MouseEvent) {
|
|
|
218
237
|
:start-time="turn.startTime"
|
|
219
238
|
:start-date="turn.startDate"
|
|
220
239
|
:language="turn.language"
|
|
240
|
+
:show-language="showLanguage"
|
|
221
241
|
interactive />
|
|
222
242
|
</SpeakerPopover>
|
|
223
243
|
<button
|
|
@@ -230,6 +250,7 @@ function onCheckboxChange(event: MouseEvent) {
|
|
|
230
250
|
:start-time="turn.startTime"
|
|
231
251
|
:start-date="turn.startDate"
|
|
232
252
|
:language="turn.language"
|
|
253
|
+
:show-language="showLanguage"
|
|
233
254
|
interactive />
|
|
234
255
|
</button>
|
|
235
256
|
<SpeakerLabel
|
|
@@ -237,7 +258,8 @@ function onCheckboxChange(event: MouseEvent) {
|
|
|
237
258
|
:speaker="speaker"
|
|
238
259
|
:start-time="turn.startTime"
|
|
239
260
|
:start-date="turn.startDate"
|
|
240
|
-
:language="turn.language"
|
|
261
|
+
:language="turn.language"
|
|
262
|
+
:show-language="showLanguage" />
|
|
241
263
|
<div v-if="isEditing || turnLock" class="turn-edit-actions">
|
|
242
264
|
<template v-if="isEditing">
|
|
243
265
|
<Button
|
|
@@ -308,8 +330,13 @@ function onCheckboxChange(event: MouseEvent) {
|
|
|
308
330
|
user-select: none;
|
|
309
331
|
border-radius: var(--radius-sm);
|
|
310
332
|
padding: var(--spacing-xxs) 0;
|
|
311
|
-
|
|
312
|
-
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/* Reserve the edit-actions height (Button sm) so entering/leaving edit mode
|
|
336
|
+
never shifts the layout — but only where those actions can appear at all.
|
|
337
|
+
Read-only, the row holds a line of metadata and has no reason to be 36px
|
|
338
|
+
tall. */
|
|
339
|
+
.turn-header--with-actions {
|
|
313
340
|
min-height: 36px;
|
|
314
341
|
}
|
|
315
342
|
|
|
@@ -339,7 +366,7 @@ function onCheckboxChange(event: MouseEvent) {
|
|
|
339
366
|
|
|
340
367
|
.turn-text {
|
|
341
368
|
margin-top: var(--spacing-xs);
|
|
342
|
-
font-size: var(--font-size
|
|
369
|
+
font-size: var(--transcript-font-size);
|
|
343
370
|
line-height: var(--line-height);
|
|
344
371
|
color: var(--color-text-primary);
|
|
345
372
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ref, watch, onMounted, onBeforeUnmount, type Ref } from "vue"
|
|
1
|
+
import { ref, watch, nextTick, onMounted, onBeforeUnmount, type Ref } from "vue"
|
|
2
2
|
import { useCore } from "../core"
|
|
3
3
|
import { activeWordRange } from "../utils/wordRange"
|
|
4
4
|
|
|
@@ -84,6 +84,9 @@ export function useFollowPlayback(
|
|
|
84
84
|
// track — symmetric with the isPlaying re-enable above.
|
|
85
85
|
const stopSeekListener = core.on("audio:seek", () => {
|
|
86
86
|
isFollowing.value = true
|
|
87
|
+
// The active word is recomputed on the next flush — scroll after it, so a
|
|
88
|
+
// seek landing on the already-active word still brings it back into view.
|
|
89
|
+
nextTick(scrollToActive)
|
|
87
90
|
})
|
|
88
91
|
|
|
89
92
|
function onManualScroll() {
|
|
@@ -1,26 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
let mql: MediaQueryList | null = null
|
|
10
|
-
|
|
11
|
-
function onChange(e: MediaQueryListEvent) {
|
|
12
|
-
isMobile.value = e.matches
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
onMounted(() => {
|
|
16
|
-
mql = window.matchMedia(MOBILE_BREAKPOINT)
|
|
17
|
-
isMobile.value = mql.matches
|
|
18
|
-
mql.addEventListener('change', onChange)
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
onBeforeUnmount(() => {
|
|
22
|
-
mql?.removeEventListener('change', onChange)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
return { isMobile }
|
|
1
|
+
import type { Ref } from "vue"
|
|
2
|
+
import { useCore } from "../core/useCore"
|
|
3
|
+
|
|
4
|
+
/** The core owns the breakpoint (see core/modules/viewport) — this is the
|
|
5
|
+
* component-side reader, kept as a composable for the call sites that had
|
|
6
|
+
* one, and for plugin panels that switch layout at the same width. */
|
|
7
|
+
export function useIsMobile(): { isMobile: Readonly<Ref<boolean>> } {
|
|
8
|
+
return { isMobile: useCore().isMobile }
|
|
26
9
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CoreCapabilities, SummaryCapabilities } from "./types"
|
|
2
|
+
|
|
3
|
+
/** What the reports offered before capabilities covered them: generate and
|
|
4
|
+
* edit, but no generation of a report that is already up to date. */
|
|
5
|
+
export const DEFAULT_SUMMARY_CAPABILITIES: Readonly<SummaryCapabilities> =
|
|
6
|
+
Object.freeze({ generate: true, edit: true, forceGenerate: false })
|
|
7
|
+
|
|
8
|
+
/** The report permissions, defaults filled in for a host that sets none. */
|
|
9
|
+
export function computeSummaryCapabilities(
|
|
10
|
+
capabilities: CoreCapabilities,
|
|
11
|
+
): Readonly<SummaryCapabilities> {
|
|
12
|
+
return capabilities.summary ?? DEFAULT_SUMMARY_CAPABILITIES
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether the reader may chat. A host that sets nothing keeps the chat. */
|
|
16
|
+
export function computeCanChat(capabilities: CoreCapabilities): boolean {
|
|
17
|
+
return capabilities.chat !== "disabled"
|
|
18
|
+
}
|
package/src/core/createCore.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { ref, computed, shallowReactive, type Component } from "vue"
|
|
1
|
+
import { ref, computed, shallowReactive, watch, type Component } from "vue"
|
|
2
2
|
import type { DownloadFormat } from "@linto-ai/transcript-ui-ui"
|
|
3
3
|
import type { Channel, EditorDocument } from "../types/editor"
|
|
4
4
|
import type {
|
|
5
5
|
Core,
|
|
6
6
|
CoreOptions,
|
|
7
|
+
EditorTheme,
|
|
7
8
|
CoreEventMap,
|
|
8
9
|
CoreCapabilities,
|
|
9
10
|
CorePlugin,
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
UISlot,
|
|
12
13
|
} from "./types"
|
|
13
14
|
import { createEventBus } from "./modules/eventBus"
|
|
15
|
+
import { createViewport } from "./modules/viewport"
|
|
14
16
|
import { createSpeakersStore } from "./stores/speakersStore"
|
|
15
17
|
import { createChannelStore } from "./stores/channelStore"
|
|
16
18
|
import type { ChannelStore } from "./types"
|
|
@@ -18,6 +20,9 @@ import { ensureDocumentSpeakers } from "./helpers/ensureDocumentSpeakers"
|
|
|
18
20
|
import { ensureSpeakersFromTurns } from "./helpers/ensureSpeakersFromTurns"
|
|
19
21
|
import * as utils from "../utils"
|
|
20
22
|
|
|
23
|
+
/** 1.125rem, the --font-size-base the transcript used before it was tunable. */
|
|
24
|
+
const DEFAULT_TRANSCRIPT_FONT_SIZE = 18
|
|
25
|
+
|
|
21
26
|
// No backend can generate this — it's just the turns joined into text — so
|
|
22
27
|
// it's the one format the core always offers and fulfills itself.
|
|
23
28
|
const DEFAULT_VERBATIM_FORMATS: DownloadFormat[] = [
|
|
@@ -49,6 +54,49 @@ export function createCore(options: CoreOptions = {}): Core {
|
|
|
49
54
|
|
|
50
55
|
const { on, off, emit, clear: clearEvents } = createEventBus<CoreEventMap>()
|
|
51
56
|
|
|
57
|
+
// ── Reading ────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
const transcriptFontSize = ref(DEFAULT_TRANSCRIPT_FONT_SIZE)
|
|
60
|
+
const theme = ref<EditorTheme>("light")
|
|
61
|
+
const primaryColor = ref<string | null>(null)
|
|
62
|
+
// Announced like the reading size: the editor applies the theme to its own
|
|
63
|
+
// root, and a host that wants its chrome to follow listens for it.
|
|
64
|
+
const stopThemeSync = watch(theme, (next) =>
|
|
65
|
+
emit("theme:change", { theme: next }),
|
|
66
|
+
)
|
|
67
|
+
// Announced rather than stored: the editor keeps no preferences of its own
|
|
68
|
+
// (it holds no storage at all), so a host that wants this to survive a
|
|
69
|
+
// reload listens and restores it — the same deal as the watermark.
|
|
70
|
+
const stopFontSizeSync = watch(transcriptFontSize, (fontSize) =>
|
|
71
|
+
emit("transcript:fontSize", { fontSize }),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// ── Layout chrome ──────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
// The core owns the media query rather than each component watching its
|
|
77
|
+
// own: a host rendering its own header reads the same value the layout
|
|
78
|
+
// switches on, and there is a single listener to release on destroy.
|
|
79
|
+
const viewport = createViewport(onViewportChange)
|
|
80
|
+
const sidebarOpen = ref(false)
|
|
81
|
+
|
|
82
|
+
// Only the phone-width layout mounts the sidebar drawer, so "open" is
|
|
83
|
+
// refused anywhere else instead of being stored: a host header that isn't
|
|
84
|
+
// width-gated would otherwise leave the drawer armed and drop it on screen,
|
|
85
|
+
// focus trapped, at the next resize below the breakpoint.
|
|
86
|
+
function setSidebarOpen(open: boolean): void {
|
|
87
|
+
if (open && !viewport.isMobile.value) return
|
|
88
|
+
if (open === sidebarOpen.value) return
|
|
89
|
+
sidebarOpen.value = open
|
|
90
|
+
emit("sidebar:open", { open })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function onViewportChange(isMobile: boolean): void {
|
|
94
|
+
emit("viewport:change", { isMobile })
|
|
95
|
+
// Same invariant, the other way round: leaving phone width unmounts the
|
|
96
|
+
// drawer, so the state can't keep claiming it is open.
|
|
97
|
+
if (!isMobile) setSidebarOpen(false)
|
|
98
|
+
}
|
|
99
|
+
|
|
52
100
|
// ── Speakers ───────────────────────────────────────────────────────
|
|
53
101
|
|
|
54
102
|
const speakersInternal = createSpeakersStore(emit)
|
|
@@ -116,8 +164,28 @@ export function createCore(options: CoreOptions = {}): Core {
|
|
|
116
164
|
if (channelId === activeChannelId.value) return
|
|
117
165
|
activeChannelId.value = channelId
|
|
118
166
|
emit("channel:change", { channelId })
|
|
167
|
+
// A channel change restores the track that channel was on, without a
|
|
168
|
+
// translation:change of its own.
|
|
169
|
+
emitActiveTrackChange()
|
|
119
170
|
}
|
|
120
171
|
|
|
172
|
+
// ── Active track ───────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function emitActiveTrackChange(): void {
|
|
175
|
+
const channel = activeChannel.value
|
|
176
|
+
if (!channel) return
|
|
177
|
+
emit("activeTrack:change", {
|
|
178
|
+
channelId: channel.id,
|
|
179
|
+
translationId: channel.activeTranslation.value.id,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A channel store doesn't know whether it is the displayed one: the core
|
|
184
|
+
// relays its translation:change only for the active channel.
|
|
185
|
+
on("translation:change", ({ channelId }) => {
|
|
186
|
+
if (channelId === activeChannel.value?.id) emitActiveTrackChange()
|
|
187
|
+
})
|
|
188
|
+
|
|
121
189
|
function setChannel(channelId: string, channel: Channel): void {
|
|
122
190
|
if (!channels.has(channelId)) return
|
|
123
191
|
|
|
@@ -145,6 +213,9 @@ export function createCore(options: CoreOptions = {}): Core {
|
|
|
145
213
|
cleanups.forEach((fn) => fn())
|
|
146
214
|
cleanups.length = 0
|
|
147
215
|
for (const channel of channels.values()) channel.dispose()
|
|
216
|
+
stopFontSizeSync()
|
|
217
|
+
stopThemeSync()
|
|
218
|
+
viewport.destroy()
|
|
148
219
|
clearEvents()
|
|
149
220
|
}
|
|
150
221
|
|
|
@@ -166,6 +237,12 @@ export function createCore(options: CoreOptions = {}): Core {
|
|
|
166
237
|
speakers,
|
|
167
238
|
channels,
|
|
168
239
|
activeChannel,
|
|
240
|
+
transcriptFontSize,
|
|
241
|
+
theme,
|
|
242
|
+
primaryColor,
|
|
243
|
+
isMobile: viewport.isMobile,
|
|
244
|
+
sidebarOpen,
|
|
245
|
+
setSidebarOpen,
|
|
169
246
|
components,
|
|
170
247
|
onActiveTranslation,
|
|
171
248
|
setDocument,
|
package/src/core/index.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
export { createCore } from './createCore'
|
|
2
2
|
export { provideCore, useCore } from './useCore'
|
|
3
3
|
export { CROSS_TRANSLATION_ID } from './stores'
|
|
4
|
+
export {
|
|
5
|
+
DEFAULT_SUMMARY_CAPABILITIES,
|
|
6
|
+
computeSummaryCapabilities,
|
|
7
|
+
computeCanChat,
|
|
8
|
+
} from './capabilities'
|
|
4
9
|
export type {
|
|
5
10
|
Core,
|
|
6
11
|
CorePlugin,
|
|
7
12
|
CoreEventMap,
|
|
8
13
|
CoreOptions,
|
|
9
14
|
CoreCapabilities,
|
|
15
|
+
SummaryCapabilities,
|
|
10
16
|
TurnEventKey,
|
|
11
17
|
UISlot,
|
|
12
18
|
ReadableTranslation,
|
|
@@ -43,6 +49,7 @@ export type {
|
|
|
43
49
|
LLMServicesPluginApi,
|
|
44
50
|
ChatRole,
|
|
45
51
|
ChatMessage,
|
|
52
|
+
ChatMessageError,
|
|
46
53
|
ChatSession,
|
|
47
54
|
ChatPluginApi,
|
|
48
55
|
} from './types'
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ref, type Ref } from "vue"
|
|
2
|
+
|
|
3
|
+
/** Same width the layout's CSS switches on — keep the two in sync. */
|
|
4
|
+
const MOBILE_BREAKPOINT = "(max-width: 767px)"
|
|
5
|
+
|
|
6
|
+
export interface Viewport {
|
|
7
|
+
readonly isMobile: Readonly<Ref<boolean>>
|
|
8
|
+
destroy(): void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Owns the mobile-width media query for the whole core: one listener for
|
|
12
|
+
* every consumer, released on core.destroy(). `onChange` fires on every
|
|
13
|
+
* crossing of the breakpoint, synchronously — what the core hangs its
|
|
14
|
+
* layout invariants on, rather than a watcher that lands a tick later.
|
|
15
|
+
*
|
|
16
|
+
* Outside a browser (unit tests run without a DOM) there is no viewport to
|
|
17
|
+
* watch, so isMobile stays false rather than the core failing to build. */
|
|
18
|
+
export function createViewport(
|
|
19
|
+
onChange: (isMobile: boolean) => void,
|
|
20
|
+
): Viewport {
|
|
21
|
+
const isMobile = ref(false)
|
|
22
|
+
|
|
23
|
+
if (
|
|
24
|
+
typeof window === "undefined" ||
|
|
25
|
+
typeof window.matchMedia !== "function"
|
|
26
|
+
) {
|
|
27
|
+
return { isMobile, destroy() {} }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Read synchronously, not on mount: the first render must already match
|
|
31
|
+
// the viewport, or a phone briefly mounts desktop-only UI before swapping.
|
|
32
|
+
const query = window.matchMedia(MOBILE_BREAKPOINT)
|
|
33
|
+
isMobile.value = query.matches
|
|
34
|
+
|
|
35
|
+
function onQueryChange(event: MediaQueryListEvent): void {
|
|
36
|
+
isMobile.value = event.matches
|
|
37
|
+
onChange(event.matches)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
query.addEventListener("change", onQueryChange)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
isMobile,
|
|
44
|
+
destroy() {
|
|
45
|
+
query.removeEventListener("change", onQueryChange)
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -65,7 +65,10 @@ export function createChannelStore(
|
|
|
65
65
|
const normalized = translationId === sourceTranslation.id ? null : translationId
|
|
66
66
|
if (normalized === activeTranslationId.value) return
|
|
67
67
|
activeTranslationId.value = normalized
|
|
68
|
-
emit("translation:change", {
|
|
68
|
+
emit("translation:change", {
|
|
69
|
+
channelId: id,
|
|
70
|
+
translationId: activeTranslation.value.id,
|
|
71
|
+
})
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
function reset(): void {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { createTranslationStore } from "../translationStore"
|
|
3
|
+
|
|
4
|
+
function makeStore() {
|
|
5
|
+
return createTranslationStore(
|
|
6
|
+
{ id: "tr-1", languages: ["fr"], isSource: true, turns: [] },
|
|
7
|
+
() => {},
|
|
8
|
+
() => {},
|
|
9
|
+
)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("translationStore — advanceLastModifiedAt", () => {
|
|
13
|
+
it("moves forward only", () => {
|
|
14
|
+
const store = makeStore()
|
|
15
|
+
store.advanceLastModifiedAt(2000)
|
|
16
|
+
store.advanceLastModifiedAt(1000)
|
|
17
|
+
store.advanceLastModifiedAt(2000)
|
|
18
|
+
expect(store.lastModifiedAt.value).toBe(2000)
|
|
19
|
+
|
|
20
|
+
store.advanceLastModifiedAt(3000)
|
|
21
|
+
expect(store.lastModifiedAt.value).toBe(3000)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("ignores null and non-finite timestamps", () => {
|
|
25
|
+
const store = makeStore()
|
|
26
|
+
store.advanceLastModifiedAt(null)
|
|
27
|
+
store.advanceLastModifiedAt(Number.NaN)
|
|
28
|
+
expect(store.lastModifiedAt.value).toBeNull()
|
|
29
|
+
|
|
30
|
+
store.advanceLastModifiedAt(1000)
|
|
31
|
+
store.advanceLastModifiedAt(null)
|
|
32
|
+
store.advanceLastModifiedAt(Number.POSITIVE_INFINITY)
|
|
33
|
+
expect(store.lastModifiedAt.value).toBe(1000)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -28,7 +28,10 @@ export function createTranslationStore(
|
|
|
28
28
|
const turns = shallowRef<Turn[]>(init.turns)
|
|
29
29
|
const lastModifiedAt = ref<number | null>(null)
|
|
30
30
|
|
|
31
|
-
function
|
|
31
|
+
function advanceLastModifiedAt(ts: number | null): void {
|
|
32
|
+
if (ts == null || !Number.isFinite(ts)) return
|
|
33
|
+
const current = lastModifiedAt.value
|
|
34
|
+
if (current != null && ts <= current) return
|
|
32
35
|
lastModifiedAt.value = ts
|
|
33
36
|
}
|
|
34
37
|
|
|
@@ -118,7 +121,7 @@ export function createTranslationStore(
|
|
|
118
121
|
audio,
|
|
119
122
|
turns,
|
|
120
123
|
lastModifiedAt,
|
|
121
|
-
|
|
124
|
+
advanceLastModifiedAt,
|
|
122
125
|
addTurn,
|
|
123
126
|
prependTurns,
|
|
124
127
|
updateTurn,
|