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