@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,12 @@
|
|
|
1
|
+
import type { Core } from "../types"
|
|
2
|
+
|
|
3
|
+
export function renameSpeaker(
|
|
4
|
+
core: Core,
|
|
5
|
+
speakerId: string,
|
|
6
|
+
newName: string,
|
|
7
|
+
): void {
|
|
8
|
+
const trimmed = newName.trim()
|
|
9
|
+
const existing = core.speakers.all.get(speakerId)
|
|
10
|
+
if (!existing || !trimmed || trimmed === existing.name) return
|
|
11
|
+
core.speakers.update(speakerId, { name: trimmed })
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Core } from "../types"
|
|
2
|
+
|
|
3
|
+
/** Point a turn at another speaker, in every translation that carries the
|
|
4
|
+
* turn (translations of a channel share turn ids). */
|
|
5
|
+
export function switchTurnSpeaker(
|
|
6
|
+
core: Core,
|
|
7
|
+
turnId: string,
|
|
8
|
+
newSpeakerId: string,
|
|
9
|
+
): void {
|
|
10
|
+
for (const channel of core.channels.values()) {
|
|
11
|
+
for (const translation of channel.translations.values()) {
|
|
12
|
+
const turn = translation.getTurn(turnId)
|
|
13
|
+
if (!turn || turn.speakerId === newSpeakerId) continue
|
|
14
|
+
translation.updateTurn(turnId, { speakerId: newSpeakerId })
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { countTurnsForSpeaker } from "../countTurnsForSpeaker"
|
|
3
|
+
import { makeTestCore } from "./makeTestCore"
|
|
4
|
+
|
|
5
|
+
describe("countTurnsForSpeaker", () => {
|
|
6
|
+
it("counts the turns assigned to the speaker in the active translation", () => {
|
|
7
|
+
const core = makeTestCore()
|
|
8
|
+
expect(countTurnsForSpeaker(core, "spk-1")).toBe(2)
|
|
9
|
+
expect(countTurnsForSpeaker(core, "spk-2")).toBe(1)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it("returns 0 for an unknown speaker", () => {
|
|
13
|
+
const core = makeTestCore()
|
|
14
|
+
expect(countTurnsForSpeaker(core, "spk-404")).toBe(0)
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { createSpeakerAndAssign } from "../createSpeakerAndAssign"
|
|
3
|
+
import { makeTestCore } from "./makeTestCore"
|
|
4
|
+
|
|
5
|
+
describe("createSpeakerAndAssign", () => {
|
|
6
|
+
it("creates the speaker (with a color) and assigns the turn", () => {
|
|
7
|
+
const core = makeTestCore()
|
|
8
|
+
const id = createSpeakerAndAssign(core, "turn-1", "Julie")
|
|
9
|
+
expect(id).not.toBeNull()
|
|
10
|
+
const speaker = core.speakers.all.get(id!)
|
|
11
|
+
expect(speaker?.name).toBe("Julie")
|
|
12
|
+
expect(speaker?.color).toBeTruthy()
|
|
13
|
+
expect(
|
|
14
|
+
core.activeChannel.value?.sourceTranslation.getTurn("turn-1")?.speakerId,
|
|
15
|
+
).toBe(id)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("returns null on a blank name", () => {
|
|
19
|
+
const core = makeTestCore()
|
|
20
|
+
expect(createSpeakerAndAssign(core, "turn-1", " ")).toBeNull()
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createCore } from "../../createCore"
|
|
2
|
+
import type { Core } from "../../types"
|
|
3
|
+
import type { Turn } from "../../../types/editor"
|
|
4
|
+
|
|
5
|
+
function makeTurn(id: string, speakerId: string | null): Turn {
|
|
6
|
+
return { id, speakerId, text: `text of ${id}`, words: [], language: "fr" }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A core with one channel, one source translation, three turns and two
|
|
10
|
+
* speakers — the minimal document the speaker helpers operate on. */
|
|
11
|
+
export function makeTestCore(): Core {
|
|
12
|
+
const core = createCore()
|
|
13
|
+
core.setDocument({
|
|
14
|
+
title: "test",
|
|
15
|
+
speakers: new Map([
|
|
16
|
+
["spk-1", { id: "spk-1", name: "Marie", color: "#111111" }],
|
|
17
|
+
["spk-2", { id: "spk-2", name: "Thomas", color: "#222222" }],
|
|
18
|
+
]),
|
|
19
|
+
channels: [
|
|
20
|
+
{
|
|
21
|
+
id: "ch-1",
|
|
22
|
+
name: "channel 1",
|
|
23
|
+
duration: 60,
|
|
24
|
+
translations: [
|
|
25
|
+
{
|
|
26
|
+
id: "tr-1",
|
|
27
|
+
languages: ["fr"],
|
|
28
|
+
isSource: true,
|
|
29
|
+
turns: [
|
|
30
|
+
makeTurn("turn-1", "spk-1"),
|
|
31
|
+
makeTurn("turn-2", "spk-2"),
|
|
32
|
+
makeTurn("turn-3", "spk-1"),
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
})
|
|
39
|
+
return core
|
|
40
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { mergeSpeakers } from "../mergeSpeakers"
|
|
3
|
+
import { makeTestCore } from "./makeTestCore"
|
|
4
|
+
|
|
5
|
+
describe("mergeSpeakers", () => {
|
|
6
|
+
it("reassigns every turn then removes the merged-away speaker", () => {
|
|
7
|
+
const core = makeTestCore()
|
|
8
|
+
mergeSpeakers(core, "spk-1", "spk-2")
|
|
9
|
+
const turns = core.activeChannel.value!.sourceTranslation.turns.value
|
|
10
|
+
expect(turns.every((t) => t.speakerId === "spk-2")).toBe(true)
|
|
11
|
+
expect(core.speakers.all.has("spk-1")).toBe(false)
|
|
12
|
+
expect(core.speakers.all.has("spk-2")).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("is a no-op on same ids or unknown speakers", () => {
|
|
16
|
+
const core = makeTestCore()
|
|
17
|
+
mergeSpeakers(core, "spk-1", "spk-1")
|
|
18
|
+
mergeSpeakers(core, "spk-404", "spk-2")
|
|
19
|
+
expect(core.speakers.all.has("spk-1")).toBe(true)
|
|
20
|
+
expect(
|
|
21
|
+
core.activeChannel.value?.sourceTranslation.getTurn("turn-1")?.speakerId,
|
|
22
|
+
).toBe("spk-1")
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { renameSpeaker } from "../renameSpeaker"
|
|
3
|
+
import { makeTestCore } from "./makeTestCore"
|
|
4
|
+
|
|
5
|
+
describe("renameSpeaker", () => {
|
|
6
|
+
it("renames an existing speaker (trimmed)", () => {
|
|
7
|
+
const core = makeTestCore()
|
|
8
|
+
renameSpeaker(core, "spk-1", " Marie Dupont ")
|
|
9
|
+
expect(core.speakers.all.get("spk-1")?.name).toBe("Marie Dupont")
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it("ignores unknown speakers and empty names", () => {
|
|
13
|
+
const core = makeTestCore()
|
|
14
|
+
renameSpeaker(core, "spk-404", "X")
|
|
15
|
+
renameSpeaker(core, "spk-1", " ")
|
|
16
|
+
expect(core.speakers.all.get("spk-1")?.name).toBe("Marie")
|
|
17
|
+
expect(core.speakers.all.has("spk-404")).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { switchTurnSpeaker } from "../switchTurnSpeaker"
|
|
3
|
+
import { makeTestCore } from "./makeTestCore"
|
|
4
|
+
|
|
5
|
+
function getTurn(core: ReturnType<typeof makeTestCore>, turnId: string) {
|
|
6
|
+
return core.activeChannel.value?.sourceTranslation.getTurn(turnId)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("switchTurnSpeaker", () => {
|
|
10
|
+
it("reassigns the turn to the new speaker", () => {
|
|
11
|
+
const core = makeTestCore()
|
|
12
|
+
switchTurnSpeaker(core, "turn-1", "spk-2")
|
|
13
|
+
expect(getTurn(core, "turn-1")?.speakerId).toBe("spk-2")
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("leaves other turns untouched", () => {
|
|
17
|
+
const core = makeTestCore()
|
|
18
|
+
switchTurnSpeaker(core, "turn-1", "spk-2")
|
|
19
|
+
expect(getTurn(core, "turn-3")?.speakerId).toBe("spk-1")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("ignores unknown turn ids", () => {
|
|
23
|
+
const core = makeTestCore()
|
|
24
|
+
switchTurnSpeaker(core, "turn-404", "spk-2")
|
|
25
|
+
expect(getTurn(core, "turn-1")?.speakerId).toBe("spk-1")
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Turn, Word } from "../../types/editor"
|
|
2
|
+
import { findTurnIndex } from "./findTurnIndex"
|
|
3
|
+
|
|
4
|
+
export function updateTurnWords(
|
|
5
|
+
turns: Turn[],
|
|
6
|
+
turnId: string,
|
|
7
|
+
words: Word[],
|
|
8
|
+
): { turns: Turn[]; updated: Turn } | null {
|
|
9
|
+
const idx = findTurnIndex(turns, turnId)
|
|
10
|
+
if (idx === -1) return null
|
|
11
|
+
const turn = turns[idx]!
|
|
12
|
+
const updated: Turn = {
|
|
13
|
+
...turn,
|
|
14
|
+
words,
|
|
15
|
+
text: null,
|
|
16
|
+
startTime: words[0]?.startTime ?? turn.startTime,
|
|
17
|
+
endTime: words[words.length - 1]?.endTime ?? turn.endTime,
|
|
18
|
+
}
|
|
19
|
+
const next = turns.slice()
|
|
20
|
+
next[idx] = updated
|
|
21
|
+
return { turns: next, updated }
|
|
22
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export { createCore } from './createCore'
|
|
2
|
+
export { provideCore, useCore } from './useCore'
|
|
3
|
+
export { CROSS_TRANSLATION_ID } from './stores'
|
|
4
|
+
export type {
|
|
5
|
+
Core,
|
|
6
|
+
CorePlugin,
|
|
7
|
+
CoreEventMap,
|
|
8
|
+
CoreOptions,
|
|
9
|
+
CoreCapabilities,
|
|
10
|
+
TurnEventKey,
|
|
11
|
+
UISlot,
|
|
12
|
+
ReadableTranslation,
|
|
13
|
+
TranslationInfo,
|
|
14
|
+
TurnStore,
|
|
15
|
+
TranslationStore,
|
|
16
|
+
ChannelStore,
|
|
17
|
+
SpeakersStore,
|
|
18
|
+
AudioPluginApi,
|
|
19
|
+
TranscriptionEditorPluginApi,
|
|
20
|
+
TurnLock,
|
|
21
|
+
TurnUpdate,
|
|
22
|
+
WireTurn,
|
|
23
|
+
TurnSplit,
|
|
24
|
+
TurnsMerged,
|
|
25
|
+
TurnDeleted,
|
|
26
|
+
TurnSpeakerUpdated,
|
|
27
|
+
SpeakerRenamed,
|
|
28
|
+
SpeakerReplaced,
|
|
29
|
+
LivePluginApi,
|
|
30
|
+
LivePartialEventData,
|
|
31
|
+
LiveFinalEventData,
|
|
32
|
+
LiveTranslationEventData,
|
|
33
|
+
SubtitlePluginApi,
|
|
34
|
+
WatermarkPluginApi,
|
|
35
|
+
WatermarkToken,
|
|
36
|
+
LLMService,
|
|
37
|
+
LLMServiceInit,
|
|
38
|
+
LLMServiceStatus,
|
|
39
|
+
LLMServiceVersion,
|
|
40
|
+
LLMServiceGeneration,
|
|
41
|
+
LLMServiceGenerationStatus,
|
|
42
|
+
LLMServicesPluginApi,
|
|
43
|
+
ChatRole,
|
|
44
|
+
ChatMessage,
|
|
45
|
+
ChatSession,
|
|
46
|
+
ChatPluginApi,
|
|
47
|
+
} from './types'
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
type EventHandler<EventMap, K extends keyof EventMap> = (
|
|
2
|
+
payload: EventMap[K],
|
|
3
|
+
) => void
|
|
4
|
+
type AnyHandler = (payload: never) => void
|
|
5
|
+
|
|
6
|
+
export function createEventBus<EventMap>() {
|
|
7
|
+
const listeners = new Map<keyof EventMap, Set<AnyHandler>>()
|
|
8
|
+
|
|
9
|
+
function on<K extends keyof EventMap>(
|
|
10
|
+
event: K,
|
|
11
|
+
handler: EventHandler<EventMap, K>,
|
|
12
|
+
): () => void {
|
|
13
|
+
let set = listeners.get(event)
|
|
14
|
+
if (!set) {
|
|
15
|
+
set = new Set()
|
|
16
|
+
listeners.set(event, set)
|
|
17
|
+
}
|
|
18
|
+
set.add(handler as AnyHandler)
|
|
19
|
+
return () => off(event, handler)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function off<K extends keyof EventMap>(
|
|
23
|
+
event: K,
|
|
24
|
+
handler: EventHandler<EventMap, K>,
|
|
25
|
+
): void {
|
|
26
|
+
listeners.get(event)?.delete(handler as AnyHandler)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function emit<K extends keyof EventMap>(
|
|
30
|
+
event: K,
|
|
31
|
+
payload: EventMap[K],
|
|
32
|
+
): void {
|
|
33
|
+
listeners.get(event)?.forEach((h) =>
|
|
34
|
+
(h as EventHandler<EventMap, K>)(payload),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clear(): void {
|
|
39
|
+
listeners.clear()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { on, off, emit, clear }
|
|
43
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { ref, computed, shallowReactive } from "vue"
|
|
2
|
+
import type { Channel } from "../../types/editor"
|
|
3
|
+
import type {
|
|
4
|
+
ChannelStore,
|
|
5
|
+
CoreEventMap,
|
|
6
|
+
ReadableTranslation,
|
|
7
|
+
TranslationStore,
|
|
8
|
+
} from "../types"
|
|
9
|
+
import { createTranslationStore } from "./translationStore"
|
|
10
|
+
import {
|
|
11
|
+
CROSS_TRANSLATION_ID,
|
|
12
|
+
createCrossTranslationStore,
|
|
13
|
+
} from "./crossTranslationStore"
|
|
14
|
+
|
|
15
|
+
type Emit = <K extends keyof CoreEventMap>(event: K, payload: CoreEventMap[K]) => void
|
|
16
|
+
type On = <K extends keyof CoreEventMap>(
|
|
17
|
+
event: K,
|
|
18
|
+
handler: (payload: CoreEventMap[K]) => void,
|
|
19
|
+
) => () => void
|
|
20
|
+
type SpeakersEnsure = (speakerId: string | null, name?: string) => void
|
|
21
|
+
|
|
22
|
+
export function createChannelStore(
|
|
23
|
+
channel: Channel,
|
|
24
|
+
emit: Emit,
|
|
25
|
+
on: On,
|
|
26
|
+
speakersEnsure: SpeakersEnsure,
|
|
27
|
+
): ChannelStore {
|
|
28
|
+
const { id, name, description, duration } = channel
|
|
29
|
+
|
|
30
|
+
const translations = shallowReactive(new Map<string, TranslationStore>())
|
|
31
|
+
let sourceTranslation!: TranslationStore
|
|
32
|
+
|
|
33
|
+
for (const tr of channel.translations) {
|
|
34
|
+
const store = createTranslationStore(tr, emit, speakersEnsure)
|
|
35
|
+
translations.set(tr.id, store)
|
|
36
|
+
if (tr.isSource && !sourceTranslation) sourceTranslation = store
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!sourceTranslation) {
|
|
40
|
+
sourceTranslation = translations.values().next().value!
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const crossTranslation = createCrossTranslationStore(
|
|
44
|
+
sourceTranslation,
|
|
45
|
+
translations,
|
|
46
|
+
emit,
|
|
47
|
+
on,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const selectableTranslations: ReadableTranslation[] = [...translations.values()]
|
|
51
|
+
if (crossTranslation) selectableTranslations.push(crossTranslation)
|
|
52
|
+
|
|
53
|
+
const activeTranslationId = ref<string | null>(null)
|
|
54
|
+
const isLoadingHistory = ref(false)
|
|
55
|
+
const hasMoreHistory = ref(true)
|
|
56
|
+
|
|
57
|
+
const activeTranslation = computed<ReadableTranslation>(() => {
|
|
58
|
+
const activeId = activeTranslationId.value
|
|
59
|
+
if (activeId === CROSS_TRANSLATION_ID) return crossTranslation ?? sourceTranslation
|
|
60
|
+
if (activeId) return translations.get(activeId) ?? sourceTranslation
|
|
61
|
+
return sourceTranslation
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
function setActiveTranslation(translationId: string | null): void {
|
|
65
|
+
const normalized = translationId === sourceTranslation.id ? null : translationId
|
|
66
|
+
if (normalized === activeTranslationId.value) return
|
|
67
|
+
activeTranslationId.value = normalized
|
|
68
|
+
emit("translation:change", { translationId: activeTranslation.value.id })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function reset(): void {
|
|
72
|
+
for (const translation of translations.values()) {
|
|
73
|
+
translation.setTurns([])
|
|
74
|
+
}
|
|
75
|
+
isLoadingHistory.value = false
|
|
76
|
+
hasMoreHistory.value = true
|
|
77
|
+
emit("channel:reset", { channelId: id })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function dispose(): void {
|
|
81
|
+
crossTranslation?.dispose()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
name,
|
|
87
|
+
description,
|
|
88
|
+
duration,
|
|
89
|
+
translations,
|
|
90
|
+
sourceTranslation,
|
|
91
|
+
crossTranslation,
|
|
92
|
+
selectableTranslations,
|
|
93
|
+
activeTranslation,
|
|
94
|
+
isLoadingHistory,
|
|
95
|
+
hasMoreHistory,
|
|
96
|
+
setActiveTranslation,
|
|
97
|
+
reset,
|
|
98
|
+
dispose,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { computed } from "vue"
|
|
2
|
+
import type { Turn } from "../../types/editor"
|
|
3
|
+
import type {
|
|
4
|
+
CoreEventMap,
|
|
5
|
+
ReadableTranslation,
|
|
6
|
+
TranslationStore,
|
|
7
|
+
} from "../types"
|
|
8
|
+
import { extractLangCode } from "../../utils/extractLangCode"
|
|
9
|
+
import { isSameLanguage } from "../../utils/isSameLanguage"
|
|
10
|
+
|
|
11
|
+
export const CROSS_TRANSLATION_ID = "cross"
|
|
12
|
+
|
|
13
|
+
type Emit = <K extends keyof CoreEventMap>(
|
|
14
|
+
event: K,
|
|
15
|
+
payload: CoreEventMap[K],
|
|
16
|
+
) => void
|
|
17
|
+
type On = <K extends keyof CoreEventMap>(
|
|
18
|
+
event: K,
|
|
19
|
+
handler: (payload: CoreEventMap[K]) => void,
|
|
20
|
+
) => () => void
|
|
21
|
+
|
|
22
|
+
export interface CrossTranslationStore extends ReadableTranslation {
|
|
23
|
+
/** Unsubscribe the underlying turn-event relay. */
|
|
24
|
+
dispose(): void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Virtual, read-only translation for a bilingual document: each turn is shown
|
|
29
|
+
* in the *other* language. Returns null when not applicable — i.e. the source
|
|
30
|
+
* doesn't have exactly two languages, or a translation track is missing for one
|
|
31
|
+
* of them. Otherwise turn content is composed from the two matching translation
|
|
32
|
+
* tracks (keyed by turnId). Stores nothing — `turns` is a computed.
|
|
33
|
+
*
|
|
34
|
+
* Because the active-translation event scope filters on `translationId`, and the
|
|
35
|
+
* real tracks emit under their own id, this store relays their turn events under
|
|
36
|
+
* `CROSS_TRANSLATION_ID` so that subtitle/scroller listeners fire while cross is
|
|
37
|
+
* active. Call `dispose()` to detach the relay.
|
|
38
|
+
*/
|
|
39
|
+
export function createCrossTranslationStore(
|
|
40
|
+
source: TranslationStore,
|
|
41
|
+
translations: Map<string, TranslationStore>,
|
|
42
|
+
emit: Emit,
|
|
43
|
+
on: On,
|
|
44
|
+
): CrossTranslationStore | null {
|
|
45
|
+
const langs = source.languages.map(extractLangCode)
|
|
46
|
+
if (langs.length !== 2) return null
|
|
47
|
+
const tracksByLanguage = new Map<string, TranslationStore>()
|
|
48
|
+
|
|
49
|
+
for (const tr of translations.values()) {
|
|
50
|
+
if (tr.id === source.id) {
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (tr.languages.length !== 1) {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
if (!tr.languages[0]) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
tracksByLanguage.set(extractLangCode(tr.languages[0]), tr)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const lang of langs) {
|
|
64
|
+
if (!tracksByLanguage.has(lang)) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const [langA, langB] = langs
|
|
70
|
+
if (!langA || !langB) return null
|
|
71
|
+
|
|
72
|
+
// The two translation tracks the cross composes from (excludes any extra
|
|
73
|
+
// language track). Both tracks hold every turn — one in each language — so
|
|
74
|
+
// membership alone isn't enough to pick a side (see relay below).
|
|
75
|
+
const crossTrackIds = new Set([
|
|
76
|
+
tracksByLanguage.get(langA)!.id,
|
|
77
|
+
tracksByLanguage.get(langB)!.id,
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
const turns = computed<Turn[]>(() =>
|
|
81
|
+
source.turns.value.map((turn) => getTurn(turn.id) ?? turn),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
function getTurn(turnId: string): Turn | undefined {
|
|
85
|
+
const sourceTurn = source.getTurn(turnId)
|
|
86
|
+
if (!sourceTurn) return undefined
|
|
87
|
+
|
|
88
|
+
const target = isSameLanguage(sourceTurn.language, langA) ? langB : langA
|
|
89
|
+
if (!target) return sourceTurn
|
|
90
|
+
|
|
91
|
+
const targetTurn = tracksByLanguage.get(target)?.getTurn(turnId)
|
|
92
|
+
if (!targetTurn) return sourceTurn
|
|
93
|
+
|
|
94
|
+
return targetTurn
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Relay underlying track events under the cross id ──────────────────
|
|
98
|
+
// The active-translation scope filters on translationId, and the real tracks
|
|
99
|
+
// emit under their own id, so we re-emit as "cross". Both tracks hold every
|
|
100
|
+
// turn, so we relay only the side opposite the original — i.e. where the
|
|
101
|
+
// turn's language differs from its sourceLanguage. This is data-driven, so it
|
|
102
|
+
// doesn't depend on the source/translation event ordering.
|
|
103
|
+
const unsubs: Array<() => void> = []
|
|
104
|
+
|
|
105
|
+
function isOppositeSide(turn: Turn, translationId: string): boolean {
|
|
106
|
+
if (!crossTrackIds.has(translationId)) return false
|
|
107
|
+
if (turn.sourceLanguage == null) return true
|
|
108
|
+
return !isSameLanguage(turn.language, turn.sourceLanguage)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function relayAddOrUpdate(event: "turn:add" | "turn:update"): void {
|
|
112
|
+
unsubs.push(
|
|
113
|
+
on(event, ({ turn, translationId }) => {
|
|
114
|
+
if (!isOppositeSide(turn, translationId)) return
|
|
115
|
+
emit(event, { turn, translationId: CROSS_TRANSLATION_ID })
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
relayAddOrUpdate("turn:add")
|
|
120
|
+
relayAddOrUpdate("turn:update")
|
|
121
|
+
unsubs.push(
|
|
122
|
+
on("turn:remove", ({ turnId, translationId }) => {
|
|
123
|
+
if (!crossTrackIds.has(translationId)) return
|
|
124
|
+
emit("turn:remove", { turnId, translationId: CROSS_TRANSLATION_ID })
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
function dispose(): void {
|
|
129
|
+
unsubs.forEach((fn) => fn())
|
|
130
|
+
unsubs.length = 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
id: CROSS_TRANSLATION_ID,
|
|
135
|
+
isSource: false,
|
|
136
|
+
languages: source.languages,
|
|
137
|
+
turns,
|
|
138
|
+
getTurn,
|
|
139
|
+
dispose,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createTranslationStore } from "./translationStore"
|
|
2
|
+
export {
|
|
3
|
+
createCrossTranslationStore,
|
|
4
|
+
CROSS_TRANSLATION_ID,
|
|
5
|
+
} from "./crossTranslationStore"
|
|
6
|
+
export { createChannelStore } from "./channelStore"
|
|
7
|
+
export { createSpeakersStore } from "./speakersStore"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { shallowReactive } from "vue"
|
|
2
|
+
import type { Speaker } from "../../types/editor"
|
|
3
|
+
import type { CoreEventMap, SpeakersStore } from "../types"
|
|
4
|
+
import { ensureSpeaker } from "../helpers/ensureSpeaker"
|
|
5
|
+
import { speakerEquals } from "../helpers/speakerEquals"
|
|
6
|
+
|
|
7
|
+
type Emit = <K extends keyof CoreEventMap>(event: K, payload: CoreEventMap[K]) => void
|
|
8
|
+
|
|
9
|
+
export function createSpeakersStore(
|
|
10
|
+
emit: Emit,
|
|
11
|
+
): SpeakersStore & { clear(): void } {
|
|
12
|
+
const all = shallowReactive(new Map<string, Speaker>())
|
|
13
|
+
|
|
14
|
+
function ensure(speakerId: string | null, name?: string): void {
|
|
15
|
+
const speaker = ensureSpeaker(all, speakerId, name)
|
|
16
|
+
if (!speaker) return
|
|
17
|
+
all.set(speaker.id, speaker)
|
|
18
|
+
emit("speaker:add", { speaker })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function update(speakerId: string, patch: Partial<Omit<Speaker, "id">>): void {
|
|
22
|
+
const existing = all.get(speakerId)
|
|
23
|
+
if (!existing) return
|
|
24
|
+
const updated = { ...existing, ...patch }
|
|
25
|
+
if (speakerEquals(existing, updated)) return
|
|
26
|
+
all.set(speakerId, updated)
|
|
27
|
+
emit("speaker:update", { speaker: updated })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function updateOrCreate(speaker: Speaker): void {
|
|
31
|
+
const existing = all.get(speaker.id)
|
|
32
|
+
if (existing) {
|
|
33
|
+
if (speakerEquals(existing, speaker)) return
|
|
34
|
+
all.set(speaker.id, speaker)
|
|
35
|
+
emit("speaker:update", { speaker })
|
|
36
|
+
} else {
|
|
37
|
+
all.set(speaker.id, speaker)
|
|
38
|
+
emit("speaker:add", { speaker })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function remove(speakerId: string): void {
|
|
43
|
+
if (!all.has(speakerId)) return
|
|
44
|
+
all.delete(speakerId)
|
|
45
|
+
emit("speaker:remove", { speakerId })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clear(): void {
|
|
49
|
+
all.clear()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { all, ensure, update, updateOrCreate, delete: remove, clear }
|
|
53
|
+
}
|