@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.
Files changed (247) hide show
  1. package/README.md +89 -0
  2. package/dist/EphiaBinding-BvRmlqqC.d.ts +36 -0
  3. package/dist/EphiaFloatingButton-CxiF86VW.d.ts +65 -0
  4. package/dist/EphiaTextarea-B4_CAVUg.d.ts +183 -0
  5. package/dist/NativeBinding-ChG0GeSz.d.ts +53 -0
  6. package/dist/TargetBinding-BKGQwUMc.d.ts +89 -0
  7. package/dist/TiptapBinding-B-agfV2H.d.ts +45 -0
  8. package/dist/Transport-zdeA4Pou.d.ts +63 -0
  9. package/dist/audio-state-kZ3KSvux.d.ts +39 -0
  10. package/dist/chunk-35AJK2IO.js +1 -0
  11. package/dist/chunk-35AJK2IO.js.map +1 -0
  12. package/dist/chunk-3LXZODL4.js +886 -0
  13. package/dist/chunk-3LXZODL4.js.map +1 -0
  14. package/dist/chunk-5IK5TLSK.js +67 -0
  15. package/dist/chunk-5IK5TLSK.js.map +1 -0
  16. package/dist/chunk-7E43RY75.js +9 -0
  17. package/dist/chunk-7E43RY75.js.map +1 -0
  18. package/dist/chunk-A5UEXJ5R.js +183 -0
  19. package/dist/chunk-A5UEXJ5R.js.map +1 -0
  20. package/dist/chunk-AEE554FT.js +51 -0
  21. package/dist/chunk-AEE554FT.js.map +1 -0
  22. package/dist/chunk-DIEWY3IT.js +1332 -0
  23. package/dist/chunk-DIEWY3IT.js.map +1 -0
  24. package/dist/chunk-EGIAN7FH.js +18 -0
  25. package/dist/chunk-EGIAN7FH.js.map +1 -0
  26. package/dist/chunk-EMOEAPVU.js +486 -0
  27. package/dist/chunk-EMOEAPVU.js.map +1 -0
  28. package/dist/chunk-IDC7FHIZ.js +40 -0
  29. package/dist/chunk-IDC7FHIZ.js.map +1 -0
  30. package/dist/chunk-ITJFN3VM.js +601 -0
  31. package/dist/chunk-ITJFN3VM.js.map +1 -0
  32. package/dist/chunk-K24GNU27.js +22 -0
  33. package/dist/chunk-K24GNU27.js.map +1 -0
  34. package/dist/chunk-LXMCRXXF.js +778 -0
  35. package/dist/chunk-LXMCRXXF.js.map +1 -0
  36. package/dist/chunk-MJCEOOLW.js +122 -0
  37. package/dist/chunk-MJCEOOLW.js.map +1 -0
  38. package/dist/chunk-N7U5M3VZ.js +33 -0
  39. package/dist/chunk-N7U5M3VZ.js.map +1 -0
  40. package/dist/chunk-PSYX674B.js +27 -0
  41. package/dist/chunk-PSYX674B.js.map +1 -0
  42. package/dist/chunk-RFQRV7ML.js +33 -0
  43. package/dist/chunk-RFQRV7ML.js.map +1 -0
  44. package/dist/chunk-THNHRV2B.js +18 -0
  45. package/dist/chunk-THNHRV2B.js.map +1 -0
  46. package/dist/chunk-VSLGR64U.js +62 -0
  47. package/dist/chunk-VSLGR64U.js.map +1 -0
  48. package/dist/chunk-W2ZP674X.js +346 -0
  49. package/dist/chunk-W2ZP674X.js.map +1 -0
  50. package/dist/chunk-YWZUMUYE.js +695 -0
  51. package/dist/chunk-YWZUMUYE.js.map +1 -0
  52. package/dist/client-options-Uo6jXO8k.d.ts +64 -0
  53. package/dist/connection-state-Bk33YprE.d.ts +32 -0
  54. package/dist/core/bindings/index.d.ts +24 -0
  55. package/dist/core/bindings/index.js +1025 -0
  56. package/dist/core/bindings/index.js.map +1 -0
  57. package/dist/core/index.d.ts +383 -0
  58. package/dist/core/index.js +1284 -0
  59. package/dist/core/index.js.map +1 -0
  60. package/dist/createEphiaClient-BhdZ183V.d.ts +69 -0
  61. package/dist/devices/speechmike/index.d.ts +148 -0
  62. package/dist/devices/speechmike/index.js +40 -0
  63. package/dist/devices/speechmike/index.js.map +1 -0
  64. package/dist/headless/index.d.ts +10 -0
  65. package/dist/headless/index.js +25 -0
  66. package/dist/headless/index.js.map +1 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +119 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/react/index.d.ts +38 -0
  71. package/dist/react/index.js +70 -0
  72. package/dist/react/index.js.map +1 -0
  73. package/dist/rich-editor/index.d.ts +46 -0
  74. package/dist/rich-editor/index.js +13 -0
  75. package/dist/rich-editor/index.js.map +1 -0
  76. package/dist/schema-B2ycPlNB.d.ts +87 -0
  77. package/dist/session-APaXR48R.d.ts +12 -0
  78. package/dist/shared/index.d.ts +16 -0
  79. package/dist/shared/index.js +30 -0
  80. package/dist/shared/index.js.map +1 -0
  81. package/dist/style.css +1093 -0
  82. package/dist/testing/index.d.ts +84 -0
  83. package/dist/testing/index.js +36 -0
  84. package/dist/testing/index.js.map +1 -0
  85. package/dist/types-D5SXPSwR.d.ts +32 -0
  86. package/dist/ui/index.d.ts +30 -0
  87. package/dist/ui/index.js +34 -0
  88. package/dist/ui/index.js.map +1 -0
  89. package/dist/useEphiaSpeechMike-CjD7DWnh.d.ts +64 -0
  90. package/package.json +110 -0
  91. package/src/core/audio/audio-worklet-source.ts +30 -0
  92. package/src/core/audio/index.ts +3 -0
  93. package/src/core/audio/voice-level-meter.test.ts +27 -0
  94. package/src/core/audio/voice-level-meter.ts +270 -0
  95. package/src/core/bindings/EphiaBinding.ts +41 -0
  96. package/src/core/bindings/SegmentBindingBridge.test.ts +422 -0
  97. package/src/core/bindings/SegmentBindingBridge.ts +377 -0
  98. package/src/core/bindings/TargetBinding.ts +142 -0
  99. package/src/core/bindings/adapters/NativeAdapter.test.ts +85 -0
  100. package/src/core/bindings/adapters/NativeAdapter.ts +216 -0
  101. package/src/core/bindings/adapters/ProseMirrorAdapter.ts +231 -0
  102. package/src/core/bindings/adapters/index.ts +2 -0
  103. package/src/core/bindings/binding-factory.ts +78 -0
  104. package/src/core/bindings/detect-editor-type.ts +87 -0
  105. package/src/core/bindings/index.ts +13 -0
  106. package/src/core/bindings/insertion-boundary.test.ts +38 -0
  107. package/src/core/bindings/insertion-boundary.ts +26 -0
  108. package/src/core/bindings/native/NativeBinding.test.ts +277 -0
  109. package/src/core/bindings/native/NativeBinding.ts +239 -0
  110. package/src/core/bindings/resolver.ts +18 -0
  111. package/src/core/bindings/targets/codemirror.binding.ts +293 -0
  112. package/src/core/bindings/targets/contenteditable.binding.ts +452 -0
  113. package/src/core/bindings/targets/index.ts +10 -0
  114. package/src/core/bindings/targets/monaco.binding.ts +315 -0
  115. package/src/core/bindings/targets/tiptap.binding.test.ts +417 -0
  116. package/src/core/bindings/targets/tiptap.binding.ts +1192 -0
  117. package/src/core/bindings/tiptap/TiptapBinding.test.ts +63 -0
  118. package/src/core/bindings/tiptap/TiptapBinding.ts +464 -0
  119. package/src/core/bindings/types.ts +41 -0
  120. package/src/core/client/EphiaAudioClient.ts +654 -0
  121. package/src/core/client/audio-capture.ts +263 -0
  122. package/src/core/client/client-options.ts +39 -0
  123. package/src/core/client/client-state.ts +18 -0
  124. package/src/core/client/constants.ts +23 -0
  125. package/src/core/client/session-api.ts +415 -0
  126. package/src/core/connection/connection-state.ts +78 -0
  127. package/src/core/connection/index.ts +6 -0
  128. package/src/core/index.ts +47 -0
  129. package/src/core/operations/textToDocumentOperations.test.ts +69 -0
  130. package/src/core/operations/textToDocumentOperations.ts +92 -0
  131. package/src/core/runtime/DictationRuntime.test.ts +578 -0
  132. package/src/core/runtime/DictationRuntime.ts +434 -0
  133. package/src/core/runtime/TranscriptApplier.test.ts +355 -0
  134. package/src/core/runtime/TranscriptApplier.ts +229 -0
  135. package/src/core/runtime/index.ts +18 -0
  136. package/src/core/session/index.ts +2 -0
  137. package/src/core/session/session-machine.test.ts +16 -0
  138. package/src/core/session/session-machine.ts +59 -0
  139. package/src/core/targets/EditorContextCollector.ts +71 -0
  140. package/src/core/targets/TargetManager.test.ts +194 -0
  141. package/src/core/targets/TargetManager.ts +194 -0
  142. package/src/core/targets/index.ts +10 -0
  143. package/src/core/text-processing/index.ts +11 -0
  144. package/src/core/text-processing/overlap.test.ts +35 -0
  145. package/src/core/text-processing/overlap.ts +101 -0
  146. package/src/core/text-processing/voice-formatting.normalizer.test.ts +132 -0
  147. package/src/core/text-processing/voice-formatting.normalizer.ts +284 -0
  148. package/src/core/transcript/client-transcript.reducer.ts +366 -0
  149. package/src/core/transcript/client-transcript.state.ts +25 -0
  150. package/src/core/transcript/index.ts +19 -0
  151. package/src/core/transcript/transcript.assembler.test.ts +205 -0
  152. package/src/core/transcript/transcript.assembler.ts +152 -0
  153. package/src/core/transcript/transcript.reducer.test.ts +199 -0
  154. package/src/core/transcript/transcript.reducer.ts +771 -0
  155. package/src/core/transcript/transcript.state.ts +123 -0
  156. package/src/core/transport/LiveKitTransport.publish.test.ts +226 -0
  157. package/src/core/transport/LiveKitTransport.ts +459 -0
  158. package/src/core/transport/MockTransport.ts +231 -0
  159. package/src/core/transport/Transport.ts +82 -0
  160. package/src/debug/sdk-debug-collector.ts +79 -0
  161. package/src/devices/index.ts +2 -0
  162. package/src/devices/speechmike/__tests__/EphiaSpeechMikeProvider.test.tsx +99 -0
  163. package/src/devices/speechmike/__tests__/speechmike-audio-resolver.test.ts +96 -0
  164. package/src/devices/speechmike/__tests__/speechmike-button-router.test.ts +66 -0
  165. package/src/devices/speechmike/__tests__/speechmike-device-manager.test.ts +201 -0
  166. package/src/devices/speechmike/__tests__/speechmike-led-controller.test.ts +68 -0
  167. package/src/devices/speechmike/browser.ts +80 -0
  168. package/src/devices/speechmike/constants.ts +74 -0
  169. package/src/devices/speechmike/dictation-support-loader.ts +81 -0
  170. package/src/devices/speechmike/index.ts +11 -0
  171. package/src/devices/speechmike/react/EphiaSpeechMikeContext.ts +34 -0
  172. package/src/devices/speechmike/react/EphiaSpeechMikeProvider.tsx +287 -0
  173. package/src/devices/speechmike/react/useEphiaSpeechMike.ts +26 -0
  174. package/src/devices/speechmike/speechmike-audio-resolver.ts +58 -0
  175. package/src/devices/speechmike/speechmike-button-router.ts +73 -0
  176. package/src/devices/speechmike/speechmike-device-manager.ts +461 -0
  177. package/src/devices/speechmike/speechmike-led-controller.ts +78 -0
  178. package/src/devices/speechmike/types.ts +96 -0
  179. package/src/dictation_support.d.ts +31 -0
  180. package/src/global.d.ts +10 -0
  181. package/src/headless/createEphiaClient.ts +220 -0
  182. package/src/headless/index.ts +18 -0
  183. package/src/index.ts +89 -0
  184. package/src/react/EphiaAuto.tsx +87 -0
  185. package/src/react/components/EphiaDictationButton.tsx +88 -0
  186. package/src/react/components/EphiaStatusBar.tsx +59 -0
  187. package/src/react/components/EphiaTextarea.tsx +295 -0
  188. package/src/react/ephia-react.css +318 -0
  189. package/src/react/hooks/targets/index.ts +3 -0
  190. package/src/react/hooks/targets/useEphiaCodemirror.ts +35 -0
  191. package/src/react/hooks/targets/useEphiaMonaco.ts +35 -0
  192. package/src/react/hooks/targets/useEphiaTiptap.ts +23 -0
  193. package/src/react/hooks/useEphia.lifecycle.test.tsx +389 -0
  194. package/src/react/hooks/useEphia.ts +367 -0
  195. package/src/react/hooks/useEphiaDiscardTarget.ts +53 -0
  196. package/src/react/hooks/useEphiaServerEvent.ts +33 -0
  197. package/src/react/hooks/useEphiaTarget.ts +47 -0
  198. package/src/react/hooks/useEphiaTranscript.ts +22 -0
  199. package/src/react/index.ts +58 -0
  200. package/src/react/provider/EphiaContext.ts +63 -0
  201. package/src/react/provider/EphiaInternalContext.ts +32 -0
  202. package/src/react/provider/EphiaProvider.tsx +373 -0
  203. package/src/react/registry/binding-factory.ts +7 -0
  204. package/src/react/registry/detect-editor-type.ts +2 -0
  205. package/src/react/registry/events.ts +37 -0
  206. package/src/react/registry/registries/CodeMirrorInstanceRegistry.ts +24 -0
  207. package/src/react/registry/registries/MonacoInstanceRegistry.ts +23 -0
  208. package/src/react/registry/registries/TargetRegistry.ts +327 -0
  209. package/src/react/registry/registries/TiptapInstanceRegistry.ts +43 -0
  210. package/src/react/registry/registries/index.ts +5 -0
  211. package/src/react/store/create-ephia-store.ts +36 -0
  212. package/src/react/store/types.ts +41 -0
  213. package/src/react/utils/flash-range.ts +24 -0
  214. package/src/react/utils/index.ts +1 -0
  215. package/src/rich-editor/adapters/tiptap.test.ts +86 -0
  216. package/src/rich-editor/adapters/tiptap.ts +23 -0
  217. package/src/rich-editor/index.ts +3 -0
  218. package/src/rich-editor/types.ts +24 -0
  219. package/src/rich-editor/use-ephia-rich-editor.test.tsx +202 -0
  220. package/src/rich-editor/use-ephia-rich-editor.ts +47 -0
  221. package/src/shared/config/endpoint.test.ts +45 -0
  222. package/src/shared/config/endpoint.ts +39 -0
  223. package/src/shared/config/schema.ts +32 -0
  224. package/src/shared/effective-text.ts +13 -0
  225. package/src/shared/errors/EphiaSdkError.ts +54 -0
  226. package/src/shared/errors/messages.ts +40 -0
  227. package/src/shared/index.ts +27 -0
  228. package/src/shared/state/audio-state.ts +45 -0
  229. package/src/shared/state/index.ts +2 -0
  230. package/src/shared/store/document-store.ts +32 -0
  231. package/src/shared/store/index.ts +2 -0
  232. package/src/shared/types/editors.ts +28 -0
  233. package/src/shared/types/session.ts +12 -0
  234. package/src/style.css +2 -0
  235. package/src/testing/index.tsx +60 -0
  236. package/src/ui/assets/ephia-logo.svg +4 -0
  237. package/src/ui/components/EphiaLogo.tsx +77 -0
  238. package/src/ui/index.ts +24 -0
  239. package/src/ui/primitives/Button.tsx +53 -0
  240. package/src/ui/primitives/Spinner.tsx +21 -0
  241. package/src/ui/primitives/index.ts +5 -0
  242. package/src/ui/recorder/EphiaFloatingButton.tsx +489 -0
  243. package/src/ui/recorder/MinimalProcessingBars.tsx +122 -0
  244. package/src/ui/recorder/StandardIntensityVisualizer.tsx +148 -0
  245. package/src/ui/recorder/appearance.ts +9 -0
  246. package/src/ui/recorder/index.ts +8 -0
  247. package/src/ui/theme.css +775 -0
