@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,124 @@
|
|
|
1
|
+
import { ref, watch, onMounted, onBeforeUnmount, type Ref } from "vue"
|
|
2
|
+
import { useCore } from "../core"
|
|
3
|
+
import { activeWordRange } from "../utils/wordRange"
|
|
4
|
+
|
|
5
|
+
const SCROLL_KEYS = new Set([
|
|
6
|
+
"ArrowUp",
|
|
7
|
+
"ArrowDown",
|
|
8
|
+
"PageUp",
|
|
9
|
+
"PageDown",
|
|
10
|
+
"Home",
|
|
11
|
+
"End",
|
|
12
|
+
" ", // Space
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
export function useFollowPlayback(
|
|
16
|
+
scrollContainer: Readonly<Ref<HTMLElement | null | undefined>>,
|
|
17
|
+
) {
|
|
18
|
+
const core = useCore()
|
|
19
|
+
const isFollowing = ref(true)
|
|
20
|
+
const prefersReducedMotion = window.matchMedia(
|
|
21
|
+
"(prefers-reduced-motion: reduce)",
|
|
22
|
+
).matches
|
|
23
|
+
|
|
24
|
+
function scrollToActive() {
|
|
25
|
+
const container = scrollContainer.value
|
|
26
|
+
if (!container || !isFollowing.value) return
|
|
27
|
+
|
|
28
|
+
const behavior: ScrollBehavior = prefersReducedMotion ? "instant" : "smooth"
|
|
29
|
+
|
|
30
|
+
// Editor: the active word is located as a character Range (the plain-text
|
|
31
|
+
// doc has no per-word element) — center its rect in the container.
|
|
32
|
+
const wordId = core.audio?.activeWordId.value
|
|
33
|
+
if (wordId) {
|
|
34
|
+
const range = activeWordRange(container, core, wordId)
|
|
35
|
+
const rect = range?.getBoundingClientRect()
|
|
36
|
+
if (rect && (rect.height > 0 || rect.width > 0)) {
|
|
37
|
+
const containerRect = container.getBoundingClientRect()
|
|
38
|
+
const delta =
|
|
39
|
+
rect.top + rect.height / 2 - (containerRect.top + container.clientHeight / 2)
|
|
40
|
+
container.scrollBy({ top: delta, behavior })
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const turnId = core.audio?.activeTurnId.value
|
|
46
|
+
const target =
|
|
47
|
+
// Non-editor list view still tags the active word this way.
|
|
48
|
+
container.querySelector<HTMLElement>("[data-word-active]") ??
|
|
49
|
+
(turnId
|
|
50
|
+
? container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`)
|
|
51
|
+
: null)
|
|
52
|
+
if (!target) return
|
|
53
|
+
target.scrollIntoView({ behavior, block: "center" })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Follow the active word (works with the editor + word timestamps)
|
|
57
|
+
watch(
|
|
58
|
+
() => core.audio?.activeWordId.value,
|
|
59
|
+
(id) => {
|
|
60
|
+
if (id) scrollToActive()
|
|
61
|
+
},
|
|
62
|
+
{ flush: "post" },
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// Fallback: at least follow the turn (no editor or no word timestamps)
|
|
66
|
+
watch(
|
|
67
|
+
() => core.audio?.activeTurnId.value,
|
|
68
|
+
(id) => {
|
|
69
|
+
if (id) scrollToActive()
|
|
70
|
+
},
|
|
71
|
+
{ flush: "post" },
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// Re-enable follow when playback starts
|
|
75
|
+
watch(
|
|
76
|
+
() => core.audio?.isPlaying.value,
|
|
77
|
+
(playing) => {
|
|
78
|
+
if (playing) isFollowing.value = true
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
function onManualScroll() {
|
|
83
|
+
isFollowing.value = false
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function checkKeyDownIsScroll(e: KeyboardEvent) {
|
|
87
|
+
if (SCROLL_KEYS.has(e.key)) {
|
|
88
|
+
onManualScroll()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setupScrollListener(handler: (e: Event) => void) {
|
|
93
|
+
const el = scrollContainer.value
|
|
94
|
+
if (!el) return
|
|
95
|
+
el.addEventListener("wheel", handler, { passive: true })
|
|
96
|
+
el.addEventListener("touchstart", handler, { passive: true })
|
|
97
|
+
el.addEventListener("pointerdown", handler, { passive: true })
|
|
98
|
+
el.addEventListener("keydown", checkKeyDownIsScroll)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function downScrollListener(handler: (e: Event) => void) {
|
|
102
|
+
const el = scrollContainer.value
|
|
103
|
+
if (!el) return
|
|
104
|
+
el.removeEventListener("wheel", handler)
|
|
105
|
+
el.removeEventListener("touchstart", handler)
|
|
106
|
+
el.removeEventListener("pointerdown", handler)
|
|
107
|
+
el.removeEventListener("keydown", checkKeyDownIsScroll)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
onMounted(() => {
|
|
111
|
+
setupScrollListener(onManualScroll)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
onBeforeUnmount(() => {
|
|
115
|
+
downScrollListener(onManualScroll)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
function resumeFollow() {
|
|
119
|
+
isFollowing.value = true
|
|
120
|
+
scrollToActive()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { isFollowing, resumeFollow }
|
|
124
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = '(max-width: 767px)'
|
|
4
|
+
|
|
5
|
+
export function useIsMobile() {
|
|
6
|
+
const isMobile = ref(false)
|
|
7
|
+
let mql: MediaQueryList | null = null
|
|
8
|
+
|
|
9
|
+
function onChange(e: MediaQueryListEvent) {
|
|
10
|
+
isMobile.value = e.matches
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
onMounted(() => {
|
|
14
|
+
mql = window.matchMedia(MOBILE_BREAKPOINT)
|
|
15
|
+
isMobile.value = mql.matches
|
|
16
|
+
mql.addEventListener('change', onChange)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
onBeforeUnmount(() => {
|
|
20
|
+
mql?.removeEventListener('change', onChange)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
return { isMobile }
|
|
24
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
shallowReactive,
|
|
3
|
+
computed,
|
|
4
|
+
watch,
|
|
5
|
+
onMounted,
|
|
6
|
+
onBeforeUnmount,
|
|
7
|
+
provide,
|
|
8
|
+
inject,
|
|
9
|
+
type Ref,
|
|
10
|
+
type ComputedRef,
|
|
11
|
+
type InjectionKey,
|
|
12
|
+
} from "vue"
|
|
13
|
+
import type { Turn, Speaker } from "../types/editor"
|
|
14
|
+
import type { Core } from "../core/types"
|
|
15
|
+
import { formatTime } from "../utils/time"
|
|
16
|
+
|
|
17
|
+
export interface TurnSelection {
|
|
18
|
+
readonly count: ComputedRef<number>
|
|
19
|
+
readonly hasSelection: ComputedRef<boolean>
|
|
20
|
+
isSelected(turnId: string): boolean
|
|
21
|
+
toggle(turnId: string): void
|
|
22
|
+
selectRange(turnId: string): void
|
|
23
|
+
clear(): void
|
|
24
|
+
copyText(): Promise<void>
|
|
25
|
+
copyWithMetadata(): Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const turnSelectionKey: InjectionKey<TurnSelection> = Symbol("turnSelection")
|
|
29
|
+
|
|
30
|
+
function getTurnText(turn: Turn): string {
|
|
31
|
+
if (turn.words.length > 0) {
|
|
32
|
+
return turn.words.map((w) => w.text).join(" ")
|
|
33
|
+
}
|
|
34
|
+
return turn.text ?? ""
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function provideTurnSelection(
|
|
38
|
+
turns: Ref<Turn[]> | ComputedRef<Turn[]>,
|
|
39
|
+
speakers: Map<string, Speaker>,
|
|
40
|
+
core: Core,
|
|
41
|
+
): TurnSelection {
|
|
42
|
+
const selectedIds = shallowReactive(new Map<string, true>())
|
|
43
|
+
let lastToggledId: string | null = null
|
|
44
|
+
|
|
45
|
+
const count = computed(() => selectedIds.size)
|
|
46
|
+
const hasSelection = computed(() => selectedIds.size > 0)
|
|
47
|
+
|
|
48
|
+
function isSelected(turnId: string): boolean {
|
|
49
|
+
return selectedIds.has(turnId)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Selection mode (multi-turn copy) and the editor don't coexist: with the
|
|
53
|
+
// transcriptionEditor plugin loaded, clicks belong to editing (text) and
|
|
54
|
+
// speaker assignment (header) — selection never starts.
|
|
55
|
+
function isSelectionDisabled(): boolean {
|
|
56
|
+
return core.transcriptionEditor !== undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toggle(turnId: string) {
|
|
60
|
+
if (isSelectionDisabled()) return
|
|
61
|
+
if (selectedIds.has(turnId)) {
|
|
62
|
+
selectedIds.delete(turnId)
|
|
63
|
+
} else {
|
|
64
|
+
selectedIds.set(turnId, true)
|
|
65
|
+
}
|
|
66
|
+
lastToggledId = turnId
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function selectRange(turnId: string) {
|
|
70
|
+
if (isSelectionDisabled()) return
|
|
71
|
+
if (lastToggledId === null) {
|
|
72
|
+
toggle(turnId)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
const ids = turns.value.map((t) => t.id)
|
|
76
|
+
const fromIndex = ids.indexOf(lastToggledId)
|
|
77
|
+
const toIndex = ids.indexOf(turnId)
|
|
78
|
+
if (fromIndex === -1 || toIndex === -1) {
|
|
79
|
+
toggle(turnId)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const start = Math.min(fromIndex, toIndex)
|
|
83
|
+
const end = Math.max(fromIndex, toIndex)
|
|
84
|
+
for (let i = start; i <= end; i++) {
|
|
85
|
+
const id = ids[i]
|
|
86
|
+
if (id != null) selectedIds.set(id, true)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function clear() {
|
|
91
|
+
selectedIds.clear()
|
|
92
|
+
lastToggledId = null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function copyText(): Promise<void> {
|
|
96
|
+
const selected = turns.value.filter((t) => selectedIds.has(t.id))
|
|
97
|
+
const text = selected.map(getTurnText).join("\n\n")
|
|
98
|
+
await navigator.clipboard.writeText(text)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function copyWithMetadata(): Promise<void> {
|
|
102
|
+
const selected = turns.value.filter((t) => selectedIds.has(t.id))
|
|
103
|
+
const blocks = selected.map((turn) => {
|
|
104
|
+
const speaker = turn.speakerId
|
|
105
|
+
? speakers.get(turn.speakerId)
|
|
106
|
+
: undefined
|
|
107
|
+
const name = speaker?.name ?? ""
|
|
108
|
+
const time = turn.startTime != null ? formatTime(turn.startTime) : ""
|
|
109
|
+
const header = [name, time].filter(Boolean).join(" (") + (time ? ")" : "")
|
|
110
|
+
const text = getTurnText(turn)
|
|
111
|
+
return header ? `${header}\n${text}` : text
|
|
112
|
+
})
|
|
113
|
+
await navigator.clipboard.writeText(blocks.join("\n\n"))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Prune stale IDs when turns change
|
|
117
|
+
watch(
|
|
118
|
+
() => turns.value,
|
|
119
|
+
(currentTurns) => {
|
|
120
|
+
if (selectedIds.size === 0) return
|
|
121
|
+
const currentIds = new Set(currentTurns.map((t) => t.id))
|
|
122
|
+
for (const id of [...selectedIds.keys()]) {
|
|
123
|
+
if (!currentIds.has(id)) selectedIds.delete(id)
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
// Clear selection on channel/translation change
|
|
129
|
+
const unsubChannel = core.on("channel:change", clear)
|
|
130
|
+
const unsubTranslation = core.on("translation:change", clear)
|
|
131
|
+
|
|
132
|
+
// Escape to clear
|
|
133
|
+
function onKeydown(e: KeyboardEvent) {
|
|
134
|
+
if (e.key === "Escape" && selectedIds.size > 0) {
|
|
135
|
+
clear()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
onMounted(() => {
|
|
140
|
+
document.addEventListener("keydown", onKeydown)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
onBeforeUnmount(() => {
|
|
144
|
+
document.removeEventListener("keydown", onKeydown)
|
|
145
|
+
unsubChannel()
|
|
146
|
+
unsubTranslation()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const selection: TurnSelection = {
|
|
150
|
+
count,
|
|
151
|
+
hasSelection,
|
|
152
|
+
isSelected,
|
|
153
|
+
toggle,
|
|
154
|
+
selectRange,
|
|
155
|
+
clear,
|
|
156
|
+
copyText,
|
|
157
|
+
copyWithMetadata,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
provide(turnSelectionKey, selection)
|
|
161
|
+
return selection
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function useTurnSelection(): TurnSelection {
|
|
165
|
+
const sel = inject(turnSelectionKey)
|
|
166
|
+
if (!sel)
|
|
167
|
+
throw new Error("useTurnSelection() requires provideTurnSelection()")
|
|
168
|
+
return sel
|
|
169
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Tableau 10 palette for speakers — single source of truth.
|
|
2
|
+
* 10 hues ordered for max contrast between consecutive speakers. */
|
|
3
|
+
export const SPEAKER_COLORS = [
|
|
4
|
+
"#4E79A7", // bleu
|
|
5
|
+
"#F28E2B", // orange
|
|
6
|
+
"#59A14F", // vert
|
|
7
|
+
"#E15759", // rouge
|
|
8
|
+
"#B07AA1", // violet
|
|
9
|
+
"#76B7B2", // turquoise
|
|
10
|
+
"#FF9DA7", // rose
|
|
11
|
+
"#9C755F", // brun
|
|
12
|
+
"#BAB0AC", // gris chaud
|
|
13
|
+
"#4E4CD2", // indigo
|
|
14
|
+
] as const
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { ref, computed, shallowReactive, type Component } from "vue"
|
|
2
|
+
import type { Channel, EditorDocument } from "../types/editor"
|
|
3
|
+
import type {
|
|
4
|
+
Core,
|
|
5
|
+
CoreOptions,
|
|
6
|
+
CoreEventMap,
|
|
7
|
+
CoreCapabilities,
|
|
8
|
+
CorePlugin,
|
|
9
|
+
TurnEventKey,
|
|
10
|
+
UISlot,
|
|
11
|
+
} from "./types"
|
|
12
|
+
import { createEventBus } from "./modules/eventBus"
|
|
13
|
+
import { createSpeakersStore } from "./stores/speakersStore"
|
|
14
|
+
import { createChannelStore } from "./stores/channelStore"
|
|
15
|
+
import type { ChannelStore } from "./types"
|
|
16
|
+
import { ensureDocumentSpeakers } from "./helpers/ensureDocumentSpeakers"
|
|
17
|
+
import { ensureSpeakersFromTurns } from "./helpers/ensureSpeakersFromTurns"
|
|
18
|
+
import * as utils from "../utils"
|
|
19
|
+
|
|
20
|
+
export function createCore(options: CoreOptions = {}): Core {
|
|
21
|
+
// ── State ──────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const title = ref("")
|
|
24
|
+
const date = ref<string | number | null>(null)
|
|
25
|
+
const activeChannelId = ref(options.activeChannelId ?? "")
|
|
26
|
+
const capabilities = ref<CoreCapabilities>(
|
|
27
|
+
options.capabilities ?? { text: "edit", speakers: "edit" },
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// ── Event bus ──────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const { on, off, emit, clear: clearEvents } = createEventBus<CoreEventMap>()
|
|
33
|
+
|
|
34
|
+
// ── Speakers ───────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const speakersInternal = createSpeakersStore(emit)
|
|
37
|
+
const speakers = speakersInternal
|
|
38
|
+
|
|
39
|
+
// ── Channels ───────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const channels = shallowReactive(new Map<string, ChannelStore>())
|
|
42
|
+
|
|
43
|
+
// ── Plugin UI ──────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const components = shallowReactive<Partial<Record<UISlot, Component>>>({})
|
|
46
|
+
|
|
47
|
+
const activeChannel = computed<ChannelStore | undefined>(() =>
|
|
48
|
+
channels.get(activeChannelId.value) ?? [...channels.values()][0],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// ── Scoped events ─────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function onActiveTranslation<K extends TurnEventKey>(
|
|
54
|
+
event: K,
|
|
55
|
+
handler: (payload: CoreEventMap[K]) => void,
|
|
56
|
+
): () => void {
|
|
57
|
+
return on(event, (payload) => {
|
|
58
|
+
const channel = activeChannel.value
|
|
59
|
+
if (channel && payload.translationId === channel.activeTranslation.value.id) {
|
|
60
|
+
handler(payload)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Build from document ────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function buildFromDocument(doc: EditorDocument): void {
|
|
68
|
+
title.value = doc.title
|
|
69
|
+
date.value = doc.date ?? null
|
|
70
|
+
speakersInternal.clear()
|
|
71
|
+
for (const channel of channels.values()) channel.dispose()
|
|
72
|
+
channels.clear()
|
|
73
|
+
|
|
74
|
+
for (const spkRef of ensureDocumentSpeakers(doc)) {
|
|
75
|
+
speakers.ensure(spkRef.id, spkRef.name)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const ch of doc.channels) {
|
|
79
|
+
channels.set(ch.id, createChannelStore(ch, emit, on, speakers.ensure))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (channels.size > 0 && !channels.has(activeChannelId.value)) {
|
|
83
|
+
activeChannelId.value = channels.keys().next().value!
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Document ───────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function setDocument(doc: EditorDocument): void {
|
|
90
|
+
utils.validateEditorDocument(doc)
|
|
91
|
+
buildFromDocument(doc)
|
|
92
|
+
emit("document:change", undefined as never)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Channel ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function setActiveChannel(channelId: string): void {
|
|
98
|
+
if (channelId === activeChannelId.value) return
|
|
99
|
+
activeChannelId.value = channelId
|
|
100
|
+
emit("channel:change", { channelId })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function setChannel(channelId: string, channel: Channel): void {
|
|
104
|
+
if (!channels.has(channelId)) return
|
|
105
|
+
|
|
106
|
+
for (const translation of channel.translations) {
|
|
107
|
+
ensureSpeakersFromTurns(translation.turns, speakers.ensure)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
channels.get(channelId)?.dispose()
|
|
111
|
+
channels.set(channelId, createChannelStore(channel, emit, on, speakers.ensure))
|
|
112
|
+
emit("channel:sync", { channelId })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Plugins ────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const cleanups: Array<() => void> = []
|
|
118
|
+
|
|
119
|
+
function use(plugin: CorePlugin): void {
|
|
120
|
+
if (plugin.components) Object.assign(components, plugin.components)
|
|
121
|
+
const cleanup = plugin.install(core)
|
|
122
|
+
if (cleanup) cleanups.push(cleanup)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function destroy(): void {
|
|
126
|
+
emit("destroy", undefined as never)
|
|
127
|
+
cleanups.forEach((fn) => fn())
|
|
128
|
+
cleanups.length = 0
|
|
129
|
+
for (const channel of channels.values()) channel.dispose()
|
|
130
|
+
clearEvents()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Initial document ──────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
if (options.document) {
|
|
136
|
+
buildFromDocument(options.document)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Assemble ──────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
const core: Core = {
|
|
142
|
+
title,
|
|
143
|
+
date,
|
|
144
|
+
activeChannelId,
|
|
145
|
+
capabilities,
|
|
146
|
+
speakers,
|
|
147
|
+
channels,
|
|
148
|
+
activeChannel,
|
|
149
|
+
components,
|
|
150
|
+
onActiveTranslation,
|
|
151
|
+
setDocument,
|
|
152
|
+
setActiveChannel,
|
|
153
|
+
setChannel,
|
|
154
|
+
on,
|
|
155
|
+
off,
|
|
156
|
+
emit,
|
|
157
|
+
use,
|
|
158
|
+
destroy,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return core
|
|
162
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Speaker } from "../../types/editor"
|
|
2
|
+
import { SPEAKER_COLORS } from "../../constants/speakers"
|
|
3
|
+
|
|
4
|
+
export function addSpeaker(
|
|
5
|
+
speakers: Map<string, Speaker>,
|
|
6
|
+
id: string,
|
|
7
|
+
name: string,
|
|
8
|
+
): Speaker {
|
|
9
|
+
const color = SPEAKER_COLORS[speakers.size % SPEAKER_COLORS.length]!
|
|
10
|
+
return { id, name, color }
|
|
11
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Core } from "../types"
|
|
2
|
+
|
|
3
|
+
/** Number of turns assigned to a speaker in the active translation — what the
|
|
4
|
+
* user currently sees (merge dialog, speaker menus). */
|
|
5
|
+
export function countTurnsForSpeaker(core: Core, speakerId: string): number {
|
|
6
|
+
const translation = core.activeChannel.value?.activeTranslation.value
|
|
7
|
+
if (!translation) return 0
|
|
8
|
+
return translation.turns.value.filter((t) => t.speakerId === speakerId).length
|
|
9
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Core } from "../types"
|
|
2
|
+
import { addSpeaker } from "./addSpeaker"
|
|
3
|
+
import { switchTurnSpeaker } from "./switchTurnSpeaker"
|
|
4
|
+
|
|
5
|
+
export function createSpeakerAndAssign(
|
|
6
|
+
core: Core,
|
|
7
|
+
turnId: string,
|
|
8
|
+
name: string,
|
|
9
|
+
): string | null {
|
|
10
|
+
const trimmed = name.trim()
|
|
11
|
+
if (!trimmed) return null
|
|
12
|
+
const speaker = addSpeaker(core.speakers.all, crypto.randomUUID(), trimmed)
|
|
13
|
+
core.speakers.updateOrCreate(speaker)
|
|
14
|
+
switchTurnSpeaker(core, turnId, speaker.id)
|
|
15
|
+
return speaker.id
|
|
16
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { EditorDocument } from "../../types/editor"
|
|
2
|
+
|
|
3
|
+
export function ensureDocumentSpeakers(
|
|
4
|
+
doc: EditorDocument,
|
|
5
|
+
): { id: string; name: string }[] {
|
|
6
|
+
const seen = new Set<string>()
|
|
7
|
+
const refs: { id: string; name: string }[] = []
|
|
8
|
+
|
|
9
|
+
for (const [id, speaker] of doc.speakers) {
|
|
10
|
+
seen.add(id)
|
|
11
|
+
refs.push({ id, name: speaker.name })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
for (const channel of doc.channels) {
|
|
15
|
+
for (const translation of channel.translations) {
|
|
16
|
+
for (const turn of translation.turns) {
|
|
17
|
+
if (turn.speakerId && !seen.has(turn.speakerId)) {
|
|
18
|
+
seen.add(turn.speakerId)
|
|
19
|
+
refs.push({ id: turn.speakerId, name: turn.speakerId })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return refs
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Speaker } from "../../types/editor"
|
|
2
|
+
import { addSpeaker } from "./addSpeaker"
|
|
3
|
+
|
|
4
|
+
export function ensureSpeaker(
|
|
5
|
+
speakers: Map<string, Speaker>,
|
|
6
|
+
speakerId: string | null,
|
|
7
|
+
name?: string,
|
|
8
|
+
): Speaker | null {
|
|
9
|
+
if (!speakerId) return null
|
|
10
|
+
if (speakers.has(speakerId)) return null
|
|
11
|
+
return addSpeaker(speakers, speakerId, name ?? speakerId)
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Turn } from "../../types/editor"
|
|
2
|
+
|
|
3
|
+
export function ensureSpeakersFromTurns(
|
|
4
|
+
turns: Turn[],
|
|
5
|
+
ensure: (speakerId: string | null, name?: string) => void,
|
|
6
|
+
): void {
|
|
7
|
+
const seen = new Set<string>()
|
|
8
|
+
for (const turn of turns) {
|
|
9
|
+
if (turn.speakerId && !seen.has(turn.speakerId)) {
|
|
10
|
+
seen.add(turn.speakerId)
|
|
11
|
+
ensure(turn.speakerId)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { addSpeaker } from "./addSpeaker"
|
|
2
|
+
export { ensureSpeaker } from "./ensureSpeaker"
|
|
3
|
+
export { speakerEquals } from "./speakerEquals"
|
|
4
|
+
export { findTurnIndex } from "./findTurnIndex"
|
|
5
|
+
export { insertTurn } from "./insertTurn"
|
|
6
|
+
export { patchTurn } from "./patchTurn"
|
|
7
|
+
export { removeTurn } from "./removeTurn"
|
|
8
|
+
export { updateTurnWords } from "./updateTurnWords"
|
|
9
|
+
export { ensureDocumentSpeakers } from "./ensureDocumentSpeakers"
|
|
10
|
+
export { ensureSpeakersFromTurns } from "./ensureSpeakersFromTurns"
|
|
11
|
+
export { renameSpeaker } from "./renameSpeaker"
|
|
12
|
+
export { switchTurnSpeaker } from "./switchTurnSpeaker"
|
|
13
|
+
export { createSpeakerAndAssign } from "./createSpeakerAndAssign"
|
|
14
|
+
export { mergeSpeakers } from "./mergeSpeakers"
|
|
15
|
+
export { countTurnsForSpeaker } from "./countTurnsForSpeaker"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Core } from "../types"
|
|
2
|
+
|
|
3
|
+
/** Reassign every turn of the merged-away speaker to the surviving one, then
|
|
4
|
+
* drop the merged-away speaker — in that order, so no turn ever references a
|
|
5
|
+
* removed speaker. */
|
|
6
|
+
export function mergeSpeakers(
|
|
7
|
+
core: Core,
|
|
8
|
+
fromSpeakerId: string,
|
|
9
|
+
toSpeakerId: string,
|
|
10
|
+
): void {
|
|
11
|
+
if (fromSpeakerId === toSpeakerId) return
|
|
12
|
+
if (
|
|
13
|
+
!core.speakers.all.has(fromSpeakerId) ||
|
|
14
|
+
!core.speakers.all.has(toSpeakerId)
|
|
15
|
+
) {
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const channel of core.channels.values()) {
|
|
20
|
+
for (const translation of channel.translations.values()) {
|
|
21
|
+
for (const turn of translation.turns.value) {
|
|
22
|
+
if (turn.speakerId === fromSpeakerId) {
|
|
23
|
+
translation.updateTurn(turn.id, { speakerId: toSpeakerId })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
core.speakers.delete(fromSpeakerId)
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Turn } from "../../types/editor"
|
|
2
|
+
import { findTurnIndex } from "./findTurnIndex"
|
|
3
|
+
|
|
4
|
+
export function patchTurn(
|
|
5
|
+
turns: Turn[],
|
|
6
|
+
turnId: string,
|
|
7
|
+
patch: Partial<Turn>,
|
|
8
|
+
): { turns: Turn[]; updated: Turn } | null {
|
|
9
|
+
const idx = findTurnIndex(turns, turnId)
|
|
10
|
+
if (idx === -1) return null
|
|
11
|
+
const updated = { ...turns[idx]!, ...patch, id: turnId }
|
|
12
|
+
const next = turns.slice()
|
|
13
|
+
next[idx] = updated
|
|
14
|
+
return { turns: next, updated }
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Turn } from "../../types/editor"
|
|
2
|
+
import { findTurnIndex } from "./findTurnIndex"
|
|
3
|
+
|
|
4
|
+
export function removeTurn(turns: Turn[], turnId: string): Turn[] | null {
|
|
5
|
+
const idx = findTurnIndex(turns, turnId)
|
|
6
|
+
if (idx === -1) return null
|
|
7
|
+
return turns.filter((_, i) => i !== idx)
|
|
8
|
+
}
|