@ephia/dova-sdk 1.0.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/README.md +89 -0
- package/dist/EphiaBinding-BvRmlqqC.d.ts +36 -0
- package/dist/EphiaFloatingButton-CxiF86VW.d.ts +65 -0
- package/dist/EphiaTextarea-B4_CAVUg.d.ts +183 -0
- package/dist/NativeBinding-ChG0GeSz.d.ts +53 -0
- package/dist/TargetBinding-BKGQwUMc.d.ts +89 -0
- package/dist/TiptapBinding-B-agfV2H.d.ts +45 -0
- package/dist/Transport-zdeA4Pou.d.ts +63 -0
- package/dist/audio-state-kZ3KSvux.d.ts +39 -0
- package/dist/chunk-35AJK2IO.js +1 -0
- package/dist/chunk-35AJK2IO.js.map +1 -0
- package/dist/chunk-3LXZODL4.js +886 -0
- package/dist/chunk-3LXZODL4.js.map +1 -0
- package/dist/chunk-5IK5TLSK.js +67 -0
- package/dist/chunk-5IK5TLSK.js.map +1 -0
- package/dist/chunk-7E43RY75.js +9 -0
- package/dist/chunk-7E43RY75.js.map +1 -0
- package/dist/chunk-A5UEXJ5R.js +183 -0
- package/dist/chunk-A5UEXJ5R.js.map +1 -0
- package/dist/chunk-AEE554FT.js +51 -0
- package/dist/chunk-AEE554FT.js.map +1 -0
- package/dist/chunk-DIEWY3IT.js +1332 -0
- package/dist/chunk-DIEWY3IT.js.map +1 -0
- package/dist/chunk-EGIAN7FH.js +18 -0
- package/dist/chunk-EGIAN7FH.js.map +1 -0
- package/dist/chunk-EMOEAPVU.js +486 -0
- package/dist/chunk-EMOEAPVU.js.map +1 -0
- package/dist/chunk-IDC7FHIZ.js +40 -0
- package/dist/chunk-IDC7FHIZ.js.map +1 -0
- package/dist/chunk-ITJFN3VM.js +601 -0
- package/dist/chunk-ITJFN3VM.js.map +1 -0
- package/dist/chunk-K24GNU27.js +22 -0
- package/dist/chunk-K24GNU27.js.map +1 -0
- package/dist/chunk-LXMCRXXF.js +778 -0
- package/dist/chunk-LXMCRXXF.js.map +1 -0
- package/dist/chunk-MJCEOOLW.js +122 -0
- package/dist/chunk-MJCEOOLW.js.map +1 -0
- package/dist/chunk-N7U5M3VZ.js +33 -0
- package/dist/chunk-N7U5M3VZ.js.map +1 -0
- package/dist/chunk-PSYX674B.js +27 -0
- package/dist/chunk-PSYX674B.js.map +1 -0
- package/dist/chunk-RFQRV7ML.js +33 -0
- package/dist/chunk-RFQRV7ML.js.map +1 -0
- package/dist/chunk-THNHRV2B.js +18 -0
- package/dist/chunk-THNHRV2B.js.map +1 -0
- package/dist/chunk-VSLGR64U.js +62 -0
- package/dist/chunk-VSLGR64U.js.map +1 -0
- package/dist/chunk-W2ZP674X.js +346 -0
- package/dist/chunk-W2ZP674X.js.map +1 -0
- package/dist/chunk-YWZUMUYE.js +695 -0
- package/dist/chunk-YWZUMUYE.js.map +1 -0
- package/dist/client-options-Uo6jXO8k.d.ts +64 -0
- package/dist/connection-state-Bk33YprE.d.ts +32 -0
- package/dist/core/bindings/index.d.ts +24 -0
- package/dist/core/bindings/index.js +1025 -0
- package/dist/core/bindings/index.js.map +1 -0
- package/dist/core/index.d.ts +383 -0
- package/dist/core/index.js +1284 -0
- package/dist/core/index.js.map +1 -0
- package/dist/createEphiaClient-BhdZ183V.d.ts +69 -0
- package/dist/devices/speechmike/index.d.ts +148 -0
- package/dist/devices/speechmike/index.js +40 -0
- package/dist/devices/speechmike/index.js.map +1 -0
- package/dist/headless/index.d.ts +10 -0
- package/dist/headless/index.js +25 -0
- package/dist/headless/index.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +119 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.d.ts +38 -0
- package/dist/react/index.js +70 -0
- package/dist/react/index.js.map +1 -0
- package/dist/rich-editor/index.d.ts +46 -0
- package/dist/rich-editor/index.js +13 -0
- package/dist/rich-editor/index.js.map +1 -0
- package/dist/schema-B2ycPlNB.d.ts +87 -0
- package/dist/session-APaXR48R.d.ts +12 -0
- package/dist/shared/index.d.ts +16 -0
- package/dist/shared/index.js +30 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/style.css +1093 -0
- package/dist/testing/index.d.ts +84 -0
- package/dist/testing/index.js +36 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types-D5SXPSwR.d.ts +32 -0
- package/dist/ui/index.d.ts +30 -0
- package/dist/ui/index.js +34 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/useEphiaSpeechMike-CjD7DWnh.d.ts +64 -0
- package/package.json +110 -0
- package/src/core/audio/audio-worklet-source.ts +30 -0
- package/src/core/audio/index.ts +3 -0
- package/src/core/audio/voice-level-meter.test.ts +27 -0
- package/src/core/audio/voice-level-meter.ts +270 -0
- package/src/core/bindings/EphiaBinding.ts +41 -0
- package/src/core/bindings/SegmentBindingBridge.test.ts +422 -0
- package/src/core/bindings/SegmentBindingBridge.ts +377 -0
- package/src/core/bindings/TargetBinding.ts +142 -0
- package/src/core/bindings/adapters/NativeAdapter.test.ts +85 -0
- package/src/core/bindings/adapters/NativeAdapter.ts +216 -0
- package/src/core/bindings/adapters/ProseMirrorAdapter.ts +231 -0
- package/src/core/bindings/adapters/index.ts +2 -0
- package/src/core/bindings/binding-factory.ts +78 -0
- package/src/core/bindings/detect-editor-type.ts +87 -0
- package/src/core/bindings/index.ts +13 -0
- package/src/core/bindings/insertion-boundary.test.ts +38 -0
- package/src/core/bindings/insertion-boundary.ts +26 -0
- package/src/core/bindings/native/NativeBinding.test.ts +277 -0
- package/src/core/bindings/native/NativeBinding.ts +239 -0
- package/src/core/bindings/resolver.ts +18 -0
- package/src/core/bindings/targets/codemirror.binding.ts +293 -0
- package/src/core/bindings/targets/contenteditable.binding.ts +452 -0
- package/src/core/bindings/targets/index.ts +10 -0
- package/src/core/bindings/targets/monaco.binding.ts +315 -0
- package/src/core/bindings/targets/tiptap.binding.test.ts +417 -0
- package/src/core/bindings/targets/tiptap.binding.ts +1192 -0
- package/src/core/bindings/tiptap/TiptapBinding.test.ts +63 -0
- package/src/core/bindings/tiptap/TiptapBinding.ts +464 -0
- package/src/core/bindings/types.ts +41 -0
- package/src/core/client/EphiaAudioClient.ts +654 -0
- package/src/core/client/audio-capture.ts +263 -0
- package/src/core/client/client-options.ts +39 -0
- package/src/core/client/client-state.ts +18 -0
- package/src/core/client/constants.ts +23 -0
- package/src/core/client/session-api.ts +415 -0
- package/src/core/connection/connection-state.ts +78 -0
- package/src/core/connection/index.ts +6 -0
- package/src/core/index.ts +47 -0
- package/src/core/operations/textToDocumentOperations.test.ts +69 -0
- package/src/core/operations/textToDocumentOperations.ts +92 -0
- package/src/core/runtime/DictationRuntime.test.ts +578 -0
- package/src/core/runtime/DictationRuntime.ts +434 -0
- package/src/core/runtime/TranscriptApplier.test.ts +355 -0
- package/src/core/runtime/TranscriptApplier.ts +229 -0
- package/src/core/runtime/index.ts +18 -0
- package/src/core/session/index.ts +2 -0
- package/src/core/session/session-machine.test.ts +16 -0
- package/src/core/session/session-machine.ts +59 -0
- package/src/core/targets/EditorContextCollector.ts +71 -0
- package/src/core/targets/TargetManager.test.ts +194 -0
- package/src/core/targets/TargetManager.ts +194 -0
- package/src/core/targets/index.ts +10 -0
- package/src/core/text-processing/index.ts +11 -0
- package/src/core/text-processing/overlap.test.ts +35 -0
- package/src/core/text-processing/overlap.ts +101 -0
- package/src/core/text-processing/voice-formatting.normalizer.test.ts +132 -0
- package/src/core/text-processing/voice-formatting.normalizer.ts +284 -0
- package/src/core/transcript/client-transcript.reducer.ts +366 -0
- package/src/core/transcript/client-transcript.state.ts +25 -0
- package/src/core/transcript/index.ts +19 -0
- package/src/core/transcript/transcript.assembler.test.ts +205 -0
- package/src/core/transcript/transcript.assembler.ts +152 -0
- package/src/core/transcript/transcript.reducer.test.ts +199 -0
- package/src/core/transcript/transcript.reducer.ts +771 -0
- package/src/core/transcript/transcript.state.ts +123 -0
- package/src/core/transport/LiveKitTransport.publish.test.ts +226 -0
- package/src/core/transport/LiveKitTransport.ts +459 -0
- package/src/core/transport/MockTransport.ts +231 -0
- package/src/core/transport/Transport.ts +82 -0
- package/src/debug/sdk-debug-collector.ts +79 -0
- package/src/devices/index.ts +2 -0
- package/src/devices/speechmike/__tests__/EphiaSpeechMikeProvider.test.tsx +99 -0
- package/src/devices/speechmike/__tests__/speechmike-audio-resolver.test.ts +96 -0
- package/src/devices/speechmike/__tests__/speechmike-button-router.test.ts +66 -0
- package/src/devices/speechmike/__tests__/speechmike-device-manager.test.ts +201 -0
- package/src/devices/speechmike/__tests__/speechmike-led-controller.test.ts +68 -0
- package/src/devices/speechmike/browser.ts +80 -0
- package/src/devices/speechmike/constants.ts +74 -0
- package/src/devices/speechmike/dictation-support-loader.ts +81 -0
- package/src/devices/speechmike/index.ts +11 -0
- package/src/devices/speechmike/react/EphiaSpeechMikeContext.ts +34 -0
- package/src/devices/speechmike/react/EphiaSpeechMikeProvider.tsx +287 -0
- package/src/devices/speechmike/react/useEphiaSpeechMike.ts +26 -0
- package/src/devices/speechmike/speechmike-audio-resolver.ts +58 -0
- package/src/devices/speechmike/speechmike-button-router.ts +73 -0
- package/src/devices/speechmike/speechmike-device-manager.ts +461 -0
- package/src/devices/speechmike/speechmike-led-controller.ts +78 -0
- package/src/devices/speechmike/types.ts +96 -0
- package/src/dictation_support.d.ts +31 -0
- package/src/global.d.ts +10 -0
- package/src/headless/createEphiaClient.ts +220 -0
- package/src/headless/index.ts +18 -0
- package/src/index.ts +89 -0
- package/src/react/EphiaAuto.tsx +87 -0
- package/src/react/components/EphiaDictationButton.tsx +88 -0
- package/src/react/components/EphiaStatusBar.tsx +59 -0
- package/src/react/components/EphiaTextarea.tsx +295 -0
- package/src/react/ephia-react.css +318 -0
- package/src/react/hooks/targets/index.ts +3 -0
- package/src/react/hooks/targets/useEphiaCodemirror.ts +35 -0
- package/src/react/hooks/targets/useEphiaMonaco.ts +35 -0
- package/src/react/hooks/targets/useEphiaTiptap.ts +23 -0
- package/src/react/hooks/useEphia.lifecycle.test.tsx +389 -0
- package/src/react/hooks/useEphia.ts +367 -0
- package/src/react/hooks/useEphiaDiscardTarget.ts +53 -0
- package/src/react/hooks/useEphiaServerEvent.ts +33 -0
- package/src/react/hooks/useEphiaTarget.ts +47 -0
- package/src/react/hooks/useEphiaTranscript.ts +22 -0
- package/src/react/index.ts +58 -0
- package/src/react/provider/EphiaContext.ts +63 -0
- package/src/react/provider/EphiaInternalContext.ts +32 -0
- package/src/react/provider/EphiaProvider.tsx +373 -0
- package/src/react/registry/binding-factory.ts +7 -0
- package/src/react/registry/detect-editor-type.ts +2 -0
- package/src/react/registry/events.ts +37 -0
- package/src/react/registry/registries/CodeMirrorInstanceRegistry.ts +24 -0
- package/src/react/registry/registries/MonacoInstanceRegistry.ts +23 -0
- package/src/react/registry/registries/TargetRegistry.ts +327 -0
- package/src/react/registry/registries/TiptapInstanceRegistry.ts +43 -0
- package/src/react/registry/registries/index.ts +5 -0
- package/src/react/store/create-ephia-store.ts +36 -0
- package/src/react/store/types.ts +41 -0
- package/src/react/utils/flash-range.ts +24 -0
- package/src/react/utils/index.ts +1 -0
- package/src/rich-editor/adapters/tiptap.test.ts +86 -0
- package/src/rich-editor/adapters/tiptap.ts +23 -0
- package/src/rich-editor/index.ts +3 -0
- package/src/rich-editor/types.ts +24 -0
- package/src/rich-editor/use-ephia-rich-editor.test.tsx +202 -0
- package/src/rich-editor/use-ephia-rich-editor.ts +47 -0
- package/src/shared/config/endpoint.test.ts +45 -0
- package/src/shared/config/endpoint.ts +39 -0
- package/src/shared/config/schema.ts +32 -0
- package/src/shared/effective-text.ts +13 -0
- package/src/shared/errors/EphiaSdkError.ts +54 -0
- package/src/shared/errors/messages.ts +40 -0
- package/src/shared/index.ts +27 -0
- package/src/shared/state/audio-state.ts +45 -0
- package/src/shared/state/index.ts +2 -0
- package/src/shared/store/document-store.ts +32 -0
- package/src/shared/store/index.ts +2 -0
- package/src/shared/types/editors.ts +28 -0
- package/src/shared/types/session.ts +12 -0
- package/src/style.css +2 -0
- package/src/testing/index.tsx +60 -0
- package/src/ui/assets/ephia-logo.svg +4 -0
- package/src/ui/components/EphiaLogo.tsx +77 -0
- package/src/ui/index.ts +24 -0
- package/src/ui/primitives/Button.tsx +53 -0
- package/src/ui/primitives/Spinner.tsx +21 -0
- package/src/ui/primitives/index.ts +5 -0
- package/src/ui/recorder/EphiaFloatingButton.tsx +489 -0
- package/src/ui/recorder/MinimalProcessingBars.tsx +122 -0
- package/src/ui/recorder/StandardIntensityVisualizer.tsx +148 -0
- package/src/ui/recorder/appearance.ts +9 -0
- package/src/ui/recorder/index.ts +8 -0
- package/src/ui/theme.css +775 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { NativeBinding } from "./NativeBinding";
|
|
5
|
+
|
|
6
|
+
function textarea(value = "", selectionStart?: number, selectionEnd?: number) {
|
|
7
|
+
const el = document.createElement("textarea");
|
|
8
|
+
el.value = value;
|
|
9
|
+
document.body.appendChild(el);
|
|
10
|
+
if (selectionStart !== undefined) {
|
|
11
|
+
const end = selectionEnd ?? selectionStart;
|
|
12
|
+
el.setSelectionRange(selectionStart, end);
|
|
13
|
+
}
|
|
14
|
+
return el;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("NativeBinding — prefix/suffix model", () => {
|
|
18
|
+
it("inserts segment text at cursor, preserving suffix", () => {
|
|
19
|
+
const el = textarea("AAA BBB", 4, 4);
|
|
20
|
+
const b = new NativeBinding(el);
|
|
21
|
+
b.attach();
|
|
22
|
+
|
|
23
|
+
b.upsertSegment({ id: "s1", text: "DICT", stage: "canonical" });
|
|
24
|
+
|
|
25
|
+
expect(el.value).toBe("AAA DICTBBB");
|
|
26
|
+
el.remove();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("inserts segment replacing selection, preserving text after selectionEnd", () => {
|
|
30
|
+
const el = textarea("hello old world", 6, 9);
|
|
31
|
+
const b = new NativeBinding(el);
|
|
32
|
+
b.attach();
|
|
33
|
+
|
|
34
|
+
b.upsertSegment({ id: "s1", text: "new", stage: "canonical" });
|
|
35
|
+
|
|
36
|
+
expect(el.value).toBe("hello new world");
|
|
37
|
+
el.remove();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("previews do not remove suffix, suffix stays after preview text", () => {
|
|
41
|
+
const el = textarea("AAA BBB", 4, 4);
|
|
42
|
+
const b = new NativeBinding(el);
|
|
43
|
+
b.attach();
|
|
44
|
+
|
|
45
|
+
b.previewSegment({ id: "s1", text: "PREV" });
|
|
46
|
+
|
|
47
|
+
expect(el.value).toBe("AAA PREVBBB");
|
|
48
|
+
el.remove();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("upsert after preview replaces preview text (same segment)", () => {
|
|
52
|
+
const el = textarea("AAA BBB", 4, 4);
|
|
53
|
+
const b = new NativeBinding(el);
|
|
54
|
+
b.attach();
|
|
55
|
+
|
|
56
|
+
b.previewSegment({ id: "s1", text: "PREV" });
|
|
57
|
+
b.upsertSegment({ id: "s1", text: "FINAL", stage: "canonical" });
|
|
58
|
+
|
|
59
|
+
expect(el.value).toBe("AAA FINALBBB");
|
|
60
|
+
el.remove();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("updates same segmentId in place without duplication", () => {
|
|
64
|
+
const el = textarea("Before.", 7, 7);
|
|
65
|
+
const b = new NativeBinding(el);
|
|
66
|
+
b.attach();
|
|
67
|
+
|
|
68
|
+
b.upsertSegment({ id: "s1", text: " premier", stage: "provisional" });
|
|
69
|
+
b.upsertSegment({ id: "s1", text: " premier segment corrigé", stage: "canonical" });
|
|
70
|
+
|
|
71
|
+
expect(el.value).toBe("Before. premier segment corrigé");
|
|
72
|
+
expect(el.value).not.toContain("premier premier");
|
|
73
|
+
el.remove();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("removes segment and keeps suffix intact", () => {
|
|
77
|
+
const el = textarea("Avant Après", 6, 6);
|
|
78
|
+
const b = new NativeBinding(el);
|
|
79
|
+
b.attach();
|
|
80
|
+
|
|
81
|
+
b.upsertSegment({ id: "s1", text: "DICT ", stage: "canonical" });
|
|
82
|
+
expect(el.value).toBe("Avant DICT Après");
|
|
83
|
+
|
|
84
|
+
b.removeSegment("s1");
|
|
85
|
+
expect(el.value).toBe("Avant Après");
|
|
86
|
+
el.remove();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("suffix preserved after multiple segments", () => {
|
|
90
|
+
const el = textarea("fin", 0, 0);
|
|
91
|
+
const b = new NativeBinding(el);
|
|
92
|
+
b.attach();
|
|
93
|
+
|
|
94
|
+
b.upsertSegment({ id: "s1", text: "un ", stage: "canonical" });
|
|
95
|
+
b.upsertSegment({ id: "s2", text: "deux ", stage: "canonical" });
|
|
96
|
+
|
|
97
|
+
expect(el.value).toBe("un deux fin");
|
|
98
|
+
el.remove();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("getEditorContext reports middle_of_sentence when suffix non-empty", () => {
|
|
102
|
+
const el = textarea("Avant Après", 6, 6);
|
|
103
|
+
const b = new NativeBinding(el);
|
|
104
|
+
b.attach();
|
|
105
|
+
|
|
106
|
+
const ctx = b.getEditorContext("report");
|
|
107
|
+
expect(ctx.insertionMode).toBe("middle_of_sentence");
|
|
108
|
+
expect(ctx.rightContext).toBe("Après");
|
|
109
|
+
el.remove();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("getEditorContext reports append when suffix is empty", () => {
|
|
113
|
+
const el = textarea("Texte.", 6, 6);
|
|
114
|
+
const b = new NativeBinding(el);
|
|
115
|
+
b.attach();
|
|
116
|
+
|
|
117
|
+
const ctx = b.getEditorContext("report");
|
|
118
|
+
expect(ctx.insertionMode).toBe("append");
|
|
119
|
+
el.remove();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("warns and ignores upsert for a removed segment", () => {
|
|
123
|
+
const el = textarea("", 0, 0);
|
|
124
|
+
const warn = vi.fn();
|
|
125
|
+
const b = new NativeBinding(el, { warn });
|
|
126
|
+
b.attach();
|
|
127
|
+
|
|
128
|
+
b.upsertSegment({ id: "s1", text: "texte", stage: "canonical" });
|
|
129
|
+
b.removeSegment("s1");
|
|
130
|
+
b.upsertSegment({ id: "s1", text: "autre", stage: "canonical" });
|
|
131
|
+
|
|
132
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
133
|
+
expect(el.value).toBe("");
|
|
134
|
+
el.remove();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("identity equals the DOM element", () => {
|
|
138
|
+
const el = textarea();
|
|
139
|
+
const b = new NativeBinding(el);
|
|
140
|
+
expect(b.identity).toBe(el);
|
|
141
|
+
el.remove();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("NativeBinding — boundary safety", () => {
|
|
146
|
+
it("adds space when segment collides with existing text", () => {
|
|
147
|
+
const el = textarea("contraste.", 10, 10);
|
|
148
|
+
const b = new NativeBinding(el);
|
|
149
|
+
b.attach();
|
|
150
|
+
|
|
151
|
+
b.upsertSegment({ id: "s1", text: "Indication", stage: "provisional" });
|
|
152
|
+
|
|
153
|
+
expect(el.value).toBe("contraste. Indication");
|
|
154
|
+
el.remove();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("clears sdk prefix when canonical provides its own whitespace", () => {
|
|
158
|
+
const el = textarea("contraste.", 10, 10);
|
|
159
|
+
const b = new NativeBinding(el);
|
|
160
|
+
b.attach();
|
|
161
|
+
|
|
162
|
+
// Provisional arrives without space — SDK adds " ".
|
|
163
|
+
b.upsertSegment({ id: "s1", text: "Indication", stage: "provisional" });
|
|
164
|
+
expect(el.value).toBe("contraste. Indication");
|
|
165
|
+
|
|
166
|
+
// Canonical arrives with its own leading space — SDK prefix must be cleared.
|
|
167
|
+
b.upsertSegment({ id: "s1", text: " Indication corrigée", stage: "canonical" });
|
|
168
|
+
expect(el.value).toBe("contraste. Indication corrigée");
|
|
169
|
+
el.remove();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("preview applies boundary and upsert reuses it", () => {
|
|
173
|
+
const el = textarea("contraste.", 10, 10);
|
|
174
|
+
const b = new NativeBinding(el);
|
|
175
|
+
b.attach();
|
|
176
|
+
|
|
177
|
+
b.previewSegment({ id: "s1", text: "Ind" });
|
|
178
|
+
expect(el.value).toBe("contraste. Ind");
|
|
179
|
+
|
|
180
|
+
b.previewSegment({ id: "s1", text: "Indication" });
|
|
181
|
+
expect(el.value).toBe("contraste. Indication");
|
|
182
|
+
|
|
183
|
+
// Canonical without own prefix — SDK prefix kept.
|
|
184
|
+
b.upsertSegment({ id: "s1", text: "Indication corrigée", stage: "canonical" });
|
|
185
|
+
expect(el.value).toBe("contraste. Indication corrigée");
|
|
186
|
+
el.remove();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("no double space when text already starts with whitespace on provisional", () => {
|
|
190
|
+
const el = textarea("contraste.", 10, 10);
|
|
191
|
+
const b = new NativeBinding(el);
|
|
192
|
+
b.attach();
|
|
193
|
+
|
|
194
|
+
// Backend provisional already has a leading space.
|
|
195
|
+
b.upsertSegment({ id: "s1", text: " Indication", stage: "provisional" });
|
|
196
|
+
|
|
197
|
+
// Should be exactly one space, not two.
|
|
198
|
+
expect(el.value).toBe("contraste. Indication");
|
|
199
|
+
el.remove();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("prepareForDictation resyncs after manual edits between sessions", () => {
|
|
203
|
+
const el = textarea("Avant.", 6, 6);
|
|
204
|
+
const b = new NativeBinding(el);
|
|
205
|
+
b.attach();
|
|
206
|
+
|
|
207
|
+
b.upsertSegment({ id: "s1", text: " dicté", stage: "canonical" });
|
|
208
|
+
expect(el.value).toBe("Avant. dicté");
|
|
209
|
+
|
|
210
|
+
// User stops, deletes dictated text, moves cursor to end of "Avant."
|
|
211
|
+
el.value = "Avant.";
|
|
212
|
+
el.setSelectionRange(6, 6);
|
|
213
|
+
|
|
214
|
+
b.prepareForDictation();
|
|
215
|
+
b.upsertSegment({ id: "s2", text: " suite", stage: "canonical" });
|
|
216
|
+
|
|
217
|
+
expect(el.value).toBe("Avant. suite");
|
|
218
|
+
expect(el.value).not.toContain("dicté");
|
|
219
|
+
el.remove();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("canonical absorb clears segment boundary prefix when text is self-contained", () => {
|
|
223
|
+
const el = textarea("Scanner cérébral ", 17, 17);
|
|
224
|
+
const b = new NativeBinding(el);
|
|
225
|
+
b.attach();
|
|
226
|
+
|
|
227
|
+
b.upsertSegment({
|
|
228
|
+
id: "seg1",
|
|
229
|
+
text: "sans injection de produit de contraste.",
|
|
230
|
+
stage: "provisional",
|
|
231
|
+
});
|
|
232
|
+
b.upsertSegment({
|
|
233
|
+
id: "seg2",
|
|
234
|
+
text: "Avec injection de produits de contraste.",
|
|
235
|
+
stage: "provisional",
|
|
236
|
+
});
|
|
237
|
+
expect(el.value).toBe(
|
|
238
|
+
"Scanner cérébral sans injection de produit de contraste. Avec injection de produits de contraste.",
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
b.upsertSegment({
|
|
242
|
+
id: "seg1",
|
|
243
|
+
text: "Scanner cérébral avec injection de produit de contraste dans un contexte.",
|
|
244
|
+
stage: "canonical",
|
|
245
|
+
});
|
|
246
|
+
b.removeSegment("seg2");
|
|
247
|
+
|
|
248
|
+
expect(el.value).toBe(
|
|
249
|
+
"Scanner cérébral avec injection de produit de contraste dans un contexte.",
|
|
250
|
+
);
|
|
251
|
+
expect(el.value).not.toMatch(/Scanner cérébral Scanner cérébral/);
|
|
252
|
+
el.remove();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("canonical merge fragment keeps segment boundary prefix", () => {
|
|
256
|
+
const el = textarea("Scanner cérébral ", 17, 17);
|
|
257
|
+
const b = new NativeBinding(el);
|
|
258
|
+
b.attach();
|
|
259
|
+
|
|
260
|
+
b.upsertSegment({
|
|
261
|
+
id: "seg1",
|
|
262
|
+
text: "sans injection de produit de contraste.",
|
|
263
|
+
stage: "provisional",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
b.upsertSegment({
|
|
267
|
+
id: "seg1",
|
|
268
|
+
text: "avec injection de produit de contraste.",
|
|
269
|
+
stage: "canonical",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(el.value).toBe(
|
|
273
|
+
"Scanner cérébral avec injection de produit de contraste.",
|
|
274
|
+
);
|
|
275
|
+
el.remove();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { EditorContext } from "ephia-protocol";
|
|
2
|
+
import type { EphiaBinding, PreviewSegmentInput, UpsertSegmentInput } from "../EphiaBinding";
|
|
3
|
+
import { ensureMinimalAppendBoundary } from "../insertion-boundary";
|
|
4
|
+
|
|
5
|
+
// Bypass React controlled components — use native setter to avoid onChange fights.
|
|
6
|
+
function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
|
7
|
+
const proto =
|
|
8
|
+
el instanceof HTMLTextAreaElement
|
|
9
|
+
? HTMLTextAreaElement.prototype
|
|
10
|
+
: HTMLInputElement.prototype;
|
|
11
|
+
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
|
|
12
|
+
setter?.call(el, value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function dispatchInputEvent(el: HTMLInputElement | HTMLTextAreaElement): void {
|
|
16
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Binding V2 pour <textarea> et <input type="text">.
|
|
21
|
+
*
|
|
22
|
+
* Modèle : prefix | segments dictés | preview | suffix
|
|
23
|
+
*
|
|
24
|
+
* Le prefix et le suffix sont verrouillés à l'attach() depuis la sélection
|
|
25
|
+
* courante. Tous les segments dictés sont insérés entre les deux.
|
|
26
|
+
* Même segmentId = mise à jour en place (jamais d'append).
|
|
27
|
+
*/
|
|
28
|
+
export class NativeBinding implements EphiaBinding {
|
|
29
|
+
readonly kind = "native";
|
|
30
|
+
readonly identity: HTMLInputElement | HTMLTextAreaElement;
|
|
31
|
+
|
|
32
|
+
private readonly el: HTMLInputElement | HTMLTextAreaElement;
|
|
33
|
+
private readonly warn: (msg: string, details?: Record<string, unknown>) => void;
|
|
34
|
+
|
|
35
|
+
// Text before/after the dictation zone, locked at attach().
|
|
36
|
+
private prefix = "";
|
|
37
|
+
private suffix = "";
|
|
38
|
+
|
|
39
|
+
// Committed segment texts, keyed by id, in insertion order (Map preserves insertion order).
|
|
40
|
+
private readonly segmentTexts = new Map<string, string>();
|
|
41
|
+
// Tracks all segment ids ever seen (for idempotent range-lost guard).
|
|
42
|
+
private readonly knownSegmentIds = new Set<string>();
|
|
43
|
+
// Boundary prefix computed once per segment at first insertion (preview or upsert).
|
|
44
|
+
private readonly segmentBoundaryPrefixes = new Map<string, string>();
|
|
45
|
+
|
|
46
|
+
// Current streaming preview (displayed but not committed).
|
|
47
|
+
private previewSegmentId: string | null = null;
|
|
48
|
+
private previewText = "";
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
el: HTMLInputElement | HTMLTextAreaElement,
|
|
52
|
+
options?: { warn?: (msg: string, details?: Record<string, unknown>) => void }
|
|
53
|
+
) {
|
|
54
|
+
this.el = el;
|
|
55
|
+
this.identity = el;
|
|
56
|
+
this.warn = options?.warn ?? ((msg, d) => console.warn(msg, d));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
attach(): void {
|
|
60
|
+
this._syncInsertionAnchor();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resync prefix/suffix and clear segment memory from the current DOM value.
|
|
65
|
+
* Called before each dictation so manual edits between sessions are preserved.
|
|
66
|
+
*/
|
|
67
|
+
prepareForDictation(): void {
|
|
68
|
+
this._syncInsertionAnchor();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
detach(): void {
|
|
72
|
+
this.segmentTexts.clear();
|
|
73
|
+
this.knownSegmentIds.clear();
|
|
74
|
+
this.segmentBoundaryPrefixes.clear();
|
|
75
|
+
this.previewSegmentId = null;
|
|
76
|
+
this.previewText = "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getText(): string {
|
|
80
|
+
return this.prefix + this._committedText() + this.suffix;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getEditorContext(targetId = ""): EditorContext {
|
|
84
|
+
const committed = this._committedText();
|
|
85
|
+
const value = this.prefix + committed + this.suffix;
|
|
86
|
+
const cursorOffset = this.prefix.length + committed.length;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
targetId,
|
|
90
|
+
documentEmpty: value.trim().length === 0,
|
|
91
|
+
insertionMode: this.suffix.length > 0 ? "middle_of_sentence" : "append",
|
|
92
|
+
leftContext: value.slice(Math.max(0, cursorOffset - 400), cursorOffset),
|
|
93
|
+
rightContext: value.slice(cursorOffset, cursorOffset + 200),
|
|
94
|
+
selectedText: null,
|
|
95
|
+
cursorOffset,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
previewSegment(input: PreviewSegmentInput): void {
|
|
100
|
+
// Compute boundary prefix once per segment (first preview for this id).
|
|
101
|
+
if (!this.segmentBoundaryPrefixes.has(input.id)) {
|
|
102
|
+
const leftText = this.prefix + this._committedText();
|
|
103
|
+
const withBoundary = ensureMinimalAppendBoundary(leftText, input.text);
|
|
104
|
+
const prefix = withBoundary.length > input.text.length
|
|
105
|
+
? withBoundary.slice(0, withBoundary.length - input.text.length)
|
|
106
|
+
: "";
|
|
107
|
+
this.segmentBoundaryPrefixes.set(input.id, prefix);
|
|
108
|
+
}
|
|
109
|
+
this.previewSegmentId = input.id;
|
|
110
|
+
const boundaryPrefix = this.segmentBoundaryPrefixes.get(input.id) ?? "";
|
|
111
|
+
this.previewText = boundaryPrefix + input.text;
|
|
112
|
+
this._render();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
upsertSegment(input: UpsertSegmentInput): void {
|
|
116
|
+
if (this.previewSegmentId === input.id) {
|
|
117
|
+
this.previewSegmentId = null;
|
|
118
|
+
this.previewText = "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (this.knownSegmentIds.has(input.id) && !this.segmentTexts.has(input.id)) {
|
|
122
|
+
this.warn("[ephia:native] segment range lost; upsert ignored", { segmentId: input.id });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!this.segmentBoundaryPrefixes.has(input.id)) {
|
|
127
|
+
// First upsert for this segment with no prior preview — compute boundary from left context.
|
|
128
|
+
const leftText = this.prefix + this._committedText();
|
|
129
|
+
const withBoundary = ensureMinimalAppendBoundary(leftText, input.text);
|
|
130
|
+
const prefix = withBoundary.length > input.text.length
|
|
131
|
+
? withBoundary.slice(0, withBoundary.length - input.text.length)
|
|
132
|
+
: "";
|
|
133
|
+
this.segmentBoundaryPrefixes.set(input.id, prefix);
|
|
134
|
+
} else if (input.text.length > 0 && /^\s/.test(input.text)) {
|
|
135
|
+
// Backend canonical provides its own boundary — clear ours to avoid double spacing.
|
|
136
|
+
this.segmentBoundaryPrefixes.set(input.id, "");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.segmentTexts.set(input.id, input.text);
|
|
140
|
+
this.knownSegmentIds.add(input.id);
|
|
141
|
+
|
|
142
|
+
if (input.stage === "canonical") {
|
|
143
|
+
this._reconcileCanonicalSegmentInsertion(input.id, input.text);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this._render();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
removeSegment(id: string): void {
|
|
150
|
+
if (this.previewSegmentId === id) {
|
|
151
|
+
this.previewSegmentId = null;
|
|
152
|
+
this.previewText = "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this.segmentTexts.delete(id);
|
|
156
|
+
this.segmentBoundaryPrefixes.delete(id);
|
|
157
|
+
this.knownSegmentIds.add(id);
|
|
158
|
+
this._render();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
removeSegments(ids: string[]): void {
|
|
162
|
+
for (const id of ids) this.removeSegment(id);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private _syncInsertionAnchor(): void {
|
|
166
|
+
const start = this.el.selectionStart ?? this.el.value.length;
|
|
167
|
+
const end = this.el.selectionEnd ?? start;
|
|
168
|
+
|
|
169
|
+
this.prefix = this.el.value.slice(0, start);
|
|
170
|
+
this.suffix = this.el.value.slice(end);
|
|
171
|
+
|
|
172
|
+
this.segmentTexts.clear();
|
|
173
|
+
this.knownSegmentIds.clear();
|
|
174
|
+
this.segmentBoundaryPrefixes.clear();
|
|
175
|
+
this.previewSegmentId = null;
|
|
176
|
+
this.previewText = "";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ------------------------------------------------------------------
|
|
180
|
+
// Internals
|
|
181
|
+
// ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
private _committedText(): string {
|
|
184
|
+
return [...this.segmentTexts.keys()].map((id) => {
|
|
185
|
+
const prefix = this.segmentBoundaryPrefixes.get(id) ?? "";
|
|
186
|
+
return prefix + (this.segmentTexts.get(id) ?? "");
|
|
187
|
+
}).join("");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Canonical upserts (incl. absorb/merge) may carry the full corrected phrase.
|
|
192
|
+
* Drop segment/binding prefixes that would duplicate its opening.
|
|
193
|
+
*/
|
|
194
|
+
private _reconcileCanonicalSegmentInsertion(segmentId: string, text: string): void {
|
|
195
|
+
const boundaryPrefix = this.segmentBoundaryPrefixes.get(segmentId);
|
|
196
|
+
if (boundaryPrefix) {
|
|
197
|
+
const boundaryCore = boundaryPrefix.trimEnd();
|
|
198
|
+
if (
|
|
199
|
+
text.startsWith(boundaryPrefix)
|
|
200
|
+
|| (boundaryCore.length > 0 && text.startsWith(boundaryCore))
|
|
201
|
+
) {
|
|
202
|
+
this.segmentBoundaryPrefixes.set(segmentId, "");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this._trimBindingPrefixOverlap(text);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Trim binding.prefix when canonical text already opens with its trailing words. */
|
|
210
|
+
private _trimBindingPrefixOverlap(text: string): void {
|
|
211
|
+
if (!this.prefix || !text) return;
|
|
212
|
+
|
|
213
|
+
for (let overlap = Math.min(this.prefix.length, text.length); overlap > 0; overlap--) {
|
|
214
|
+
const suffix = this.prefix.slice(this.prefix.length - overlap);
|
|
215
|
+
if (suffix.trim().length === 0) continue;
|
|
216
|
+
if (text.startsWith(suffix)) {
|
|
217
|
+
this.prefix = this.prefix.slice(0, this.prefix.length - overlap);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private _render(): void {
|
|
224
|
+
const committed = this._committedText();
|
|
225
|
+
const next = this.prefix + committed + this.previewText + this.suffix;
|
|
226
|
+
|
|
227
|
+
if (this.el.value !== next) {
|
|
228
|
+
setNativeValue(this.el, next);
|
|
229
|
+
dispatchInputEvent(this.el);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const insertionPos = this.prefix.length + committed.length;
|
|
233
|
+
try {
|
|
234
|
+
this.el.setSelectionRange(insertionPos, insertionPos);
|
|
235
|
+
} catch {
|
|
236
|
+
/* noop */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Résout une cible de binding (string selector ou Element).
|
|
3
|
+
* SSR-safe : vérifie l'existence de document.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function resolveTarget(target: string | Element | null | undefined): Element | null {
|
|
7
|
+
if (!target) return null;
|
|
8
|
+
if (typeof target === "string") {
|
|
9
|
+
if (typeof document === "undefined") return null;
|
|
10
|
+
try {
|
|
11
|
+
return document.querySelector(target);
|
|
12
|
+
} catch {
|
|
13
|
+
console.warn(`[ephia] invalid target selector: "${target}"`);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return target;
|
|
18
|
+
}
|