@@ -0,0 +1,377 @@
1
+ import type {
2
+ TargetBinding,
3
+ SessionAnchor,
4
+ BeginSessionOptions,
5
+ CommitFinalOptions,
6
+ } from "./TargetBinding";
7
+ import type { DocumentOperation } from "ephia-protocol";
8
+ import type { BindingAdapter, SegmentStatus } from "./types";
9
+
10
+ export interface SegmentBindingBridgeOptions {
11
+ onSegmentReviewing?(): void;
12
+ onSegmentReviewDone?(): void;
13
+ partialDebounceMs?: number;
14
+ }
15
+
16
+ const DEFAULT_PARTIAL_DEBOUNCE_MS = 120;
17
+
18
+ /**
19
+ * Bridge qui adapte l'interface segment-based (BindingAdapter) vers
20
+ * l'interface event-based legacy (TargetBinding).
21
+ *
22
+ * Le reste du SDK (EphiaProvider, useEphia) continue de parler TargetBinding.
23
+ * Les nouveaux adapters implémentent BindingAdapter.
24
+ */
25
+ export class SegmentBindingBridge implements TargetBinding {
26
+ kind = "segment-bridge";
27
+
28
+ private adapter: BindingAdapter;
29
+ private element: HTMLElement;
30
+ private anchor: SessionAnchor | null = null;
31
+ private _knownSegmentIds = new Set<string>();
32
+ private _committedSegmentIds = new Set<string>();
33
+ private _committedSegmentTexts = new Map<string, string>();
34
+ private _opts: SegmentBindingBridgeOptions;
35
+ private _pendingInserts = new Map<string, { timer: ReturnType<typeof setTimeout>; text: string; revision: number }>();
36
+
37
+ constructor(adapter: BindingAdapter, element: HTMLElement, opts: SegmentBindingBridgeOptions = {}) {
38
+ this.adapter = adapter;
39
+ this.element = element;
40
+ this._opts = opts;
41
+ }
42
+
43
+ attach(): void {
44
+ this.adapter.attach();
45
+ }
46
+
47
+ detach(): void {
48
+ this._clearAllPendingInserts();
49
+ this.adapter.detach();
50
+ this.anchor = null;
51
+ this._knownSegmentIds.clear();
52
+ this._committedSegmentIds.clear();
53
+ this._committedSegmentTexts.clear();
54
+ }
55
+
56
+ // ─── Session lifecycle ─────────────────────────────────────────────────────
57
+
58
+ beginSession(opts: BeginSessionOptions = {}): SessionAnchor {
59
+ this.adapter.attach();
60
+ const replaceSelection = opts.replaceSelection ?? true;
61
+ const isFocused = document.activeElement === this.element;
62
+
63
+ let start = 0;
64
+ let end = 0;
65
+ let hadSelection = false;
66
+
67
+ if (this.element instanceof HTMLTextAreaElement || this.element instanceof HTMLInputElement) {
68
+ start = this.element.selectionStart ?? this.element.value.length;
69
+ end = this.element.selectionEnd ?? start;
70
+ hadSelection = start !== end;
71
+ } else if (this.element.getAttribute("contenteditable") === "true") {
72
+ const sel = window.getSelection();
73
+ if (sel && sel.rangeCount > 0 && this.element.contains(sel.anchorNode)) {
74
+ const range = sel.getRangeAt(0);
75
+ const preCaret = range.cloneRange();
76
+ preCaret.selectNodeContents(this.element);
77
+ preCaret.setEnd(range.startContainer, range.startOffset);
78
+ start = preCaret.toString().length;
79
+ const postCaret = range.cloneRange();
80
+ postCaret.selectNodeContents(this.element);
81
+ postCaret.setEnd(range.endContainer, range.endOffset);
82
+ end = postCaret.toString().length;
83
+ hadSelection = start !== end;
84
+ } else {
85
+ start = this.element.textContent?.length ?? 0;
86
+ end = start;
87
+ }
88
+ }
89
+
90
+ if (!isFocused) {
91
+ start = this.adapter.getTextContent().length;
92
+ end = start;
93
+ hadSelection = false;
94
+ }
95
+
96
+ if (hadSelection && replaceSelection) {
97
+ // On ne supprime pas ici — l'adapter reçoit l'anchor et gère la plage.
98
+ }
99
+
100
+ this.anchor = {
101
+ initialStart: start,
102
+ initialEnd: end,
103
+ hadSelection,
104
+ end: hadSelection && replaceSelection ? start : end,
105
+ selectionPendingDelete: hadSelection && replaceSelection,
106
+ };
107
+ this.adapter.beginSession?.(this.anchor);
108
+
109
+ return this.anchor;
110
+ }
111
+
112
+ endSession(): void {
113
+ this._clearAllPendingInserts();
114
+ this.adapter.endSession?.();
115
+ this.anchor = null;
116
+ this.adapter.detach();
117
+ this._knownSegmentIds.clear();
118
+ this._committedSegmentIds.clear();
119
+ this._committedSegmentTexts.clear();
120
+ }
121
+
122
+ getSessionAnchor(): SessionAnchor | null {
123
+ return this.anchor;
124
+ }
125
+
126
+ // ─── Cursor / Range rects (fallback sur l'élément) ─────────────────────────
127
+
128
+ getCursorRect(): DOMRect | null {
129
+ try {
130
+ if (this.element instanceof HTMLTextAreaElement || this.element instanceof HTMLInputElement) {
131
+ const pos = this.anchor?.end ?? this.element.selectionStart ?? this.element.value.length;
132
+ // Simplification : retourne le rect de l'élément
133
+ return this.element.getBoundingClientRect();
134
+ }
135
+ return this.element.getBoundingClientRect();
136
+ } catch {
137
+ return this.element.getBoundingClientRect();
138
+ }
139
+ }
140
+
141
+ getRangeRect(_start: number, _end: number): DOMRect | null {
142
+ return this.element.getBoundingClientRect();
143
+ }
144
+
145
+ // ─── Streaming insertion (traduction segment → partial/final) ──────────────
146
+
147
+ insertPartial(segmentId: string, text: string, revision: number): void {
148
+ const pending = this._pendingInserts.get(segmentId);
149
+ if (pending) {
150
+ if (revision < pending.revision) return;
151
+ clearTimeout(pending.timer);
152
+ }
153
+
154
+ const timer = setTimeout(() => {
155
+ this._pendingInserts.delete(segmentId);
156
+ this._doInsertPartial(segmentId, text, revision);
157
+ }, this._opts.partialDebounceMs ?? DEFAULT_PARTIAL_DEBOUNCE_MS);
158
+
159
+ this._pendingInserts.set(segmentId, { timer, text, revision });
160
+ }
161
+
162
+ private _doInsertPartial(segmentId: string, text: string, _revision: number): void {
163
+ const existing = this._knownSegmentIds.has(segmentId);
164
+ if (existing) {
165
+ this.adapter.updateSegment(segmentId, text, "streaming");
166
+ } else {
167
+ this._knownSegmentIds.add(segmentId);
168
+ this.adapter.insertSegment({ id: segmentId, text, status: "streaming" });
169
+ }
170
+ if (this.anchor) {
171
+ this.anchor.end = this.adapter.getCursorOffset?.() ?? this.anchor.end;
172
+ }
173
+ }
174
+
175
+ commitFinal(segmentId: string, text: string, options?: CommitFinalOptions): void {
176
+ if (
177
+ this._committedSegmentIds.has(segmentId) &&
178
+ this._committedSegmentTexts.get(segmentId) === text
179
+ ) {
180
+ return;
181
+ }
182
+
183
+ // Flush immédiat si une preview est en attente pour ce segment
184
+ const pending = this._pendingInserts.get(segmentId);
185
+ if (pending) {
186
+ clearTimeout(pending.timer);
187
+ this._pendingInserts.delete(segmentId);
188
+ }
189
+
190
+ const existing = this._knownSegmentIds.has(segmentId);
191
+ if (existing) {
192
+ this.adapter.updateSegment(segmentId, text, "committed");
193
+ } else {
194
+ // Fallback : si un final arrive sans partial (rare), insérer directement
195
+ this._knownSegmentIds.add(segmentId);
196
+ this.adapter.insertSegment({ id: segmentId, text, status: "committed" });
197
+ }
198
+ this.adapter.commitSegment(segmentId);
199
+ this._committedSegmentIds.add(segmentId);
200
+ this._committedSegmentTexts.set(segmentId, text);
201
+ for (const absorbedId of options?.absorbedSegmentIds ?? []) {
202
+ if (absorbedId === segmentId) continue;
203
+ const pendingAbsorbed = this._pendingInserts.get(absorbedId);
204
+ if (pendingAbsorbed) {
205
+ clearTimeout(pendingAbsorbed.timer);
206
+ this._pendingInserts.delete(absorbedId);
207
+ }
208
+ if (!this._knownSegmentIds.has(absorbedId)) continue;
209
+ this.adapter.removeSegment(absorbedId);
210
+ this._knownSegmentIds.delete(absorbedId);
211
+ this._committedSegmentIds.delete(absorbedId);
212
+ this._committedSegmentTexts.delete(absorbedId);
213
+ }
214
+ if (this.anchor) {
215
+ this.anchor.end = this.adapter.getCursorOffset?.() ?? this.adapter.getTextContent().length;
216
+ }
217
+ }
218
+
219
+ notifyReviewing(): void {
220
+ this._opts.onSegmentReviewing?.();
221
+ }
222
+
223
+ notifyReviewDone(): void {
224
+ this._opts.onSegmentReviewDone?.();
225
+ }
226
+
227
+ clearPartial(segmentId: string): void {
228
+ const pending = this._pendingInserts.get(segmentId);
229
+ if (pending) {
230
+ clearTimeout(pending.timer);
231
+ this._pendingInserts.delete(segmentId);
232
+ }
233
+ this.adapter.removeSegment(segmentId);
234
+ this._knownSegmentIds.delete(segmentId);
235
+ this._committedSegmentIds.delete(segmentId);
236
+ this._committedSegmentTexts.delete(segmentId);
237
+ }
238
+
239
+ clearAll(): void {
240
+ this._clearAllPendingInserts();
241
+ if (this.adapter.clearAll) {
242
+ this.adapter.clearAll();
243
+ for (const id of [...this._knownSegmentIds]) {
244
+ if (!this._committedSegmentIds.has(id)) {
245
+ this._knownSegmentIds.delete(id);
246
+ }
247
+ }
248
+ return;
249
+ }
250
+ for (const id of [...this._knownSegmentIds]) {
251
+ if (this._committedSegmentIds.has(id)) continue;
252
+ this.adapter.removeSegment(id);
253
+ this._knownSegmentIds.delete(id);
254
+ }
255
+ }
256
+
257
+ private _clearAllPendingInserts(): void {
258
+ for (const { timer } of this._pendingInserts.values()) {
259
+ clearTimeout(timer);
260
+ }
261
+ this._pendingInserts.clear();
262
+ }
263
+
264
+ getSegmentRange(_segmentId: string): { start: number; end: number } | null {
265
+ // Les nouveaux adapters ne trackent pas les ranges publics
266
+ return null;
267
+ }
268
+
269
+ // ─── Lecture ───────────────────────────────────────────────────────────────
270
+
271
+ getText(): string {
272
+ return this.adapter.getTextContent();
273
+ }
274
+
275
+ getSelection() {
276
+ if (this.element instanceof HTMLTextAreaElement || this.element instanceof HTMLInputElement) {
277
+ const start = this.element.selectionStart ?? 0;
278
+ const end = this.element.selectionEnd ?? 0;
279
+ if (start === end) return null;
280
+ return {
281
+ text: this.element.value.slice(start, end),
282
+ range: { start, end },
283
+ };
284
+ }
285
+ return null;
286
+ }
287
+
288
+ getCursorOffset(): number | null {
289
+ return this.adapter.getCursorOffset();
290
+ }
291
+
292
+ // ─── Opérations structurées (simplifié) ────────────────────────────────────
293
+
294
+ applyOperation(operation: DocumentOperation): void {
295
+ if (this.adapter.applyOperation) {
296
+ this.adapter.applyOperation(operation);
297
+ return;
298
+ }
299
+ // Fallback pour les adapters natifs / contenteditable
300
+ const text = this.adapter.getTextContent();
301
+
302
+ switch (operation.type) {
303
+ case "replace": {
304
+ let start: number;
305
+ let end: number;
306
+ if (operation.range) {
307
+ start = operation.range.start;
308
+ end = operation.range.end;
309
+ } else if (operation.targetText) {
310
+ const idx = text.indexOf(operation.targetText);
311
+ if (idx === -1) return;
312
+ start = idx;
313
+ end = idx + operation.targetText.length;
314
+ } else {
315
+ return;
316
+ }
317
+ const before = text.slice(0, start);
318
+ const after = text.slice(end);
319
+ const newText = before + operation.replacement + after;
320
+ this._replaceAll(newText);
321
+ break;
322
+ }
323
+ case "insert": {
324
+ let pos = this.adapter.getCursorOffset() ?? text.length;
325
+ if (typeof operation.position === "number") pos = operation.position;
326
+ else if (operation.position === "end") pos = text.length;
327
+ else if (operation.position === "start") pos = 0;
328
+ const before = text.slice(0, pos);
329
+ const after = text.slice(pos);
330
+ this._replaceAll(before + operation.text + after);
331
+ break;
332
+ }
333
+ case "delete": {
334
+ let start: number;
335
+ let end: number;
336
+ if (operation.range) {
337
+ start = operation.range.start;
338
+ end = operation.range.end;
339
+ } else if (operation.targetText) {
340
+ const idx = text.indexOf(operation.targetText);
341
+ if (idx === -1) return;
342
+ start = idx;
343
+ end = idx + operation.targetText.length;
344
+ } else {
345
+ return;
346
+ }
347
+ const before = text.slice(0, start);
348
+ const after = text.slice(end);
349
+ this._replaceAll(before + after);
350
+ break;
351
+ }
352
+ case "replace_all": {
353
+ this._replaceAll(operation.replacement);
354
+ break;
355
+ }
356
+ default:
357
+ console.warn("[SegmentBindingBridge] Operation not supported:", operation.type);
358
+ }
359
+ }
360
+
361
+ // ─── Helpers ───────────────────────────────────────────────────────────────
362
+
363
+ private _replaceAll(newText: string): void {
364
+ if (this.element instanceof HTMLTextAreaElement || this.element instanceof HTMLInputElement) {
365
+ const setter = Object.getOwnPropertyDescriptor(
366
+ this.element instanceof HTMLTextAreaElement
367
+ ? HTMLTextAreaElement.prototype
368
+ : HTMLInputElement.prototype,
369
+ "value"
370
+ )?.set;
371
+ setter?.call(this.element, newText);
372
+ this.element.dispatchEvent(new Event("input", { bubbles: true }));
373
+ } else if (this.element.getAttribute("contenteditable") === "true") {
374
+ this.element.textContent = newText;
375
+ }
376
+ }
377
+ }
@@ -0,0 +1,142 @@
1
+ import type { DocumentOperation } from "ephia-protocol";
2
+
3
+ export interface SessionAnchor {
4
+ /** Position où la dictée a commencé (immutable pendant la session). */
5
+ readonly initialStart: number;
6
+ /** Fin initiale (égale à initialStart si pas de sélection). */
7
+ readonly initialEnd: number;
8
+ /** True si la session a démarré avec une sélection non-vide. */
9
+ readonly hadSelection: boolean;
10
+ /** Position courante de la fin du texte dicté (mise à jour à chaque insert). */
11
+ end: number;
12
+ /**
13
+ * True tant que la sélection initiale n'a pas encore été supprimée.
14
+ * Le binding la supprimera atomiquement avec le premier insert de texte dicté.
15
+ */
16
+ selectionPendingDelete: boolean;
17
+ }
18
+
19
+ export interface BeginSessionOptions {
20
+ /**
21
+ * Si true, la sélection courante (si non vide) sera supprimée au premier
22
+ * insert et remplacée par le texte dicté. Défaut : true.
23
+ */
24
+ replaceSelection?: boolean;
25
+ }
26
+
27
+ export interface CommitFinalOptions {
28
+ absorbedSegmentIds?: string[];
29
+ }
30
+
31
+ export interface TargetBinding {
32
+ kind: string;
33
+
34
+ attach(): void;
35
+ detach(): void;
36
+
37
+ // ─── Session lifecycle ─────────────────────────────────────────────────────
38
+ /**
39
+ * Verrouille un anchor au début d'une session de dictée. Tous les inserts
40
+ * suivants se feront relativement à cet anchor, indépendamment du curseur
41
+ * de l'utilisateur. Si l'utilisateur avait une sélection non-vide, elle
42
+ * sera remplacée (sauf si replaceSelection: false).
43
+ */
44
+ beginSession?(opts?: BeginSessionOptions): SessionAnchor;
45
+
46
+ /** Termine la session et relâche l'anchor. Les textes restent dans le document. */
47
+ endSession?(): void;
48
+
49
+ /** Retourne l'anchor courant, ou null si pas de session active. */
50
+ getSessionAnchor?(): SessionAnchor | null;
51
+
52
+ /** Position visuelle du point d'insertion (anchor.end pendant la session, curseur sinon). */
53
+ getCursorRect?(): DOMRect | null;
54
+
55
+ /**
56
+ * Rectangle englobant d'une plage de texte. Utilisé pour le flash visuel
57
+ * (revised, committed). Retourne null si non supporté ou plage hors vue.
58
+ */
59
+ getRangeRect?(start: number, end: number): DOMRect | null;
60
+
61
+ // ─── Streaming insertion ───────────────────────────────────────────────────
62
+ insertPartial?(segmentId: string, text: string, revision: number): void;
63
+ commitFinal(segmentId: string, text: string, options?: CommitFinalOptions): void;
64
+ clearPartial(segmentId: string): void;
65
+ clearAll?(): void;
66
+
67
+ /** Retourne la plage [start, end] d'un segment commis dans le document, ou null si inconnu. */
68
+ getSegmentRange?(segmentId: string): { start: number; end: number } | null;
69
+
70
+ /** Récupère le texte complet du document */
71
+ getText(): string;
72
+
73
+ /** Récupère la sélection actuelle */
74
+ getSelection?(): {
75
+ text: string;
76
+ range: {
77
+ start: number;
78
+ end: number;
79
+ };
80
+ } | null;
81
+
82
+ /** Récupère la position du curseur */
83
+ getCursorOffset?(): number | null;
84
+
85
+ /** Applique une opération structurée au document */
86
+ applyOperation(operation: DocumentOperation): void;
87
+
88
+ /**
89
+ * Applique une séquence d'opérations (ex: insert_text/line_break/paragraph_break
90
+ * issue de textToDocumentOperations()). applyOperation() seul ne traite
91
+ * qu'une opération à la fois et n'a pas de notion de curseur implicite entre
92
+ * deux appels — un binding qui veut positionner correctement chaque
93
+ * opération relativement aux précédentes (ex: TipTap, cf. P4) doit fournir
94
+ * sa propre implémentation. Sinon, applyOperationsSequentially() (ci-dessous)
95
+ * sert de filet par défaut.
96
+ */
97
+ applyOperations?(operations: DocumentOperation[]): void;
98
+
99
+ /** Appelé quand un segment passe en état "reviewing" (correction SMART en cours) */
100
+ notifyReviewing?(): void;
101
+
102
+ /** Appelé quand la review d'un segment est terminée (corrected ou confirmed) */
103
+ notifyReviewDone?(): void;
104
+ }
105
+
106
+ /**
107
+ * Insère un joiner intelligent entre deux morceaux de texte.
108
+ * - Pas de joiner si un côté est déjà séparé par un whitespace ou une ponctuation
109
+ * - Espace par défaut sinon
110
+ */
111
+ export function pickJoiner(before: string, next: string): string {
112
+ if (!before || !next) return "";
113
+ const lastChar = before[before.length - 1];
114
+ const firstChar = next[0];
115
+ if (/\s/.test(lastChar)) return "";
116
+ if (/\s/.test(firstChar)) return "";
117
+ if (/^[,.;:!?)\]]/.test(firstChar)) return "";
118
+ if (/[(\[]/.test(lastChar)) return "";
119
+ return " ";
120
+ }
121
+
122
+ export function endsWithNewline(text: string): boolean {
123
+ return text.length > 0 && text[text.length - 1] === "\n";
124
+ }
125
+
126
+ /**
127
+ * Filet par défaut pour les bindings qui ne fournissent pas leur propre
128
+ * applyOperations() : appelle binding.applyOperations() s'il existe, sinon
129
+ * boucle sur applyOperation() opération par opération (P3).
130
+ */
131
+ export function applyOperationsSequentially(
132
+ binding: TargetBinding,
133
+ operations: DocumentOperation[]
134
+ ): void {
135
+ if (binding.applyOperations) {
136
+ binding.applyOperations(operations);
137
+ return;
138
+ }
139
+ for (const operation of operations) {
140
+ binding.applyOperation(operation);
141
+ }
142
+ }
@@ -0,0 +1,85 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { NativeAdapter } from "./NativeAdapter";
5
+
6
+ describe("NativeAdapter", () => {
7
+ it("updates a committed native segment when Small final arrives with same segmentId", () => {
8
+ const textarea = document.createElement("textarea");
9
+ document.body.appendChild(textarea);
10
+
11
+ const adapter = new NativeAdapter(textarea);
12
+
13
+ adapter.attach();
14
+ adapter.insertSegment({
15
+ id: "efde10b0",
16
+ text: "\nTechnique :\nacquisition",
17
+ status: "streaming",
18
+ });
19
+ adapter.commitSegment("efde10b0");
20
+
21
+ adapter.updateSegment(
22
+ "efde10b0",
23
+ "\nTechnique :\nacquisition hélicoïdale sans injection.",
24
+ "committed"
25
+ );
26
+
27
+ expect(textarea.value).toBe(
28
+ "\nTechnique :\nacquisition hélicoïdale sans injection."
29
+ );
30
+
31
+ textarea.remove();
32
+ });
33
+
34
+ it("does not duplicate native committed segment on same segmentId update", () => {
35
+ const textarea = document.createElement("textarea");
36
+ document.body.appendChild(textarea);
37
+
38
+ const adapter = new NativeAdapter(textarea);
39
+
40
+ adapter.attach();
41
+ adapter.insertSegment({
42
+ id: "efde10b0",
43
+ text: "\nTechnique :\nacquisition",
44
+ status: "streaming",
45
+ });
46
+ adapter.commitSegment("efde10b0");
47
+
48
+ adapter.updateSegment(
49
+ "efde10b0",
50
+ "\nTechnique :\nacquisition hélicoïdale.",
51
+ "committed"
52
+ );
53
+
54
+ expect(textarea.value).toBe("\nTechnique :\nacquisition hélicoïdale.");
55
+ expect(textarea.value).not.toContain("acquisition\nTechnique");
56
+
57
+ textarea.remove();
58
+ });
59
+
60
+ it("keeps leading newlines in native committed segment updates", () => {
61
+ const textarea = document.createElement("textarea");
62
+ textarea.value = "Avant";
63
+ document.body.appendChild(textarea);
64
+
65
+ const adapter = new NativeAdapter(textarea);
66
+
67
+ adapter.attach();
68
+ adapter.insertSegment({
69
+ id: "efde10b0",
70
+ text: "\nTechnique :\nacquisition",
71
+ status: "streaming",
72
+ });
73
+ adapter.commitSegment("efde10b0");
74
+
75
+ adapter.updateSegment(
76
+ "efde10b0",
77
+ "\nTechnique :\nacquisition hélicoïdale.",
78
+ "committed"
79
+ );
80
+
81
+ expect(textarea.value).toBe("Avant\nTechnique :\nacquisition hélicoïdale.");
82
+
83
+ textarea.remove();
84
+ });
85
+ });