@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.
- package/README.md +89 -0
- package/dist/EphiaBinding-BvRmlqqC.d.ts +36 -0
- package/dist/EphiaFloatingButton-CxiF86VW.d.ts +65 -0
- package/dist/EphiaTextarea-B4_CAVUg.d.ts +183 -0
- package/dist/NativeBinding-ChG0GeSz.d.ts +53 -0
- package/dist/TargetBinding-BKGQwUMc.d.ts +89 -0
- package/dist/TiptapBinding-B-agfV2H.d.ts +45 -0
- package/dist/Transport-zdeA4Pou.d.ts +63 -0
- package/dist/audio-state-kZ3KSvux.d.ts +39 -0
- package/dist/chunk-35AJK2IO.js +1 -0
- package/dist/chunk-35AJK2IO.js.map +1 -0
- package/dist/chunk-3LXZODL4.js +886 -0
- package/dist/chunk-3LXZODL4.js.map +1 -0
- package/dist/chunk-5IK5TLSK.js +67 -0
- package/dist/chunk-5IK5TLSK.js.map +1 -0
- package/dist/chunk-7E43RY75.js +9 -0
- package/dist/chunk-7E43RY75.js.map +1 -0
- package/dist/chunk-A5UEXJ5R.js +183 -0
- package/dist/chunk-A5UEXJ5R.js.map +1 -0
- package/dist/chunk-AEE554FT.js +51 -0
- package/dist/chunk-AEE554FT.js.map +1 -0
- package/dist/chunk-DIEWY3IT.js +1332 -0
- package/dist/chunk-DIEWY3IT.js.map +1 -0
- package/dist/chunk-EGIAN7FH.js +18 -0
- package/dist/chunk-EGIAN7FH.js.map +1 -0
- package/dist/chunk-EMOEAPVU.js +486 -0
- package/dist/chunk-EMOEAPVU.js.map +1 -0
- package/dist/chunk-IDC7FHIZ.js +40 -0
- package/dist/chunk-IDC7FHIZ.js.map +1 -0
- package/dist/chunk-ITJFN3VM.js +601 -0
- package/dist/chunk-ITJFN3VM.js.map +1 -0
- package/dist/chunk-K24GNU27.js +22 -0
- package/dist/chunk-K24GNU27.js.map +1 -0
- package/dist/chunk-LXMCRXXF.js +778 -0
- package/dist/chunk-LXMCRXXF.js.map +1 -0
- package/dist/chunk-MJCEOOLW.js +122 -0
- package/dist/chunk-MJCEOOLW.js.map +1 -0
- package/dist/chunk-N7U5M3VZ.js +33 -0
- package/dist/chunk-N7U5M3VZ.js.map +1 -0
- package/dist/chunk-PSYX674B.js +27 -0
- package/dist/chunk-PSYX674B.js.map +1 -0
- package/dist/chunk-RFQRV7ML.js +33 -0
- package/dist/chunk-RFQRV7ML.js.map +1 -0
- package/dist/chunk-THNHRV2B.js +18 -0
- package/dist/chunk-THNHRV2B.js.map +1 -0
- package/dist/chunk-VSLGR64U.js +62 -0
- package/dist/chunk-VSLGR64U.js.map +1 -0
- package/dist/chunk-W2ZP674X.js +346 -0
- package/dist/chunk-W2ZP674X.js.map +1 -0
- package/dist/chunk-YWZUMUYE.js +695 -0
- package/dist/chunk-YWZUMUYE.js.map +1 -0
- package/dist/client-options-Uo6jXO8k.d.ts +64 -0
- package/dist/connection-state-Bk33YprE.d.ts +32 -0
- package/dist/core/bindings/index.d.ts +24 -0
- package/dist/core/bindings/index.js +1025 -0
- package/dist/core/bindings/index.js.map +1 -0
- package/dist/core/index.d.ts +383 -0
- package/dist/core/index.js +1284 -0
- package/dist/core/index.js.map +1 -0
- package/dist/createEphiaClient-BhdZ183V.d.ts +69 -0
- package/dist/devices/speechmike/index.d.ts +148 -0
- package/dist/devices/speechmike/index.js +40 -0
- package/dist/devices/speechmike/index.js.map +1 -0
- package/dist/headless/index.d.ts +10 -0
- package/dist/headless/index.js +25 -0
- package/dist/headless/index.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +119 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.d.ts +38 -0
- package/dist/react/index.js +70 -0
- package/dist/react/index.js.map +1 -0
- package/dist/rich-editor/index.d.ts +46 -0
- package/dist/rich-editor/index.js +13 -0
- package/dist/rich-editor/index.js.map +1 -0
- package/dist/schema-B2ycPlNB.d.ts +87 -0
- package/dist/session-APaXR48R.d.ts +12 -0
- package/dist/shared/index.d.ts +16 -0
- package/dist/shared/index.js +30 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/style.css +1093 -0
- package/dist/testing/index.d.ts +84 -0
- package/dist/testing/index.js +36 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types-D5SXPSwR.d.ts +32 -0
- package/dist/ui/index.d.ts +30 -0
- package/dist/ui/index.js +34 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/useEphiaSpeechMike-CjD7DWnh.d.ts +64 -0
- package/package.json +110 -0
- package/src/core/audio/audio-worklet-source.ts +30 -0
- package/src/core/audio/index.ts +3 -0
- package/src/core/audio/voice-level-meter.test.ts +27 -0
- package/src/core/audio/voice-level-meter.ts +270 -0
- package/src/core/bindings/EphiaBinding.ts +41 -0
- package/src/core/bindings/SegmentBindingBridge.test.ts +422 -0
- package/src/core/bindings/SegmentBindingBridge.ts +377 -0
- package/src/core/bindings/TargetBinding.ts +142 -0
- package/src/core/bindings/adapters/NativeAdapter.test.ts +85 -0
- package/src/core/bindings/adapters/NativeAdapter.ts +216 -0
- package/src/core/bindings/adapters/ProseMirrorAdapter.ts +231 -0
- package/src/core/bindings/adapters/index.ts +2 -0
- package/src/core/bindings/binding-factory.ts +78 -0
- package/src/core/bindings/detect-editor-type.ts +87 -0
- package/src/core/bindings/index.ts +13 -0
- package/src/core/bindings/insertion-boundary.test.ts +38 -0
- package/src/core/bindings/insertion-boundary.ts +26 -0
- package/src/core/bindings/native/NativeBinding.test.ts +277 -0
- package/src/core/bindings/native/NativeBinding.ts +239 -0
- package/src/core/bindings/resolver.ts +18 -0
- package/src/core/bindings/targets/codemirror.binding.ts +293 -0
- package/src/core/bindings/targets/contenteditable.binding.ts +452 -0
- package/src/core/bindings/targets/index.ts +10 -0
- package/src/core/bindings/targets/monaco.binding.ts +315 -0
- package/src/core/bindings/targets/tiptap.binding.test.ts +417 -0
- package/src/core/bindings/targets/tiptap.binding.ts +1192 -0
- package/src/core/bindings/tiptap/TiptapBinding.test.ts +63 -0
- package/src/core/bindings/tiptap/TiptapBinding.ts +464 -0
- package/src/core/bindings/types.ts +41 -0
- package/src/core/client/EphiaAudioClient.ts +654 -0
- package/src/core/client/audio-capture.ts +263 -0
- package/src/core/client/client-options.ts +39 -0
- package/src/core/client/client-state.ts +18 -0
- package/src/core/client/constants.ts +23 -0
- package/src/core/client/session-api.ts +415 -0
- package/src/core/connection/connection-state.ts +78 -0
- package/src/core/connection/index.ts +6 -0
- package/src/core/index.ts +47 -0
- package/src/core/operations/textToDocumentOperations.test.ts +69 -0
- package/src/core/operations/textToDocumentOperations.ts +92 -0
- package/src/core/runtime/DictationRuntime.test.ts +578 -0
- package/src/core/runtime/DictationRuntime.ts +434 -0
- package/src/core/runtime/TranscriptApplier.test.ts +355 -0
- package/src/core/runtime/TranscriptApplier.ts +229 -0
- package/src/core/runtime/index.ts +18 -0
- package/src/core/session/index.ts +2 -0
- package/src/core/session/session-machine.test.ts +16 -0
- package/src/core/session/session-machine.ts +59 -0
- package/src/core/targets/EditorContextCollector.ts +71 -0
- package/src/core/targets/TargetManager.test.ts +194 -0
- package/src/core/targets/TargetManager.ts +194 -0
- package/src/core/targets/index.ts +10 -0
- package/src/core/text-processing/index.ts +11 -0
- package/src/core/text-processing/overlap.test.ts +35 -0
- package/src/core/text-processing/overlap.ts +101 -0
- package/src/core/text-processing/voice-formatting.normalizer.test.ts +132 -0
- package/src/core/text-processing/voice-formatting.normalizer.ts +284 -0
- package/src/core/transcript/client-transcript.reducer.ts +366 -0
- package/src/core/transcript/client-transcript.state.ts +25 -0
- package/src/core/transcript/index.ts +19 -0
- package/src/core/transcript/transcript.assembler.test.ts +205 -0
- package/src/core/transcript/transcript.assembler.ts +152 -0
- package/src/core/transcript/transcript.reducer.test.ts +199 -0
- package/src/core/transcript/transcript.reducer.ts +771 -0
- package/src/core/transcript/transcript.state.ts +123 -0
- package/src/core/transport/LiveKitTransport.publish.test.ts +226 -0
- package/src/core/transport/LiveKitTransport.ts +459 -0
- package/src/core/transport/MockTransport.ts +231 -0
- package/src/core/transport/Transport.ts +82 -0
- package/src/debug/sdk-debug-collector.ts +79 -0
- package/src/devices/index.ts +2 -0
- package/src/devices/speechmike/__tests__/EphiaSpeechMikeProvider.test.tsx +99 -0
- package/src/devices/speechmike/__tests__/speechmike-audio-resolver.test.ts +96 -0
- package/src/devices/speechmike/__tests__/speechmike-button-router.test.ts +66 -0
- package/src/devices/speechmike/__tests__/speechmike-device-manager.test.ts +201 -0
- package/src/devices/speechmike/__tests__/speechmike-led-controller.test.ts +68 -0
- package/src/devices/speechmike/browser.ts +80 -0
- package/src/devices/speechmike/constants.ts +74 -0
- package/src/devices/speechmike/dictation-support-loader.ts +81 -0
- package/src/devices/speechmike/index.ts +11 -0
- package/src/devices/speechmike/react/EphiaSpeechMikeContext.ts +34 -0
- package/src/devices/speechmike/react/EphiaSpeechMikeProvider.tsx +287 -0
- package/src/devices/speechmike/react/useEphiaSpeechMike.ts +26 -0
- package/src/devices/speechmike/speechmike-audio-resolver.ts +58 -0
- package/src/devices/speechmike/speechmike-button-router.ts +73 -0
- package/src/devices/speechmike/speechmike-device-manager.ts +461 -0
- package/src/devices/speechmike/speechmike-led-controller.ts +78 -0
- package/src/devices/speechmike/types.ts +96 -0
- package/src/dictation_support.d.ts +31 -0
- package/src/global.d.ts +10 -0
- package/src/headless/createEphiaClient.ts +220 -0
- package/src/headless/index.ts +18 -0
- package/src/index.ts +89 -0
- package/src/react/EphiaAuto.tsx +87 -0
- package/src/react/components/EphiaDictationButton.tsx +88 -0
- package/src/react/components/EphiaStatusBar.tsx +59 -0
- package/src/react/components/EphiaTextarea.tsx +295 -0
- package/src/react/ephia-react.css +318 -0
- package/src/react/hooks/targets/index.ts +3 -0
- package/src/react/hooks/targets/useEphiaCodemirror.ts +35 -0
- package/src/react/hooks/targets/useEphiaMonaco.ts +35 -0
- package/src/react/hooks/targets/useEphiaTiptap.ts +23 -0
- package/src/react/hooks/useEphia.lifecycle.test.tsx +389 -0
- package/src/react/hooks/useEphia.ts +367 -0
- package/src/react/hooks/useEphiaDiscardTarget.ts +53 -0
- package/src/react/hooks/useEphiaServerEvent.ts +33 -0
- package/src/react/hooks/useEphiaTarget.ts +47 -0
- package/src/react/hooks/useEphiaTranscript.ts +22 -0
- package/src/react/index.ts +58 -0
- package/src/react/provider/EphiaContext.ts +63 -0
- package/src/react/provider/EphiaInternalContext.ts +32 -0
- package/src/react/provider/EphiaProvider.tsx +373 -0
- package/src/react/registry/binding-factory.ts +7 -0
- package/src/react/registry/detect-editor-type.ts +2 -0
- package/src/react/registry/events.ts +37 -0
- package/src/react/registry/registries/CodeMirrorInstanceRegistry.ts +24 -0
- package/src/react/registry/registries/MonacoInstanceRegistry.ts +23 -0
- package/src/react/registry/registries/TargetRegistry.ts +327 -0
- package/src/react/registry/registries/TiptapInstanceRegistry.ts +43 -0
- package/src/react/registry/registries/index.ts +5 -0
- package/src/react/store/create-ephia-store.ts +36 -0
- package/src/react/store/types.ts +41 -0
- package/src/react/utils/flash-range.ts +24 -0
- package/src/react/utils/index.ts +1 -0
- package/src/rich-editor/adapters/tiptap.test.ts +86 -0
- package/src/rich-editor/adapters/tiptap.ts +23 -0
- package/src/rich-editor/index.ts +3 -0
- package/src/rich-editor/types.ts +24 -0
- package/src/rich-editor/use-ephia-rich-editor.test.tsx +202 -0
- package/src/rich-editor/use-ephia-rich-editor.ts +47 -0
- package/src/shared/config/endpoint.test.ts +45 -0
- package/src/shared/config/endpoint.ts +39 -0
- package/src/shared/config/schema.ts +32 -0
- package/src/shared/effective-text.ts +13 -0
- package/src/shared/errors/EphiaSdkError.ts +54 -0
- package/src/shared/errors/messages.ts +40 -0
- package/src/shared/index.ts +27 -0
- package/src/shared/state/audio-state.ts +45 -0
- package/src/shared/state/index.ts +2 -0
- package/src/shared/store/document-store.ts +32 -0
- package/src/shared/store/index.ts +2 -0
- package/src/shared/types/editors.ts +28 -0
- package/src/shared/types/session.ts +12 -0
- package/src/style.css +2 -0
- package/src/testing/index.tsx +60 -0
- package/src/ui/assets/ephia-logo.svg +4 -0
- package/src/ui/components/EphiaLogo.tsx +77 -0
- package/src/ui/index.ts +24 -0
- package/src/ui/primitives/Button.tsx +53 -0
- package/src/ui/primitives/Spinner.tsx +21 -0
- package/src/ui/primitives/index.ts +5 -0
- package/src/ui/recorder/EphiaFloatingButton.tsx +489 -0
- package/src/ui/recorder/MinimalProcessingBars.tsx +122 -0
- package/src/ui/recorder/StandardIntensityVisualizer.tsx +148 -0
- package/src/ui/recorder/appearance.ts +9 -0
- package/src/ui/recorder/index.ts +8 -0
- package/src/ui/theme.css +775 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { BindingAdapter, SegmentStatus, TextSegment } from "../types";
|
|
2
|
+
import { pickJoiner } from "../TargetBinding";
|
|
3
|
+
import type { SessionAnchor } from "../TargetBinding";
|
|
4
|
+
|
|
5
|
+
type SegmentEntry = {
|
|
6
|
+
text: string;
|
|
7
|
+
joiner: string;
|
|
8
|
+
status: SegmentStatus;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Bypass React controlled component
|
|
12
|
+
function setNativeValue(
|
|
13
|
+
el: HTMLInputElement | HTMLTextAreaElement,
|
|
14
|
+
value: string
|
|
15
|
+
) {
|
|
16
|
+
const proto =
|
|
17
|
+
el instanceof HTMLTextAreaElement
|
|
18
|
+
? HTMLTextAreaElement.prototype
|
|
19
|
+
: HTMLInputElement.prototype;
|
|
20
|
+
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
|
|
21
|
+
setter?.call(el, value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function dispatchInputEvent(el: HTMLInputElement | HTMLTextAreaElement) {
|
|
25
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function dispatchCustomEvent<T>(
|
|
29
|
+
el: HTMLInputElement | HTMLTextAreaElement,
|
|
30
|
+
type: string,
|
|
31
|
+
detail: T
|
|
32
|
+
) {
|
|
33
|
+
el.dispatchEvent(new CustomEvent(type, { bubbles: false, detail }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Adapter natif pour <textarea> et <input>.
|
|
38
|
+
*
|
|
39
|
+
* Stratégie :
|
|
40
|
+
* - Les partials sont écrits directement dans le champ sans notifier React.
|
|
41
|
+
* - React n'est notifié (via dispatchEvent) qu'au commit final.
|
|
42
|
+
* - Cela évite le "fight" entre le binding et les onChange React qui transforment
|
|
43
|
+
* la valeur (validation, masque, uppercase, etc.).
|
|
44
|
+
*/
|
|
45
|
+
export class NativeAdapter implements BindingAdapter {
|
|
46
|
+
private element: HTMLInputElement | HTMLTextAreaElement;
|
|
47
|
+
// Texte avant/après la plage de dictée verrouillée au début de session.
|
|
48
|
+
private prefixText = "";
|
|
49
|
+
private suffixText = "";
|
|
50
|
+
// Segments déjà commités pendant cette session, adressables par id pour les
|
|
51
|
+
// révisions tardives (Voxtral Small).
|
|
52
|
+
private committedSegments = new Map<string, SegmentEntry>();
|
|
53
|
+
// Segments en cours de streaming (preview).
|
|
54
|
+
private segments = new Map<string, SegmentEntry>();
|
|
55
|
+
|
|
56
|
+
constructor(el: HTMLInputElement | HTMLTextAreaElement) {
|
|
57
|
+
this.element = el;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
attach() {
|
|
61
|
+
this.prefixText = this.element.value;
|
|
62
|
+
this.suffixText = "";
|
|
63
|
+
this.committedSegments.clear();
|
|
64
|
+
this.segments.clear();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beginSession(anchor: SessionAnchor) {
|
|
68
|
+
const value = this.element.value;
|
|
69
|
+
const start = Math.max(0, Math.min(anchor.initialStart, value.length));
|
|
70
|
+
const end = Math.max(start, Math.min(anchor.initialEnd, value.length));
|
|
71
|
+
this.prefixText = value.slice(0, start);
|
|
72
|
+
this.suffixText = value.slice(end);
|
|
73
|
+
this.committedSegments.clear();
|
|
74
|
+
this.segments.clear();
|
|
75
|
+
this._setSelectionToInsertionEnd();
|
|
76
|
+
this._dispatchCursorPosition();
|
|
77
|
+
this._dispatchPartialCleared();
|
|
78
|
+
anchor.selectionPendingDelete = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
detach() {
|
|
82
|
+
this.segments.clear();
|
|
83
|
+
this.committedSegments.clear();
|
|
84
|
+
this.suffixText = "";
|
|
85
|
+
this._dispatchPartialCleared();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
insertSegment(seg: TextSegment) {
|
|
89
|
+
const before = this._buildInsertionText();
|
|
90
|
+
// Si le segment commence par \n (commande résolue côté backend), pas d'espace
|
|
91
|
+
const joiner = seg.text.startsWith("\n") ? "" : pickJoiner(before, seg.text);
|
|
92
|
+
this.segments.set(seg.id, { text: seg.text, joiner, status: seg.status });
|
|
93
|
+
this._render();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
updateSegment(id: string, text: string, status: SegmentStatus) {
|
|
97
|
+
const seg = this.segments.get(id) ?? this.committedSegments.get(id);
|
|
98
|
+
if (!seg) return;
|
|
99
|
+
// Le joiner calculé à l'insertion est conservé — seul le texte change.
|
|
100
|
+
seg.text = text;
|
|
101
|
+
seg.status = status;
|
|
102
|
+
this._render();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
commitSegment(id: string) {
|
|
106
|
+
const streamingSeg = this.segments.get(id);
|
|
107
|
+
if (streamingSeg) {
|
|
108
|
+
// Déplacer le segment streaming vers les segments commités ; il reste
|
|
109
|
+
// adressable par id pour une révision Voxtral Small ultérieure.
|
|
110
|
+
this.committedSegments.set(id, streamingSeg);
|
|
111
|
+
this.segments.delete(id);
|
|
112
|
+
} else if (!this.committedSegments.has(id)) {
|
|
113
|
+
// Segment inconnu : rien à committer.
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const expectedValue = this._buildText();
|
|
118
|
+
if (this.element.value !== expectedValue) {
|
|
119
|
+
setNativeValue(this.element, expectedValue);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this._setSelectionToInsertionEnd();
|
|
123
|
+
this._dispatchCursorPosition();
|
|
124
|
+
this._dispatchPartialUpdate();
|
|
125
|
+
dispatchInputEvent(this.element);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
removeSegment(id: string) {
|
|
129
|
+
this.segments.delete(id);
|
|
130
|
+
this.committedSegments.delete(id);
|
|
131
|
+
this._render();
|
|
132
|
+
this._dispatchPartialUpdate();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
clearAll() {
|
|
136
|
+
this.segments.clear();
|
|
137
|
+
this._render();
|
|
138
|
+
this._dispatchPartialCleared();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getTextContent() {
|
|
142
|
+
return this.element.value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
getSegmentText(id: string): string | null {
|
|
146
|
+
return this.segments.get(id)?.text ?? this.committedSegments.get(id)?.text ?? null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
getCursorOffset() {
|
|
150
|
+
return this.element.selectionEnd ?? this.element.value.length;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private _buildStreamingText() {
|
|
154
|
+
return [...this.segments.values()]
|
|
155
|
+
.map((s) => s.joiner + s.text)
|
|
156
|
+
.join("");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private _buildCommittedText() {
|
|
160
|
+
return [...this.committedSegments.values()]
|
|
161
|
+
.map((s) => s.joiner + s.text)
|
|
162
|
+
.join("");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Texte avant le suffixe : sert à calculer les joiners et le caret.
|
|
166
|
+
private _buildInsertionText() {
|
|
167
|
+
return this.prefixText + this._buildCommittedText() + this._buildStreamingText();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Reconstruit la valeur affichée : prefix + dictée en cours + suffixe intact.
|
|
171
|
+
private _buildText() {
|
|
172
|
+
return this._buildInsertionText() + this.suffixText;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private _insertionEnd() {
|
|
176
|
+
return this._buildInsertionText().length;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private _setSelectionToInsertionEnd() {
|
|
180
|
+
const pos = this._insertionEnd();
|
|
181
|
+
try {
|
|
182
|
+
this.element.setSelectionRange(pos, pos);
|
|
183
|
+
} catch {
|
|
184
|
+
/* noop */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private _render() {
|
|
189
|
+
setNativeValue(this.element, this._buildText());
|
|
190
|
+
this._setSelectionToInsertionEnd();
|
|
191
|
+
this._dispatchCursorPosition();
|
|
192
|
+
this._dispatchPartialUpdate();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private _dispatchCursorPosition() {
|
|
196
|
+
dispatchCustomEvent(this.element, "ephia:cursor-position", {
|
|
197
|
+
position: this._insertionEnd(),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private _dispatchPartialUpdate() {
|
|
202
|
+
const text = this._buildStreamingText();
|
|
203
|
+
if (!text) {
|
|
204
|
+
this._dispatchPartialCleared();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
dispatchCustomEvent(this.element, "ephia:partial", {
|
|
208
|
+
text,
|
|
209
|
+
insertAt: this.prefixText.length + this._buildCommittedText().length,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private _dispatchPartialCleared() {
|
|
214
|
+
dispatchCustomEvent(this.element, "ephia:partial-cleared", {});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
2
|
+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
|
3
|
+
import type { EditorView } from "@tiptap/pm/view";
|
|
4
|
+
import type { Transaction } from "@tiptap/pm/state";
|
|
5
|
+
import { BindingAdapter, SegmentStatus, TextSegment } from "../types";
|
|
6
|
+
import type { DocumentOperation } from "ephia-protocol";
|
|
7
|
+
import { pickJoiner } from "../TargetBinding";
|
|
8
|
+
|
|
9
|
+
// --- Plugin PM auto-injecté ---
|
|
10
|
+
|
|
11
|
+
interface EphiaSegmentState {
|
|
12
|
+
segments: Map<string, { from: number; to: number; text: string; status: SegmentStatus }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ephiaKey = new PluginKey<EphiaSegmentState>("ephia-segments");
|
|
16
|
+
|
|
17
|
+
const setSegmentsEffect = (
|
|
18
|
+
tr: Transaction,
|
|
19
|
+
segments: EphiaSegmentState["segments"]
|
|
20
|
+
) => {
|
|
21
|
+
tr.setMeta(ephiaKey, { segments: new Map(segments) } as EphiaSegmentState);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ephiaPlugin = new Plugin<EphiaSegmentState>({
|
|
25
|
+
key: ephiaKey,
|
|
26
|
+
state: {
|
|
27
|
+
init(): EphiaSegmentState {
|
|
28
|
+
return { segments: new Map() };
|
|
29
|
+
},
|
|
30
|
+
apply(tr, prev): EphiaSegmentState {
|
|
31
|
+
// Remap les positions à chaque transaction (undo, typing, etc.)
|
|
32
|
+
const remapped = new Map(
|
|
33
|
+
[...prev.segments.entries()].map(([id, seg]) => [
|
|
34
|
+
id,
|
|
35
|
+
{
|
|
36
|
+
...seg,
|
|
37
|
+
from: tr.mapping.map(seg.from),
|
|
38
|
+
to: tr.mapping.map(seg.to, 1),
|
|
39
|
+
},
|
|
40
|
+
])
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Appliquer les effets Ephia
|
|
44
|
+
const meta = tr.getMeta(ephiaKey) as EphiaSegmentState | undefined;
|
|
45
|
+
if (meta?.segments) {
|
|
46
|
+
return { segments: meta.segments };
|
|
47
|
+
}
|
|
48
|
+
return { segments: remapped };
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
props: {
|
|
52
|
+
decorations(state) {
|
|
53
|
+
const pluginState = ephiaKey.getState(state);
|
|
54
|
+
if (!pluginState) return DecorationSet.empty;
|
|
55
|
+
|
|
56
|
+
const decos: Decoration[] = [];
|
|
57
|
+
for (const seg of pluginState.segments.values()) {
|
|
58
|
+
if (seg.from >= seg.to) continue;
|
|
59
|
+
decos.push(
|
|
60
|
+
Decoration.inline(seg.from, seg.to, {
|
|
61
|
+
class: `ephia-${seg.status}`,
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return DecorationSet.create(state.doc, decos);
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// --- Adapter ---
|
|
71
|
+
|
|
72
|
+
export class ProseMirrorAdapter implements BindingAdapter {
|
|
73
|
+
private view: EditorView;
|
|
74
|
+
private segments = new Map<
|
|
75
|
+
string,
|
|
76
|
+
{ from: number; to: number; text: string; joinerLength: number; status: SegmentStatus }
|
|
77
|
+
>();
|
|
78
|
+
|
|
79
|
+
constructor(view: EditorView) {
|
|
80
|
+
this.view = view;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
attach() {
|
|
84
|
+
// Injecter le plugin PM si pas déjà présent
|
|
85
|
+
const alreadyPresent = this.view.state.plugins.some(
|
|
86
|
+
(p) => p.spec.key === ephiaKey
|
|
87
|
+
);
|
|
88
|
+
if (!alreadyPresent) {
|
|
89
|
+
this.view.updateState(
|
|
90
|
+
this.view.state.reconfigure({
|
|
91
|
+
plugins: [...this.view.state.plugins, ephiaPlugin],
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
detach() {
|
|
98
|
+
this.segments.clear();
|
|
99
|
+
this._dispatch();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
insertSegment(seg: TextSegment) {
|
|
103
|
+
const { from } = this.view.state.selection;
|
|
104
|
+
const before = this.view.state.doc.textContent;
|
|
105
|
+
const joiner = pickJoiner(before, seg.text);
|
|
106
|
+
const fullText = joiner + seg.text;
|
|
107
|
+
const tr = this.view.state.tr.insertText(fullText, from);
|
|
108
|
+
const to = from + fullText.length;
|
|
109
|
+
this.view.dispatch(tr);
|
|
110
|
+
this.segments.set(seg.id, { from, to, text: seg.text, joinerLength: joiner.length, status: seg.status });
|
|
111
|
+
this._dispatch();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
updateSegment(id: string, text: string, status: SegmentStatus) {
|
|
115
|
+
const seg = this.segments.get(id);
|
|
116
|
+
if (!seg) return;
|
|
117
|
+
// Remplacer uniquement la partie texte — le joiner en tête du segment est conservé
|
|
118
|
+
const textFrom = seg.from + seg.joinerLength;
|
|
119
|
+
const tr = this.view.state.tr.replaceWith(
|
|
120
|
+
textFrom,
|
|
121
|
+
seg.to,
|
|
122
|
+
this.view.state.schema.text(text)
|
|
123
|
+
);
|
|
124
|
+
const newTo = textFrom + text.length;
|
|
125
|
+
this.view.dispatch(tr);
|
|
126
|
+
this.segments.set(id, { ...seg, to: newTo, text, status });
|
|
127
|
+
this._dispatch();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
commitSegment(id: string) {
|
|
131
|
+
const seg = this.segments.get(id);
|
|
132
|
+
if (!seg) return;
|
|
133
|
+
this.segments.set(id, { ...seg, status: "committed" });
|
|
134
|
+
this._dispatch();
|
|
135
|
+
// Retirer la décoration après l'animation CSS (1.2s)
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
this.segments.delete(id);
|
|
138
|
+
this._dispatch();
|
|
139
|
+
}, 1400);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
removeSegment(id: string) {
|
|
143
|
+
const seg = this.segments.get(id);
|
|
144
|
+
if (!seg) return;
|
|
145
|
+
const tr = this.view.state.tr.delete(seg.from, seg.to);
|
|
146
|
+
this.view.dispatch(tr);
|
|
147
|
+
this.segments.delete(id);
|
|
148
|
+
this._dispatch();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getTextContent() {
|
|
152
|
+
return this.view.state.doc.textContent;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
getSegmentText(id: string): string | null {
|
|
156
|
+
const seg = this.segments.get(id);
|
|
157
|
+
if (!seg || seg.status === "committed") return null;
|
|
158
|
+
return seg.text;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getCursorOffset() {
|
|
162
|
+
return this.view.state.selection.from;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
applyOperation(operation: DocumentOperation): void {
|
|
166
|
+
switch (operation.type) {
|
|
167
|
+
case "replace": {
|
|
168
|
+
let start: number, end: number;
|
|
169
|
+
if (operation.range) {
|
|
170
|
+
start = operation.range.start;
|
|
171
|
+
end = operation.range.end;
|
|
172
|
+
} else if (operation.targetText) {
|
|
173
|
+
const text = this.view.state.doc.textContent;
|
|
174
|
+
const idx = text.indexOf(operation.targetText);
|
|
175
|
+
if (idx === -1) return;
|
|
176
|
+
start = idx;
|
|
177
|
+
end = idx + operation.targetText.length;
|
|
178
|
+
} else return;
|
|
179
|
+
const tr = this.view.state.tr.replaceWith(
|
|
180
|
+
start,
|
|
181
|
+
end,
|
|
182
|
+
this.view.state.schema.text(operation.replacement)
|
|
183
|
+
);
|
|
184
|
+
this.view.dispatch(tr);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "insert": {
|
|
188
|
+
let pos = this.view.state.selection.from;
|
|
189
|
+
if (typeof operation.position === "number") pos = operation.position;
|
|
190
|
+
else if (operation.position === "end") pos = this.view.state.doc.content.size - 2;
|
|
191
|
+
else if (operation.position === "start") pos = 1;
|
|
192
|
+
const tr = this.view.state.tr.insertText(operation.text, pos);
|
|
193
|
+
this.view.dispatch(tr);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case "delete": {
|
|
197
|
+
let start: number, end: number;
|
|
198
|
+
if (operation.range) {
|
|
199
|
+
start = operation.range.start;
|
|
200
|
+
end = operation.range.end;
|
|
201
|
+
} else if (operation.targetText) {
|
|
202
|
+
const text = this.view.state.doc.textContent;
|
|
203
|
+
const idx = text.indexOf(operation.targetText);
|
|
204
|
+
if (idx === -1) return;
|
|
205
|
+
start = idx;
|
|
206
|
+
end = idx + operation.targetText.length;
|
|
207
|
+
} else return;
|
|
208
|
+
const tr = this.view.state.tr.delete(start, end);
|
|
209
|
+
this.view.dispatch(tr);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case "replace_all": {
|
|
213
|
+
const tr = this.view.state.tr.replaceWith(
|
|
214
|
+
0,
|
|
215
|
+
this.view.state.doc.content.size,
|
|
216
|
+
this.view.state.schema.text(operation.replacement)
|
|
217
|
+
);
|
|
218
|
+
this.view.dispatch(tr);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
default:
|
|
222
|
+
console.warn("[ProseMirrorAdapter] Operation not supported:", operation.type);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private _dispatch() {
|
|
227
|
+
const tr = this.view.state.tr.setMeta("addToHistory", false);
|
|
228
|
+
setSegmentsEffect(tr, this.segments);
|
|
229
|
+
this.view.dispatch(tr);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { TargetBinding } from "./TargetBinding";
|
|
2
|
+
import { createContentEditableBinding } from "./targets/contenteditable.binding";
|
|
3
|
+
import { SegmentBindingBridge } from "./SegmentBindingBridge";
|
|
4
|
+
import { createTiptapBinding } from "./targets/tiptap.binding";
|
|
5
|
+
import { createCodeMirrorBinding } from "./targets/codemirror.binding";
|
|
6
|
+
import { createMonacoBinding } from "./targets/monaco.binding";
|
|
7
|
+
import { NativeAdapter } from "./adapters/NativeAdapter";
|
|
8
|
+
import { TiptapInstanceRegistry } from "../../react/registry/registries/TiptapInstanceRegistry";
|
|
9
|
+
import { CodeMirrorInstanceRegistry } from "../../react/registry/registries/CodeMirrorInstanceRegistry";
|
|
10
|
+
import { MonacoInstanceRegistry } from "../../react/registry/registries/MonacoInstanceRegistry";
|
|
11
|
+
import type { EditorType } from "./detect-editor-type";
|
|
12
|
+
|
|
13
|
+
export interface TargetOptions {
|
|
14
|
+
mode: TargetMode;
|
|
15
|
+
context?: string;
|
|
16
|
+
insertion?: TargetInsertionMode;
|
|
17
|
+
editor?: EditorType;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type TargetMode = "write" | "read" | "write+read" | "write+agent";
|
|
21
|
+
export type TargetInsertionMode = "preview-inline" | "final-only" | "preview-floating" | "none";
|
|
22
|
+
|
|
23
|
+
export interface TargetDescriptor {
|
|
24
|
+
id: string;
|
|
25
|
+
element: HTMLElement;
|
|
26
|
+
editorType: EditorType;
|
|
27
|
+
mode: TargetMode;
|
|
28
|
+
insertion: TargetInsertionMode;
|
|
29
|
+
binding: TargetBinding | null;
|
|
30
|
+
_focusinListener?: (() => void);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createBindingForElement(
|
|
34
|
+
el: HTMLElement,
|
|
35
|
+
editorType: EditorType,
|
|
36
|
+
opts?: TargetOptions
|
|
37
|
+
): TargetBinding | null {
|
|
38
|
+
switch (editorType) {
|
|
39
|
+
case "tiptap": {
|
|
40
|
+
const editor = TiptapInstanceRegistry.get(el);
|
|
41
|
+
if (!editor) return null;
|
|
42
|
+
return createTiptapBinding(editor, el);
|
|
43
|
+
}
|
|
44
|
+
case "codemirror": {
|
|
45
|
+
const view = CodeMirrorInstanceRegistry.get(el);
|
|
46
|
+
if (!view) return null;
|
|
47
|
+
return createCodeMirrorBinding(view);
|
|
48
|
+
}
|
|
49
|
+
case "monaco": {
|
|
50
|
+
const editor = MonacoInstanceRegistry.get(el);
|
|
51
|
+
if (!editor) return null;
|
|
52
|
+
return createMonacoBinding(editor);
|
|
53
|
+
}
|
|
54
|
+
case "lexical":
|
|
55
|
+
case "slate": {
|
|
56
|
+
console.error(
|
|
57
|
+
`[ephia] Adapter "${editorType}" n'est pas encore supporté. Dictée désactivée pour cet éditeur.`
|
|
58
|
+
);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
case "contenteditable": {
|
|
62
|
+
const target =
|
|
63
|
+
el.getAttribute("contenteditable") === "true"
|
|
64
|
+
? el
|
|
65
|
+
: el.querySelector<HTMLElement>('[contenteditable="true"]') ?? el;
|
|
66
|
+
return createContentEditableBinding(target);
|
|
67
|
+
}
|
|
68
|
+
case "native": {
|
|
69
|
+
const target =
|
|
70
|
+
el.tagName === "INPUT" || el.tagName === "TEXTAREA"
|
|
71
|
+
? (el as HTMLTextAreaElement | HTMLInputElement)
|
|
72
|
+
: el.querySelector<HTMLTextAreaElement | HTMLInputElement>("input, textarea");
|
|
73
|
+
if (!target) return null;
|
|
74
|
+
const nativeAdapter = new NativeAdapter(target);
|
|
75
|
+
return new SegmentBindingBridge(nativeAdapter, target);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export type EditorType =
|
|
2
|
+
| "tiptap"
|
|
3
|
+
| "contenteditable"
|
|
4
|
+
| "native"
|
|
5
|
+
| "codemirror"
|
|
6
|
+
| "monaco"
|
|
7
|
+
| "lexical"
|
|
8
|
+
| "slate";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Détecte le type d'éditeur d'un élément.
|
|
12
|
+
*
|
|
13
|
+
* Ordre de priorité :
|
|
14
|
+
* 1. data-ephia-editor explicite (source of truth)
|
|
15
|
+
* 2. Heuristiques structurelles fiables
|
|
16
|
+
* 3. Récursion sur l'enfant direct si wrapper
|
|
17
|
+
*/
|
|
18
|
+
export function detectEditorType(el: HTMLElement): EditorType {
|
|
19
|
+
const explicit = el.getAttribute("data-ephia-editor");
|
|
20
|
+
if (explicit === "tiptap") return "tiptap";
|
|
21
|
+
if (explicit === "contenteditable") return "contenteditable";
|
|
22
|
+
if (explicit === "native") return "native";
|
|
23
|
+
if (explicit === "codemirror") return "codemirror";
|
|
24
|
+
if (explicit === "monaco") return "monaco";
|
|
25
|
+
if (explicit === "lexical") return "lexical";
|
|
26
|
+
if (explicit === "slate") return "slate";
|
|
27
|
+
|
|
28
|
+
if (
|
|
29
|
+
el.classList.contains("cm-editor") ||
|
|
30
|
+
el.querySelector(".cm-editor") !== null ||
|
|
31
|
+
el.classList.contains("cm-content")
|
|
32
|
+
) {
|
|
33
|
+
return "codemirror";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (
|
|
37
|
+
el.classList.contains("monaco-editor") ||
|
|
38
|
+
el.querySelector(".monaco-editor") !== null
|
|
39
|
+
) {
|
|
40
|
+
return "monaco";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (
|
|
44
|
+
el.classList.contains("ProseMirror") ||
|
|
45
|
+
el.querySelector(".ProseMirror") !== null ||
|
|
46
|
+
el.hasAttribute("data-tiptap")
|
|
47
|
+
) {
|
|
48
|
+
return "tiptap";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
el.hasAttribute("data-lexical-editor") ||
|
|
53
|
+
el.querySelector("[data-lexical-editor]") !== null
|
|
54
|
+
) {
|
|
55
|
+
console.warn(
|
|
56
|
+
"[ephia-audio] Lexical détecté. L'injection DOM directe sera neutralisée par la réconciliation Lexical.\n" +
|
|
57
|
+
"Utilisez useEphiaTarget avec data-ephia-editor=\"contenteditable\" pour forcer le fallback CE, ou attendez un adapter Lexical dédié."
|
|
58
|
+
);
|
|
59
|
+
return "lexical";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
el.hasAttribute("data-slate-editor") ||
|
|
64
|
+
el.querySelector("[data-slate-editor]") !== null
|
|
65
|
+
) {
|
|
66
|
+
console.warn(
|
|
67
|
+
"[ephia-audio] Slate détecté. L'injection DOM directe sera neutralisée par Slate.\n" +
|
|
68
|
+
"Utilisez data-ephia-editor=\"contenteditable\" pour forcer le fallback CE, ou attendez un adapter Slate dédié."
|
|
69
|
+
);
|
|
70
|
+
return "slate";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (el.getAttribute("contenteditable") === "true") {
|
|
74
|
+
return "contenteditable";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
|
78
|
+
return "native";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const inner = el.querySelector<HTMLElement>(
|
|
82
|
+
'.cm-editor, .monaco-editor, .ProseMirror, input, textarea, [contenteditable="true"]'
|
|
83
|
+
);
|
|
84
|
+
if (inner) return detectEditorType(inner);
|
|
85
|
+
|
|
86
|
+
return "native";
|
|
87
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// ── V2 bindings (EphiaBinding interface + implementations) ──
|
|
2
|
+
export type { EphiaBinding, UpsertSegmentInput, PreviewSegmentInput } from "./EphiaBinding";
|
|
3
|
+
export { NativeBinding } from "./native/NativeBinding";
|
|
4
|
+
export { TiptapBinding, EphiaV2PreviewMark, EphiaV2CommittedMark } from "./tiptap/TiptapBinding";
|
|
5
|
+
|
|
6
|
+
// ── Legacy TipTap marks (V1 targets) ──
|
|
7
|
+
export {
|
|
8
|
+
createTiptapBinding as createLegacyTiptapBinding,
|
|
9
|
+
EphiaCommittedMark,
|
|
10
|
+
EphiaPlaceholderMark,
|
|
11
|
+
EphiaPreviewMark,
|
|
12
|
+
EphiaRevisedMark,
|
|
13
|
+
} from "./targets";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ensureMinimalAppendBoundary } from "./insertion-boundary";
|
|
3
|
+
|
|
4
|
+
describe("ensureMinimalAppendBoundary", () => {
|
|
5
|
+
it("returns incoming unchanged when left is empty", () => {
|
|
6
|
+
expect(ensureMinimalAppendBoundary("", "Bonjour")).toBe("Bonjour");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns incoming unchanged when incoming is empty", () => {
|
|
10
|
+
expect(ensureMinimalAppendBoundary("texte.", "")).toBe("");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns incoming unchanged when left ends with whitespace", () => {
|
|
14
|
+
expect(ensureMinimalAppendBoundary("texte. ", "Suite")).toBe("Suite");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns incoming unchanged when incoming starts with whitespace", () => {
|
|
18
|
+
expect(ensureMinimalAppendBoundary("texte.", " Suite")).toBe(" Suite");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns incoming unchanged when incoming starts with punctuation", () => {
|
|
22
|
+
expect(ensureMinimalAppendBoundary("texte", ",")).toBe(",");
|
|
23
|
+
expect(ensureMinimalAppendBoundary("texte", ".")).toBe(".");
|
|
24
|
+
expect(ensureMinimalAppendBoundary("texte", ":")).toBe(":");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("prepends a space when two words would collide", () => {
|
|
28
|
+
expect(ensureMinimalAppendBoundary("contraste.", "Indication")).toBe(
|
|
29
|
+
" Indication",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does not add a newline — only a space", () => {
|
|
34
|
+
const result = ensureMinimalAppendBoundary("fin de phrase.", "Nouvelle phrase.");
|
|
35
|
+
expect(result).toBe(" Nouvelle phrase.");
|
|
36
|
+
expect(result).not.toContain("\n");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety net: ensures a minimal space is inserted between two words when the
|
|
3
|
+
* backend sends a segment without any leading whitespace.
|
|
4
|
+
*
|
|
5
|
+
* Rules:
|
|
6
|
+
* - SDK only adds a single space. Never a newline.
|
|
7
|
+
* - Only applied on the *first* insertion of a segment, not on updates.
|
|
8
|
+
* - Not applied if the left context already ends with whitespace.
|
|
9
|
+
* - Not applied if the incoming text starts with whitespace or punctuation.
|
|
10
|
+
*/
|
|
11
|
+
export function ensureMinimalAppendBoundary(
|
|
12
|
+
leftText: string,
|
|
13
|
+
incomingText: string,
|
|
14
|
+
): string {
|
|
15
|
+
if (!leftText || !incomingText) return incomingText;
|
|
16
|
+
|
|
17
|
+
const left = leftText.at(-1);
|
|
18
|
+
const right = incomingText.at(0);
|
|
19
|
+
|
|
20
|
+
if (!left || !right) return incomingText;
|
|
21
|
+
if (/\s/.test(left)) return incomingText;
|
|
22
|
+
if (/\s/.test(right)) return incomingText;
|
|
23
|
+
if (/^[,.;:!?)\]}>]/.test(right)) return incomingText;
|
|
24
|
+
|
|
25
|
+
return ` ${incomingText}`;
|
|
26
|
+
}
|