@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.
- package/dist/lib/browser/index.mjs +2499 -1708
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +2499 -1708
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/extensions/autocomplete/placeholder.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.d.ts +6 -2
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts +6 -2
- package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/sync.d.ts +2 -2
- package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
- package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/blocks.d.ts.map +1 -1
- package/dist/types/src/extensions/busy.d.ts +13 -0
- package/dist/types/src/extensions/busy.d.ts.map +1 -0
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/extensions/dnd.d.ts +5 -1
- package/dist/types/src/extensions/dnd.d.ts.map +1 -1
- package/dist/types/src/extensions/factories.d.ts +2 -2
- package/dist/types/src/extensions/factories.d.ts.map +1 -1
- package/dist/types/src/extensions/fader.d.ts.map +1 -0
- package/dist/types/src/extensions/index.d.ts +6 -1
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/marker.d.ts +40 -0
- package/dist/types/src/extensions/marker.d.ts.map +1 -0
- package/dist/types/src/extensions/pending/index.d.ts +3 -0
- package/dist/types/src/extensions/pending/index.d.ts.map +1 -0
- package/dist/types/src/extensions/pending/pending-text-stream.d.ts +75 -0
- package/dist/types/src/extensions/pending/pending-text-stream.d.ts.map +1 -0
- package/dist/types/src/extensions/pending/pending-text-stream.test.d.ts +2 -0
- package/dist/types/src/extensions/pending/pending-text-stream.test.d.ts.map +1 -0
- package/dist/types/src/extensions/pending/pending-text.d.ts +46 -0
- package/dist/types/src/extensions/pending/pending-text.d.ts.map +1 -0
- package/dist/types/src/extensions/pending/pending-text.test.d.ts +2 -0
- package/dist/types/src/extensions/pending/pending-text.test.d.ts.map +1 -0
- package/dist/types/src/extensions/pos.d.ts +49 -0
- package/dist/types/src/extensions/pos.d.ts.map +1 -0
- package/dist/types/src/extensions/pos.test.d.ts +2 -0
- package/dist/types/src/extensions/pos.test.d.ts.map +1 -0
- package/dist/types/src/extensions/scrolling/auto-scroll.d.ts.map +1 -1
- package/dist/types/src/extensions/tags/index.d.ts +2 -2
- package/dist/types/src/extensions/tags/index.d.ts.map +1 -1
- package/dist/types/src/extensions/tags/stub.d.ts +35 -0
- package/dist/types/src/extensions/tags/stub.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/widgets/anchor.d.ts +13 -0
- package/dist/types/src/extensions/tags/widgets/anchor.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/widgets/index.d.ts +2 -0
- package/dist/types/src/extensions/tags/widgets/index.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/xml-tags.d.ts +9 -5
- package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -1
- package/dist/types/src/extensions/typewriter.d.ts.map +1 -0
- package/dist/types/src/extensions/typewriter.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +1 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/styles/theme.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +36 -34
- package/src/extensions/autocomplete/placeholder.ts +11 -1
- package/src/extensions/automerge/automerge.test.tsx +25 -53
- package/src/extensions/automerge/automerge.ts +7 -3
- package/src/extensions/automerge/cursor.ts +7 -2
- package/src/extensions/automerge/sync.ts +2 -2
- package/src/extensions/automerge/update-automerge.ts +2 -2
- package/src/extensions/blocks.ts +36 -36
- package/src/extensions/busy.ts +32 -0
- package/src/extensions/comments.ts +5 -9
- package/src/extensions/dnd.ts +127 -3
- package/src/extensions/factories.test.ts +1 -1
- package/src/extensions/factories.ts +2 -2
- package/src/extensions/index.ts +6 -1
- package/src/extensions/markdown/styles.ts +5 -5
- package/src/extensions/marker.ts +143 -0
- package/src/extensions/pending/index.ts +6 -0
- package/src/extensions/pending/pending-text-stream.test.ts +133 -0
- package/src/extensions/pending/pending-text-stream.ts +269 -0
- package/src/extensions/pending/pending-text.test.ts +134 -0
- package/src/extensions/pending/pending-text.ts +264 -0
- package/src/extensions/pos.test.ts +97 -0
- package/src/extensions/pos.ts +206 -0
- package/src/extensions/scrolling/auto-scroll.ts +12 -8
- package/src/extensions/tags/DESIGN.md +58 -0
- package/src/extensions/tags/index.ts +2 -2
- package/src/extensions/tags/stub.ts +80 -0
- package/src/extensions/tags/widgets/anchor.ts +30 -0
- package/src/extensions/{preview → tags/widgets}/index.ts +1 -1
- package/src/extensions/tags/xml-tags.ts +116 -74
- package/src/extensions/tags/xml-util.test.ts +1 -1
- package/src/index.ts +1 -1
- package/src/styles/theme.ts +0 -1
- package/dist/types/src/extensions/preview/index.d.ts +0 -2
- package/dist/types/src/extensions/preview/index.d.ts.map +0 -1
- package/dist/types/src/extensions/preview/preview.d.ts +0 -34
- package/dist/types/src/extensions/preview/preview.d.ts.map +0 -1
- package/dist/types/src/extensions/tags/fader.d.ts.map +0 -1
- package/dist/types/src/extensions/tags/typewriter.d.ts.map +0 -1
- package/dist/types/src/extensions/tags/typewriter.test.d.ts.map +0 -1
- package/src/extensions/preview/preview.ts +0 -242
- /package/dist/types/src/extensions/{tags/fader.d.ts → fader.d.ts} +0 -0
- /package/dist/types/src/extensions/{tags/typewriter.d.ts → typewriter.d.ts} +0 -0
- /package/dist/types/src/extensions/{tags/typewriter.test.d.ts → typewriter.test.d.ts} +0 -0
- /package/src/extensions/{tags/fader.ts → fader.ts} +0 -0
- /package/src/extensions/{tags/typewriter.test.ts → typewriter.test.ts} +0 -0
- /package/src/extensions/{tags/typewriter.ts → typewriter.ts} +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2026 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { markdown, markdownLanguage
|
|
5
|
+
import { insertNewlineContinueMarkup, markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
|
6
6
|
import { EditorSelection, EditorState } from '@codemirror/state';
|
|
7
7
|
import { describe, test } from 'vitest';
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ import { vscodeDarkStyle, vscodeLightStyle } from '@uiw/codemirror-theme-vscode'
|
|
|
22
22
|
import defaultsDeep from 'lodash.defaultsdeep';
|
|
23
23
|
|
|
24
24
|
import { generateName } from '@dxos/display-name';
|
|
25
|
-
import {
|
|
25
|
+
import { Doc } from '@dxos/echo-doc';
|
|
26
26
|
import { log } from '@dxos/log';
|
|
27
27
|
import { type Messenger } from '@dxos/protocols';
|
|
28
28
|
import { type Identity } from '@dxos/protocols/proto/dxos/client/services';
|
|
@@ -267,7 +267,7 @@ export const createThemeExtensions = ({
|
|
|
267
267
|
|
|
268
268
|
export type DataExtensionsProps<T> = {
|
|
269
269
|
id: string;
|
|
270
|
-
text?:
|
|
270
|
+
text?: Doc.Accessor<T>;
|
|
271
271
|
messenger?: Messenger;
|
|
272
272
|
identity?: Identity | null;
|
|
273
273
|
};
|
package/src/extensions/index.ts
CHANGED
|
@@ -9,21 +9,25 @@ export * from './awareness';
|
|
|
9
9
|
export * from './blast';
|
|
10
10
|
export * from './blocks';
|
|
11
11
|
export * from './bookmarks';
|
|
12
|
+
export * from './busy';
|
|
12
13
|
export * from './comments';
|
|
13
14
|
export * from './debug';
|
|
14
15
|
export * from './dnd';
|
|
15
16
|
export * from './factories';
|
|
17
|
+
export * from './fader';
|
|
16
18
|
export * from './focus';
|
|
17
19
|
export * from './folding';
|
|
18
20
|
export * from './hashtag';
|
|
19
21
|
export * from './json';
|
|
20
22
|
export * from './listener';
|
|
23
|
+
export * from './marker';
|
|
21
24
|
export * from './markdown';
|
|
22
25
|
export * from './mention';
|
|
23
26
|
export * from './modal';
|
|
24
27
|
export * from './modes';
|
|
25
28
|
export * from './outliner';
|
|
26
|
-
export * from './
|
|
29
|
+
export * from './pending';
|
|
30
|
+
export * from './pos';
|
|
27
31
|
export * from './replacer';
|
|
28
32
|
export * from './scrolling';
|
|
29
33
|
export * from './selection';
|
|
@@ -32,3 +36,4 @@ export * from './snippets';
|
|
|
32
36
|
export * from './state';
|
|
33
37
|
export * from './submit';
|
|
34
38
|
export * from './tags';
|
|
39
|
+
export * from './typewriter';
|
|
@@ -156,11 +156,11 @@ export const formattingStyles = EditorView.theme({
|
|
|
156
156
|
},
|
|
157
157
|
'.cm-image-wrapper': {
|
|
158
158
|
'grid-template-columns': '1fr',
|
|
159
|
-
display: 'grid',
|
|
160
|
-
margin: '0.5rem 0',
|
|
161
|
-
overflow: 'hidden',
|
|
162
|
-
transitionDuration: '350ms',
|
|
163
|
-
transitionProperty: 'height',
|
|
159
|
+
'display': 'grid',
|
|
160
|
+
'margin': '0.5rem 0',
|
|
161
|
+
'overflow': 'hidden',
|
|
162
|
+
'transitionDuration': '350ms',
|
|
163
|
+
'transitionProperty': 'height',
|
|
164
164
|
'& > *': {
|
|
165
165
|
'grid-row-start': 1,
|
|
166
166
|
'grid-column-start': 1,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Extension } from '@codemirror/state';
|
|
6
|
+
import { Decoration, EditorView } from '@codemirror/view';
|
|
7
|
+
|
|
8
|
+
import { Domino } from '@dxos/ui';
|
|
9
|
+
import { hues } from '@dxos/ui-theme';
|
|
10
|
+
import { type ChromaticPalette } from '@dxos/ui-types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reusable, hue-tinted text markers for CodeMirror decorations (shared by comments and pending-text).
|
|
14
|
+
* The tint is driven by a `data-hue` attribute holding a standard ui-theme hue, mapped to the
|
|
15
|
+
* `--color-<hue>-*` tokens — rather than hardcoded per-feature CSS variables.
|
|
16
|
+
*/
|
|
17
|
+
export type MarkerHue = ChromaticPalette;
|
|
18
|
+
|
|
19
|
+
const MARKED = ['.cm-marker > span', '.cm-marker-text'].join(', ');
|
|
20
|
+
|
|
21
|
+
// Map `data-hue` to the standard ui-theme surface/foreground tokens (one rule per hue).
|
|
22
|
+
const hueVars = Object.fromEntries(
|
|
23
|
+
hues.flatMap((hue) =>
|
|
24
|
+
['.cm-marker', '.cm-marker-text'].map((selector) => [
|
|
25
|
+
`${selector}[data-hue="${hue}"]`,
|
|
26
|
+
{
|
|
27
|
+
'--cm-marker-surface': `var(--color-${hue}-bg)`,
|
|
28
|
+
'--cm-marker-text': `var(--color-${hue}-fg)`,
|
|
29
|
+
},
|
|
30
|
+
]),
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Shared theme for hue-tinted markers and their inline button affordances. Include once per editor;
|
|
36
|
+
* both `markerMark` (range marks) and `markerText` (widget spans) read from it.
|
|
37
|
+
*/
|
|
38
|
+
export const markerTheme = (): Extension =>
|
|
39
|
+
EditorView.theme({
|
|
40
|
+
...hueVars,
|
|
41
|
+
// The comment-style surface "padding" is a box-shadow of the same colour so adjacent lines join.
|
|
42
|
+
[MARKED]: {
|
|
43
|
+
boxDecorationBreak: 'clone',
|
|
44
|
+
backgroundColor: 'var(--cm-marker-surface)',
|
|
45
|
+
boxShadow: '0 0 0 3px var(--cm-marker-surface)',
|
|
46
|
+
color: 'var(--cm-marker-text) !important',
|
|
47
|
+
borderRadius: '0.25rem',
|
|
48
|
+
},
|
|
49
|
+
// `inline-block` (not flex) so the finalized + interim spans flow as one continuous block.
|
|
50
|
+
'.cm-marker-text': {
|
|
51
|
+
display: 'inline-block',
|
|
52
|
+
},
|
|
53
|
+
// Align a trailing icon (e.g. the recording spinner) inline with the text — the global reset
|
|
54
|
+
// sets `svg { display: block }`, which would otherwise push it onto its own line.
|
|
55
|
+
'.cm-marker-text svg': {
|
|
56
|
+
display: 'inline-block',
|
|
57
|
+
verticalAlign: '-0.15em',
|
|
58
|
+
marginInlineStart: '0.25rem',
|
|
59
|
+
},
|
|
60
|
+
'.cm-marker-buttons': {
|
|
61
|
+
display: 'inline-flex',
|
|
62
|
+
alignItems: 'center',
|
|
63
|
+
gap: '0.25rem',
|
|
64
|
+
},
|
|
65
|
+
'.cm-marker-button': {
|
|
66
|
+
display: 'inline-flex',
|
|
67
|
+
padding: '4px',
|
|
68
|
+
borderRadius: '4px',
|
|
69
|
+
cursor: 'pointer',
|
|
70
|
+
},
|
|
71
|
+
'.cm-marker-button:hover': {
|
|
72
|
+
backgroundColor: 'var(--color-hover-bg)',
|
|
73
|
+
},
|
|
74
|
+
'.cm-marker-button-success:hover': {
|
|
75
|
+
backgroundColor: 'var(--color-success-bg)',
|
|
76
|
+
},
|
|
77
|
+
'.cm-marker-button-error:hover': {
|
|
78
|
+
backgroundColor: 'var(--color-error-bg)',
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Range mark tinted by hue (used by comments to highlight a selection). */
|
|
83
|
+
export const markerMark = (hue: MarkerHue, options: { class?: string; attributes?: Record<string, string> } = {}) =>
|
|
84
|
+
Decoration.mark({
|
|
85
|
+
class: ['cm-marker', options.class].filter(Boolean).join(' '),
|
|
86
|
+
attributes: { 'data-hue': hue, ...options.attributes },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export type MarkerTextOptions = {
|
|
90
|
+
hue: MarkerHue;
|
|
91
|
+
className?: string;
|
|
92
|
+
/** Optional trailing icon (e.g. a recording indicator). */
|
|
93
|
+
icon?: string;
|
|
94
|
+
/** Extra classes for the trailing icon (e.g. `animate-spin`). */
|
|
95
|
+
iconClassNames?: string;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** A hue-tinted text span for use inside widgets; optionally trailed by an icon. */
|
|
99
|
+
export const markerText = (
|
|
100
|
+
text: string,
|
|
101
|
+
{ hue, className, icon, iconClassNames }: MarkerTextOptions,
|
|
102
|
+
): Domino<HTMLElement> => {
|
|
103
|
+
const span = Domino.of('span')
|
|
104
|
+
.classNames('cm-marker-text', className)
|
|
105
|
+
.attributes({ 'data-hue': hue })
|
|
106
|
+
.append(Domino.of('span').text(text));
|
|
107
|
+
if (icon) {
|
|
108
|
+
// Preserve the sprite sizing (`Domino.svg` sets `h-[1em] w-[1em]`) since `classNames` replaces.
|
|
109
|
+
span.append(Domino.svg(icon).classNames('shrink-0 h-[1em] w-[1em]', iconClassNames));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return span;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type MarkerButton = {
|
|
116
|
+
icon: string;
|
|
117
|
+
label: string;
|
|
118
|
+
className?: string;
|
|
119
|
+
testId?: string;
|
|
120
|
+
onClick: () => void;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Inline group of icon buttons (e.g. confirm/cancel affordances). */
|
|
124
|
+
export const markerButtons = (buttons: MarkerButton[]): Domino<HTMLElement> =>
|
|
125
|
+
Domino.of('span')
|
|
126
|
+
.classNames('cm-marker-buttons')
|
|
127
|
+
.append(
|
|
128
|
+
...buttons.map((button) =>
|
|
129
|
+
Domino.of('button')
|
|
130
|
+
.classNames('cm-marker-button', button.className)
|
|
131
|
+
.attributes({
|
|
132
|
+
'type': 'button',
|
|
133
|
+
'aria-label': button.label,
|
|
134
|
+
...(button.testId ? { 'data-testid': button.testId } : {}),
|
|
135
|
+
})
|
|
136
|
+
.append(Domino.svg(button.icon))
|
|
137
|
+
// `mousedown` + preventDefault so clicking the affordance does not steal the editor selection.
|
|
138
|
+
.on('mousedown', (event) => {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
button.onClick();
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { PendingTextStreamer, type Scheduler, memoryPendingTextSink } from './pending-text-stream';
|
|
8
|
+
|
|
9
|
+
// Deterministic fake clock: `advance` runs due timers in time order (including timers they schedule).
|
|
10
|
+
const createFakeScheduler = (): Scheduler & { advance: (ms: number) => void } => {
|
|
11
|
+
let now = 0;
|
|
12
|
+
let seq = 0;
|
|
13
|
+
const timers = new Map<number, { time: number; fn: () => void }>();
|
|
14
|
+
return {
|
|
15
|
+
setTimeout: (fn, ms) => {
|
|
16
|
+
const id = ++seq;
|
|
17
|
+
timers.set(id, { time: now + ms, fn });
|
|
18
|
+
return id;
|
|
19
|
+
},
|
|
20
|
+
clearTimeout: (handle) => {
|
|
21
|
+
timers.delete(handle as number);
|
|
22
|
+
},
|
|
23
|
+
advance: (ms) => {
|
|
24
|
+
now += ms;
|
|
25
|
+
let ran = true;
|
|
26
|
+
while (ran) {
|
|
27
|
+
ran = false;
|
|
28
|
+
const due = [...timers].filter(([, t]) => t.time <= now).sort((a, b) => a[1].time - b[1].time);
|
|
29
|
+
if (due.length > 0) {
|
|
30
|
+
const [id, timer] = due[0];
|
|
31
|
+
timers.delete(id);
|
|
32
|
+
timer.fn();
|
|
33
|
+
ran = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Flush pending microtasks (postProcess awaits its callback).
|
|
41
|
+
const flushMicrotasks = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
42
|
+
|
|
43
|
+
describe('PendingTextStreamer', () => {
|
|
44
|
+
test('holds text until the initial buffer elapses', ({ expect }) => {
|
|
45
|
+
const scheduler = createFakeScheduler();
|
|
46
|
+
const sink = memoryPendingTextSink();
|
|
47
|
+
const streamer = new PendingTextStreamer(sink, { initialBufferMs: 1000, scheduler });
|
|
48
|
+
|
|
49
|
+
streamer.start({ placeholder: 'Recording…' });
|
|
50
|
+
streamer.push('hello world');
|
|
51
|
+
expect(sink.snapshot()).toMatchObject({ final: '', placeholder: 'Recording…' });
|
|
52
|
+
|
|
53
|
+
scheduler.advance(1000);
|
|
54
|
+
expect(sink.snapshot().final).toBe('hello world');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('batch mode reveals each chunk immediately when unbuffered', ({ expect }) => {
|
|
58
|
+
const scheduler = createFakeScheduler();
|
|
59
|
+
const sink = memoryPendingTextSink();
|
|
60
|
+
const streamer = new PendingTextStreamer(sink, { scheduler });
|
|
61
|
+
|
|
62
|
+
streamer.start();
|
|
63
|
+
streamer.push('hello');
|
|
64
|
+
streamer.push('world');
|
|
65
|
+
expect(sink.snapshot().final).toBe('hello world');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('word mode reveals one word per interval', ({ expect }) => {
|
|
69
|
+
const scheduler = createFakeScheduler();
|
|
70
|
+
const sink = memoryPendingTextSink();
|
|
71
|
+
const streamer = new PendingTextStreamer(sink, { mode: 'word', wordIntervalMs: 100, scheduler });
|
|
72
|
+
|
|
73
|
+
streamer.start();
|
|
74
|
+
streamer.push('one two three');
|
|
75
|
+
expect(sink.snapshot().final).toBe('one');
|
|
76
|
+
scheduler.advance(100);
|
|
77
|
+
expect(sink.snapshot().final).toBe('one two');
|
|
78
|
+
scheduler.advance(100);
|
|
79
|
+
expect(sink.snapshot().final).toBe('one two three');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('post-processes the finalized buffer once it settles', async ({ expect }) => {
|
|
83
|
+
const scheduler = createFakeScheduler();
|
|
84
|
+
const sink = memoryPendingTextSink();
|
|
85
|
+
const streamer = new PendingTextStreamer(sink, {
|
|
86
|
+
postProcessDebounceMs: 500,
|
|
87
|
+
postProcess: (text) => text.toUpperCase(),
|
|
88
|
+
scheduler,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
streamer.start();
|
|
92
|
+
streamer.push('hello world');
|
|
93
|
+
expect(sink.snapshot().final).toBe('hello world');
|
|
94
|
+
|
|
95
|
+
scheduler.advance(500);
|
|
96
|
+
await flushMicrotasks();
|
|
97
|
+
expect(sink.snapshot().final).toBe('HELLO WORLD');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('flush reveals queued words and runs post-process immediately', async ({ expect }) => {
|
|
101
|
+
const scheduler = createFakeScheduler();
|
|
102
|
+
const sink = memoryPendingTextSink();
|
|
103
|
+
const streamer = new PendingTextStreamer(sink, {
|
|
104
|
+
mode: 'word',
|
|
105
|
+
wordIntervalMs: 100,
|
|
106
|
+
postProcess: (text) => `[${text}]`,
|
|
107
|
+
scheduler,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
streamer.start();
|
|
111
|
+
streamer.push('alpha beta gamma');
|
|
112
|
+
expect(sink.snapshot().final).toBe('alpha');
|
|
113
|
+
|
|
114
|
+
void streamer.flush();
|
|
115
|
+
expect(sink.snapshot().final).toBe('alpha beta gamma');
|
|
116
|
+
await flushMicrotasks();
|
|
117
|
+
expect(sink.snapshot().final).toBe('[alpha beta gamma]');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('awaiting flush() resolves only after async post-process settles (drain contract)', async ({ expect }) => {
|
|
121
|
+
const sink = memoryPendingTextSink();
|
|
122
|
+
const streamer = new PendingTextStreamer(sink, {
|
|
123
|
+
mode: 'batch',
|
|
124
|
+
postProcess: async (text) => `[${text}]`,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
streamer.start();
|
|
128
|
+
streamer.push('alpha beta');
|
|
129
|
+
// Drain: a single await must guarantee the post-process pass has applied.
|
|
130
|
+
await streamer.flush();
|
|
131
|
+
expect(sink.snapshot().final).toBe('[alpha beta]');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -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
|
+
}
|