@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.17",
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.17",
43
- "@linto-ai/transcript-ui-ui": "0.9.17",
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 = defineProps<{
9
- modelValue: string
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
- value: VERBATIM_TAB,
29
- label: t("tabs.verbatim"),
30
- icon: "file-text",
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
- <Button
180
- v-if="showResumeButton"
181
- size="sm"
182
- icon="arrow-down"
183
- class="resume-scroll-btn"
184
- :aria-label="t('transcription.resumeScroll')"
185
- @click="onResumeClick">
186
- {{ t("transcription.resumeScroll") }}
187
- </Button>
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
- .resume-scroll-btn {
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: -50% 8px;
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 }