@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,327 @@
1
+ import type { EphiaStore } from "../../store/create-ephia-store";
2
+ import { detectEditorType } from "../detect-editor-type";
3
+ import {
4
+ createBindingForElement,
5
+ type TargetDescriptor,
6
+ type TargetInsertionMode,
7
+ type TargetOptions,
8
+ } from "../binding-factory";
9
+ import {
10
+ EPHIA_TARGET_FOCUSED,
11
+ EPHIA_TIPTAP_MOUNTED,
12
+ EPHIA_TIPTAP_DESTROYED,
13
+ EPHIA_CODEMIRROR_MOUNTED,
14
+ EPHIA_CODEMIRROR_DESTROYED,
15
+ EPHIA_MONACO_MOUNTED,
16
+ EPHIA_MONACO_DESTROYED,
17
+ dispatchTargetFocused,
18
+ } from "../events";
19
+
20
+ const DATA_TARGET = "data-ephia-target";
21
+ const DATA_MODE = "data-ephia-mode";
22
+ // const DATA_CONTEXT = "data-ephia-context"; // TODO: réactiver
23
+ const DATA_EDITOR = "data-ephia-editor";
24
+ const DATA_INSERTION = "data-ephia-insertion";
25
+
26
+ export interface TargetRegistryOptions {
27
+ store: EphiaStore;
28
+ }
29
+
30
+ export class TargetRegistry {
31
+ private descriptors = new Map<string, TargetDescriptor>();
32
+ private observer: MutationObserver | null = null;
33
+ private store: EphiaStore;
34
+ private abortController: AbortController | null = null;
35
+
36
+ constructor(opts: TargetRegistryOptions) {
37
+ this.store = opts.store;
38
+ }
39
+
40
+ // ─── Lifecycle ──────────────────────────────────────────────────────────────
41
+
42
+ mount(scope?: Element): void {
43
+ this.scanDocument(scope);
44
+ this.startObserver(scope);
45
+ this.attachGlobalListeners();
46
+ }
47
+
48
+ unmount(): void {
49
+ this.observer?.disconnect();
50
+ this.observer = null;
51
+ this.abortController?.abort();
52
+ this.abortController = null;
53
+ for (const desc of this.descriptors.values()) {
54
+ desc.binding?.detach();
55
+ if (desc._focusinListener && desc.element) {
56
+ desc.element.removeEventListener("focusin", desc._focusinListener);
57
+ }
58
+ }
59
+ this.descriptors.clear();
60
+ }
61
+
62
+ // ─── API publique ────────────────────────────────────────────────────────────
63
+
64
+ register(id: string, el: HTMLElement, opts: TargetOptions): void {
65
+ el.setAttribute(DATA_TARGET, id);
66
+ if (opts.mode) el.setAttribute(DATA_MODE, opts.mode);
67
+ // if (opts.context) el.setAttribute(DATA_CONTEXT, opts.context); // TODO: réactiver
68
+ if (opts.insertion) el.setAttribute(DATA_INSERTION, opts.insertion);
69
+ if (opts.editor) el.setAttribute(DATA_EDITOR, opts.editor);
70
+ this.attachElement(el);
71
+ }
72
+
73
+ unregister(id: string): void {
74
+ const desc = this.descriptors.get(id);
75
+ if (!desc) return;
76
+ desc.binding?.detach();
77
+ if (desc._focusinListener && desc.element) {
78
+ desc.element.removeEventListener("focusin", desc._focusinListener);
79
+ }
80
+ this.descriptors.delete(id);
81
+
82
+ if (this.store.getState().activeTargetId === id) {
83
+ // Ne pas changer le target actif en pleine session — le binding
84
+ // détaché a déjà le texte en cours et changer de target actif
85
+ // ferait pointer les events suivants vers null ou un autre élément.
86
+ const status = this.store.getState().status;
87
+ if (status === "idle" || status === "error") {
88
+ const first = this.getFirstWritable();
89
+ this.store.getState()._setActiveTarget(first?.id ?? null);
90
+ }
91
+ }
92
+ }
93
+
94
+ get(id: string): TargetDescriptor | undefined {
95
+ return this.descriptors.get(id);
96
+ }
97
+
98
+ getActiveBinding() {
99
+ const id = this.store.getState().activeTargetId;
100
+ if (!id) return null;
101
+ return this.descriptors.get(id)?.binding ?? null;
102
+ }
103
+
104
+ getFirstWritable(): TargetDescriptor | undefined {
105
+ for (const desc of this.descriptors.values()) {
106
+ if (desc.mode.includes("write")) return desc;
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ values(): TargetDescriptor[] {
112
+ return Array.from(this.descriptors.values());
113
+ }
114
+
115
+ setActive(id: string): void {
116
+ if (!this.descriptors.has(id)) return;
117
+ // Lock the active target during an active session to prevent focus changes
118
+ // from redirecting transcript insertion to a different element.
119
+ const status = this.store.getState().status;
120
+ if (status !== "idle" && status !== "error") return;
121
+ this.store.getState()._setActiveTarget(id);
122
+ }
123
+
124
+ readContent(id: string): string {
125
+ return this.descriptors.get(id)?.binding?.getText() ?? "";
126
+ }
127
+
128
+ get size(): number {
129
+ return this.descriptors.size;
130
+ }
131
+
132
+ // ─── Scan + Observer ─────────────────────────────────────────────────────────
133
+
134
+ private scanDocument(scope?: Element): void {
135
+ if (typeof document === "undefined") return;
136
+ const root = scope ?? document;
137
+ root.querySelectorAll<HTMLElement>(`[${DATA_TARGET}]`).forEach((el) => {
138
+ this.attachElement(el);
139
+ });
140
+ }
141
+
142
+ private startObserver(scope?: Element): void {
143
+ if (typeof MutationObserver === "undefined") return;
144
+
145
+ this.observer = new MutationObserver((mutations) => {
146
+ for (const mutation of mutations) {
147
+ if (mutation.type === "childList") {
148
+ mutation.addedNodes.forEach((node) => {
149
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
150
+ const el = node as HTMLElement;
151
+ if (el.hasAttribute(DATA_TARGET)) this.attachElement(el);
152
+ el.querySelectorAll<HTMLElement>(`[${DATA_TARGET}]`).forEach((child) => {
153
+ this.attachElement(child);
154
+ });
155
+ });
156
+ mutation.removedNodes.forEach((node) => {
157
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
158
+ const el = node as HTMLElement;
159
+ const id = el.getAttribute(DATA_TARGET);
160
+ if (id) this.unregister(id);
161
+ el.querySelectorAll<HTMLElement>(`[${DATA_TARGET}]`).forEach((child) => {
162
+ const childId = child.getAttribute(DATA_TARGET);
163
+ if (childId) this.unregister(childId);
164
+ });
165
+ });
166
+ }
167
+ }
168
+ });
169
+
170
+ const target = scope ?? document.body;
171
+ this.observer.observe(target, { childList: true, subtree: true });
172
+ }
173
+
174
+ // ─── Global DOM event listeners ──────────────────────────────────────────────
175
+
176
+ private attachGlobalListeners(): void {
177
+ this.abortController = new AbortController();
178
+ const { signal } = this.abortController;
179
+
180
+ document.addEventListener(
181
+ EPHIA_TARGET_FOCUSED,
182
+ (e) => {
183
+ this.setActive((e as CustomEvent<{ id: string }>).detail.id);
184
+ },
185
+ { signal }
186
+ );
187
+ document.addEventListener(
188
+ EPHIA_TIPTAP_MOUNTED,
189
+ (e) => { this.onEditorReady((e as CustomEvent<{ el: HTMLElement }>).detail.el, "tiptap"); },
190
+ { signal }
191
+ );
192
+ document.addEventListener(
193
+ EPHIA_TIPTAP_DESTROYED,
194
+ (e) => { this.onEditorGone((e as CustomEvent<{ el: HTMLElement }>).detail.el); },
195
+ { signal }
196
+ );
197
+ document.addEventListener(
198
+ EPHIA_CODEMIRROR_MOUNTED,
199
+ (e) => { this.onEditorReady((e as CustomEvent<{ el: HTMLElement }>).detail.el, "codemirror"); },
200
+ { signal }
201
+ );
202
+ document.addEventListener(
203
+ EPHIA_CODEMIRROR_DESTROYED,
204
+ (e) => { this.onEditorGone((e as CustomEvent<{ el: HTMLElement }>).detail.el); },
205
+ { signal }
206
+ );
207
+ document.addEventListener(
208
+ EPHIA_MONACO_MOUNTED,
209
+ (e) => { this.onEditorReady((e as CustomEvent<{ el: HTMLElement }>).detail.el, "monaco"); },
210
+ { signal }
211
+ );
212
+ document.addEventListener(
213
+ EPHIA_MONACO_DESTROYED,
214
+ (e) => { this.onEditorGone((e as CustomEvent<{ el: HTMLElement }>).detail.el); },
215
+ { signal }
216
+ );
217
+ }
218
+
219
+ // ─── Élément attachment ───────────────────────────────────────────────────────
220
+
221
+ private attachElement(el: HTMLElement): void {
222
+ const id = el.getAttribute(DATA_TARGET);
223
+ if (!id) return;
224
+
225
+ const existing = this.descriptors.get(id);
226
+ if (existing) {
227
+ // Si le nœud DOM a été remplacé (même id, nouvel élément), mettre à jour.
228
+ if (existing.element !== el) {
229
+ existing.binding?.detach();
230
+ if (existing._focusinListener) {
231
+ existing.element.removeEventListener("focusin", existing._focusinListener);
232
+ }
233
+ // Recréer le binding sur le nouveau nœud
234
+ const mode = (el.getAttribute(DATA_MODE) ?? "write") as TargetDescriptor["mode"];
235
+ // const context = el.getAttribute(DATA_CONTEXT) ?? undefined; // TODO: réactiver
236
+ const insertion = (el.getAttribute(DATA_INSERTION) ?? "preview-inline") as TargetInsertionMode;
237
+ const editorType = detectEditorType(el);
238
+ const binding = createBindingForElement(el, editorType, { mode, insertion });
239
+ const focusinListener = this.createFocusinListener(id, mode);
240
+ if (focusinListener) el.addEventListener("focusin", focusinListener);
241
+ const updated: TargetDescriptor = {
242
+ ...existing,
243
+ element: el,
244
+ editorType,
245
+ mode,
246
+ // context,
247
+ insertion,
248
+ binding,
249
+ _focusinListener: focusinListener ?? undefined,
250
+ };
251
+ this.descriptors.set(id, updated);
252
+ }
253
+ return;
254
+ }
255
+
256
+ const mode = (el.getAttribute(DATA_MODE) ?? "write") as TargetDescriptor["mode"];
257
+ // const context = el.getAttribute(DATA_CONTEXT) ?? undefined; // TODO: réactiver
258
+ const insertion = (el.getAttribute(DATA_INSERTION) ?? "preview-inline") as TargetInsertionMode;
259
+ const editorType = detectEditorType(el);
260
+
261
+ const binding = createBindingForElement(el, editorType, { mode, insertion });
262
+
263
+ const focusinListener = this.createFocusinListener(id, mode);
264
+ if (focusinListener) el.addEventListener("focusin", focusinListener);
265
+
266
+ const desc: TargetDescriptor = {
267
+ id,
268
+ element: el,
269
+ editorType,
270
+ mode,
271
+ // context,
272
+ insertion,
273
+ binding,
274
+ _focusinListener: focusinListener ?? undefined,
275
+ };
276
+ this.descriptors.set(id, desc);
277
+
278
+ // Auto-activer le premier target writable
279
+ if (this.store.getState().activeTargetId === null && mode.includes("write")) {
280
+ this.store.getState()._setActiveTarget(id);
281
+ }
282
+ }
283
+
284
+ private createFocusinListener(
285
+ id: string,
286
+ mode: TargetDescriptor["mode"]
287
+ ): (() => void) | null {
288
+ if (!mode.includes("write")) return null;
289
+ return () => dispatchTargetFocused(id);
290
+ }
291
+
292
+ // ─── Async editor resolution (TipTap, CodeMirror, Monaco) ───────────────────
293
+
294
+ private onEditorReady(el: HTMLElement, editorType: "tiptap" | "codemirror" | "monaco"): void {
295
+ for (const [id, desc] of this.descriptors) {
296
+ if (
297
+ desc.element === el ||
298
+ desc.element.contains(el) ||
299
+ el.contains(desc.element)
300
+ ) {
301
+ // Détacher l'ancien binding (ex: contenteditable fallback) avant de
302
+ // remplacer par le binding natif de l'éditeur.
303
+ if (desc.binding !== null) {
304
+ desc.binding.detach();
305
+ }
306
+ const binding = createBindingForElement(desc.element, editorType);
307
+ if (binding) {
308
+ desc.binding = binding;
309
+ desc.editorType = editorType;
310
+ this.descriptors.set(id, desc);
311
+ }
312
+ return;
313
+ }
314
+ }
315
+ }
316
+
317
+ private onEditorGone(el: HTMLElement): void {
318
+ for (const [id, desc] of this.descriptors) {
319
+ if (desc.element === el || desc.element.contains(el)) {
320
+ desc.binding?.detach();
321
+ desc.binding = null;
322
+ this.descriptors.set(id, desc);
323
+ return;
324
+ }
325
+ }
326
+ }
327
+ }
@@ -0,0 +1,43 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { dispatchTiptapMounted, dispatchTiptapDestroyed } from "../events";
3
+
4
+ /**
5
+ * Registre global des instances TipTap, indexé par l'élément conteneur.
6
+ *
7
+ * Utilise une WeakMap pour éviter les fuites mémoire — si l'élément est
8
+ * retiré du DOM et n'a plus de référence, l'entrée est collectée automatiquement.
9
+ *
10
+ * Usage :
11
+ * // Dans le composant TipTap
12
+ * const ref = useRef<HTMLDivElement>(null)
13
+ * useEffect(() => {
14
+ * if (!editor || !ref.current) return
15
+ * TiptapInstanceRegistry.set(ref.current, editor)
16
+ * return () => TiptapInstanceRegistry.delete(ref.current!)
17
+ * }, [editor])
18
+ *
19
+ * // Ou via le hook useEphiaTiptap (recommandé)
20
+ */
21
+ const registry = new WeakMap<HTMLElement, Editor>();
22
+
23
+ export const TiptapInstanceRegistry = {
24
+ set(el: HTMLElement, editor: Editor): void {
25
+ registry.set(el, editor);
26
+ dispatchTiptapMounted(el);
27
+ },
28
+
29
+ get(el: HTMLElement): Editor | undefined {
30
+ if (registry.has(el)) return registry.get(el);
31
+ // Chercher dans les ancêtres directs (cas wrapper)
32
+ const proseMirror = el.querySelector<HTMLElement>(".ProseMirror");
33
+ if (proseMirror?.parentElement) {
34
+ return registry.get(proseMirror.parentElement);
35
+ }
36
+ return undefined;
37
+ },
38
+
39
+ delete(el: HTMLElement): void {
40
+ registry.delete(el);
41
+ dispatchTiptapDestroyed(el);
42
+ },
43
+ };
@@ -0,0 +1,5 @@
1
+ export { CodeMirrorInstanceRegistry } from "./CodeMirrorInstanceRegistry";
2
+ export { MonacoInstanceRegistry } from "./MonacoInstanceRegistry";
3
+ export { TargetRegistry } from "./TargetRegistry";
4
+ export type { TargetRegistryOptions } from "./TargetRegistry";
5
+ export { TiptapInstanceRegistry } from "./TiptapInstanceRegistry";
@@ -0,0 +1,36 @@
1
+ import { createStore } from "zustand/vanilla";
2
+ import type { StoreApi } from "zustand/vanilla";
3
+ import { initialConnectionState } from "../../core/connection/connection-state";
4
+ import { initialAudioState } from "../../shared/state/audio-state";
5
+ import type { EphiaStoreState } from "./types";
6
+
7
+ export type EphiaStore = StoreApi<EphiaStoreState>;
8
+
9
+ const baseState = {
10
+ status: "idle" as const,
11
+ sessionId: null,
12
+ error: null,
13
+ activeTargetId: null,
14
+ partial: null,
15
+ connection: initialConnectionState,
16
+ audio: initialAudioState,
17
+ };
18
+
19
+ export function createEphiaStore(): EphiaStore {
20
+ return createStore<EphiaStoreState>((set) => ({
21
+ ...baseState,
22
+
23
+ _setStatus: (status) => set({ status }),
24
+ _setSessionId: (sessionId) => set({ sessionId }),
25
+ _setError: (error) => set({ error }),
26
+ _setActiveTarget: (activeTargetId) => set({ activeTargetId }),
27
+ _setPartial: (partial) => set({ partial }),
28
+ _setConnection: (connection) => set({ connection }),
29
+ _setAudio: (audio) => set({ audio }),
30
+ _reset: () => set({
31
+ ...baseState,
32
+ connection: { ...initialConnectionState },
33
+ audio: { ...initialAudioState },
34
+ }),
35
+ }));
36
+ }
@@ -0,0 +1,41 @@
1
+ 'use client'
2
+
3
+ import type { EphiaConnectionState } from "../../core/connection/connection-state";
4
+ import type { EphiaAudioState } from "../../shared/state/audio-state";
5
+
6
+ export type EphiaStatus =
7
+ | "idle"
8
+ | "recording"
9
+ | "processing"
10
+ | "error";
11
+
12
+ export interface EphiaPartial {
13
+ targetId: string | null;
14
+ text: string;
15
+ segmentId: string;
16
+ }
17
+
18
+ export interface EphiaError {
19
+ code: string;
20
+ message: string;
21
+ }
22
+
23
+ export interface EphiaStoreState {
24
+ status: EphiaStatus;
25
+ sessionId: string | null;
26
+ error: EphiaError | null;
27
+ activeTargetId: string | null;
28
+ partial: EphiaPartial | null;
29
+ connection: EphiaConnectionState;
30
+ audio: EphiaAudioState;
31
+
32
+ // Internal mutators — prefixed with _ to signal "don't call from user code"
33
+ _setStatus: (status: EphiaStatus) => void;
34
+ _setSessionId: (sessionId: string | null) => void;
35
+ _setError: (error: EphiaError | null) => void;
36
+ _setActiveTarget: (id: string | null) => void;
37
+ _setPartial: (partial: EphiaPartial | null) => void;
38
+ _setConnection: (connection: EphiaConnectionState) => void;
39
+ _setAudio: (audio: EphiaAudioState) => void;
40
+ _reset: () => void;
41
+ }
@@ -0,0 +1,24 @@
1
+ import type { TargetBinding } from "../../core/bindings/TargetBinding";
2
+
3
+ const FLASH_DURATION_MS = 700;
4
+
5
+ export function flashRange(
6
+ binding: TargetBinding,
7
+ start: number,
8
+ end: number,
9
+ variant: "revised" | "committed" = "committed"
10
+ ): void {
11
+ if (typeof document === "undefined") return;
12
+ const rect = binding.getRangeRect?.(start, end);
13
+ if (!rect || rect.width < 1 || rect.height < 1) return;
14
+
15
+ const flash = document.createElement("div");
16
+ if (variant === "committed") {
17
+ flash.className = "ephia-committed-flash";
18
+ } else if (variant === "revised") {
19
+ flash.className = "ephia-reformat-flash ephia-reformat-flash--revised";
20
+ }
21
+ flash.style.cssText = `left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px;`;
22
+ document.body.appendChild(flash);
23
+ setTimeout(() => flash.remove(), FLASH_DURATION_MS + 100);
24
+ }
@@ -0,0 +1 @@
1
+ export { flashRange } from "./flash-range";
@@ -0,0 +1,86 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { Editor, Node } from "@tiptap/core";
5
+ import { createTiptapEphiaAdapter } from "./tiptap";
6
+ import { EphiaV2PreviewMark, EphiaV2CommittedMark } from "../../core/bindings/tiptap/TiptapBinding";
7
+
8
+ const DocumentNode = Node.create({ name: "doc", topNode: true, content: "block+" });
9
+ const ParagraphNode = Node.create({
10
+ name: "paragraph",
11
+ group: "block",
12
+ content: "inline*",
13
+ parseHTML: () => [{ tag: "p" }],
14
+ renderHTML: () => ["p", 0],
15
+ });
16
+ const TextNode = Node.create({ name: "text", group: "inline" });
17
+
18
+ function createEditor(): Editor {
19
+ return new Editor({
20
+ element: document.createElement("div"),
21
+ extensions: [DocumentNode, ParagraphNode, TextNode, EphiaV2PreviewMark, EphiaV2CommittedMark],
22
+ content: "<p></p>",
23
+ });
24
+ }
25
+
26
+ describe("createTiptapEphiaAdapter", () => {
27
+ it("returns null for null editor", () => {
28
+ expect(createTiptapEphiaAdapter(null)).toBeNull();
29
+ });
30
+
31
+ it("returns the same adapter object for the same editor instance", () => {
32
+ const editor = createEditor();
33
+ try {
34
+ const adapter1 = createTiptapEphiaAdapter(editor);
35
+ const adapter2 = createTiptapEphiaAdapter(editor);
36
+ expect(adapter1).toBe(adapter2);
37
+ } finally {
38
+ editor.destroy();
39
+ }
40
+ });
41
+
42
+ it("returns different adapters for different editor instances", () => {
43
+ const editorA = createEditor();
44
+ const editorB = createEditor();
45
+ try {
46
+ expect(createTiptapEphiaAdapter(editorA)).not.toBe(
47
+ createTiptapEphiaAdapter(editorB),
48
+ );
49
+ } finally {
50
+ editorA.destroy();
51
+ editorB.destroy();
52
+ }
53
+ });
54
+
55
+ it("adapter has kind tiptap", () => {
56
+ const editor = createEditor();
57
+ try {
58
+ expect(createTiptapEphiaAdapter(editor)?.kind).toBe("tiptap");
59
+ } finally {
60
+ editor.destroy();
61
+ }
62
+ });
63
+
64
+ it("adapter identity is the editor instance", () => {
65
+ const editor = createEditor();
66
+ try {
67
+ expect(createTiptapEphiaAdapter(editor)?.identity).toBe(editor);
68
+ } finally {
69
+ editor.destroy();
70
+ }
71
+ });
72
+
73
+ it("adapter creates a binding that can attach and detach", () => {
74
+ const editor = createEditor();
75
+ try {
76
+ const adapter = createTiptapEphiaAdapter(editor)!;
77
+ const binding = adapter.createBinding({ targetId: "test" });
78
+ expect(() => {
79
+ binding.attach();
80
+ binding.detach();
81
+ }).not.toThrow();
82
+ } finally {
83
+ editor.destroy();
84
+ }
85
+ });
86
+ });
@@ -0,0 +1,23 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { TiptapBinding } from "../../core/bindings/tiptap/TiptapBinding";
3
+ import type { EphiaRichEditorAdapter } from "../types";
4
+
5
+ const tiptapAdapterCache = new WeakMap<Editor, EphiaRichEditorAdapter>();
6
+
7
+ export function createTiptapEphiaAdapter(
8
+ editor: Editor | null,
9
+ ): EphiaRichEditorAdapter | null {
10
+ if (!editor) return null;
11
+
12
+ const cached = tiptapAdapterCache.get(editor);
13
+ if (cached) return cached;
14
+
15
+ const adapter: EphiaRichEditorAdapter = {
16
+ kind: "tiptap",
17
+ identity: editor,
18
+ createBinding: () => new TiptapBinding(editor),
19
+ };
20
+
21
+ tiptapAdapterCache.set(editor, adapter);
22
+ return adapter;
23
+ }
@@ -0,0 +1,3 @@
1
+ export { useEphiaRichEditor } from "./use-ephia-rich-editor";
2
+ export { createTiptapEphiaAdapter } from "./adapters/tiptap";
3
+ export type { EphiaRichEditorAdapter, UseEphiaRichEditorOptions } from "./types";
@@ -0,0 +1,24 @@
1
+ import type { EphiaBinding } from "../core/bindings/EphiaBinding";
2
+
3
+ export interface EphiaRichEditorAdapter {
4
+ kind: string;
5
+ /**
6
+ * Stable identity for this editor instance across React renders.
7
+ * Official adapters always provide this. Custom adapters should too.
8
+ * When present, useEphiaRichEditor depends on this instead of the adapter object,
9
+ * making the hook safe against inline adapter creation (e.g. without useMemo).
10
+ */
11
+ identity?: object;
12
+ createBinding(options: {
13
+ targetId: string;
14
+ label?: string;
15
+ }): EphiaBinding;
16
+ getElement?(): HTMLElement | null;
17
+ }
18
+
19
+ export interface UseEphiaRichEditorOptions {
20
+ id: string;
21
+ mode?: string;
22
+ label?: string;
23
+ enabled?: boolean;
24
+ }