@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
@@ -2,19 +2,142 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import type { Extension } from '@codemirror/state';
6
- import { EditorView, dropCursor } from '@codemirror/view';
5
+ import { type Extension, StateEffect, StateField } from '@codemirror/state';
6
+ import { EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
7
7
 
8
8
  export type DropOptions = {
9
9
  onDrop?: (view: EditorView, event: { files: FileList }) => void;
10
10
  };
11
11
 
12
+ /**
13
+ * Whether a drag carries external files. Internal element drags (e.g. an embed's resize handle using
14
+ * native HTML5 DnD) carry custom data-transfer types, not `Files`, so this gates the file-drop UI to
15
+ * real file drags and leaves other drags untouched (otherwise the drop cursor and drop handler would
16
+ * fire for — and interfere with — an in-document resize drag).
17
+ */
18
+ const isFileDrag = (event: DragEvent): boolean =>
19
+ !!event.dataTransfer && Array.from(event.dataTransfer.types).includes('Files');
20
+
21
+ const setDropCursorPos = StateEffect.define<number | null>();
22
+
23
+ const dropCursorPos = StateField.define<number | null>({
24
+ create: () => null,
25
+ update(pos, tr) {
26
+ if (pos != null && tr.docChanged) {
27
+ pos = tr.changes.mapPos(pos);
28
+ }
29
+ return tr.effects.reduce((value, effect) => (effect.is(setDropCursorPos) ? effect.value : value), pos);
30
+ },
31
+ });
32
+
33
+ type CursorMeasure = { left: number; top: number; height: number };
34
+
35
+ // Mirrors `@codemirror/view`'s built-in drop cursor, but the `dragover` observer only tracks file
36
+ // drags (see `isFileDrag`) so non-file drags never render the cursor.
37
+ const drawDropCursor = ViewPlugin.fromClass(
38
+ class {
39
+ cursor: HTMLElement | null = null;
40
+ readonly measureReq: { read: () => CursorMeasure | null; write: (measure: CursorMeasure | null) => void };
41
+
42
+ constructor(readonly view: EditorView) {
43
+ this.measureReq = { read: this.readPos.bind(this), write: this.drawCursor.bind(this) };
44
+ }
45
+
46
+ update(update: ViewUpdate) {
47
+ const pos = update.state.field(dropCursorPos);
48
+ if (pos == null) {
49
+ this.cursor?.remove();
50
+ this.cursor = null;
51
+ } else {
52
+ if (!this.cursor) {
53
+ this.cursor = this.view.scrollDOM.appendChild(document.createElement('div'));
54
+ this.cursor.className = 'cm-dropCursor';
55
+ }
56
+ if (update.startState.field(dropCursorPos) !== pos || update.docChanged || update.geometryChanged) {
57
+ this.view.requestMeasure(this.measureReq);
58
+ }
59
+ }
60
+ }
61
+
62
+ readPos(): CursorMeasure | null {
63
+ const pos = this.view.state.field(dropCursorPos);
64
+ const rect = pos != null && this.view.coordsAtPos(pos);
65
+ if (!rect) {
66
+ return null;
67
+ }
68
+ const outer = this.view.scrollDOM.getBoundingClientRect();
69
+ return {
70
+ left: rect.left - outer.left + this.view.scrollDOM.scrollLeft * this.view.scaleX,
71
+ top: rect.top - outer.top + this.view.scrollDOM.scrollTop * this.view.scaleY,
72
+ height: rect.bottom - rect.top,
73
+ };
74
+ }
75
+
76
+ drawCursor(measure: CursorMeasure | null) {
77
+ if (!this.cursor) {
78
+ return;
79
+ }
80
+ const { scaleX, scaleY } = this.view;
81
+ if (measure) {
82
+ this.cursor.style.left = measure.left / scaleX + 'px';
83
+ this.cursor.style.top = measure.top / scaleY + 'px';
84
+ this.cursor.style.height = measure.height / scaleY + 'px';
85
+ } else {
86
+ this.cursor.style.left = '-100000px';
87
+ }
88
+ }
89
+
90
+ destroy() {
91
+ this.cursor?.remove();
92
+ }
93
+
94
+ setDropPos(pos: number | null) {
95
+ if (this.view.state.field(dropCursorPos) !== pos) {
96
+ this.view.dispatch({ effects: setDropCursorPos.of(pos) });
97
+ }
98
+ }
99
+ },
100
+ {
101
+ eventObservers: {
102
+ dragover(event) {
103
+ if (isFileDrag(event)) {
104
+ // Mark the editor a valid drop target so the `drop` event fires for external files.
105
+ event.preventDefault();
106
+ this.setDropPos(this.view.posAtCoords({ x: event.clientX, y: event.clientY }));
107
+ }
108
+ },
109
+ dragleave(event) {
110
+ const related = event.relatedTarget instanceof Node ? event.relatedTarget : null;
111
+ if (event.target === this.view.contentDOM || !this.view.contentDOM.contains(related)) {
112
+ this.setDropPos(null);
113
+ }
114
+ },
115
+ dragend() {
116
+ this.setDropPos(null);
117
+ },
118
+ drop() {
119
+ this.setDropPos(null);
120
+ },
121
+ },
122
+ },
123
+ );
124
+
125
+ /**
126
+ * Editor extension that accepts external file drops (drawing a drop cursor and invoking
127
+ * `options.onDrop`) while leaving internal drags — e.g. an embed's resize handle — untouched.
128
+ */
12
129
  export const dropFile = (options: DropOptions = {}): Extension => {
13
130
  return [
14
131
  styles,
15
- dropCursor(),
132
+ dropCursorPos,
133
+ drawDropCursor,
16
134
  EditorView.domEventHandlers({
17
135
  drop: (event, view) => {
136
+ // Leave non-file drops (e.g. an embed resize drag) to their own handlers.
137
+ if (!isFileDrag(event)) {
138
+ return false;
139
+ }
140
+
18
141
  event.preventDefault();
19
142
  const files = event.dataTransfer?.files;
20
143
  const pos = view.posAtCoords(event);
@@ -22,6 +145,7 @@ export const dropFile = (options: DropOptions = {}): Extension => {
22
145
  view.dispatch({ selection: { anchor: pos } });
23
146
  options.onDrop?.(view, { files });
24
147
  }
148
+ return true;
25
149
  },
26
150
  }),
27
151
  ];
@@ -2,7 +2,7 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import { markdown, markdownLanguage, insertNewlineContinueMarkup } from '@codemirror/lang-markdown';
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 { type DocAccessor } from '@dxos/echo-client';
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?: DocAccessor<T>;
270
+ text?: Doc.Accessor<T>;
271
271
  messenger?: Messenger;
272
272
  identity?: Identity | null;
273
273
  };
@@ -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 './preview';
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';
@@ -82,7 +82,8 @@ export const formattingStyles = EditorView.theme({
82
82
  },
83
83
  '& .cm-codeblock-line': {
84
84
  background: 'var(--color-cm-codeblock)',
85
- paddingInline: '1rem !important',
85
+ paddingLeft: '1rem !important',
86
+ paddingRight: '1.5rem !important',
86
87
  },
87
88
  '& .cm-codeblock-start': {
88
89
  borderTopLeftRadius: '.25rem',
@@ -155,11 +156,11 @@ export const formattingStyles = EditorView.theme({
155
156
  },
156
157
  '.cm-image-wrapper': {
157
158
  'grid-template-columns': '1fr',
158
- display: 'grid',
159
- margin: '0.5rem 0',
160
- overflow: 'hidden',
161
- transitionDuration: '350ms',
162
- transitionProperty: 'height',
159
+ 'display': 'grid',
160
+ 'margin': '0.5rem 0',
161
+ 'overflow': 'hidden',
162
+ 'transitionDuration': '350ms',
163
+ 'transitionProperty': 'height',
163
164
  '& > *': {
164
165
  'grid-row-start': 1,
165
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,6 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './pending-text';
6
+ export * from './pending-text-stream';
@@ -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
+ });