@dxos/ui-editor 0.9.0 → 0.9.1-staging.ee54ba693a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/lib/browser/index.mjs +2533 -1744
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +2533 -1744
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/defaults.d.ts.map +1 -1
  8. package/dist/types/src/extensions/autocomplete/placeholder.d.ts.map +1 -1
  9. package/dist/types/src/extensions/automerge/automerge.d.ts +6 -2
  10. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  11. package/dist/types/src/extensions/automerge/cursor.d.ts +6 -2
  12. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  13. package/dist/types/src/extensions/automerge/sync.d.ts +2 -2
  14. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  15. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  16. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  17. package/dist/types/src/extensions/blocks.d.ts.map +1 -1
  18. package/dist/types/src/extensions/busy.d.ts +13 -0
  19. package/dist/types/src/extensions/busy.d.ts.map +1 -0
  20. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  21. package/dist/types/src/extensions/dnd.d.ts +5 -1
  22. package/dist/types/src/extensions/dnd.d.ts.map +1 -1
  23. package/dist/types/src/extensions/factories.d.ts +2 -2
  24. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  25. package/dist/types/src/extensions/fader.d.ts.map +1 -0
  26. package/dist/types/src/extensions/index.d.ts +6 -1
  27. package/dist/types/src/extensions/index.d.ts.map +1 -1
  28. package/dist/types/src/extensions/markdown/styles.d.ts.map +1 -1
  29. package/dist/types/src/extensions/marker.d.ts +40 -0
  30. package/dist/types/src/extensions/marker.d.ts.map +1 -0
  31. package/dist/types/src/extensions/pending/index.d.ts +3 -0
  32. package/dist/types/src/extensions/pending/index.d.ts.map +1 -0
  33. package/dist/types/src/extensions/pending/pending-text-stream.d.ts +75 -0
  34. package/dist/types/src/extensions/pending/pending-text-stream.d.ts.map +1 -0
  35. package/dist/types/src/extensions/pending/pending-text-stream.test.d.ts +2 -0
  36. package/dist/types/src/extensions/pending/pending-text-stream.test.d.ts.map +1 -0
  37. package/dist/types/src/extensions/pending/pending-text.d.ts +46 -0
  38. package/dist/types/src/extensions/pending/pending-text.d.ts.map +1 -0
  39. package/dist/types/src/extensions/pending/pending-text.test.d.ts +2 -0
  40. package/dist/types/src/extensions/pending/pending-text.test.d.ts.map +1 -0
  41. package/dist/types/src/extensions/pos.d.ts +49 -0
  42. package/dist/types/src/extensions/pos.d.ts.map +1 -0
  43. package/dist/types/src/extensions/pos.test.d.ts +2 -0
  44. package/dist/types/src/extensions/pos.test.d.ts.map +1 -0
  45. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts.map +1 -1
  46. package/dist/types/src/extensions/selection.d.ts +12 -1
  47. package/dist/types/src/extensions/selection.d.ts.map +1 -1
  48. package/dist/types/src/extensions/selection.test.d.ts +2 -0
  49. package/dist/types/src/extensions/selection.test.d.ts.map +1 -0
  50. package/dist/types/src/extensions/tags/index.d.ts +2 -2
  51. package/dist/types/src/extensions/tags/index.d.ts.map +1 -1
  52. package/dist/types/src/extensions/tags/stub.d.ts +35 -0
  53. package/dist/types/src/extensions/tags/stub.d.ts.map +1 -0
  54. package/dist/types/src/extensions/tags/widgets/anchor.d.ts +13 -0
  55. package/dist/types/src/extensions/tags/widgets/anchor.d.ts.map +1 -0
  56. package/dist/types/src/extensions/tags/widgets/index.d.ts +2 -0
  57. package/dist/types/src/extensions/tags/widgets/index.d.ts.map +1 -0
  58. package/dist/types/src/extensions/tags/xml-tags.d.ts +9 -5
  59. package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -1
  60. package/dist/types/src/extensions/typewriter.d.ts.map +1 -0
  61. package/dist/types/src/extensions/typewriter.test.d.ts.map +1 -0
  62. package/dist/types/src/index.d.ts +1 -1
  63. package/dist/types/src/index.d.ts.map +1 -1
  64. package/dist/types/src/styles/theme.d.ts.map +1 -1
  65. package/dist/types/tsconfig.tsbuildinfo +1 -1
  66. package/package.json +37 -35
  67. package/src/defaults.ts +3 -1
  68. package/src/extensions/autocomplete/placeholder.ts +11 -1
  69. package/src/extensions/automerge/automerge.test.tsx +25 -53
  70. package/src/extensions/automerge/automerge.ts +7 -3
  71. package/src/extensions/automerge/cursor.ts +7 -2
  72. package/src/extensions/automerge/sync.ts +2 -2
  73. package/src/extensions/automerge/update-automerge.ts +2 -2
  74. package/src/extensions/blocks.ts +36 -36
  75. package/src/extensions/busy.ts +32 -0
  76. package/src/extensions/comments.ts +5 -9
  77. package/src/extensions/dnd.ts +127 -3
  78. package/src/extensions/factories.test.ts +1 -1
  79. package/src/extensions/factories.ts +2 -2
  80. package/src/extensions/index.ts +6 -1
  81. package/src/extensions/markdown/styles.ts +7 -6
  82. package/src/extensions/marker.ts +143 -0
  83. package/src/extensions/pending/index.ts +6 -0
  84. package/src/extensions/pending/pending-text-stream.test.ts +133 -0
  85. package/src/extensions/pending/pending-text-stream.ts +269 -0
  86. package/src/extensions/pending/pending-text.test.ts +134 -0
  87. package/src/extensions/pending/pending-text.ts +264 -0
  88. package/src/extensions/pos.test.ts +97 -0
  89. package/src/extensions/pos.ts +206 -0
  90. package/src/extensions/scrolling/auto-scroll.ts +12 -8
  91. package/src/extensions/selection.test.ts +26 -0
  92. package/src/extensions/selection.ts +14 -15
  93. package/src/extensions/tags/DESIGN.md +58 -0
  94. package/src/extensions/tags/index.ts +2 -2
  95. package/src/extensions/tags/stub.ts +80 -0
  96. package/src/extensions/tags/widgets/anchor.ts +30 -0
  97. package/src/extensions/{preview → tags/widgets}/index.ts +1 -1
  98. package/src/extensions/tags/xml-tags.ts +116 -74
  99. package/src/extensions/tags/xml-util.test.ts +1 -1
  100. package/src/index.ts +1 -1
  101. package/src/styles/theme.ts +0 -1
  102. package/dist/types/src/extensions/preview/index.d.ts +0 -2
  103. package/dist/types/src/extensions/preview/index.d.ts.map +0 -1
  104. package/dist/types/src/extensions/preview/preview.d.ts +0 -34
  105. package/dist/types/src/extensions/preview/preview.d.ts.map +0 -1
  106. package/dist/types/src/extensions/tags/fader.d.ts.map +0 -1
  107. package/dist/types/src/extensions/tags/typewriter.d.ts.map +0 -1
  108. package/dist/types/src/extensions/tags/typewriter.test.d.ts.map +0 -1
  109. package/src/extensions/preview/preview.ts +0 -242
  110. /package/dist/types/src/extensions/{tags/fader.d.ts → fader.d.ts} +0 -0
  111. /package/dist/types/src/extensions/{tags/typewriter.d.ts → typewriter.d.ts} +0 -0
  112. /package/dist/types/src/extensions/{tags/typewriter.test.d.ts → typewriter.test.d.ts} +0 -0
  113. /package/src/extensions/{tags/fader.ts → fader.ts} +0 -0
  114. /package/src/extensions/{tags/typewriter.test.ts → typewriter.test.ts} +0 -0
  115. /package/src/extensions/{tags/typewriter.ts → typewriter.ts} +0 -0
