@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,452 @@
1
+ import type {
2
+ BeginSessionOptions,
3
+ SessionAnchor,
4
+ TargetBinding,
5
+ } from "../TargetBinding";
6
+ import { pickJoiner } from "../TargetBinding";
7
+ import type { DocumentOperation } from "ephia-protocol";
8
+
9
+ function findTextNodeAtOffset(
10
+ root: HTMLElement,
11
+ offset: number
12
+ ): { node: Text; offset: number } | null {
13
+ let remaining = offset;
14
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
15
+ let node: Text | null;
16
+ while ((node = walker.nextNode() as Text | null)) {
17
+ const len = node.textContent?.length ?? 0;
18
+ if (remaining < len) {
19
+ return { node, offset: remaining };
20
+ }
21
+ remaining -= len;
22
+ }
23
+ // Fin du document
24
+ const last = root.lastChild;
25
+ if (last && last.nodeType === Node.TEXT_NODE) {
26
+ return { node: last as Text, offset: (last.textContent?.length ?? 0) };
27
+ }
28
+ return null;
29
+ }
30
+
31
+ function getCursorOffsetInElement(element: HTMLElement): number | null {
32
+ const sel = window.getSelection();
33
+ if (!sel || sel.rangeCount === 0) return null;
34
+ const range = sel.getRangeAt(0);
35
+ if (!element.contains(range.endContainer)) return null;
36
+ const preCaretRange = range.cloneRange();
37
+ preCaretRange.selectNodeContents(element);
38
+ preCaretRange.setEnd(range.endContainer, range.endOffset);
39
+ return preCaretRange.toString().length;
40
+ }
41
+
42
+ function getSelectionOffsets(
43
+ element: HTMLElement
44
+ ): { start: number; end: number } | null {
45
+ const sel = window.getSelection();
46
+ if (!sel || sel.rangeCount === 0) return null;
47
+ const range = sel.getRangeAt(0);
48
+ if (!element.contains(range.startContainer) || !element.contains(range.endContainer)) {
49
+ return null;
50
+ }
51
+ const startRange = range.cloneRange();
52
+ startRange.selectNodeContents(element);
53
+ startRange.setEnd(range.startContainer, range.startOffset);
54
+ const start = startRange.toString().length;
55
+ const endRange = range.cloneRange();
56
+ endRange.selectNodeContents(element);
57
+ endRange.setEnd(range.endContainer, range.endOffset);
58
+ const end = endRange.toString().length;
59
+ return { start, end };
60
+ }
61
+
62
+ function deleteRangeByOffsets(element: HTMLElement, start: number, end: number): void {
63
+ if (start === end) return;
64
+ const startResult = findTextNodeAtOffset(element, start);
65
+ const endResult = findTextNodeAtOffset(element, end);
66
+ if (!startResult || !endResult) return;
67
+ const range = document.createRange();
68
+ range.setStart(startResult.node, startResult.offset);
69
+ range.setEnd(endResult.node, endResult.offset);
70
+ range.deleteContents();
71
+ }
72
+
73
+ function insertSpanAtOffset(
74
+ element: HTMLElement,
75
+ offset: number,
76
+ span: HTMLElement
77
+ ): void {
78
+ const result = findTextNodeAtOffset(element, offset);
79
+ if (!result) {
80
+ element.appendChild(span);
81
+ return;
82
+ }
83
+ const range = document.createRange();
84
+ range.setStart(result.node, result.offset);
85
+ range.collapse(true);
86
+ range.insertNode(span);
87
+ }
88
+
89
+ interface SessionSegment {
90
+ span: HTMLElement;
91
+ revision: number;
92
+ committed: boolean;
93
+ joiner: string;
94
+ }
95
+
96
+ export function createContentEditableBinding(element: HTMLElement): TargetBinding {
97
+ let anchor: SessionAnchor | null = null;
98
+ const segments = new Map<string, SessionSegment>();
99
+ let inputHandler: (() => void) | null = null;
100
+ const committedTimeouts = new Set<number>();
101
+
102
+ /** Recalcule anchor.end à partir de la position de tous les spans de la session. */
103
+ const recomputeAnchorEnd = () => {
104
+ if (!anchor) return;
105
+ let end = anchor.initialStart;
106
+ for (const seg of segments.values()) {
107
+ const range = document.createRange();
108
+ range.selectNodeContents(element);
109
+ range.setEndAfter(seg.span);
110
+ const offset = range.toString().length;
111
+ if (offset > end) end = offset;
112
+ }
113
+ anchor.end = end;
114
+ };
115
+
116
+ return {
117
+ kind: "contenteditable",
118
+
119
+ attach(): void {
120
+ if (!inputHandler) {
121
+ inputHandler = () => recomputeAnchorEnd();
122
+ element.addEventListener("input", inputHandler);
123
+ }
124
+ },
125
+
126
+ detach(): void {
127
+ if (inputHandler) {
128
+ element.removeEventListener("input", inputHandler);
129
+ inputHandler = null;
130
+ }
131
+ for (const id of committedTimeouts) clearTimeout(id);
132
+ committedTimeouts.clear();
133
+ segments.clear();
134
+ anchor = null;
135
+ },
136
+
137
+ // ─── Session lifecycle ───────────────────────────────────────────────────
138
+ beginSession(opts: BeginSessionOptions = {}): SessionAnchor {
139
+ const replaceSelection = opts.replaceSelection ?? true;
140
+ const sel = getSelectionOffsets(element);
141
+ const isFocused = document.activeElement === element || element.contains(document.activeElement);
142
+
143
+ let start: number;
144
+ let end: number;
145
+ let hadSelection = false;
146
+
147
+ if (sel && isFocused) {
148
+ start = sel.start;
149
+ end = sel.end;
150
+ hadSelection = start !== end;
151
+ } else {
152
+ // Pas de selection / pas focused → fin du document
153
+ start = element.textContent?.length ?? 0;
154
+ end = start;
155
+ }
156
+
157
+ // Ne pas supprimer la sélection ici — différé au premier insert.
158
+
159
+ anchor = {
160
+ initialStart: start,
161
+ initialEnd: end, // conserver la vraie fin de sélection
162
+ hadSelection,
163
+ end: start, // point d'insertion = début de sélection
164
+ selectionPendingDelete: hadSelection && replaceSelection,
165
+ };
166
+ segments.clear();
167
+ return anchor;
168
+ },
169
+
170
+ endSession(): void {
171
+ anchor = null;
172
+ segments.clear();
173
+ },
174
+
175
+ getSessionAnchor(): SessionAnchor | null {
176
+ return anchor;
177
+ },
178
+
179
+ getCursorRect(): DOMRect | null {
180
+ if (anchor) {
181
+ // Position visuelle de anchor.end
182
+ const result = findTextNodeAtOffset(element, anchor.end);
183
+ if (result) {
184
+ const range = document.createRange();
185
+ range.setStart(result.node, result.offset);
186
+ range.collapse(true);
187
+ const rect = range.getBoundingClientRect();
188
+ if (rect.width === 0 && rect.height === 0) {
189
+ // Range vide → fallback sur l'élément
190
+ return element.getBoundingClientRect();
191
+ }
192
+ return rect;
193
+ }
194
+ }
195
+ const sel = window.getSelection();
196
+ if (sel && sel.rangeCount > 0 && element.contains(sel.anchorNode)) {
197
+ return sel.getRangeAt(0).getBoundingClientRect();
198
+ }
199
+ return element.getBoundingClientRect();
200
+ },
201
+
202
+ // ─── Insertion streaming ─────────────────────────────────────────────────
203
+ insertPartial(segmentId: string, text: string, revision: number): void {
204
+ // Pas de session → créer une session implicite
205
+ if (!anchor) {
206
+ const offset = getCursorOffsetInElement(element) ?? element.textContent?.length ?? 0;
207
+ anchor = {
208
+ initialStart: offset,
209
+ initialEnd: offset,
210
+ hadSelection: false,
211
+ end: offset,
212
+ selectionPendingDelete: false,
213
+ };
214
+ }
215
+
216
+ // Suppression lazy de la sélection au premier insert
217
+ if (anchor.selectionPendingDelete && anchor.initialEnd > anchor.initialStart) {
218
+ deleteRangeByOffsets(element, anchor.initialStart, anchor.initialEnd);
219
+ anchor.end = anchor.initialStart;
220
+ anchor.selectionPendingDelete = false;
221
+ }
222
+
223
+ const existing = segments.get(segmentId);
224
+
225
+ if (existing) {
226
+ if (revision <= existing.revision) return;
227
+ const oldLen = existing.span.textContent?.length ?? 0;
228
+ // Préserver le joiner lors des mises à jour du partial
229
+ const newContent = existing.joiner + text;
230
+ existing.span.textContent = newContent;
231
+ existing.revision = revision;
232
+ anchor.end += newContent.length - oldLen;
233
+ } else {
234
+ const before = element.textContent?.slice(0, anchor.end) ?? "";
235
+ const joiner = pickJoiner(before, text);
236
+
237
+ const span = document.createElement("span");
238
+ span.setAttribute("data-ephia-segment", segmentId);
239
+ span.setAttribute("data-ephia-state", "partial");
240
+ span.classList.add("ephia-streaming");
241
+ span.textContent = joiner + text;
242
+
243
+ insertSpanAtOffset(element, anchor.end, span);
244
+ segments.set(segmentId, { span, revision, committed: false, joiner });
245
+ anchor.end += joiner.length + text.length;
246
+ }
247
+ },
248
+
249
+ commitFinal(segmentId: string, text: string): void {
250
+ if (!anchor) {
251
+ // Fallback hors session
252
+ const offset = getCursorOffsetInElement(element) ?? element.textContent?.length ?? 0;
253
+ const before = element.textContent?.slice(0, offset) ?? "";
254
+ const joiner = pickJoiner(before, text);
255
+ const span = document.createElement("span");
256
+ span.setAttribute("data-ephia-segment", segmentId);
257
+ span.setAttribute("data-ephia-state", "final");
258
+ span.textContent = joiner + text;
259
+ insertSpanAtOffset(element, offset, span);
260
+ return;
261
+ }
262
+
263
+ // Suppression lazy de la sélection au premier commit (si pas déjà fait par insertPartial)
264
+ if (anchor.selectionPendingDelete && anchor.initialEnd > anchor.initialStart) {
265
+ deleteRangeByOffsets(element, anchor.initialStart, anchor.initialEnd);
266
+ anchor.end = anchor.initialStart;
267
+ anchor.selectionPendingDelete = false;
268
+ }
269
+
270
+ const existing = segments.get(segmentId);
271
+
272
+ if (existing) {
273
+ const oldLen = existing.span.textContent?.length ?? 0;
274
+ // Garder le joiner si présent dans le partial (recalcul propre)
275
+ const beforeSpanRange = document.createRange();
276
+ beforeSpanRange.selectNodeContents(element);
277
+ beforeSpanRange.setEndBefore(existing.span);
278
+ const before = beforeSpanRange.toString();
279
+ const joiner = pickJoiner(before, text);
280
+ existing.span.textContent = joiner + text;
281
+ existing.span.setAttribute("data-ephia-state", "final");
282
+ existing.span.classList.remove("ephia-streaming");
283
+ existing.span.classList.add("ephia-committed");
284
+ existing.committed = true;
285
+ existing.joiner = joiner;
286
+ existing.revision = Number.MAX_SAFE_INTEGER;
287
+ anchor.end += (joiner.length + text.length) - oldLen;
288
+
289
+ // Retirer la classe committed après l'animation (1.2s + marge)
290
+ const timeoutId = window.setTimeout(() => {
291
+ existing.span.classList.remove("ephia-committed");
292
+ committedTimeouts.delete(timeoutId);
293
+ }, 1300);
294
+ committedTimeouts.add(timeoutId);
295
+ } else {
296
+ // Pas de partial préalable
297
+ const before = element.textContent?.slice(0, anchor.end) ?? "";
298
+ const joiner = pickJoiner(before, text);
299
+ const span = document.createElement("span");
300
+ span.setAttribute("data-ephia-segment", segmentId);
301
+ span.setAttribute("data-ephia-state", "final");
302
+ span.textContent = joiner + text;
303
+ insertSpanAtOffset(element, anchor.end, span);
304
+ segments.set(segmentId, {
305
+ span,
306
+ revision: Number.MAX_SAFE_INTEGER,
307
+ committed: true,
308
+ joiner,
309
+ });
310
+ anchor.end += joiner.length + text.length;
311
+ }
312
+ recomputeAnchorEnd();
313
+ },
314
+
315
+ clearPartial(segmentId: string): void {
316
+ const existing = segments.get(segmentId);
317
+ if (!existing || !anchor) return;
318
+ existing.span.remove();
319
+ segments.delete(segmentId);
320
+ // Recalculer anchor.end proprement après suppression d'un span
321
+ recomputeAnchorEnd();
322
+ },
323
+
324
+ clearAll(): void {
325
+ for (const [segmentId, segment] of [...segments]) {
326
+ if (segment.committed) continue;
327
+ segment.span.remove();
328
+ segments.delete(segmentId);
329
+ }
330
+ recomputeAnchorEnd();
331
+ },
332
+
333
+ getText(): string {
334
+ return element.textContent ?? "";
335
+ },
336
+
337
+ getSelection() {
338
+ const off = getSelectionOffsets(element);
339
+ if (!off) return null;
340
+ return {
341
+ text: (element.textContent ?? "").slice(off.start, off.end),
342
+ range: off,
343
+ };
344
+ },
345
+
346
+ getCursorOffset(): number | null {
347
+ return getCursorOffsetInElement(element);
348
+ },
349
+
350
+ getSegmentRange(segmentId: string): { start: number; end: number } | null {
351
+ const seg = segments.get(segmentId);
352
+ if (!seg) return null;
353
+ const beforeRange = document.createRange();
354
+ beforeRange.selectNodeContents(element);
355
+ beforeRange.setEndBefore(seg.span);
356
+ const start = beforeRange.toString().length;
357
+ return { start, end: start + (seg.span.textContent?.length ?? 0) };
358
+ },
359
+
360
+ getRangeRect(start: number, end: number): DOMRect | null {
361
+ const startResult = findTextNodeAtOffset(element, start);
362
+ const endResult = findTextNodeAtOffset(element, end);
363
+ if (!startResult || !endResult) return null;
364
+ const range = document.createRange();
365
+ range.setStart(startResult.node, startResult.offset);
366
+ range.setEnd(endResult.node, endResult.offset);
367
+ return range.getBoundingClientRect();
368
+ },
369
+
370
+ applyOperation(operation: DocumentOperation): void {
371
+ switch (operation.type) {
372
+ case "replace": {
373
+ let start: number;
374
+ let end: number;
375
+ if (operation.range) {
376
+ start = operation.range.start;
377
+ end = operation.range.end;
378
+ } else if (operation.targetText) {
379
+ const text = element.textContent ?? "";
380
+ const idx = text.indexOf(operation.targetText);
381
+ if (idx === -1) {
382
+ console.warn("[contenteditableBinding] replace ignored: targetText not found");
383
+ return;
384
+ }
385
+ start = idx;
386
+ end = idx + operation.targetText.length;
387
+ } else {
388
+ console.warn("[contenteditableBinding] replace ignored: missing range or targetText");
389
+ return;
390
+ }
391
+ deleteRangeByOffsets(element, start, end);
392
+ const result = findTextNodeAtOffset(element, start);
393
+ if (result) {
394
+ const range = document.createRange();
395
+ range.setStart(result.node, result.offset);
396
+ range.collapse(true);
397
+ range.insertNode(document.createTextNode(operation.replacement));
398
+ }
399
+ recomputeAnchorEnd();
400
+ break;
401
+ }
402
+ case "insert": {
403
+ let pos = getCursorOffsetInElement(element) ?? (element.textContent?.length ?? 0);
404
+ if (typeof operation.position === "number") pos = operation.position;
405
+ else if (operation.position === "end") pos = element.textContent?.length ?? 0;
406
+ else if (operation.position === "start") pos = 0;
407
+ const result = findTextNodeAtOffset(element, pos);
408
+ if (result) {
409
+ const range = document.createRange();
410
+ range.setStart(result.node, result.offset);
411
+ range.collapse(true);
412
+ range.insertNode(document.createTextNode(operation.text));
413
+ } else {
414
+ element.appendChild(document.createTextNode(operation.text));
415
+ }
416
+ recomputeAnchorEnd();
417
+ break;
418
+ }
419
+ case "delete": {
420
+ let start: number;
421
+ let end: number;
422
+ if (operation.range) {
423
+ start = operation.range.start;
424
+ end = operation.range.end;
425
+ } else if (operation.targetText) {
426
+ const text = element.textContent ?? "";
427
+ const idx = text.indexOf(operation.targetText);
428
+ if (idx === -1) {
429
+ console.warn("[contenteditableBinding] delete ignored: targetText not found");
430
+ return;
431
+ }
432
+ start = idx;
433
+ end = idx + operation.targetText.length;
434
+ } else {
435
+ console.warn("[contenteditableBinding] delete ignored: missing range or targetText");
436
+ return;
437
+ }
438
+ deleteRangeByOffsets(element, start, end);
439
+ recomputeAnchorEnd();
440
+ break;
441
+ }
442
+ case "replace_all":
443
+ element.textContent = operation.replacement;
444
+ segments.clear();
445
+ recomputeAnchorEnd();
446
+ break;
447
+ default:
448
+ console.warn("[contenteditableBinding] Operation not supported:", operation.type);
449
+ }
450
+ },
451
+ };
452
+ }
@@ -0,0 +1,10 @@
1
+ export { createCodeMirrorBinding } from "./codemirror.binding";
2
+ export { createContentEditableBinding } from "./contenteditable.binding";
3
+ export { createMonacoBinding } from "./monaco.binding";
4
+ export {
5
+ createTiptapBinding,
6
+ EphiaCommittedMark,
7
+ EphiaPlaceholderMark,
8
+ EphiaPreviewMark,
9
+ EphiaRevisedMark,
10
+ } from "./tiptap.binding";