@dxos/ui-editor 0.9.1-main.c7dcc2e112 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/lib/browser/index.mjs +2499 -1708
  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 +2499 -1708
  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/extensions/autocomplete/placeholder.d.ts.map +1 -1
  8. package/dist/types/src/extensions/automerge/automerge.d.ts +6 -2
  9. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  10. package/dist/types/src/extensions/automerge/cursor.d.ts +6 -2
  11. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  12. package/dist/types/src/extensions/automerge/sync.d.ts +2 -2
  13. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  14. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  15. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  16. package/dist/types/src/extensions/blocks.d.ts.map +1 -1
  17. package/dist/types/src/extensions/busy.d.ts +13 -0
  18. package/dist/types/src/extensions/busy.d.ts.map +1 -0
  19. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  20. package/dist/types/src/extensions/dnd.d.ts +5 -1
  21. package/dist/types/src/extensions/dnd.d.ts.map +1 -1
  22. package/dist/types/src/extensions/factories.d.ts +2 -2
  23. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  24. package/dist/types/src/extensions/fader.d.ts.map +1 -0
  25. package/dist/types/src/extensions/index.d.ts +6 -1
  26. package/dist/types/src/extensions/index.d.ts.map +1 -1
  27. package/dist/types/src/extensions/marker.d.ts +40 -0
  28. package/dist/types/src/extensions/marker.d.ts.map +1 -0
  29. package/dist/types/src/extensions/pending/index.d.ts +3 -0
  30. package/dist/types/src/extensions/pending/index.d.ts.map +1 -0
  31. package/dist/types/src/extensions/pending/pending-text-stream.d.ts +75 -0
  32. package/dist/types/src/extensions/pending/pending-text-stream.d.ts.map +1 -0
  33. package/dist/types/src/extensions/pending/pending-text-stream.test.d.ts +2 -0
  34. package/dist/types/src/extensions/pending/pending-text-stream.test.d.ts.map +1 -0
  35. package/dist/types/src/extensions/pending/pending-text.d.ts +46 -0
  36. package/dist/types/src/extensions/pending/pending-text.d.ts.map +1 -0
  37. package/dist/types/src/extensions/pending/pending-text.test.d.ts +2 -0
  38. package/dist/types/src/extensions/pending/pending-text.test.d.ts.map +1 -0
  39. package/dist/types/src/extensions/pos.d.ts +49 -0
  40. package/dist/types/src/extensions/pos.d.ts.map +1 -0
  41. package/dist/types/src/extensions/pos.test.d.ts +2 -0
  42. package/dist/types/src/extensions/pos.test.d.ts.map +1 -0
  43. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts.map +1 -1
  44. package/dist/types/src/extensions/tags/index.d.ts +2 -2
  45. package/dist/types/src/extensions/tags/index.d.ts.map +1 -1
  46. package/dist/types/src/extensions/tags/stub.d.ts +35 -0
  47. package/dist/types/src/extensions/tags/stub.d.ts.map +1 -0
  48. package/dist/types/src/extensions/tags/widgets/anchor.d.ts +13 -0
  49. package/dist/types/src/extensions/tags/widgets/anchor.d.ts.map +1 -0
  50. package/dist/types/src/extensions/tags/widgets/index.d.ts +2 -0
  51. package/dist/types/src/extensions/tags/widgets/index.d.ts.map +1 -0
  52. package/dist/types/src/extensions/tags/xml-tags.d.ts +9 -5
  53. package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -1
  54. package/dist/types/src/extensions/typewriter.d.ts.map +1 -0
  55. package/dist/types/src/extensions/typewriter.test.d.ts.map +1 -0
  56. package/dist/types/src/index.d.ts +1 -1
  57. package/dist/types/src/index.d.ts.map +1 -1
  58. package/dist/types/src/styles/theme.d.ts.map +1 -1
  59. package/dist/types/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +36 -34
  61. package/src/extensions/autocomplete/placeholder.ts +11 -1
  62. package/src/extensions/automerge/automerge.test.tsx +25 -53
  63. package/src/extensions/automerge/automerge.ts +7 -3
  64. package/src/extensions/automerge/cursor.ts +7 -2
  65. package/src/extensions/automerge/sync.ts +2 -2
  66. package/src/extensions/automerge/update-automerge.ts +2 -2
  67. package/src/extensions/blocks.ts +36 -36
  68. package/src/extensions/busy.ts +32 -0
  69. package/src/extensions/comments.ts +5 -9
  70. package/src/extensions/dnd.ts +127 -3
  71. package/src/extensions/factories.test.ts +1 -1
  72. package/src/extensions/factories.ts +2 -2
  73. package/src/extensions/index.ts +6 -1
  74. package/src/extensions/markdown/styles.ts +5 -5
  75. package/src/extensions/marker.ts +143 -0
  76. package/src/extensions/pending/index.ts +6 -0
  77. package/src/extensions/pending/pending-text-stream.test.ts +133 -0
  78. package/src/extensions/pending/pending-text-stream.ts +269 -0
  79. package/src/extensions/pending/pending-text.test.ts +134 -0
  80. package/src/extensions/pending/pending-text.ts +264 -0
  81. package/src/extensions/pos.test.ts +97 -0
  82. package/src/extensions/pos.ts +206 -0
  83. package/src/extensions/scrolling/auto-scroll.ts +12 -8
  84. package/src/extensions/tags/DESIGN.md +58 -0
  85. package/src/extensions/tags/index.ts +2 -2
  86. package/src/extensions/tags/stub.ts +80 -0
  87. package/src/extensions/tags/widgets/anchor.ts +30 -0
  88. package/src/extensions/{preview → tags/widgets}/index.ts +1 -1
  89. package/src/extensions/tags/xml-tags.ts +116 -74
  90. package/src/extensions/tags/xml-util.test.ts +1 -1
  91. package/src/index.ts +1 -1
  92. package/src/styles/theme.ts +0 -1
  93. package/dist/types/src/extensions/preview/index.d.ts +0 -2
  94. package/dist/types/src/extensions/preview/index.d.ts.map +0 -1
  95. package/dist/types/src/extensions/preview/preview.d.ts +0 -34
  96. package/dist/types/src/extensions/preview/preview.d.ts.map +0 -1
  97. package/dist/types/src/extensions/tags/fader.d.ts.map +0 -1
  98. package/dist/types/src/extensions/tags/typewriter.d.ts.map +0 -1
  99. package/dist/types/src/extensions/tags/typewriter.test.d.ts.map +0 -1
  100. package/src/extensions/preview/preview.ts +0 -242
  101. /package/dist/types/src/extensions/{tags/fader.d.ts → fader.d.ts} +0 -0
  102. /package/dist/types/src/extensions/{tags/typewriter.d.ts → typewriter.d.ts} +0 -0
  103. /package/dist/types/src/extensions/{tags/typewriter.test.d.ts → typewriter.test.d.ts} +0 -0
  104. /package/src/extensions/{tags/fader.ts → fader.ts} +0 -0
  105. /package/src/extensions/{tags/typewriter.test.ts → typewriter.test.ts} +0 -0
  106. /package/src/extensions/{tags/typewriter.ts → typewriter.ts} +0 -0
