@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,63 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+ import { Editor, Node } from '@tiptap/core'
5
+ import { TiptapBinding, EphiaV2PreviewMark, EphiaV2CommittedMark } from './TiptapBinding'
6
+
7
+ const DocumentNode = Node.create({ name: 'doc', topNode: true, content: 'block+' })
8
+ const ParagraphNode = Node.create({
9
+ name: 'paragraph',
10
+ group: 'block',
11
+ content: 'inline*',
12
+ parseHTML: () => [{ tag: 'p' }],
13
+ renderHTML: () => ['p', 0],
14
+ })
15
+ const TextNode = Node.create({ name: 'text', group: 'inline' })
16
+
17
+ function createEditor(content = '<p></p>'): Editor {
18
+ return new Editor({
19
+ element: document.createElement('div'),
20
+ extensions: [DocumentNode, ParagraphNode, TextNode, EphiaV2PreviewMark, EphiaV2CommittedMark],
21
+ content,
22
+ })
23
+ }
24
+
25
+ describe('TiptapBinding upsertSegment', () => {
26
+ it('replaces the active selection when inserting a new segment', () => {
27
+ const editor = createEditor('<p>Texte avant sélection texte après</p>')
28
+ try {
29
+ const binding = new TiptapBinding(editor)
30
+ binding.attach()
31
+
32
+ // Sélectionner "sélection"
33
+ editor.commands.setTextSelection({ from: 13, to: 22 })
34
+ expect(editor.state.doc.textContent).toContain('sélection')
35
+
36
+ binding.upsertSegment({ id: 'seg-1', text: 'remplacement', stage: 'committed', isFinal: true })
37
+
38
+ const text = editor.state.doc.textContent
39
+ expect(text).toContain('remplacement')
40
+ expect(text).toContain('texte après')
41
+ expect(text).not.toContain('sélection')
42
+ } finally {
43
+ editor.destroy()
44
+ }
45
+ })
46
+
47
+ it('inserts at cursor when there is no selection', () => {
48
+ const editor = createEditor('<p>Texte avant </p>')
49
+ try {
50
+ const binding = new TiptapBinding(editor)
51
+ binding.attach()
52
+
53
+ // Positionner le curseur à la fin
54
+ editor.commands.setTextSelection(14)
55
+
56
+ binding.upsertSegment({ id: 'seg-1', text: 'ajout', stage: 'committed', isFinal: true })
57
+
58
+ expect(editor.state.doc.textContent).toBe('Texte avant ajout')
59
+ } finally {
60
+ editor.destroy()
61
+ }
62
+ })
63
+ })
@@ -0,0 +1,464 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { Mark } from "@tiptap/core";
3
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
4
+ import type { Transaction } from "@tiptap/pm/state";
5
+ import type { Mark as ProseMirrorMark } from "@tiptap/pm/model";
6
+ import type { EditorContext } from "ephia-protocol";
7
+ import type { EphiaBinding, PreviewSegmentInput, UpsertSegmentInput } from "../EphiaBinding";
8
+ import { textToDocumentOperations } from "../../operations/textToDocumentOperations";
9
+ import { ensureMinimalAppendBoundary } from "../insertion-boundary";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // ProseMirror Plugin V2 — tracks segment positions through transactions.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ interface SegmentPos {
16
+ from: number;
17
+ to: number;
18
+ }
19
+
20
+ interface EphiaV2PluginState {
21
+ segments: Record<string, SegmentPos>;
22
+ }
23
+
24
+ const ephiaV2Key = new PluginKey<EphiaV2PluginState>("ephiaV2");
25
+
26
+ interface SegmentMeta {
27
+ segments?: Record<string, SegmentPos | null>;
28
+ }
29
+
30
+ function createEphiaV2Plugin(): Plugin {
31
+ return new Plugin({
32
+ key: ephiaV2Key,
33
+ state: {
34
+ init(): EphiaV2PluginState {
35
+ return { segments: {} };
36
+ },
37
+ apply(tr, value): EphiaV2PluginState {
38
+ const docSize = tr.doc.content.size;
39
+ const mapped: EphiaV2PluginState["segments"] = {};
40
+ for (const [id, pos] of Object.entries(value.segments)) {
41
+ const from = Math.min(Math.max(tr.mapping.map(pos.from), 0), docSize);
42
+ const to = Math.min(Math.max(tr.mapping.map(pos.to), 0), docSize);
43
+ if (from <= to && to <= docSize) mapped[id] = { from, to };
44
+ }
45
+ const meta = tr.getMeta(ephiaV2Key) as SegmentMeta | undefined;
46
+ if (meta?.segments) {
47
+ for (const [id, pos] of Object.entries(meta.segments)) {
48
+ if (pos === null) delete mapped[id];
49
+ else mapped[id] = pos;
50
+ }
51
+ }
52
+ return { segments: mapped };
53
+ },
54
+ },
55
+ });
56
+ }
57
+
58
+ function getV2State(editor: Editor): EphiaV2PluginState {
59
+ return ephiaV2Key.getState(editor.state) ?? { segments: {} };
60
+ }
61
+
62
+ function applySegmentMeta(tr: Transaction, updates: Record<string, SegmentPos | null>): void {
63
+ const current = (tr.getMeta(ephiaV2Key) as SegmentMeta | undefined) ?? {};
64
+ current.segments = { ...(current.segments ?? {}), ...updates };
65
+ tr.setMeta(ephiaV2Key, current);
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Marks
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export const EphiaV2PreviewMark = Mark.create({
73
+ name: "ephiaV2Preview",
74
+ parseHTML: () => [{ tag: "span[data-ephia-preview]" }],
75
+ renderHTML: () => ["span", { "data-ephia-preview": "true", class: "ephia-text--preview" }, 0],
76
+ });
77
+
78
+ export const EphiaV2CommittedMark = Mark.create({
79
+ name: "ephiaV2Committed",
80
+ parseHTML: () => [{ tag: "span[data-ephia-v2-committed]" }],
81
+ renderHTML: () => ["span", { "data-ephia-v2-committed": "true", class: "ephia-text--v2-committed" }, 0],
82
+ });
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Helpers
86
+ // ---------------------------------------------------------------------------
87
+
88
+ function getDocText(editor: Editor): string {
89
+ return editor.state.doc.textBetween(0, editor.state.doc.content.size, "\n\n", "\n");
90
+ }
91
+
92
+ function getCommonPrefix(a: string, b: string): number {
93
+ let i = 0;
94
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
95
+ return i;
96
+ }
97
+
98
+ function getCommonSuffix(a: string, b: string, prefixLen: number): number {
99
+ let i = 0;
100
+ while (
101
+ i < a.length - prefixLen &&
102
+ i < b.length - prefixLen &&
103
+ a[a.length - 1 - i] === b[b.length - 1 - i]
104
+ ) i++;
105
+ return i;
106
+ }
107
+
108
+ /**
109
+ * Replace text in range [from, to] with newText using a smart diff to avoid
110
+ * unnecessary delete+insert when only part of the text changed.
111
+ */
112
+ function insertOpsIntoTr(
113
+ editor: Editor,
114
+ tr: Transaction,
115
+ startCursor: number,
116
+ text: string,
117
+ mark?: ProseMirrorMark,
118
+ ): number {
119
+ const ops = textToDocumentOperations(text);
120
+ let cursor = startCursor;
121
+ for (const op of ops) {
122
+ if (op.type === "insert_text") {
123
+ if (op.text) {
124
+ const node = mark
125
+ ? editor.schema.text(op.text, [mark])
126
+ : editor.schema.text(op.text);
127
+ tr.insert(cursor, node);
128
+ cursor += op.text.length;
129
+ }
130
+ } else if (op.type === "line_break") {
131
+ const hardBreak = editor.schema.nodes.hardBreak;
132
+ if (hardBreak) {
133
+ tr.insert(cursor, hardBreak.create());
134
+ cursor += 1;
135
+ }
136
+ } else if (op.type === "paragraph_break") {
137
+ const hardBreak = editor.schema.nodes.hardBreak;
138
+ if (hardBreak) {
139
+ // Two hard breaks for paragraph separation in flat TipTap documents.
140
+ tr.insert(cursor, hardBreak.create());
141
+ cursor += 1;
142
+ tr.insert(cursor, hardBreak.create());
143
+ cursor += 1;
144
+ }
145
+ }
146
+ }
147
+ return cursor;
148
+ }
149
+
150
+ function smartReplaceRange(
151
+ editor: Editor,
152
+ tr: Transaction,
153
+ from: number,
154
+ to: number,
155
+ newText: string
156
+ ): SegmentPos {
157
+ const oldText = editor.state.doc.textBetween(from, to, "\n", "\n");
158
+ const prefixLen = getCommonPrefix(oldText, newText);
159
+ const suffixLen = getCommonSuffix(oldText, newText, prefixLen);
160
+
161
+ const deleteFrom = from + prefixLen;
162
+ const deleteTo = to - suffixLen;
163
+ const insertText = newText.slice(prefixLen, newText.length - suffixLen);
164
+
165
+ if (deleteFrom < deleteTo || insertText.length > 0) {
166
+ if (deleteFrom < deleteTo) tr.delete(deleteFrom, deleteTo);
167
+ insertOpsIntoTr(editor, tr, deleteFrom, insertText);
168
+ }
169
+
170
+ return { from, to: from + newText.length };
171
+ }
172
+
173
+ function insertTextAt(editor: Editor, tr: Transaction, pos: number, text: string, mark?: ProseMirrorMark): SegmentPos {
174
+ const end = insertOpsIntoTr(editor, tr, pos, text, mark);
175
+ return { from: pos, to: end };
176
+ }
177
+
178
+ function stripPreviewMark(
179
+ editor: Editor,
180
+ tr: Transaction,
181
+ from: number,
182
+ to: number,
183
+ ): void {
184
+ const previewMarkType = editor.schema.marks.ephiaV2Preview;
185
+ if (previewMarkType) tr.removeMark(from, to, previewMarkType);
186
+ }
187
+
188
+ function applyPreviewMark(
189
+ editor: Editor,
190
+ tr: Transaction,
191
+ from: number,
192
+ to: number,
193
+ ): void {
194
+ const previewMark = editor.schema.marks.ephiaV2Preview?.create();
195
+ if (previewMark && from < to) tr.addMark(from, to, previewMark);
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // TiptapBinding V2
200
+ // ---------------------------------------------------------------------------
201
+
202
+ export type TiptapBindingOptions = {
203
+ warn?: (msg: string, details?: Record<string, unknown>) => void;
204
+ };
205
+
206
+ /**
207
+ * Binding V2 pour TipTap.
208
+ *
209
+ * Règles :
210
+ * - même segmentId + upsertSegment = update in place (never append)
211
+ * - segmentId connu mais range perdu = warning + no append
212
+ * - removeSegments idempotent
213
+ * - cleanup visuel ne supprime jamais l'identité segment
214
+ * - le préfixe de boundary SDK est stable sur preview -> provisional -> canonical
215
+ */
216
+ export class TiptapBinding implements EphiaBinding {
217
+ readonly kind = "tiptap";
218
+ readonly identity: Editor;
219
+
220
+ private readonly editor: Editor;
221
+ private readonly warn: NonNullable<TiptapBindingOptions["warn"]>;
222
+ private readonly knownSegmentIds = new Set<string>();
223
+ private readonly insertionPrefixes = new Map<string, string>();
224
+ private readonly visualTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
225
+ private previewSegmentId: string | null = null;
226
+
227
+ constructor(editor: Editor, options?: TiptapBindingOptions) {
228
+ this.editor = editor;
229
+ this.identity = editor;
230
+ this.warn = options?.warn ?? ((msg, d) => console.warn(msg, d));
231
+ }
232
+
233
+ attach(): void {
234
+ const plugin = createEphiaV2Plugin();
235
+ this.editor.registerPlugin(plugin);
236
+ }
237
+
238
+ detach(): void {
239
+ this.editor.unregisterPlugin(ephiaV2Key);
240
+ for (const t of this.visualTimeouts.values()) clearTimeout(t);
241
+ this.visualTimeouts.clear();
242
+ this.knownSegmentIds.clear();
243
+ this.insertionPrefixes.clear();
244
+ this.previewSegmentId = null;
245
+ }
246
+
247
+ getText(): string {
248
+ return getDocText(this.editor);
249
+ }
250
+
251
+ getEditorContext(targetId = ""): EditorContext {
252
+ const { state } = this.editor;
253
+ const docSize = state.doc.content.size;
254
+ const text = getDocText(this.editor);
255
+ const { from, to } = state.selection;
256
+ const hasSelection = from !== to;
257
+
258
+ return {
259
+ targetId,
260
+ documentEmpty: text.trim().length === 0,
261
+ insertionMode: hasSelection
262
+ ? "replace_selection"
263
+ : from >= docSize - 1
264
+ ? "append"
265
+ : "middle_of_sentence",
266
+ leftContext: text.slice(Math.max(0, from - 400), from),
267
+ rightContext: text.slice(to, to + 200),
268
+ selectedText: hasSelection ? text.slice(from, to) : null,
269
+ cursorOffset: from,
270
+ };
271
+ }
272
+
273
+ previewSegment(input: PreviewSegmentInput): void {
274
+ // Remove previous preview if different segment.
275
+ if (this.previewSegmentId && this.previewSegmentId !== input.id) {
276
+ this._clearPreview(this.previewSegmentId);
277
+ }
278
+ this.previewSegmentId = input.id;
279
+ const state = getV2State(this.editor);
280
+ const existing = state.segments[input.id];
281
+ const insertPos = existing?.to ?? this.editor.state.selection.from;
282
+
283
+ if (existing) {
284
+ // Update existing preview range while preserving the first-insert boundary prefix.
285
+ const tr = this.editor.state.tr;
286
+ const safeText = this._textWithStableBoundary(input.id, input.text, existing.from);
287
+ const { from, to } = smartReplaceRange(this.editor, tr, existing.from, existing.to, safeText);
288
+ applyPreviewMark(this.editor, tr, from, to);
289
+ applySegmentMeta(tr, { [input.id]: { from, to } });
290
+ this.editor.view.dispatch(tr);
291
+ } else {
292
+ // Insert preview at cursor via textToDocumentOperations for consistent \n handling.
293
+ // Apply boundary safety for the first appearance of this segment.
294
+ const tr = this.editor.state.tr;
295
+ const pos = insertPos;
296
+ const previewMark = this.editor.schema.marks.ephiaV2Preview?.create();
297
+ const safeText = this._textWithStableBoundary(input.id, input.text, pos);
298
+ const { from, to } = insertTextAt(this.editor, tr, pos, safeText, previewMark);
299
+ applySegmentMeta(tr, { [input.id]: { from, to } });
300
+ this.editor.view.dispatch(tr);
301
+ }
302
+ }
303
+
304
+ upsertSegment(input: UpsertSegmentInput): void {
305
+ this._clearVisualTimeout(input.id);
306
+
307
+ if (this.previewSegmentId === input.id) {
308
+ this.previewSegmentId = null;
309
+ }
310
+
311
+ const state = getV2State(this.editor);
312
+ const existing = state.segments[input.id];
313
+
314
+ if (existing) {
315
+ // Update existing range while preserving the first-insert boundary prefix.
316
+ const tr = this.editor.state.tr;
317
+ const safeText = this._textWithStableBoundary(input.id, input.text, existing.from);
318
+ const { from, to } = smartReplaceRange(this.editor, tr, existing.from, existing.to, safeText);
319
+ stripPreviewMark(this.editor, tr, from, to);
320
+ applySegmentMeta(tr, { [input.id]: { from, to } });
321
+ this.editor.view.dispatch(tr);
322
+ this.knownSegmentIds.add(input.id);
323
+ this._scheduleVisualCleanup(input.id);
324
+ return;
325
+ }
326
+
327
+ if (this.knownSegmentIds.has(input.id)) {
328
+ this.warn("[ephia:tiptap] segment range lost; upsert ignored", {
329
+ segmentId: input.id,
330
+ stage: input.stage,
331
+ });
332
+ return;
333
+ }
334
+
335
+ // New segment — apply minimal boundary safety before inserting at cursor.
336
+ // Si une sélection est active, on la remplace par le texte dicté.
337
+ const { from: selFrom, to: selTo } = this.editor.state.selection;
338
+ const hasSelection = selFrom !== selTo;
339
+ const insertPos = selFrom;
340
+ const safeText = this._textWithStableBoundary(input.id, input.text, insertPos);
341
+ const tr = this.editor.state.tr;
342
+ if (hasSelection) {
343
+ tr.delete(selFrom, selTo);
344
+ }
345
+ const { from, to } = insertTextAt(this.editor, tr, insertPos, safeText);
346
+ applySegmentMeta(tr, { [input.id]: { from, to } });
347
+ this.editor.view.dispatch(tr);
348
+ this.knownSegmentIds.add(input.id);
349
+ this._scheduleVisualCleanup(input.id);
350
+ }
351
+
352
+ removeSegment(id: string): void {
353
+ const state = getV2State(this.editor);
354
+ const existing = state.segments[id];
355
+ if (!existing) {
356
+ this.knownSegmentIds.add(id);
357
+ this.insertionPrefixes.delete(id);
358
+ return;
359
+ }
360
+ if (this.previewSegmentId === id) this.previewSegmentId = null;
361
+ this._clearVisualTimeout(id);
362
+ const tr = this.editor.state.tr;
363
+ tr.delete(existing.from, existing.to);
364
+ applySegmentMeta(tr, { [id]: null });
365
+ this.editor.view.dispatch(tr);
366
+ this.knownSegmentIds.add(id);
367
+ this.insertionPrefixes.delete(id);
368
+ }
369
+
370
+ removeSegments(ids: string[]): void {
371
+ if (ids.length === 0) return;
372
+ const state = getV2State(this.editor);
373
+ const tr = this.editor.state.tr;
374
+ const updates: Record<string, SegmentPos | null> = {};
375
+
376
+ // Process removals in reverse document order to keep positions valid.
377
+ const toRemove = ids
378
+ .map((id) => ({ id, pos: state.segments[id] }))
379
+ .filter((x): x is { id: string; pos: SegmentPos } => x.pos !== undefined)
380
+ .sort((a, b) => b.pos.from - a.pos.from);
381
+
382
+ for (const { id, pos } of toRemove) {
383
+ tr.delete(pos.from, pos.to);
384
+ updates[id] = null;
385
+ this.knownSegmentIds.add(id);
386
+ this.insertionPrefixes.delete(id);
387
+ if (this.previewSegmentId === id) this.previewSegmentId = null;
388
+ this._clearVisualTimeout(id);
389
+ }
390
+
391
+ for (const id of ids) {
392
+ this.knownSegmentIds.add(id);
393
+ this.insertionPrefixes.delete(id);
394
+ }
395
+
396
+ if (toRemove.length > 0) {
397
+ applySegmentMeta(tr, updates);
398
+ this.editor.view.dispatch(tr);
399
+ }
400
+ }
401
+
402
+ // ------------------------------------------------------------------
403
+ // Internals
404
+ // ------------------------------------------------------------------
405
+
406
+ private _clearPreview(id: string): void {
407
+ const state = getV2State(this.editor);
408
+ const pos = state.segments[id];
409
+ if (!pos) return;
410
+ const tr = this.editor.state.tr;
411
+ tr.delete(pos.from, pos.to);
412
+ applySegmentMeta(tr, { [id]: null });
413
+ this.editor.view.dispatch(tr);
414
+ }
415
+
416
+ private _textWithStableBoundary(id: string, text: string, insertPos: number): string {
417
+ if (!text) return text;
418
+
419
+ // Backend-provided leading whitespace/newlines are authoritative.
420
+ // Do not prepend the SDK fallback prefix on top of them.
421
+ const firstChar = text.at(0);
422
+ if (!firstChar || /\s/.test(firstChar) || /^[,.;:!?)\]}>]/.test(firstChar)) {
423
+ this.insertionPrefixes.set(id, "");
424
+ return text;
425
+ }
426
+
427
+ let prefix = this.insertionPrefixes.get(id);
428
+ if (prefix === undefined) {
429
+ const leftText = getDocText(this.editor).slice(0, insertPos);
430
+ const safeText = ensureMinimalAppendBoundary(leftText, text);
431
+ prefix = safeText.endsWith(text) ? safeText.slice(0, safeText.length - text.length) : "";
432
+ this.insertionPrefixes.set(id, prefix);
433
+ }
434
+
435
+ return `${prefix}${text}`;
436
+ }
437
+
438
+ private _scheduleVisualCleanup(id: string): void {
439
+ this._clearVisualTimeout(id);
440
+ const timer = setTimeout(() => {
441
+ this.visualTimeouts.delete(id);
442
+ this._removeVisualMarkForSegment(id);
443
+ }, 1200);
444
+ this.visualTimeouts.set(id, timer);
445
+ }
446
+
447
+ private _clearVisualTimeout(id: string): void {
448
+ const t = this.visualTimeouts.get(id);
449
+ if (t) { clearTimeout(t); this.visualTimeouts.delete(id); }
450
+ }
451
+
452
+ private _removeVisualMarkForSegment(id: string): void {
453
+ const pos = getV2State(this.editor).segments[id];
454
+ if (!pos) return;
455
+ const { state } = this.editor;
456
+ const previewMarkType = state.schema.marks.ephiaV2Preview;
457
+ const committedMarkType = state.schema.marks.ephiaV2Committed;
458
+ if (!previewMarkType && !committedMarkType) return;
459
+ const tr = state.tr;
460
+ if (previewMarkType) tr.removeMark(pos.from, pos.to, previewMarkType);
461
+ if (committedMarkType) tr.removeMark(pos.from, pos.to, committedMarkType);
462
+ this.editor.view.dispatch(tr);
463
+ }
464
+ }
@@ -0,0 +1,41 @@
1
+ export type SegmentStatus =
2
+ | "streaming"
3
+ | "committed"
4
+ | "error";
5
+
6
+ export interface TextSegment {
7
+ id: string;
8
+ text: string;
9
+ status: SegmentStatus;
10
+ }
11
+
12
+ import type { DocumentOperation } from "ephia-protocol";
13
+ import type { SessionAnchor } from "./TargetBinding";
14
+
15
+ export interface BindingAdapter {
16
+ // Écriture des segments
17
+ insertSegment(seg: TextSegment): void;
18
+ updateSegment(id: string, text: string, status: SegmentStatus): void;
19
+ commitSegment(id: string): void;
20
+ removeSegment(id: string): void;
21
+
22
+ // Lecture
23
+ /**
24
+ * Retourne le texte du segment tel que l'adapter le connaît en interne
25
+ * (dernière valeur passée à insertSegment/updateSegment).
26
+ * Ne reflète pas les éditions DOM manuelles concurrentes.
27
+ */
28
+ getSegmentText?(id: string): string | null;
29
+ getTextContent(): string;
30
+ getCursorOffset(): number;
31
+
32
+ // Lifecycle
33
+ attach(): void;
34
+ beginSession?(anchor: SessionAnchor): void;
35
+ endSession?(): void;
36
+ detach(): void;
37
+
38
+ // Opération structurée (document.patch)
39
+ applyOperation?(operation: DocumentOperation): void;
40
+ clearAll?(): void;
41
+ }