@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.
- package/dist/lib/browser/index.mjs +2533 -1744
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +2533 -1744
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/defaults.d.ts.map +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/markdown/styles.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/selection.d.ts +12 -1
- package/dist/types/src/extensions/selection.d.ts.map +1 -1
- package/dist/types/src/extensions/selection.test.d.ts +2 -0
- package/dist/types/src/extensions/selection.test.d.ts.map +1 -0
- 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 +37 -35
- package/src/defaults.ts +3 -1
- 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 +7 -6
- 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/selection.test.ts +26 -0
- package/src/extensions/selection.ts +14 -15
- 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
package/src/extensions/dnd.ts
CHANGED
|
@@ -2,19 +2,142 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import type
|
|
6
|
-
import { EditorView,
|
|
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
|
-
|
|
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
|
|
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';
|
|
@@ -82,7 +82,8 @@ export const formattingStyles = EditorView.theme({
|
|
|
82
82
|
},
|
|
83
83
|
'& .cm-codeblock-line': {
|
|
84
84
|
background: 'var(--color-cm-codeblock)',
|
|
85
|
-
|
|
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,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
|
+
});
|