@@ -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
+ ];
@@ -0,0 +1,97 @@
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 { type Document, sourceHash } from '@dxos/nlp';
10
+
11
+ import { clearAnalysis, pos, posAnalysisField, posDecorations, posSpans, setAnalysis, spanDiverged } from './pos';
12
+
13
+ describe('posAnalysisField', () => {
14
+ const make = (doc = 'hello world') => EditorState.create({ doc, extensions: [posAnalysisField] });
15
+
16
+ test('setAnalysis adds a span', ({ expect }) => {
17
+ const state = make().update({ effects: setAnalysis.of({ from: 0, to: 5, document: docFor('hello') }) }).state;
18
+ expect(posSpans(state)).toHaveLength(1);
19
+ expect(posSpans(state)[0]).toMatchObject({ from: 0, to: 5, stale: false });
20
+ });
21
+
22
+ test('span offsets are mapped through an insertion before them', ({ expect }) => {
23
+ const withSpan = make().update({ effects: setAnalysis.of({ from: 6, to: 11, document: docFor('world') }) }).state;
24
+ const afterEdit = withSpan.update({ changes: { from: 0, insert: 'XXX' } }).state;
25
+ expect(posSpans(afterEdit)[0]).toMatchObject({ from: 9, to: 14 });
26
+ });
27
+
28
+ test('clearAnalysis removes all spans', ({ expect }) => {
29
+ const withSpan = make().update({ effects: setAnalysis.of({ from: 0, to: 5, document: docFor('hello') }) }).state;
30
+ const cleared = withSpan.update({ effects: clearAnalysis.of(null) }).state;
31
+ expect(posSpans(cleared)).toHaveLength(0);
32
+ });
33
+ });
34
+
35
+ describe('posDecorations', () => {
36
+ test('emits decorations for the analyzed tokens', ({ expect }) => {
37
+ const text = 'hello world';
38
+ let state = EditorState.create({ doc: text, extensions: [posAnalysisField, posDecorations] });
39
+ const document: Document = {
40
+ sourceHash: 'deadbeef',
41
+ sentences: [
42
+ {
43
+ index: 0,
44
+ start: 0,
45
+ end: 11,
46
+ tokens: [
47
+ { index: 0, text: 'hello', upos: 'INTJ', start: 0, end: 5 },
48
+ { index: 1, text: 'world', upos: 'NOUN', start: 6, end: 11 },
49
+ ],
50
+ },
51
+ ],
52
+ };
53
+ state = state.update({ effects: setAnalysis.of({ from: 0, to: 11, document }) }).state;
54
+ const sources = state.facet(EditorView.decorations);
55
+ expect(sources.length).toBeGreaterThan(0);
56
+ });
57
+ });
58
+
59
+ describe('pos reactive initial parse', () => {
60
+ test('parses existing content on mount (decorates without an edit)', async ({ expect }) => {
61
+ const text = 'hello world';
62
+ const fakeParse = async (input: string): Promise<Document> => docFor(input);
63
+ const view = new EditorView({ doc: text, extensions: [pos({ parse: fakeParse })] });
64
+ try {
65
+ // The mount parse and its dispatch run as microtasks (no timer); drain the queue rather than
66
+ // sleeping so the assertion is deterministic.
67
+ await flushMicrotasks();
68
+ expect(posSpans(view.state).length).toBe(1);
69
+ expect(posSpans(view.state)[0]).toMatchObject({ from: 0, to: text.length });
70
+ } finally {
71
+ view.destroy();
72
+ }
73
+ });
74
+ });
75
+
76
+ describe('spanDiverged', () => {
77
+ test('detects when live span text no longer matches the stored hash', ({ expect }) => {
78
+ const span = { from: 0, to: 5, sourceHash: sourceHash('hello'), document: docFor('hello'), stale: false };
79
+ expect(spanDiverged('hello world', span)).toBe(false);
80
+ expect(spanDiverged('hELLo world', span)).toBe(true);
81
+ });
82
+ });
83
+
84
+ /** A minimal single-token `Document` whose hash matches `text`. */
85
+ const docFor = (text: string): Document => ({
86
+ sourceHash: sourceHash(text),
87
+ sentences: [
88
+ { index: 0, start: 0, end: text.length, tokens: [{ index: 0, text, upos: 'NOUN', start: 0, end: text.length }] },
89
+ ],
90
+ });
91
+
92
+ /** Drain the microtask queue so chained `.then` callbacks settle without a real timer. */
93
+ const flushMicrotasks = async (ticks = 4): Promise<void> => {
94
+ for (let index = 0; index < ticks; index++) {
95
+ await Promise.resolve();
96
+ }
97
+ };