@@ -0,0 +1,269 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type EditorView } from '@codemirror/view';
6
+
7
+ import {
8
+ appendPendingText,
9
+ cancelPendingText,
10
+ setPendingAnchor,
11
+ setPendingFinal,
12
+ setPendingInterim,
13
+ } from './pending-text';
14
+
15
+ //
16
+ // Sinks — decouple the streamer from its target (a live editor, or memory for tests/stories).
17
+ //
18
+
19
+ /**
20
+ * Target for streamed pending text. The {@link PendingTextStreamer} writes to this interface; the
21
+ * editor implementation dispatches CodeMirror effects, while the memory implementation accumulates
22
+ * state so the mechanism can be exercised without a CodeMirror component.
23
+ */
24
+ export type PendingTextSink = {
25
+ begin(options?: { anchor?: number; placeholder?: string }): void;
26
+ /** Set the volatile in-flight tail. */
27
+ interim(text: string): void;
28
+ /** Append finalized text to the buffer. */
29
+ appendFinal(text: string): void;
30
+ /** Replace the finalized buffer wholesale (e.g. post-processed text). */
31
+ replaceFinal(text: string): void;
32
+ cancel(): void;
33
+ };
34
+
35
+ /** Sink that drives the `pendingText` extension in a live editor via its state effects. */
36
+ export const editorPendingTextSink = (view: EditorView): PendingTextSink => ({
37
+ begin: ({ anchor = view.state.selection.main.head, placeholder } = {}) =>
38
+ view.dispatch({ effects: setPendingAnchor.of({ anchor, placeholder }) }),
39
+ interim: (text) => view.dispatch({ effects: setPendingInterim.of(text) }),
40
+ appendFinal: (text) => view.dispatch({ effects: appendPendingText.of(text) }),
41
+ replaceFinal: (text) => view.dispatch({ effects: setPendingFinal.of(text) }),
42
+ cancel: () => view.dispatch({ effects: cancelPendingText.of() }),
43
+ });
44
+
45
+ export type PendingTextSnapshot = {
46
+ active: boolean;
47
+ final: string;
48
+ interim: string;
49
+ placeholder?: string;
50
+ };
51
+
52
+ /** In-memory sink for tests and CodeMirror-free stories. */
53
+ export const memoryPendingTextSink = (
54
+ onChange?: (snapshot: PendingTextSnapshot) => void,
55
+ ): PendingTextSink & { snapshot: () => PendingTextSnapshot } => {
56
+ let state: PendingTextSnapshot = { active: false, final: '', interim: '', placeholder: undefined };
57
+ const set = (next: PendingTextSnapshot) => {
58
+ state = next;
59
+ onChange?.(state);
60
+ };
61
+
62
+ return {
63
+ begin: ({ placeholder } = {}) => set({ active: true, final: '', interim: '', placeholder }),
64
+ interim: (text) => set({ ...state, interim: text }),
65
+ appendFinal: (text) => set({ ...state, final: state.final + text, interim: '', placeholder: undefined }),
66
+ replaceFinal: (text) => set({ ...state, final: text, placeholder: undefined }),
67
+ cancel: () => set({ active: false, final: '', interim: '', placeholder: undefined }),
68
+ snapshot: () => state,
69
+ };
70
+ };
71
+
72
+ //
73
+ // Streamer — buffering, pacing, and post-processing between a text source and a sink.
74
+ //
75
+
76
+ /** Minimal timer interface so tests can drive the streamer deterministically. */
77
+ export type Scheduler = {
78
+ setTimeout: (callback: () => void, ms: number) => unknown;
79
+ clearTimeout: (handle: unknown) => void;
80
+ };
81
+
82
+ const defaultScheduler: Scheduler = {
83
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
84
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
85
+ };
86
+
87
+ export type PendingTextStreamerOptions = {
88
+ /** Delay (ms) before the first text is revealed — absorbs the source's initial latency. */
89
+ initialBufferMs?: number;
90
+ /** `batch` reveals each pushed chunk whole; `word` reveals one word per {@link wordIntervalMs}. */
91
+ mode?: 'batch' | 'word';
92
+ /** Pacing (ms) for `word` mode. */
93
+ wordIntervalMs?: number;
94
+ /** Idle delay (ms) before {@link postProcess} runs after the buffer settles. */
95
+ postProcessDebounceMs?: number;
96
+ /** Rewrites the finalized buffer once it settles (e.g. entity extraction / dx-anchor links). */
97
+ postProcess?: (final: string) => string | Promise<string>;
98
+ /** Injectable timers (defaults to the global timers). */
99
+ scheduler?: Scheduler;
100
+ };
101
+
102
+ const DEFAULTS = {
103
+ initialBufferMs: 0,
104
+ mode: 'batch' as const,
105
+ wordIntervalMs: 120,
106
+ postProcessDebounceMs: 800,
107
+ };
108
+
109
+ /**
110
+ * Streams text from an arbitrary source (e.g. a transcriber, or a scripted mock) into a
111
+ * {@link PendingTextSink}, decoupling the source from the editor and centralizing the cross-cutting
112
+ * concerns: an initial buffering delay, optional word-by-word reveal, and a post-processing pass.
113
+ */
114
+ export class PendingTextStreamer {
115
+ readonly #sink: PendingTextSink;
116
+ readonly #options: Required<Omit<PendingTextStreamerOptions, 'postProcess' | 'scheduler'>> &
117
+ Pick<PendingTextStreamerOptions, 'postProcess'>;
118
+ readonly #scheduler: Scheduler;
119
+
120
+ #started = false;
121
+ #buffering = false;
122
+ #final = '';
123
+ #queue: string[] = [];
124
+ #bufferHandle: unknown = null;
125
+ #wordHandle: unknown = null;
126
+ #postHandle: unknown = null;
127
+ /** The in-flight post-process pass, tracked so `flush()` can await it (drain contract). */
128
+ #postPromise: Promise<void> | null = null;
129
+
130
+ constructor(sink: PendingTextSink, options: PendingTextStreamerOptions = {}) {
131
+ this.#sink = sink;
132
+ this.#options = { ...DEFAULTS, ...options };
133
+ this.#scheduler = options.scheduler ?? defaultScheduler;
134
+ }
135
+
136
+ /** Begin a session; nothing is revealed until the initial buffer elapses (or the first push, if 0). */
137
+ start(options: { placeholder?: string; anchor?: number } = {}): void {
138
+ this.#started = true;
139
+ this.#final = '';
140
+ this.#queue = [];
141
+ this.#sink.begin(options);
142
+ if (this.#options.initialBufferMs > 0) {
143
+ this.#buffering = true;
144
+ this.#bufferHandle = this.#scheduler.setTimeout(() => {
145
+ this.#buffering = false;
146
+ this.#bufferHandle = null;
147
+ this.#drain();
148
+ }, this.#options.initialBufferMs);
149
+ }
150
+ }
151
+
152
+ /** Feed a finalized text chunk from the source. */
153
+ push(text: string): void {
154
+ if (!this.#started) {
155
+ this.start();
156
+ }
157
+ const trimmed = text.trim();
158
+ if (trimmed.length === 0) {
159
+ return;
160
+ }
161
+ this.#queue.push(...(this.#options.mode === 'word' ? trimmed.split(/\s+/) : [trimmed]));
162
+ this.#drain();
163
+ }
164
+
165
+ /** Feed a volatile interim chunk (when the source yields partials). */
166
+ pushInterim(text: string): void {
167
+ if (!this.#started) {
168
+ this.start();
169
+ }
170
+ if (!this.#buffering) {
171
+ this.#sink.interim(text);
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Reveal everything immediately and run any pending post-process (e.g. on stop). Resolves once the
177
+ * post-process pass (entity linking, …) has settled, so callers can drain before disposing.
178
+ */
179
+ async flush(): Promise<void> {
180
+ this.#clear(this.#bufferHandle);
181
+ this.#bufferHandle = null;
182
+ this.#buffering = false;
183
+ this.#clear(this.#wordHandle);
184
+ this.#wordHandle = null;
185
+ while (this.#queue.length > 0) {
186
+ this.#appendFinal(this.#queue.shift()!);
187
+ }
188
+ this.#sink.interim('');
189
+ this.#clear(this.#postHandle);
190
+ this.#postHandle = null;
191
+ if (this.#options.postProcess && this.#final.length > 0) {
192
+ // Await any post-process already in flight (its scheduled timer may have fired), then run a
193
+ // final pass over the settled buffer — so draining truly waits for post-processing to finish.
194
+ if (this.#postPromise) {
195
+ await this.#postPromise;
196
+ }
197
+ this.#postPromise = this.#runPostProcess();
198
+ await this.#postPromise;
199
+ }
200
+ }
201
+
202
+ dispose(): void {
203
+ [this.#bufferHandle, this.#wordHandle, this.#postHandle].forEach((handle) => this.#clear(handle));
204
+ this.#bufferHandle = this.#wordHandle = this.#postHandle = null;
205
+ }
206
+
207
+ #drain(): void {
208
+ if (this.#buffering) {
209
+ return;
210
+ }
211
+ if (this.#options.mode === 'word') {
212
+ if (this.#wordHandle == null) {
213
+ this.#tickWord();
214
+ }
215
+ } else {
216
+ while (this.#queue.length > 0) {
217
+ this.#appendFinal(this.#queue.shift()!);
218
+ }
219
+ }
220
+ }
221
+
222
+ #tickWord(): void {
223
+ const word = this.#queue.shift();
224
+ if (word == null) {
225
+ this.#wordHandle = null;
226
+ return;
227
+ }
228
+ this.#appendFinal(word);
229
+ this.#wordHandle = this.#scheduler.setTimeout(() => this.#tickWord(), this.#options.wordIntervalMs);
230
+ }
231
+
232
+ #appendFinal(text: string): void {
233
+ const chunk = (this.#final.length > 0 ? ' ' : '') + text;
234
+ this.#final += chunk;
235
+ this.#sink.appendFinal(chunk);
236
+ this.#schedulePostProcess();
237
+ }
238
+
239
+ #schedulePostProcess(): void {
240
+ if (!this.#options.postProcess) {
241
+ return;
242
+ }
243
+ this.#clear(this.#postHandle);
244
+ this.#postHandle = this.#scheduler.setTimeout(() => {
245
+ this.#postHandle = null;
246
+ this.#postPromise = this.#runPostProcess();
247
+ }, this.#options.postProcessDebounceMs);
248
+ }
249
+
250
+ async #runPostProcess(): Promise<void> {
251
+ const input = this.#final;
252
+ try {
253
+ const result = await this.#options.postProcess!(input);
254
+ // Skip if the buffer grew while processing — a later pass will cover the newer text.
255
+ if (result !== input && this.#final === input) {
256
+ this.#final = result;
257
+ this.#sink.replaceFinal(result);
258
+ }
259
+ } catch {
260
+ // Post-process is best-effort (e.g. entity linking); a failure leaves the un-rewritten text.
261
+ }
262
+ }
263
+
264
+ #clear(handle: unknown): void {
265
+ if (handle != null) {
266
+ this.#scheduler.clearTimeout(handle);
267
+ }
268
+ }
269
+ }
@@ -0,0 +1,134 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { EditorState } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+ import { describe, test } from 'vitest';
8
+
9
+ import { isBusy } from '../busy';
10
+ import {
11
+ appendPendingText,
12
+ cancelPending,
13
+ commitPending,
14
+ pendingText,
15
+ pendingTextState,
16
+ setPendingAnchor,
17
+ setPendingInterim,
18
+ } from './pending-text';
19
+
20
+ const createView = (doc: string): EditorView =>
21
+ new EditorView({ state: EditorState.create({ doc, extensions: [pendingText()] }) });
22
+
23
+ // Busy is toggled via a queued microtask, so flush before asserting.
24
+ const flush = () => new Promise<void>((resolve) => queueMicrotask(resolve));
25
+
26
+ describe('pendingText extension', () => {
27
+ test('append and interim accumulate without changing the document', ({ expect }) => {
28
+ const view = createView('hello ');
29
+ view.dispatch({ effects: setPendingAnchor.of({ anchor: 6 }) });
30
+ view.dispatch({ effects: appendPendingText.of('world') });
31
+ view.dispatch({ effects: setPendingInterim.of(' and') });
32
+ expect(view.state.field(pendingTextState)).toEqual({
33
+ anchor: 6,
34
+ final: 'world',
35
+ interim: ' and',
36
+ placeholder: undefined,
37
+ });
38
+ expect(view.state.doc.toString()).toBe('hello ');
39
+ view.destroy();
40
+ });
41
+
42
+ test('appending finalized text clears the interim tail', ({ expect }) => {
43
+ const view = createView('hello ');
44
+ view.dispatch({ effects: setPendingAnchor.of({ anchor: 6 }) });
45
+ view.dispatch({ effects: setPendingInterim.of('wor') });
46
+ view.dispatch({ effects: appendPendingText.of('world') });
47
+ expect(view.state.field(pendingTextState)).toMatchObject({ anchor: 6, final: 'world', interim: '' });
48
+ view.destroy();
49
+ });
50
+
51
+ test('commit at the end of the document inserts the text and leaves a trailing blank line', ({ expect }) => {
52
+ const view = createView('hello ');
53
+ view.dispatch({ effects: setPendingAnchor.of({ anchor: 6 }) });
54
+ view.dispatch({ effects: appendPendingText.of('world') });
55
+ view.dispatch({ effects: setPendingInterim.of(' dropped') });
56
+ expect(commitPending(view)).toBe(true);
57
+ expect(view.state.doc.toString()).toBe('hello world\n\n');
58
+ expect(view.state.field(pendingTextState)).toBeNull();
59
+ view.destroy();
60
+ });
61
+
62
+ test('commit before the end of the document does not add a trailing blank line', ({ expect }) => {
63
+ const view = createView('one\ntwo');
64
+ view.dispatch({ effects: setPendingAnchor.of({ anchor: 3 }) });
65
+ view.dispatch({ effects: appendPendingText.of(' and a half') });
66
+ expect(commitPending(view)).toBe(true);
67
+ // Single newline terminates the inserted line; the document still ends at 'two' (no trailing blank line).
68
+ expect(view.state.doc.toString()).toBe('one and a half\n\ntwo');
69
+ view.destroy();
70
+ });
71
+
72
+ test('cancel discards pending text and leaves the document unchanged', ({ expect }) => {
73
+ const view = createView('hello ');
74
+ view.dispatch({ effects: setPendingAnchor.of({ anchor: 6 }) });
75
+ view.dispatch({ effects: appendPendingText.of('world') });
76
+ expect(cancelPending(view)).toBe(true);
77
+ expect(view.state.doc.toString()).toBe('hello ');
78
+ expect(view.state.field(pendingTextState)).toBeNull();
79
+ view.destroy();
80
+ });
81
+
82
+ test('commit and cancel are no-ops when there is no pending text', ({ expect }) => {
83
+ const view = createView('hello ');
84
+ expect(commitPending(view)).toBe(false);
85
+ expect(cancelPending(view)).toBe(false);
86
+ view.destroy();
87
+ });
88
+
89
+ test('a placeholder-only session is held without committable text', ({ expect }) => {
90
+ const view = createView('hello ');
91
+ view.dispatch({ effects: setPendingAnchor.of({ anchor: 6, placeholder: 'Recording…' }) });
92
+ expect(view.state.field(pendingTextState)).toEqual({
93
+ anchor: 6,
94
+ final: '',
95
+ interim: '',
96
+ placeholder: 'Recording…',
97
+ });
98
+ // Nothing finalized yet, so commit is a no-op.
99
+ expect(commitPending(view)).toBe(false);
100
+ view.destroy();
101
+ });
102
+
103
+ test('anchor survives an edit before the anchor', ({ expect }) => {
104
+ const view = createView('hello ');
105
+ view.dispatch({ effects: setPendingAnchor.of({ anchor: 6 }) });
106
+ view.dispatch({ effects: appendPendingText.of('world') });
107
+ view.dispatch({ changes: { from: 0, insert: 'X' } });
108
+ expect(view.state.field(pendingTextState)?.anchor).toBe(7);
109
+ expect(commitPending(view)).toBe(true);
110
+ expect(view.state.doc.toString()).toBe('Xhello world\n\n');
111
+ view.destroy();
112
+ });
113
+
114
+ test('the first text effect without an explicit anchor uses the selection head', ({ expect }) => {
115
+ const view = createView('hello ');
116
+ view.dispatch({ selection: { anchor: 6 } });
117
+ view.dispatch({ effects: appendPendingText.of('world') });
118
+ expect(view.state.field(pendingTextState)?.anchor).toBe(6);
119
+ view.destroy();
120
+ });
121
+
122
+ test('flags the editor busy while a session is active and clears it on commit', async ({ expect }) => {
123
+ const view = createView('hello ');
124
+ view.dispatch({ effects: setPendingAnchor.of({ anchor: 6, placeholder: 'Recording…' }) });
125
+ await flush();
126
+ expect(isBusy(view.state)).toBe(true);
127
+
128
+ view.dispatch({ effects: appendPendingText.of('world') });
129
+ commitPending(view);
130
+ await flush();
131
+ expect(isBusy(view.state)).toBe(false);
132
+ view.destroy();
133
+ });
134
+ });
@@ -0,0 +1,264 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Extension, StateEffect, StateField } from '@codemirror/state';
6
+ import { type Command, Decoration, EditorView, WidgetType, keymap } from '@codemirror/view';
7
+
8
+ import { Domino } from '@dxos/ui';
9
+
10
+ import { busy, setBusy } from '../busy';
11
+ import { type MarkerHue, markerButtons, markerText, markerTheme } from '../marker';
12
+
13
+ //
14
+ // State.
15
+ //
16
+
17
+ /**
18
+ * Pending (not-yet-committed) text injected from an external source.
19
+ * `final` is the accumulated finalized text; `interim` is the volatile in-flight tail; `placeholder`
20
+ * is shown while both are empty (e.g. "Recording…"). `anchor` is the document position the text will
21
+ * be inserted at; it is mapped through document changes so it survives concurrent edits.
22
+ */
23
+ export type PendingTextState = {
24
+ anchor: number;
25
+ final: string;
26
+ interim: string;
27
+ placeholder?: string;
28
+ };
29
+
30
+ /** Start a pending-text session at the given position, optionally showing a placeholder until text arrives. */
31
+ export const setPendingAnchor = StateEffect.define<{ anchor: number; placeholder?: string }>();
32
+
33
+ /** Append finalized text to the pending buffer; clears the interim tail and placeholder. */
34
+ export const appendPendingText = StateEffect.define<string>();
35
+
36
+ /** Replace the volatile in-flight tail. */
37
+ export const setPendingInterim = StateEffect.define<string>();
38
+
39
+ /** Replace the finalized buffer wholesale (e.g. with post-processed text). */
40
+ export const setPendingFinal = StateEffect.define<string>();
41
+
42
+ /** Discard the pending buffer without modifying the document. */
43
+ export const cancelPendingText = StateEffect.define<void>();
44
+
45
+ const emptyAt = (anchor: number, placeholder?: string): PendingTextState => ({
46
+ anchor,
47
+ final: '',
48
+ interim: '',
49
+ placeholder,
50
+ });
51
+
52
+ export const pendingTextState = StateField.define<PendingTextState | null>({
53
+ create: () => null,
54
+ update: (value, tr) => {
55
+ // Map the anchor through document changes so injected text lands at the right place.
56
+ let next = value && tr.docChanged ? { ...value, anchor: tr.changes.mapPos(value.anchor) } : value;
57
+ for (const effect of tr.effects) {
58
+ if (effect.is(setPendingAnchor)) {
59
+ next = emptyAt(effect.value.anchor, effect.value.placeholder);
60
+ } else if (effect.is(appendPendingText)) {
61
+ const base = next ?? emptyAt(tr.state.selection.main.head);
62
+ next = { ...base, final: base.final + effect.value, interim: '', placeholder: undefined };
63
+ } else if (effect.is(setPendingInterim)) {
64
+ const base = next ?? emptyAt(tr.state.selection.main.head);
65
+ next = { ...base, interim: effect.value };
66
+ } else if (effect.is(setPendingFinal)) {
67
+ // Replace the EXISTING buffer only; never resurrect a cancelled/empty session. This lets a
68
+ // late post-process result (e.g. entity linking) be dropped if the user cancelled meanwhile.
69
+ if (next) {
70
+ next = { ...next, final: effect.value, placeholder: undefined };
71
+ }
72
+ } else if (effect.is(cancelPendingText)) {
73
+ next = null;
74
+ }
75
+ }
76
+
77
+ return next;
78
+ },
79
+ });
80
+
81
+ const hasContent = (value: PendingTextState): boolean =>
82
+ value.final.length > 0 || value.interim.length > 0 || (value.placeholder?.length ?? 0) > 0;
83
+
84
+ //
85
+ // Commands.
86
+ //
87
+
88
+ /**
89
+ * Insert the finalized pending text into the document and clear the buffer. The text is terminated
90
+ * with a newline; when committed at the end of the document an extra newline is added so the document
91
+ * always ends with a blank line.
92
+ */
93
+ export const commitPending: Command = (view) => {
94
+ const value = view.state.field(pendingTextState, false);
95
+ if (!value || value.final.length === 0) {
96
+ return false;
97
+ }
98
+
99
+ const atEnd = value.anchor >= view.state.doc.length;
100
+ const insert = value.final + (atEnd ? '\n\n' : '\n');
101
+ view.dispatch({
102
+ // Place the cursor on the line after the inserted text (before the trailing blank line).
103
+ changes: { from: value.anchor, insert },
104
+ selection: { anchor: value.anchor + value.final.length + 1 },
105
+ effects: cancelPendingText.of(),
106
+ });
107
+ return true;
108
+ };
109
+
110
+ /** Discard the pending text without modifying the document. */
111
+ export const cancelPending: Command = (view) => {
112
+ if (!view.state.field(pendingTextState, false)) {
113
+ return false;
114
+ }
115
+
116
+ view.dispatch({ effects: cancelPendingText.of() });
117
+ return true;
118
+ };
119
+
120
+ // Commit when there is finalized text, otherwise just consume the key so the editor's default
121
+ // newline insertion cannot mutate the document mid-session (e.g. placeholder/interim-only state).
122
+ const commitOrConsumePending: Command = (view) => {
123
+ const value = view.state.field(pendingTextState, false);
124
+ if (!value) {
125
+ return false;
126
+ }
127
+
128
+ return value.final.length > 0 ? commitPending(view) : true;
129
+ };
130
+
131
+ //
132
+ // Decorations.
133
+ //
134
+
135
+ // Finalized + interim text share one teal marker (a single transcription); the placeholder uses a
136
+ // rose marker with a pulsing icon to signal active recording.
137
+ const TEXT_HUE: MarkerHue = 'teal';
138
+ const PLACEHOLDER_HUE: MarkerHue = 'rose';
139
+
140
+ class PendingTextWidget extends WidgetType {
141
+ constructor(private readonly _state: PendingTextState) {
142
+ super();
143
+ }
144
+
145
+ override eq(other: PendingTextWidget): boolean {
146
+ return (
147
+ other._state.final === this._state.final &&
148
+ other._state.interim === this._state.interim &&
149
+ other._state.placeholder === this._state.placeholder
150
+ );
151
+ }
152
+
153
+ override toDOM(view: EditorView): HTMLElement {
154
+ const root = Domino.of('span').classNames('cm-pending-text');
155
+ if (this._state.final.length > 0 || this._state.interim.length > 0) {
156
+ const marker = Domino.of('span').classNames('cm-marker-text').attributes({ 'data-hue': TEXT_HUE });
157
+ if (this._state.final.length > 0) {
158
+ marker.append(Domino.of('span').text(this._state.final));
159
+ }
160
+ if (this._state.interim.length > 0) {
161
+ marker.append(Domino.of('span').classNames('cm-pending-text-interim').text(this._state.interim));
162
+ }
163
+ root.append(marker);
164
+ } else if (this._state.placeholder) {
165
+ root.append(
166
+ markerText(this._state.placeholder, {
167
+ hue: PLACEHOLDER_HUE,
168
+ className: 'cm-pending-text-indicator',
169
+ icon: 'ph--circle-notch--regular',
170
+ iconClassNames: 'animate-spin',
171
+ }),
172
+ );
173
+ }
174
+
175
+ root.append(
176
+ markerButtons([
177
+ {
178
+ icon: 'ph--check--regular',
179
+ label: 'Confirm',
180
+ className: 'cm-marker-button-success',
181
+ testId: 'pending-text.confirm',
182
+ onClick: () => commitPending(view),
183
+ },
184
+ {
185
+ icon: 'ph--x--regular',
186
+ label: 'Cancel',
187
+ className: 'cm-marker-button-error',
188
+ testId: 'pending-text.cancel',
189
+ onClick: () => cancelPending(view),
190
+ },
191
+ ]),
192
+ );
193
+ return root.root;
194
+ }
195
+
196
+ override ignoreEvent(): boolean {
197
+ return true;
198
+ }
199
+ }
200
+
201
+ const pendingDecorations = EditorView.decorations.compute([pendingTextState], (state) => {
202
+ const value = state.field(pendingTextState);
203
+ if (!value || !hasContent(value)) {
204
+ return Decoration.none;
205
+ }
206
+
207
+ return Decoration.set([Decoration.widget({ widget: new PendingTextWidget(value), side: 1 }).range(value.anchor)]);
208
+ });
209
+
210
+ // Flag the editor busy while a pending session is active so other extensions (e.g. the command-hint
211
+ // placeholder) suppress themselves. Deferred to a microtask to avoid dispatching mid-update.
212
+ const busyListener = EditorView.updateListener.of((update) => {
213
+ const active = update.state.field(pendingTextState) != null;
214
+ if (active === (update.startState.field(pendingTextState) != null)) {
215
+ return;
216
+ }
217
+ queueMicrotask(() => {
218
+ if ((update.view.state.field(pendingTextState) != null) === active) {
219
+ update.view.dispatch({ effects: setBusy.of(active) });
220
+ }
221
+ });
222
+ });
223
+
224
+ const styles = EditorView.theme({
225
+ // Keep the marker and affordances on a single line (it already sits on its own line at the anchor).
226
+ '.cm-pending-text': {
227
+ display: 'inline-flex',
228
+ alignItems: 'center',
229
+ gap: '0.5rem',
230
+ },
231
+ // The interim tail is distinguished from the finalized text only by reduced opacity.
232
+ '.cm-pending-text-interim': {
233
+ opacity: 0.6,
234
+ },
235
+ // Keep the "Recording…" indicator (text + spinner) on a single line.
236
+ '.cm-pending-text-indicator': {
237
+ whiteSpace: 'nowrap',
238
+ },
239
+ });
240
+
241
+ //
242
+ // Extension.
243
+ //
244
+
245
+ /**
246
+ * Renders externally-supplied "pending" text as an inline preview at a tracked anchor, with inline
247
+ * confirm/cancel affordances. An external controller drives the buffer via the `setPendingAnchor` /
248
+ * `appendPendingText` / `setPendingInterim` / `cancelPendingText` effects; the user commits or
249
+ * discards it via the affordances or the Enter/Escape keys. While a session is active the editor is
250
+ * flagged {@link busy} so hint placeholders suppress themselves. The extension is agnostic to the
251
+ * source of the text (e.g. speech transcription), so any CodeMirror component can opt in.
252
+ */
253
+ export const pendingText = (): Extension => [
254
+ pendingTextState,
255
+ busy(),
256
+ pendingDecorations,
257
+ busyListener,
258
+ markerTheme(),
259
+ styles,
260
+ keymap.of([
261
+ { key: 'Enter', run: commitOrConsumePending },
262
+ { key: 'Escape', run: cancelPending },
263
+ ]),
264
+ ];