@linto-ai/transcript-ui-core 0.9.17 → 0.9.19
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linto-ai/transcript-ui-core",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.19",
|
|
4
4
|
"description": "State layer and UI shell for @linto-ai/transcript-ui — core, stores, format adapters, and the TranscriptUI component",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vue",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"vue": "^3.5.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@linto-ai/transcript-ui-i18n": "0.9.
|
|
43
|
-
"@linto-ai/transcript-ui-ui": "0.9.
|
|
42
|
+
"@linto-ai/transcript-ui-i18n": "0.9.19",
|
|
43
|
+
"@linto-ai/transcript-ui-ui": "0.9.19",
|
|
44
44
|
"vue-stick-to-bottom": "^1.0.0"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -17,9 +17,11 @@ import { useCore, type LLMService } from "../core"
|
|
|
17
17
|
const props = withDefaults(
|
|
18
18
|
defineProps<{
|
|
19
19
|
showHeader?: boolean
|
|
20
|
+
showVerbatim?: boolean
|
|
20
21
|
}>(),
|
|
21
22
|
{
|
|
22
23
|
showHeader: true,
|
|
24
|
+
showVerbatim: true,
|
|
23
25
|
},
|
|
24
26
|
)
|
|
25
27
|
|
|
@@ -31,9 +33,13 @@ const shownPanels = ref<string[]>([TRANSCRIPTION_TAB])
|
|
|
31
33
|
// The ?? never actually triggers — every shownPanels mutation keeps at least one entry — it's just satisfying noUncheckedIndexedAccess.
|
|
32
34
|
const activeTab = computed(() => shownPanels.value[0] ?? TRANSCRIPTION_TAB)
|
|
33
35
|
|
|
36
|
+
// Guarded by showVerbatim: split shows verbatim alongside a service, so it
|
|
37
|
+
// makes no sense (and shouldn't be reachable) wherever verbatim itself is
|
|
38
|
+
// turned off.
|
|
34
39
|
const isSplit = computed({
|
|
35
|
-
get: () => shownPanels.value.length > 1,
|
|
40
|
+
get: () => props.showVerbatim && shownPanels.value.length > 1,
|
|
36
41
|
set: (value: boolean) => {
|
|
42
|
+
if (!props.showVerbatim) return
|
|
37
43
|
shownPanels.value = value
|
|
38
44
|
? [...shownPanels.value, VERBATIM_TAB]
|
|
39
45
|
: shownPanels.value.filter((id) => id !== VERBATIM_TAB)
|
|
@@ -103,6 +109,19 @@ watch(
|
|
|
103
109
|
},
|
|
104
110
|
)
|
|
105
111
|
|
|
112
|
+
// showVerbatim is a prop, not user-driven state, so this fires rarely (a
|
|
113
|
+
// host re-rendering with a different noVerbatim) — but if it flips to false
|
|
114
|
+
// while verbatim is showing (primary or split), drop it rather than leave
|
|
115
|
+
// a panel the host just said it doesn't want reachable.
|
|
116
|
+
watch(
|
|
117
|
+
() => props.showVerbatim,
|
|
118
|
+
(canShow) => {
|
|
119
|
+
if (canShow) return
|
|
120
|
+
const withoutVerbatim = shownPanels.value.filter((id) => id !== VERBATIM_TAB)
|
|
121
|
+
shownPanels.value = withoutVerbatim.length > 0 ? withoutVerbatim : [TRANSCRIPTION_TAB]
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
|
|
106
125
|
watch(
|
|
107
126
|
() => core.activeChannelId.value,
|
|
108
127
|
() => {
|
|
@@ -146,6 +165,7 @@ function onTranslationChange(translationId: string) {
|
|
|
146
165
|
@redo="core.transcriptionEditor?.redo()" />
|
|
147
166
|
<TabBar
|
|
148
167
|
:model-value="activeTab"
|
|
168
|
+
:show-verbatim="props.showVerbatim"
|
|
149
169
|
@update:model-value="(tab) => (shownPanels = [tab])" />
|
|
150
170
|
<SelectionActionBar v-if="showTranscription" />
|
|
151
171
|
<main
|
|
@@ -5,9 +5,15 @@ import { TRANSCRIPTION_TAB, VERBATIM_TAB } from "./TabBar.constants"
|
|
|
5
5
|
import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
6
6
|
import { useCore } from "../core"
|
|
7
7
|
|
|
8
|
-
const props =
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const props = withDefaults(
|
|
9
|
+
defineProps<{
|
|
10
|
+
modelValue: string
|
|
11
|
+
showVerbatim?: boolean
|
|
12
|
+
}>(),
|
|
13
|
+
{
|
|
14
|
+
showVerbatim: true,
|
|
15
|
+
},
|
|
16
|
+
)
|
|
11
17
|
|
|
12
18
|
const emit = defineEmits<{
|
|
13
19
|
"update:modelValue": [value: string]
|
|
@@ -24,11 +30,15 @@ const tabs = computed<TabItem[]>(() => {
|
|
|
24
30
|
label: t("tabs.transcription"),
|
|
25
31
|
icon: "message-circle",
|
|
26
32
|
},
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
...(props.showVerbatim
|
|
34
|
+
? [
|
|
35
|
+
{
|
|
36
|
+
value: VERBATIM_TAB,
|
|
37
|
+
label: t("tabs.verbatim"),
|
|
38
|
+
icon: "file-text",
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
: []),
|
|
32
42
|
...services.map<TabItem>((service) => ({
|
|
33
43
|
value: service.id,
|
|
34
44
|
label: service.label.value,
|
|
@@ -44,7 +54,10 @@ function onSelect(value: string): void {
|
|
|
44
54
|
</script>
|
|
45
55
|
|
|
46
56
|
<template>
|
|
57
|
+
<!-- Nothing to switch between with a single tab (e.g. a live session with
|
|
58
|
+
verbatim hidden and no summaries) — no point showing the bar. -->
|
|
47
59
|
<Tabs
|
|
60
|
+
v-if="tabs.length > 1"
|
|
48
61
|
:tabs="tabs"
|
|
49
62
|
:model-value="modelValue"
|
|
50
63
|
@update:model-value="onSelect" />
|
|
@@ -12,10 +12,15 @@ const props = withDefaults(
|
|
|
12
12
|
defineProps<{
|
|
13
13
|
locale?: string
|
|
14
14
|
noHeader?: boolean
|
|
15
|
+
// Hides the Verbatim tab (and the tab bar entirely once that's the only
|
|
16
|
+
// tab left — see TabBar.vue) — a live session has no finished verbatim
|
|
17
|
+
// to show, see SessionLiveNG.vue.
|
|
18
|
+
noVerbatim?: boolean
|
|
15
19
|
}>(),
|
|
16
20
|
{
|
|
17
21
|
locale: "fr",
|
|
18
22
|
noHeader: false,
|
|
23
|
+
noVerbatim: false,
|
|
19
24
|
},
|
|
20
25
|
)
|
|
21
26
|
|
|
@@ -48,7 +53,8 @@ defineExpose({ core })
|
|
|
48
53
|
<div class="transcript-ui-root">
|
|
49
54
|
<Layout
|
|
50
55
|
v-if="core.channels.size"
|
|
51
|
-
:show-header="!props.noHeader"
|
|
56
|
+
:show-header="!props.noHeader"
|
|
57
|
+
:show-verbatim="!props.noVerbatim" />
|
|
52
58
|
<EditorErrorOverlay v-if="error" :message="error" />
|
|
53
59
|
<EditorLoadingOverlay v-else-if="isLoading" />
|
|
54
60
|
</div>
|
|
@@ -176,15 +176,16 @@ onBeforeUnmount(() => {
|
|
|
176
176
|
</div>
|
|
177
177
|
|
|
178
178
|
<Transition name="fade-slide">
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
179
|
+
<div v-if="showResumeButton" class="resume-scroll-anchor">
|
|
180
|
+
<Button
|
|
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
|
+
</div>
|
|
188
189
|
</Transition>
|
|
189
190
|
</div>
|
|
190
191
|
</article>
|
|
@@ -231,13 +232,22 @@ onBeforeUnmount(() => {
|
|
|
231
232
|
font-size: var(--font-size-sm);
|
|
232
233
|
}
|
|
233
234
|
|
|
234
|
-
/* Resume scroll button
|
|
235
|
-
.
|
|
235
|
+
/* Resume scroll button — placement lives on this wrapper, never on the
|
|
236
|
+
button itself: Button.vue's `.editor-btn` base rule resets `position` via
|
|
237
|
+
`all: unset` (and, in the webcomponent build, gets re-injected a second
|
|
238
|
+
time after everything else to reach teleported popovers/dialogs — see
|
|
239
|
+
packages/webcomponent/src/index.ts), so any positioning put directly on
|
|
240
|
+
the button is one rebuild away from being silently overridden again. */
|
|
241
|
+
.resume-scroll-anchor {
|
|
236
242
|
position: sticky;
|
|
237
243
|
bottom: var(--spacing-lg);
|
|
238
|
-
left: 50%;
|
|
239
|
-
translate: -50% 0;
|
|
240
244
|
z-index: var(--z-sticky);
|
|
245
|
+
display: flex;
|
|
246
|
+
flex-direction: column;
|
|
247
|
+
align-items: center;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.resume-scroll-btn {
|
|
241
251
|
/* No backdrop-filter: this button is sticky inside the tall scroll
|
|
242
252
|
container, where a backdrop-filter makes WebRender allocate a render
|
|
243
253
|
target spanning the whole scroll height — multi-GB on a long transcript. */
|
|
@@ -257,7 +267,7 @@ onBeforeUnmount(() => {
|
|
|
257
267
|
.fade-slide-enter-from,
|
|
258
268
|
.fade-slide-leave-to {
|
|
259
269
|
opacity: 0;
|
|
260
|
-
translate:
|
|
270
|
+
translate: 0 8px;
|
|
261
271
|
}
|
|
262
272
|
|
|
263
273
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -79,6 +79,13 @@ export function useFollowPlayback(
|
|
|
79
79
|
},
|
|
80
80
|
)
|
|
81
81
|
|
|
82
|
+
// Re-enable follow on any seek (widget scrub or click-to-read): the user
|
|
83
|
+
// just told the player where to be, which is exactly what follow means to
|
|
84
|
+
// track — symmetric with the isPlaying re-enable above.
|
|
85
|
+
const stopSeekListener = core.on("audio:seek", () => {
|
|
86
|
+
isFollowing.value = true
|
|
87
|
+
})
|
|
88
|
+
|
|
82
89
|
function onManualScroll() {
|
|
83
90
|
isFollowing.value = false
|
|
84
91
|
}
|
|
@@ -89,12 +96,14 @@ export function useFollowPlayback(
|
|
|
89
96
|
}
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
// wheel/touchstart/keydown are real scroll gestures — a bare pointerdown
|
|
100
|
+
// isn't (clicking a word, a button, anything in the panel triggers it
|
|
101
|
+
// without ever moving the scroll position), so it's deliberately excluded.
|
|
92
102
|
function setupScrollListener(handler: (e: Event) => void) {
|
|
93
103
|
const el = scrollContainer.value
|
|
94
104
|
if (!el) return
|
|
95
105
|
el.addEventListener("wheel", handler, { passive: true })
|
|
96
106
|
el.addEventListener("touchstart", handler, { passive: true })
|
|
97
|
-
el.addEventListener("pointerdown", handler, { passive: true })
|
|
98
107
|
el.addEventListener("keydown", checkKeyDownIsScroll)
|
|
99
108
|
}
|
|
100
109
|
|
|
@@ -103,7 +112,6 @@ export function useFollowPlayback(
|
|
|
103
112
|
if (!el) return
|
|
104
113
|
el.removeEventListener("wheel", handler)
|
|
105
114
|
el.removeEventListener("touchstart", handler)
|
|
106
|
-
el.removeEventListener("pointerdown", handler)
|
|
107
115
|
el.removeEventListener("keydown", checkKeyDownIsScroll)
|
|
108
116
|
}
|
|
109
117
|
|
|
@@ -113,6 +121,7 @@ export function useFollowPlayback(
|
|
|
113
121
|
|
|
114
122
|
onBeforeUnmount(() => {
|
|
115
123
|
downScrollListener(onManualScroll)
|
|
124
|
+
stopSeekListener()
|
|
116
125
|
})
|
|
117
126
|
|
|
118
127
|
function resumeFollow() {
|
package/src/core/types.ts
CHANGED
|
@@ -30,6 +30,10 @@ export interface CoreEventMap {
|
|
|
30
30
|
"speaker:add": { speaker: Speaker }
|
|
31
31
|
"speaker:remove": { speakerId: string }
|
|
32
32
|
"scroll:top": { translationId: string }
|
|
33
|
+
/** A seek was requested through core.audio.seekTo (widget scrub or
|
|
34
|
+
* click-to-read) — the explicit "take me to this instant" gesture that
|
|
35
|
+
* should resume follow-playback scrolling. */
|
|
36
|
+
"audio:seek": { time: number }
|
|
33
37
|
"translation:sync": { translationId: string }
|
|
34
38
|
"channel:sync": { channelId: string }
|
|
35
39
|
"channel:reset": { channelId: string }
|