@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,1192 @@
1
+ /**
2
+ * Binding TipTap historique mais encore actif.
3
+ * Ne pas supprimer tant que createTiptapBinding est utilisé par les intégrations.
4
+ * Les corrections d'insertion realtime TipTap doivent être faites ici, pas dans ProseMirrorAdapter.
5
+ */
6
+
7
+ import type { Editor } from "@tiptap/core";
8
+ import { Mark } from "@tiptap/core";
9
+ import type { Mark as ProseMirrorMark } from "@tiptap/pm/model";
10
+ import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
11
+ import type { Transaction } from "@tiptap/pm/state";
12
+ import type {
13
+ BeginSessionOptions,
14
+ CommitFinalOptions,
15
+ SessionAnchor,
16
+ TargetBinding,
17
+ } from "../TargetBinding";
18
+ import { pickJoiner } from "../TargetBinding";
19
+ import type { DocumentOperation } from "ephia-protocol";
20
+ import { documentStore } from "../../../shared/store/document-store";
21
+ import { textToDocumentOperations } from "../../operations/textToDocumentOperations";
22
+ import { stripLeadingOverlapFromTextWithInfo } from "../../text-processing/overlap";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // ProseMirror Plugin — stocke les positions des segments audio + l'anchor de
26
+ // session, et les mappe automatiquement à chaque transaction (typing, undo).
27
+ // ---------------------------------------------------------------------------
28
+
29
+ interface EphiaTiptapPluginState {
30
+ segments: Record<string, { from: number; to: number; revision: number; joinerLength?: number }>;
31
+ anchor: {
32
+ initialStart: number;
33
+ initialEnd: number;
34
+ hadSelection: boolean;
35
+ end: number;
36
+ selectionPendingDelete: boolean;
37
+ } | null;
38
+ }
39
+
40
+ const ephiaTiptapKey = new PluginKey<EphiaTiptapPluginState>("ephiaTiptap");
41
+
42
+ type SegmentUpdate = Record<string, { from: number; to: number; revision: number; joinerLength?: number } | null>;
43
+ type AnchorUpdate =
44
+ | { kind: "set"; value: EphiaTiptapPluginState["anchor"] }
45
+ | { kind: "patch"; end?: number }
46
+ | { kind: "clear" };
47
+
48
+ interface EphiaMeta {
49
+ segments?: SegmentUpdate;
50
+ anchor?: AnchorUpdate;
51
+ }
52
+
53
+ function createEphiaTiptapPlugin(): Plugin {
54
+ return new Plugin({
55
+ key: ephiaTiptapKey,
56
+ state: {
57
+ init(): EphiaTiptapPluginState {
58
+ return { segments: {}, anchor: null };
59
+ },
60
+ apply(tr, value): EphiaTiptapPluginState {
61
+ const docSize = tr.doc.content.size;
62
+
63
+ // 1. Mapper les segments existants — clamp + drop hors bornes
64
+ const mappedSegments: EphiaTiptapPluginState["segments"] = {};
65
+ for (const [id, pos] of Object.entries(value.segments)) {
66
+ const mappedFrom = Math.min(Math.max(tr.mapping.map(pos.from), 0), docSize);
67
+ const mappedTo = Math.min(Math.max(tr.mapping.map(pos.to), 0), docSize);
68
+ if (mappedFrom < 0 || mappedTo < 0 || mappedFrom > docSize || mappedTo > docSize || mappedFrom > mappedTo) {
69
+ continue; // segment supprimé / corrompu par undo profond
70
+ }
71
+ mappedSegments[id] = {
72
+ from: mappedFrom,
73
+ to: mappedTo,
74
+ revision: pos.revision,
75
+ joinerLength: pos.joinerLength,
76
+ };
77
+ }
78
+
79
+ // 2. Mapper l'anchor existant — clamp + drop hors bornes
80
+ let mappedAnchor = value.anchor
81
+ ? {
82
+ initialStart: Math.min(Math.max(tr.mapping.map(value.anchor.initialStart), 0), docSize),
83
+ initialEnd: Math.min(Math.max(tr.mapping.map(value.anchor.initialEnd), 0), docSize),
84
+ hadSelection: value.anchor.hadSelection,
85
+ end: Math.min(Math.max(tr.mapping.map(value.anchor.end), 0), docSize),
86
+ selectionPendingDelete: value.anchor.selectionPendingDelete,
87
+ }
88
+ : null;
89
+ if (mappedAnchor && (mappedAnchor.initialStart > docSize || mappedAnchor.initialEnd > docSize || mappedAnchor.end > docSize)) {
90
+ mappedAnchor = null;
91
+ }
92
+
93
+ // 3. Appliquer les mises à jour explicites
94
+ const meta = tr.getMeta(ephiaTiptapKey) as EphiaMeta | undefined;
95
+ if (meta) {
96
+ if (meta.segments) {
97
+ for (const [id, pos] of Object.entries(meta.segments)) {
98
+ if (pos === null) delete mappedSegments[id];
99
+ else mappedSegments[id] = pos;
100
+ }
101
+ }
102
+ if (meta.anchor) {
103
+ if (meta.anchor.kind === "clear") {
104
+ mappedAnchor = null;
105
+ } else if (meta.anchor.kind === "set") {
106
+ mappedAnchor = meta.anchor.value;
107
+ } else if (meta.anchor.kind === "patch" && mappedAnchor) {
108
+ mappedAnchor = {
109
+ ...mappedAnchor,
110
+ end: meta.anchor.end ?? mappedAnchor.end,
111
+ };
112
+ }
113
+ }
114
+ }
115
+
116
+ return { segments: mappedSegments, anchor: mappedAnchor };
117
+ },
118
+ },
119
+ });
120
+ }
121
+
122
+ function getPluginState(editor: Editor): EphiaTiptapPluginState {
123
+ return (
124
+ ephiaTiptapKey.getState(editor.state) ?? { segments: {}, anchor: null }
125
+ );
126
+ }
127
+
128
+ function setSegmentMeta(
129
+ tr: Transaction,
130
+ segmentId: string,
131
+ pos: { from: number; to: number; revision: number; joinerLength?: number } | null
132
+ ): void {
133
+ const current = (tr.getMeta(ephiaTiptapKey) as EphiaMeta | undefined) ?? {};
134
+ current.segments = { ...(current.segments ?? {}), [segmentId]: pos };
135
+ tr.setMeta(ephiaTiptapKey, current);
136
+ }
137
+
138
+ function setAnchorMeta(tr: Transaction, update: AnchorUpdate): void {
139
+ const current = (tr.getMeta(ephiaTiptapKey) as EphiaMeta | undefined) ?? {};
140
+ current.anchor = update;
141
+ tr.setMeta(ephiaTiptapKey, current);
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Helpers — diff intelligent pour éviter delete+insert inutiles
146
+ // ---------------------------------------------------------------------------
147
+
148
+ function getCommonPrefix(a: string, b: string): number {
149
+ let i = 0;
150
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
151
+ return i;
152
+ }
153
+
154
+ function getCommonSuffix(a: string, b: string, prefixLen: number): number {
155
+ let i = 0;
156
+ while (
157
+ i < a.length - prefixLen &&
158
+ i < b.length - prefixLen &&
159
+ a[a.length - 1 - i] === b[b.length - 1 - i]
160
+ ) {
161
+ i++;
162
+ }
163
+ return i;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Scroll helper — throttle + viewport check + user-scroll detection
168
+ // ---------------------------------------------------------------------------
169
+
170
+ const SCROLL_THROTTLE_MS = 300;
171
+ const USER_SCROLL_QUIET_MS = 500;
172
+ const PARTIAL_DEBOUNCE_MS = 60;
173
+ const SKIP_STORE_SYNC_META = "ephiaSkipStoreSync";
174
+
175
+ function isPosVisible(editor: Editor, pos: number): boolean {
176
+ try {
177
+ const coords = editor.view.coordsAtPos(pos);
178
+ const rect = editor.view.dom.getBoundingClientRect();
179
+ return coords.top >= rect.top && coords.bottom <= rect.bottom;
180
+ } catch {
181
+ return true; // par défaut, ne pas scroller
182
+ }
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Binding TipTap — session anchor + UX fluide
187
+ // ---------------------------------------------------------------------------
188
+
189
+ interface ParsedSection {
190
+ id: string;
191
+ title: string;
192
+ text: string;
193
+ level: number;
194
+ start: number;
195
+ end: number;
196
+ }
197
+
198
+ type ProseMirrorDoc = Editor["state"]["doc"];
199
+
200
+ function textBetweenWithHardBreaks(
201
+ doc: ProseMirrorDoc,
202
+ from: number,
203
+ to: number,
204
+ blockSeparator?: string
205
+ ): string {
206
+ return doc.textBetween(from, to, blockSeparator, "\n");
207
+ }
208
+
209
+ function getTextFromDoc(doc: ProseMirrorDoc): string {
210
+ return textBetweenWithHardBreaks(doc, 0, doc.content.size, "\n\n");
211
+ }
212
+
213
+ function resolveInsertPosition(
214
+ editor: Editor,
215
+ position?: number | "cursor" | "start" | "end"
216
+ ): number {
217
+ if (typeof position === "number") {
218
+ return Math.min(Math.max(position, 0), editor.state.doc.content.size);
219
+ }
220
+ if (position === "start") return 1;
221
+ if (position === "end") return Math.max(1, editor.state.doc.content.size - 1);
222
+ return editor.state.selection.from;
223
+ }
224
+
225
+ function insertOperationsAt(
226
+ editor: Editor,
227
+ tr: Transaction,
228
+ pos: number,
229
+ operations: DocumentOperation[],
230
+ mark?: ProseMirrorMark
231
+ ): { from: number; to: number; fallbackSteps: string[] } {
232
+ let cursor = pos;
233
+ const fallbackSteps: string[] = [];
234
+ const hardBreak = editor.schema.nodes.hardBreak;
235
+ const marks = mark ? [mark] : [];
236
+
237
+ const insertText = (text: string) => {
238
+ if (!text) return;
239
+ const node = editor.schema.text(text, marks);
240
+ tr.insert(cursor, node);
241
+ cursor += node.nodeSize;
242
+ };
243
+
244
+ const insertHardBreakOrFallback = () => {
245
+ if (hardBreak) {
246
+ const node = hardBreak.create();
247
+ tr.insert(cursor, node);
248
+ cursor += node.nodeSize;
249
+ return;
250
+ }
251
+ const node = editor.schema.text("\n", marks);
252
+ tr.insert(cursor, node);
253
+ cursor += node.nodeSize;
254
+ fallbackSteps.push("fallback_plain_newline_no_hardbreak_node");
255
+ };
256
+
257
+ for (const operation of operations) {
258
+ if (operation.type === "insert_text" || operation.type === "insert") {
259
+ insertText(operation.text);
260
+ } else if (operation.type === "line_break") {
261
+ insertHardBreakOrFallback();
262
+ } else if (operation.type === "paragraph_break") {
263
+ insertHardBreakOrFallback();
264
+ insertHardBreakOrFallback();
265
+ }
266
+ }
267
+
268
+ return { from: pos, to: cursor, fallbackSteps };
269
+ }
270
+
271
+ function measureOperationsSize(editor: Editor, operations: DocumentOperation[]): number {
272
+ const hardBreak = editor.schema.nodes.hardBreak;
273
+ let size = 0;
274
+ for (const operation of operations) {
275
+ if (operation.type === "insert_text" || operation.type === "insert") {
276
+ if (operation.text) {
277
+ size += editor.schema.text(operation.text).nodeSize;
278
+ }
279
+ } else if (operation.type === "line_break") {
280
+ size += hardBreak ? hardBreak.create().nodeSize : editor.schema.text("\n").nodeSize;
281
+ } else if (operation.type === "paragraph_break") {
282
+ const breakSize = hardBreak ? hardBreak.create().nodeSize : editor.schema.text("\n").nodeSize;
283
+ size += breakSize * 2;
284
+ }
285
+ }
286
+ return size;
287
+ }
288
+
289
+ function measureTextAsOperationsSize(editor: Editor, text: string): number {
290
+ return measureOperationsSize(editor, textToDocumentOperations(text));
291
+ }
292
+
293
+ function parseSegmentsFromDoc(doc: ProseMirrorDoc): ParsedSection[] {
294
+ const segments: ParsedSection[] = [];
295
+ let currentSection: ParsedSection | null = null;
296
+ let offset = 0;
297
+
298
+ doc.descendants((node, _pos) => {
299
+ if (node.type.name === "heading") {
300
+ if (currentSection) currentSection.end = offset;
301
+ currentSection = {
302
+ id: `sec_${segments.length}`,
303
+ title: node.textContent,
304
+ text: "",
305
+ level: node.attrs.level || 1,
306
+ start: offset,
307
+ end: offset,
308
+ };
309
+ segments.push(currentSection);
310
+ } else if (currentSection && node.isTextblock) {
311
+ currentSection.text +=
312
+ (currentSection.text ? "\n" : "") + node.textContent;
313
+ }
314
+ offset += node.nodeSize;
315
+ return true;
316
+ });
317
+
318
+ if (currentSection) {
319
+ (currentSection as ParsedSection).end = offset;
320
+ }
321
+ return segments;
322
+ }
323
+
324
+ function parseSegmentsFromEditor(editor: Editor): ParsedSection[] {
325
+ return parseSegmentsFromDoc(editor.state.doc);
326
+ }
327
+
328
+ /** Convertit l'anchor du plugin (positions PM) vers l'interface publique. */
329
+ function toPublicAnchor(
330
+ anchor: NonNullable<EphiaTiptapPluginState["anchor"]>
331
+ ): SessionAnchor {
332
+ return {
333
+ initialStart: anchor.initialStart,
334
+ initialEnd: anchor.initialEnd,
335
+ hadSelection: anchor.hadSelection,
336
+ end: anchor.end,
337
+ selectionPendingDelete: anchor.selectionPendingDelete,
338
+ };
339
+ }
340
+
341
+ /* ── TipTap Marks ─────────────────────────────────────────────────────────── */
342
+
343
+ /** Mark affiché sur le texte en cours de streaming (preview temps réel). */
344
+ export const EphiaPreviewMark = Mark.create({
345
+ name: "ephiaPreview",
346
+ parseHTML() {
347
+ return [{ tag: "span[data-ephia-streaming]" }];
348
+ },
349
+ renderHTML() {
350
+ return [
351
+ "span",
352
+ {
353
+ "data-ephia-streaming": "true",
354
+ class: "ephia-text--streaming",
355
+ },
356
+ 0,
357
+ ];
358
+ },
359
+ });
360
+
361
+ /** Mark temporaire affiché pendant la transition committed (streaming → normal). */
362
+ export const EphiaCommittedMark = Mark.create({
363
+ name: "ephiaCommitted",
364
+ parseHTML() {
365
+ return [{ tag: "span[data-ephia-committed]" }];
366
+ },
367
+ renderHTML() {
368
+ return [
369
+ "span",
370
+ {
371
+ "data-ephia-committed": "true",
372
+ class: "ephia-text--committed",
373
+ },
374
+ 0,
375
+ ];
376
+ },
377
+ });
378
+
379
+ /** Mark affiché sur le texte corrigé par le review pipeline. */
380
+ export const EphiaRevisedMark = Mark.create({
381
+ name: "ephiaRevised",
382
+ parseHTML() {
383
+ return [{ tag: "span[data-ephia-revised]" }];
384
+ },
385
+ renderHTML() {
386
+ return [
387
+ "span",
388
+ {
389
+ "data-ephia-revised": "true",
390
+ class: "ephia-text--revised",
391
+ },
392
+ 0,
393
+ ];
394
+ },
395
+ });
396
+
397
+ /** Mark affiché sur le placeholder de démarrage (interim avant le premier partial). */
398
+ export const EphiaPlaceholderMark = Mark.create({
399
+ name: "ephiaPlaceholder",
400
+ parseHTML() {
401
+ return [{ tag: "span[data-ephia-placeholder]" }];
402
+ },
403
+ renderHTML() {
404
+ return [
405
+ "span",
406
+ {
407
+ "data-ephia-placeholder": "true",
408
+ class: "ephia-text--placeholder",
409
+ },
410
+ 0,
411
+ ];
412
+ },
413
+ });
414
+
415
+ export function createTiptapBinding(editor: Editor, wrapperEl?: HTMLElement): TargetBinding {
416
+ const plugin = createEphiaTiptapPlugin();
417
+ editor.registerPlugin(plugin);
418
+ let lastScrollTime = 0;
419
+ let lastUserScrollTime = 0;
420
+ let lastDictationEnd: number | null = null;
421
+ let cachedSectionsDoc = editor.state.doc;
422
+ let cachedSections = parseSegmentsFromEditor(editor);
423
+
424
+ const markUserScroll = () => {
425
+ lastUserScrollTime = Date.now();
426
+ };
427
+
428
+ const maybeScrollIntoView = (tr: Transaction, ed: Editor, pos: number): void => {
429
+ const now = Date.now();
430
+ if (now - lastUserScrollTime < USER_SCROLL_QUIET_MS) return;
431
+ if (now - lastScrollTime < SCROLL_THROTTLE_MS) return;
432
+ if (isPosVisible(ed, pos)) return;
433
+ tr.scrollIntoView();
434
+ lastScrollTime = now;
435
+ };
436
+
437
+ editor.view.dom.addEventListener("scroll", markUserScroll, { passive: true });
438
+
439
+ // Timeouts de transition committed à nettoyer au detach.
440
+ const committedTimeouts = new Map<string, number>();
441
+ const knownSegmentIds = new Set<string>();
442
+
443
+ const clearCommittedTimeout = (segmentId: string): void => {
444
+ const timeoutId = committedTimeouts.get(segmentId);
445
+ if (timeoutId !== undefined) {
446
+ window.clearTimeout(timeoutId);
447
+ committedTimeouts.delete(segmentId);
448
+ }
449
+ };
450
+
451
+ // Sync to Zustand store — debouncé via rAF pour éviter de re-parser tout le
452
+ // doc (getText + parseSegments) à chaque transaction pendant le streaming.
453
+ // Sur une dictée fluide, on reçoit plusieurs partials/sec : sans debounce
454
+ // ça multiplie par N le coût des subscribers du store.
455
+ let syncRaf: number | null = null;
456
+ let pendingSyncDoc: ProseMirrorDoc | null = null;
457
+ const getCachedSections = (): ParsedSection[] => cachedSections;
458
+
459
+ const syncToStore = (event?: { transaction?: Transaction }) => {
460
+ if (event?.transaction?.getMeta(SKIP_STORE_SYNC_META)) return;
461
+ pendingSyncDoc = event?.transaction?.doc ?? editor.state.doc;
462
+ if (syncRaf !== null) return;
463
+ syncRaf = (typeof requestAnimationFrame !== "undefined"
464
+ ? requestAnimationFrame
465
+ : (cb: FrameRequestCallback) => setTimeout(cb, 16) as unknown as number)(
466
+ () => {
467
+ syncRaf = null;
468
+ const doc = pendingSyncDoc ?? editor.state.doc;
469
+ pendingSyncDoc = null;
470
+ const text = getTextFromDoc(doc);
471
+ cachedSectionsDoc = doc;
472
+ cachedSections = parseSegmentsFromDoc(doc);
473
+ const segments = cachedSections;
474
+ documentStore.getState().syncFromEditor(text, segments);
475
+ }
476
+ );
477
+ };
478
+
479
+ const syncSelection = () => {
480
+ const { from, to } = editor.state.selection;
481
+ const selectedText =
482
+ from !== to ? textBetweenWithHardBreaks(editor.state.doc, from, to) : null;
483
+ if (cachedSectionsDoc !== editor.state.doc && cachedSections.length === 0) {
484
+ cachedSectionsDoc = editor.state.doc;
485
+ cachedSections = parseSegmentsFromEditor(editor);
486
+ }
487
+ const cursor = editor.state.selection.from;
488
+ const section =
489
+ getCachedSections().find((seg) => cursor >= seg.start && cursor <= seg.end)
490
+ ?.title ?? null;
491
+ documentStore.getState().setSelection(selectedText, section);
492
+ };
493
+
494
+ editor.on("update", syncToStore);
495
+ editor.on("selectionUpdate", syncSelection);
496
+
497
+ syncToStore();
498
+ syncSelection();
499
+
500
+ const _applyPartial = (segmentId: string, text: string, revision: number): void => {
501
+ const state = getPluginState(editor);
502
+ const existing = state.segments[segmentId];
503
+ const isPlaceholder = segmentId === "ephia-startup-placeholder";
504
+ const mark = isPlaceholder
505
+ ? editor.schema.marks.ephiaPlaceholder?.create()
506
+ : editor.schema.marks.ephiaPreview?.create();
507
+
508
+ if (existing) {
509
+ // Réordering réseau : rejeter une révision périmée
510
+ if (revision <= existing.revision) return;
511
+
512
+ const oldText = textBetweenWithHardBreaks(editor.state.doc, existing.from, existing.to);
513
+ if (oldText === text) return;
514
+
515
+ const tr = editor.state.tr;
516
+ tr.setMeta("addToHistory", false);
517
+ tr.setMeta(SKIP_STORE_SYNC_META, true);
518
+
519
+ const prefixLen = getCommonPrefix(oldText, text);
520
+ const suffixLen = getCommonSuffix(oldText, text, prefixLen);
521
+
522
+ if (prefixLen > 0 || suffixLen > 0) {
523
+ const deleteFrom = existing.from + prefixLen;
524
+ const deleteTo = existing.to - suffixLen;
525
+ const insertText = text.slice(prefixLen, text.length - suffixLen);
526
+
527
+ if (deleteFrom < deleteTo) tr.delete(deleteFrom, deleteTo);
528
+ if (insertText) {
529
+ insertOperationsAt(
530
+ editor,
531
+ tr,
532
+ deleteFrom,
533
+ textToDocumentOperations(insertText),
534
+ mark
535
+ );
536
+ }
537
+ const newEnd = existing.from + measureTextAsOperationsSize(editor, text);
538
+ setSegmentMeta(tr, segmentId, {
539
+ from: existing.from,
540
+ to: newEnd,
541
+ revision,
542
+ joinerLength: existing.joinerLength,
543
+ });
544
+ } else {
545
+ tr.delete(existing.from, existing.to);
546
+ const inserted = insertOperationsAt(
547
+ editor,
548
+ tr,
549
+ existing.from,
550
+ textToDocumentOperations(text),
551
+ mark
552
+ );
553
+ setSegmentMeta(tr, segmentId, {
554
+ from: existing.from,
555
+ to: inserted.to,
556
+ revision,
557
+ joinerLength: existing.joinerLength,
558
+ });
559
+ }
560
+
561
+ // L'anchor.end est mappé automatiquement via tr.mapping
562
+ maybeScrollIntoView(tr, editor, existing.to);
563
+ editor.view.dispatch(tr);
564
+ knownSegmentIds.add(segmentId);
565
+ } else {
566
+ // Nouveau segment → insérer à l'anchor (ou au curseur si pas de session)
567
+ if (!state.anchor) {
568
+ return; // session terminée → rejeter le partial fantôme
569
+ }
570
+
571
+ const tr = editor.state.tr;
572
+ tr.setMeta("addToHistory", false);
573
+ tr.setMeta(SKIP_STORE_SYNC_META, true);
574
+
575
+ // Suppression lazy de la sélection : si une sélection était en attente,
576
+ // la supprimer atomiquement avec le premier insert dicté.
577
+ let pos = state.anchor.end;
578
+ if (
579
+ state.anchor.selectionPendingDelete &&
580
+ state.anchor.initialEnd > state.anchor.initialStart
581
+ ) {
582
+ tr.delete(state.anchor.initialStart, state.anchor.initialEnd);
583
+ pos = state.anchor.initialStart;
584
+ setAnchorMeta(tr, {
585
+ kind: "set",
586
+ value: {
587
+ ...state.anchor,
588
+ selectionPendingDelete: false,
589
+ initialEnd: state.anchor.initialStart,
590
+ end: state.anchor.initialStart,
591
+ },
592
+ });
593
+ }
594
+
595
+ const before = textBetweenWithHardBreaks(editor.state.doc, Math.max(0, pos - 500), pos);
596
+ const overlap = stripLeadingOverlapFromTextWithInfo(before, text);
597
+ text = overlap.text;
598
+ if (!text) return;
599
+ const joiner = overlap.partialWord ? "" : pickJoiner(before, text);
600
+
601
+ const fullText = joiner + text;
602
+ const inserted = insertOperationsAt(
603
+ editor,
604
+ tr,
605
+ pos,
606
+ textToDocumentOperations(fullText),
607
+ mark
608
+ );
609
+
610
+ const segFrom = pos + measureTextAsOperationsSize(editor, joiner);
611
+ const segTo = inserted.to;
612
+ setSegmentMeta(tr, segmentId, { from: segFrom, to: segTo, revision, joinerLength: joiner.length });
613
+ setAnchorMeta(tr, { kind: "patch", end: segTo });
614
+
615
+ maybeScrollIntoView(tr, editor, segTo);
616
+ editor.view.dispatch(tr);
617
+ knownSegmentIds.add(segmentId);
618
+ }
619
+ };
620
+
621
+ // ── Debounce réel des partials, par segment, pour réduire les transactions PM. ──
622
+ const pendingPartials = new Map<
623
+ string,
624
+ { text: string; revision: number; timer: ReturnType<typeof setTimeout> }
625
+ >();
626
+
627
+ const flushPendingPartial = (segmentId: string) => {
628
+ const pending = pendingPartials.get(segmentId);
629
+ if (!pending) return;
630
+ clearTimeout(pending.timer);
631
+ pendingPartials.delete(segmentId);
632
+ _applyPartial(segmentId, pending.text, pending.revision);
633
+ };
634
+
635
+ const cancelPendingPartial = (segmentId: string) => {
636
+ const pending = pendingPartials.get(segmentId);
637
+ if (!pending) return;
638
+ clearTimeout(pending.timer);
639
+ pendingPartials.delete(segmentId);
640
+ };
641
+
642
+ const cancelAllPendingPartials = () => {
643
+ for (const { timer } of pendingPartials.values()) {
644
+ clearTimeout(timer);
645
+ }
646
+ pendingPartials.clear();
647
+ };
648
+
649
+ const schedulePartial = (segmentId: string, text: string, revision: number) => {
650
+ const existing = pendingPartials.get(segmentId);
651
+ if (existing) {
652
+ if (revision < existing.revision) return;
653
+ clearTimeout(existing.timer);
654
+ }
655
+ const timer = setTimeout(() => flushPendingPartial(segmentId), PARTIAL_DEBOUNCE_MS);
656
+ pendingPartials.set(segmentId, { text, revision, timer });
657
+ };
658
+
659
+ const trimOverlappingPreviewSegments = (
660
+ tr: Transaction,
661
+ state: EphiaTiptapPluginState,
662
+ committedSegmentId: string,
663
+ committedText: string
664
+ ): void => {
665
+ const previewMark = editor.schema.marks.ephiaPreview?.create();
666
+ for (const [otherId, other] of Object.entries(state.segments)) {
667
+ if (otherId === committedSegmentId || other.revision === Number.MAX_SAFE_INTEGER) {
668
+ continue;
669
+ }
670
+ const previewText = textBetweenWithHardBreaks(editor.state.doc, other.from, other.to);
671
+ const overlap = stripLeadingOverlapFromTextWithInfo(committedText, previewText);
672
+ const trimmed = overlap.text;
673
+ if (trimmed === previewText) continue;
674
+
675
+ let from = tr.mapping.map(other.from);
676
+ let to = tr.mapping.map(other.to);
677
+ if (overlap.partialWord && from > 0 && /\s/.test(textBetweenWithHardBreaks(tr.doc, from - 1, from))) {
678
+ tr.delete(from - 1, from);
679
+ from -= 1;
680
+ to -= 1;
681
+ }
682
+ if (trimmed) {
683
+ tr.delete(from, to);
684
+ const inserted = insertOperationsAt(
685
+ editor,
686
+ tr,
687
+ from,
688
+ textToDocumentOperations(trimmed),
689
+ previewMark
690
+ );
691
+ setSegmentMeta(tr, otherId, {
692
+ from,
693
+ to: inserted.to,
694
+ revision: other.revision + 1,
695
+ });
696
+ } else {
697
+ tr.delete(from, to);
698
+ setSegmentMeta(tr, otherId, null);
699
+ }
700
+ }
701
+ };
702
+
703
+ return {
704
+ kind: "tiptap",
705
+
706
+ attach(): void {
707
+ /* Plugin déjà enregistré */
708
+ },
709
+
710
+ detach(): void {
711
+ editor.view.dom.removeEventListener("scroll", markUserScroll);
712
+ editor.unregisterPlugin(ephiaTiptapKey);
713
+ editor.off("update", syncToStore);
714
+ editor.off("selectionUpdate", syncSelection);
715
+ cancelAllPendingPartials();
716
+ if (syncRaf !== null && typeof cancelAnimationFrame !== "undefined") {
717
+ cancelAnimationFrame(syncRaf);
718
+ syncRaf = null;
719
+ }
720
+ // Annuler les timeouts committed en attente
721
+ for (const timeoutId of committedTimeouts.values()) {
722
+ clearTimeout(timeoutId);
723
+ }
724
+ committedTimeouts.clear();
725
+ knownSegmentIds.clear();
726
+ },
727
+
728
+ // ─── Session lifecycle ──────────────────────────────────────────────────
729
+ beginSession(opts: BeginSessionOptions = {}): SessionAnchor {
730
+ const replaceSelection = opts.replaceSelection ?? true;
731
+ const isFocused = editor.isFocused;
732
+
733
+ // Si l'éditeur n'a jamais reçu le focus, la sélection est par défaut au
734
+ // début du doc (pos 1) — on retombe alors sur la fin du document pour
735
+ // un comportement type Dragon Medical (insertion à la suite).
736
+ let from = editor.state.selection.from;
737
+ let to = editor.state.selection.to;
738
+ if (!isFocused && from === to) {
739
+ const docSize = editor.state.doc.content.size;
740
+ const resumePos = lastDictationEnd !== null
741
+ ? Math.min(lastDictationEnd, Math.max(1, docSize - 1))
742
+ : Math.max(1, docSize - 1);
743
+ from = resumePos;
744
+ to = resumePos;
745
+ }
746
+ const hadSelection = from !== to;
747
+ const selectionPendingDelete = hadSelection && replaceSelection;
748
+
749
+ const tr = editor.state.tr;
750
+ tr.setMeta("addToHistory", false);
751
+ tr.setMeta(SKIP_STORE_SYNC_META, true);
752
+
753
+ // Ne pas supprimer la sélection ici — la suppression est différée
754
+ // au premier insert (insertPartial / commitFinal) pour que l'user
755
+ // voie encore le texte sélectionné pendant la connexion au serveur.
756
+
757
+ const anchorValue = {
758
+ initialStart: from,
759
+ initialEnd: to, // conserver la vraie fin de sélection
760
+ hadSelection,
761
+ end: from, // point d'insertion = début de sélection
762
+ selectionPendingDelete,
763
+ };
764
+
765
+ // Place le curseur PM au début de la sélection (sans la supprimer).
766
+ try {
767
+ const resolved = tr.doc.resolve(from);
768
+ tr.setSelection(TextSelection.near(resolved));
769
+ } catch {
770
+ // Position hors bornes : on laisse PM gérer
771
+ }
772
+
773
+ setAnchorMeta(tr, { kind: "set", value: anchorValue });
774
+ editor.view.dispatch(tr);
775
+
776
+ // Re-focus l'éditeur pour que la barre clignotante soit visible
777
+ if (!isFocused) {
778
+ try {
779
+ editor.view.focus();
780
+ } catch {
781
+ /* noop */
782
+ }
783
+ }
784
+
785
+ return toPublicAnchor(anchorValue);
786
+ },
787
+
788
+ endSession(): void {
789
+ cancelAllPendingPartials();
790
+ const anchor = getPluginState(editor).anchor;
791
+ if (anchor) lastDictationEnd = anchor.end;
792
+ const tr = editor.state.tr;
793
+ tr.setMeta("addToHistory", false);
794
+ tr.setMeta(SKIP_STORE_SYNC_META, true);
795
+ setAnchorMeta(tr, { kind: "clear" });
796
+ // Nettoyer aussi les marks committed en cours d'animation
797
+ const committedMark = editor.schema.marks.ephiaCommitted;
798
+ if (committedMark) {
799
+ tr.removeMark(0, editor.state.doc.content.size, committedMark);
800
+ }
801
+ editor.view.dispatch(tr);
802
+ syncToStore();
803
+ },
804
+
805
+ getSessionAnchor(): SessionAnchor | null {
806
+ const anchor = getPluginState(editor).anchor;
807
+ return anchor ? toPublicAnchor(anchor) : null;
808
+ },
809
+
810
+ getCursorRect(): DOMRect | null {
811
+ const anchor = getPluginState(editor).anchor;
812
+ const pos = anchor ? anchor.end : editor.state.selection.from;
813
+ try {
814
+ const coords = editor.view.coordsAtPos(pos);
815
+ // coords = { top, bottom, left, right }
816
+ return new DOMRect(
817
+ coords.left,
818
+ coords.top,
819
+ coords.right - coords.left,
820
+ coords.bottom - coords.top
821
+ );
822
+ } catch {
823
+ const dom = editor.view.dom as HTMLElement;
824
+ return dom.getBoundingClientRect();
825
+ }
826
+ },
827
+
828
+ getRangeRect(start: number, end: number): DOMRect | null {
829
+ if (end <= start) return null;
830
+ try {
831
+ const a = editor.view.coordsAtPos(start);
832
+ const b = editor.view.coordsAtPos(end);
833
+ const top = Math.min(a.top, b.top);
834
+ const bottom = Math.max(a.bottom, b.bottom);
835
+ const left = Math.min(a.left, b.left);
836
+ const right = Math.max(a.right, b.right);
837
+ return new DOMRect(left, top, Math.max(2, right - left), bottom - top);
838
+ } catch {
839
+ return null;
840
+ }
841
+ },
842
+
843
+ // ─── Streaming insertion ────────────────────────────────────────────────
844
+ insertPartial(segmentId: string, text: string, revision: number): void {
845
+ schedulePartial(segmentId, text, revision);
846
+ },
847
+
848
+ commitFinal(segmentId: string, text: string, options?: CommitFinalOptions): void {
849
+ clearCommittedTimeout(segmentId);
850
+ flushPendingPartial(segmentId);
851
+ const state = getPluginState(editor);
852
+ const existing = state.segments[segmentId];
853
+ if (!existing && knownSegmentIds.has(segmentId)) {
854
+ console.warn("[ephia:tiptap] known segmentId lost range; refusing append", {
855
+ segmentId,
856
+ text,
857
+ });
858
+ return;
859
+ }
860
+
861
+ if (existing) {
862
+ // Commit final = entrée undo : ⌘Z doit pouvoir revenir avant le commit
863
+ const tr = editor.state.tr;
864
+
865
+ const oldText = textBetweenWithHardBreaks(editor.state.doc, existing.from, existing.to);
866
+ let newEnd = existing.from + measureTextAsOperationsSize(editor, text);
867
+
868
+ if (oldText !== text) {
869
+ const prefixLen = getCommonPrefix(oldText, text);
870
+ const suffixLen = getCommonSuffix(oldText, text, prefixLen);
871
+
872
+ if (prefixLen > 0 || suffixLen > 0) {
873
+ const deleteFrom = existing.from + prefixLen;
874
+ const deleteTo = existing.to - suffixLen;
875
+ const insertText = text.slice(prefixLen, text.length - suffixLen);
876
+ if (deleteFrom < deleteTo) tr.delete(deleteFrom, deleteTo);
877
+ if (insertText) {
878
+ insertOperationsAt(
879
+ editor,
880
+ tr,
881
+ deleteFrom,
882
+ textToDocumentOperations(insertText)
883
+ );
884
+ }
885
+ newEnd = existing.from + measureTextAsOperationsSize(editor, text);
886
+ } else {
887
+ tr.delete(existing.from, existing.to);
888
+ const inserted = insertOperationsAt(
889
+ editor,
890
+ tr,
891
+ existing.from,
892
+ textToDocumentOperations(text)
893
+ );
894
+ newEnd = inserted.to;
895
+ }
896
+ }
897
+
898
+ tr.removeMark(existing.from, newEnd, editor.schema.marks.ephiaPreview);
899
+
900
+ // Transition visuelle : mark committed pour le fade-out lavande → vert
901
+ const committedMark = editor.schema.marks.ephiaCommitted?.create();
902
+ if (committedMark) {
903
+ tr.addMark(existing.from, newEnd, committedMark);
904
+ }
905
+
906
+ setSegmentMeta(tr, segmentId, {
907
+ from: existing.from,
908
+ to: newEnd,
909
+ revision: Number.MAX_SAFE_INTEGER,
910
+ joinerLength: existing.joinerLength,
911
+ });
912
+ if (state.anchor) {
913
+ setAnchorMeta(tr, { kind: "patch", end: newEnd });
914
+ }
915
+ lastDictationEnd = newEnd;
916
+ trimOverlappingPreviewSegments(tr, state, segmentId, text);
917
+
918
+ for (const absorbedId of options?.absorbedSegmentIds ?? []) {
919
+ if (absorbedId === segmentId) continue;
920
+ cancelPendingPartial(absorbedId);
921
+ const absorbedSeg = state.segments[absorbedId];
922
+ if (!absorbedSeg) continue;
923
+ const aFrom = tr.mapping.map(absorbedSeg.from - (absorbedSeg.joinerLength ?? 0));
924
+ const aTo = tr.mapping.map(absorbedSeg.to);
925
+ if (aFrom < aTo) tr.delete(aFrom, aTo);
926
+ setSegmentMeta(tr, absorbedId, null);
927
+ }
928
+
929
+ maybeScrollIntoView(tr, editor, newEnd);
930
+ editor.view.dispatch(tr);
931
+ knownSegmentIds.add(segmentId);
932
+
933
+ // Retirer la mark committed après l'animation (1.2s + marge)
934
+ if (committedMark) {
935
+ const timeoutId = window.setTimeout(() => {
936
+ committedTimeouts.delete(segmentId);
937
+ const freshState = getPluginState(editor);
938
+ const seg = freshState.segments[segmentId];
939
+ if (seg && editor.schema.marks.ephiaCommitted) {
940
+ const tr2 = editor.state.tr;
941
+ tr2.setMeta("addToHistory", false);
942
+ tr2.setMeta(SKIP_STORE_SYNC_META, true);
943
+ tr2.removeMark(seg.from, seg.to, editor.schema.marks.ephiaCommitted);
944
+ editor.view.dispatch(tr2);
945
+ }
946
+ }, 1300);
947
+ committedTimeouts.set(segmentId, timeoutId);
948
+ }
949
+ } else {
950
+ // Pas de partial préalable → insérer à l'anchor (avec joiner) ou au curseur
951
+ const tr = editor.state.tr;
952
+
953
+ // Suppression lazy de la sélection (même logique que dans _applyPartial)
954
+ let pos = state.anchor ? state.anchor.end : editor.state.selection.from;
955
+ if (
956
+ state.anchor?.selectionPendingDelete &&
957
+ state.anchor.initialEnd > state.anchor.initialStart
958
+ ) {
959
+ tr.delete(state.anchor.initialStart, state.anchor.initialEnd);
960
+ pos = state.anchor.initialStart;
961
+ setAnchorMeta(tr, {
962
+ kind: "set",
963
+ value: {
964
+ ...state.anchor,
965
+ selectionPendingDelete: false,
966
+ initialEnd: state.anchor.initialStart,
967
+ end: state.anchor.initialStart,
968
+ },
969
+ });
970
+ }
971
+
972
+ let joiner = "";
973
+ if (state.anchor) {
974
+ const before = textBetweenWithHardBreaks(editor.state.doc, Math.max(0, pos - 500), pos);
975
+ const overlap = stripLeadingOverlapFromTextWithInfo(before, text);
976
+ text = overlap.text;
977
+ if (!text) return;
978
+ joiner = overlap.partialWord ? "" : pickJoiner(before, text);
979
+ }
980
+
981
+ const fullText = joiner + text;
982
+ const inserted = insertOperationsAt(
983
+ editor,
984
+ tr,
985
+ pos,
986
+ textToDocumentOperations(fullText)
987
+ );
988
+
989
+ const segFrom = pos + measureTextAsOperationsSize(editor, joiner);
990
+ const segTo = inserted.to;
991
+ setSegmentMeta(tr, segmentId, {
992
+ from: segFrom,
993
+ to: segTo,
994
+ revision: Number.MAX_SAFE_INTEGER,
995
+ joinerLength: joiner.length,
996
+ });
997
+
998
+ if (state.anchor) {
999
+ setAnchorMeta(tr, { kind: "patch", end: segTo });
1000
+ }
1001
+ lastDictationEnd = segTo;
1002
+
1003
+ trimOverlappingPreviewSegments(tr, state, segmentId, text);
1004
+
1005
+ for (const absorbedId of options?.absorbedSegmentIds ?? []) {
1006
+ if (absorbedId === segmentId) continue;
1007
+ cancelPendingPartial(absorbedId);
1008
+ const absorbedSeg = state.segments[absorbedId];
1009
+ if (!absorbedSeg) continue;
1010
+ const aFrom = tr.mapping.map(absorbedSeg.from - (absorbedSeg.joinerLength ?? 0));
1011
+ const aTo = tr.mapping.map(absorbedSeg.to);
1012
+ if (aFrom < aTo) tr.delete(aFrom, aTo);
1013
+ setSegmentMeta(tr, absorbedId, null);
1014
+ }
1015
+
1016
+ maybeScrollIntoView(tr, editor, segTo);
1017
+ editor.view.dispatch(tr);
1018
+ knownSegmentIds.add(segmentId);
1019
+ }
1020
+ },
1021
+
1022
+ clearPartial(segmentId: string): void {
1023
+ cancelPendingPartial(segmentId);
1024
+ const state = getPluginState(editor);
1025
+ const existing = state.segments[segmentId];
1026
+
1027
+ if (existing) {
1028
+ if (existing.revision === Number.MAX_SAFE_INTEGER) {
1029
+ return;
1030
+ }
1031
+ const tr = editor.state.tr;
1032
+ tr.setMeta("addToHistory", false);
1033
+ tr.setMeta(SKIP_STORE_SYNC_META, true);
1034
+ tr.delete(existing.from - (existing.joinerLength ?? 0), existing.to);
1035
+ setSegmentMeta(tr, segmentId, null);
1036
+ editor.view.dispatch(tr);
1037
+ }
1038
+ },
1039
+
1040
+ clearAll(): void {
1041
+ cancelAllPendingPartials();
1042
+ const state = getPluginState(editor);
1043
+ const previewEntries = Object.entries(state.segments).filter(
1044
+ ([, seg]) => seg.revision !== Number.MAX_SAFE_INTEGER
1045
+ );
1046
+ if (previewEntries.length === 0) return;
1047
+
1048
+ const tr = editor.state.tr;
1049
+ tr.setMeta("addToHistory", false);
1050
+ tr.setMeta(SKIP_STORE_SYNC_META, true);
1051
+ for (const [segmentId, seg] of previewEntries.sort(
1052
+ (a, b) => b[1].from - a[1].from
1053
+ )) {
1054
+ const from = tr.mapping.map(seg.from - (seg.joinerLength ?? 0));
1055
+ const to = tr.mapping.map(seg.to);
1056
+ if (from < to) tr.delete(from, to);
1057
+ setSegmentMeta(tr, segmentId, null);
1058
+ }
1059
+ editor.view.dispatch(tr);
1060
+ },
1061
+
1062
+ getText(): string {
1063
+ return getTextFromDoc(editor.state.doc);
1064
+ },
1065
+
1066
+ getSelection() {
1067
+ const { from, to } = editor.state.selection;
1068
+ if (from === to) return null;
1069
+ return {
1070
+ text: textBetweenWithHardBreaks(editor.state.doc, from, to),
1071
+ range: { start: from, end: to },
1072
+ };
1073
+ },
1074
+
1075
+ getCursorOffset(): number | null {
1076
+ return editor.state.selection.from;
1077
+ },
1078
+
1079
+ applyOperation(operation: DocumentOperation): void {
1080
+ switch (operation.type) {
1081
+ case "replace": {
1082
+ let start: number;
1083
+ let end: number;
1084
+
1085
+ if (operation.range) {
1086
+ start = operation.range.start;
1087
+ end = operation.range.end;
1088
+ } else if (operation.targetText) {
1089
+ const text = editor.getText();
1090
+ const idx = text.indexOf(operation.targetText);
1091
+ if (idx === -1) {
1092
+ console.warn(
1093
+ "[tiptapBinding] replace ignored: targetText not found"
1094
+ );
1095
+ return;
1096
+ }
1097
+ start = idx;
1098
+ end = idx + operation.targetText.length;
1099
+ } else {
1100
+ console.warn(
1101
+ "[tiptapBinding] replace ignored: missing range or targetText"
1102
+ );
1103
+ return;
1104
+ }
1105
+
1106
+ editor
1107
+ .chain()
1108
+ .deleteRange({ from: start, to: end })
1109
+ .insertContentAt(start, [{ type: "text", text: operation.replacement }])
1110
+ .run();
1111
+ break;
1112
+ }
1113
+
1114
+ case "insert":
1115
+ case "insert_text":
1116
+ case "line_break":
1117
+ case "paragraph_break": {
1118
+ const pos = resolveInsertPosition(editor, operation.position);
1119
+ const tr = editor.state.tr;
1120
+ insertOperationsAt(editor, tr, pos, [operation]);
1121
+ editor.view.dispatch(tr);
1122
+ break;
1123
+ }
1124
+
1125
+ case "delete": {
1126
+ let start: number;
1127
+ let end: number;
1128
+
1129
+ if (operation.range) {
1130
+ start = operation.range.start;
1131
+ end = operation.range.end;
1132
+ } else if (operation.targetText) {
1133
+ const text = editor.getText();
1134
+ const idx = text.indexOf(operation.targetText);
1135
+ if (idx === -1) {
1136
+ console.warn(
1137
+ "[tiptapBinding] delete ignored: targetText not found"
1138
+ );
1139
+ return;
1140
+ }
1141
+ start = idx;
1142
+ end = idx + operation.targetText.length;
1143
+ } else {
1144
+ console.warn(
1145
+ "[tiptapBinding] delete ignored: missing range or targetText"
1146
+ );
1147
+ return;
1148
+ }
1149
+
1150
+ editor.chain().deleteRange({ from: start, to: end }).run();
1151
+ break;
1152
+ }
1153
+
1154
+ case "replace_all": {
1155
+ editor.chain().setContent([{ type: "paragraph", content: [{ type: "text", text: operation.replacement }] }]).run();
1156
+ break;
1157
+ }
1158
+
1159
+ default:
1160
+ console.warn(
1161
+ "[tiptapBinding] Operation not supported:",
1162
+ operation.type
1163
+ );
1164
+ }
1165
+ },
1166
+
1167
+ applyOperations(operations: DocumentOperation[]): void {
1168
+ if (operations.length === 0) return;
1169
+ const firstInsertOperation = operations.find(
1170
+ (operation) =>
1171
+ operation.type === "insert" ||
1172
+ operation.type === "insert_text" ||
1173
+ operation.type === "line_break" ||
1174
+ operation.type === "paragraph_break"
1175
+ );
1176
+ if (!firstInsertOperation) {
1177
+ for (const operation of operations) {
1178
+ console.warn(
1179
+ "[tiptapBinding] Operation not supported in applyOperations:",
1180
+ operation.type
1181
+ );
1182
+ }
1183
+ return;
1184
+ }
1185
+
1186
+ const tr = editor.state.tr;
1187
+ const pos = resolveInsertPosition(editor, firstInsertOperation.position);
1188
+ insertOperationsAt(editor, tr, pos, operations);
1189
+ editor.view.dispatch(tr);
1190
+ },
1191
+ };
1192
+ }