@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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type EditorState, type Extension, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
|
6
|
+
import { Decoration, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
|
7
|
+
|
|
8
|
+
import { debounce } from '@dxos/async';
|
|
9
|
+
import { type Document, type Upos, sourceHash } from '@dxos/nlp';
|
|
10
|
+
|
|
11
|
+
/** One analyzed region of the document. `document` offsets are relative to the span's own text. */
|
|
12
|
+
export type PosSpan = {
|
|
13
|
+
from: number;
|
|
14
|
+
to: number;
|
|
15
|
+
/** Hash of the source text the analysis covers; compared to live text to detect divergence. */
|
|
16
|
+
sourceHash: string;
|
|
17
|
+
document: Document;
|
|
18
|
+
/** True once the underlying text diverged from `sourceHash` (decorations render dimmed). */
|
|
19
|
+
stale: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Replace/add the analysis for a region. */
|
|
23
|
+
export const setAnalysis = StateEffect.define<{ from: number; to: number; document: Document }>();
|
|
24
|
+
|
|
25
|
+
/** Remove all analysis spans. */
|
|
26
|
+
export const clearAnalysis = StateEffect.define<null>();
|
|
27
|
+
|
|
28
|
+
/** Internal: mark a span stale (dispatched by the reactive driver on divergence). */
|
|
29
|
+
export const markStale = StateEffect.define<{ from: number; to: number }>();
|
|
30
|
+
|
|
31
|
+
export const posAnalysisField = StateField.define<PosSpan[]>({
|
|
32
|
+
create: () => [],
|
|
33
|
+
update: (spans, tr) => {
|
|
34
|
+
let next = spans;
|
|
35
|
+
|
|
36
|
+
// Map existing span ranges through document changes so anchors follow edits.
|
|
37
|
+
if (tr.docChanged) {
|
|
38
|
+
next = next.map((span) => ({
|
|
39
|
+
...span,
|
|
40
|
+
from: tr.changes.mapPos(span.from, 1),
|
|
41
|
+
to: tr.changes.mapPos(span.to, -1),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const effect of tr.effects) {
|
|
46
|
+
if (effect.is(setAnalysis)) {
|
|
47
|
+
const { from, to, document } = effect.value;
|
|
48
|
+
// Replace any span at the same anchor; otherwise append.
|
|
49
|
+
const without = next.filter((span) => !(span.from === from && span.to === to));
|
|
50
|
+
next = [...without, { from, to, sourceHash: document.sourceHash, document, stale: false }];
|
|
51
|
+
} else if (effect.is(clearAnalysis)) {
|
|
52
|
+
next = [];
|
|
53
|
+
} else if (effect.is(markStale)) {
|
|
54
|
+
const { from, to } = effect.value;
|
|
55
|
+
next = next.map((span) => (span.from === from && span.to === to ? { ...span, stale: true } : span));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return next;
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** Read the current analysis spans from editor state. */
|
|
64
|
+
export const posSpans = (state: EditorState): PosSpan[] => state.field(posAnalysisField);
|
|
65
|
+
|
|
66
|
+
/** One mark per UPOS tag; CSS class drives color (see `posTheme`). Stale spans add `cm-pos-stale`. */
|
|
67
|
+
const posMark = (upos: Upos, stale: boolean) =>
|
|
68
|
+
Decoration.mark({ class: ['cm-pos', `cm-pos-${upos}`, stale && 'cm-pos-stale'].filter(Boolean).join(' ') });
|
|
69
|
+
|
|
70
|
+
export const posDecorations = EditorView.decorations.compute([posAnalysisField], (state) => {
|
|
71
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
72
|
+
const spans = state.field(posAnalysisField);
|
|
73
|
+
// Tokens are span-relative; absolute offset = span.from + token.start. Sort by absolute start so
|
|
74
|
+
// the RangeSetBuilder receives ranges in non-decreasing order.
|
|
75
|
+
const marks = spans
|
|
76
|
+
.flatMap((span) =>
|
|
77
|
+
span.document.sentences.flatMap((sentence) =>
|
|
78
|
+
sentence.tokens.map((token) => ({
|
|
79
|
+
from: span.from + token.start,
|
|
80
|
+
to: span.from + token.end,
|
|
81
|
+
deco: posMark(token.upos, span.stale),
|
|
82
|
+
})),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
.filter((mark) => mark.to <= state.doc.length && mark.from < mark.to)
|
|
86
|
+
.sort((markA, markB) => markA.from - markB.from || markA.to - markB.to);
|
|
87
|
+
for (const mark of marks) {
|
|
88
|
+
builder.add(mark.from, mark.to, mark.deco);
|
|
89
|
+
}
|
|
90
|
+
return builder.finish();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// UPOS → ui-theme hue. Content classes (NOUN/VERB/ADJ/ADV/PROPN/NUM) get distinct hues; function
|
|
94
|
+
// words share a muted neutral; PUNCT/SYM/X are unstyled.
|
|
95
|
+
const POS_HUE: Partial<Record<Upos, string>> = {
|
|
96
|
+
NOUN: 'blue',
|
|
97
|
+
PROPN: 'indigo',
|
|
98
|
+
VERB: 'red',
|
|
99
|
+
ADJ: 'green',
|
|
100
|
+
ADV: 'amber',
|
|
101
|
+
NUM: 'cyan',
|
|
102
|
+
INTJ: 'pink',
|
|
103
|
+
PRON: 'neutral',
|
|
104
|
+
DET: 'neutral',
|
|
105
|
+
ADP: 'neutral',
|
|
106
|
+
AUX: 'neutral',
|
|
107
|
+
CCONJ: 'neutral',
|
|
108
|
+
SCONJ: 'neutral',
|
|
109
|
+
PART: 'neutral',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const posTheme = (): Extension =>
|
|
113
|
+
EditorView.theme({
|
|
114
|
+
'.cm-pos': { borderRadius: '0.125rem' },
|
|
115
|
+
...Object.fromEntries(
|
|
116
|
+
Object.entries(POS_HUE).map(([upos, hue]) => [
|
|
117
|
+
`.cm-pos-${upos}`,
|
|
118
|
+
{ borderBottom: `2px solid var(--color-${hue}-500)` },
|
|
119
|
+
]),
|
|
120
|
+
),
|
|
121
|
+
'.cm-pos-stale': { opacity: '0.4' },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** A span has diverged when the current text under its (mapped) range no longer matches its hash. */
|
|
125
|
+
export const spanDiverged = (docText: string, span: { from: number; to: number; sourceHash: string }): boolean =>
|
|
126
|
+
sourceHash(docText.slice(span.from, span.to)) !== span.sourceHash;
|
|
127
|
+
|
|
128
|
+
export type PosOptions = {
|
|
129
|
+
/** When set, the extension self-parses on idle and dispatches `setAnalysis`. */
|
|
130
|
+
parse?: (text: string) => Promise<Document>;
|
|
131
|
+
/** Idle debounce before reactive parsing (ms). */
|
|
132
|
+
debounceMs?: number;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reactive driver: on doc change, mark edit-touched spans stale (immediate visual feedback), then
|
|
137
|
+
* after idle re-parse and dispatch fresh analysis. Re-hashing is bounded to spans overlapping the
|
|
138
|
+
* edit. This driver intentionally treats the whole document as a single span; per-span/sub-document
|
|
139
|
+
* analysis is left to external drivers dispatching `setAnalysis` (e.g. the transcription pipeline).
|
|
140
|
+
*/
|
|
141
|
+
const reactiveDriver = (parse: NonNullable<PosOptions['parse']>, debounceMs: number): Extension => {
|
|
142
|
+
// Monotonic id so a slow parse that resolves after a newer one (or after the doc moved on) is
|
|
143
|
+
// discarded rather than overwriting current analysis with stale spans. Parser rejections are
|
|
144
|
+
// swallowed so they don't surface as unhandled rejections and the prior analysis is preserved.
|
|
145
|
+
let latest = 0;
|
|
146
|
+
const applyParse = (view: EditorView) => {
|
|
147
|
+
const text = view.state.doc.toString();
|
|
148
|
+
const id = ++latest;
|
|
149
|
+
void parse(text)
|
|
150
|
+
.then((document) => {
|
|
151
|
+
if (id !== latest || view.state.doc.toString() !== text) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
view.dispatch({ effects: setAnalysis.of({ from: 0, to: text.length, document }) });
|
|
155
|
+
})
|
|
156
|
+
.catch(() => {});
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const run = debounce((view: EditorView) => applyParse(view), debounceMs);
|
|
160
|
+
|
|
161
|
+
// Parse existing content on mount so reactive mode decorates immediately, not just after an edit.
|
|
162
|
+
// Dispatch is deferred via the parse promise's microtask; CodeMirror forbids dispatching during construction.
|
|
163
|
+
const initial = ViewPlugin.define((view) => {
|
|
164
|
+
if (view.state.doc.length > 0) {
|
|
165
|
+
applyParse(view);
|
|
166
|
+
}
|
|
167
|
+
return {};
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const onEdit = EditorView.updateListener.of((update: ViewUpdate) => {
|
|
171
|
+
if (!update.docChanged) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// Collect the post-edit ranges this transaction touched; re-hashing is bounded to spans that
|
|
175
|
+
// overlap them, so cost scales with the edit, not the total analyzed text.
|
|
176
|
+
const touched: Array<[number, number]> = [];
|
|
177
|
+
update.changes.iterChangedRanges((_fromA, _toA, fromB, toB) => touched.push([fromB, toB]));
|
|
178
|
+
const overlapsEdit = (span: PosSpan) => touched.some(([from, to]) => from <= span.to && to >= span.from);
|
|
179
|
+
|
|
180
|
+
// Immediate: mark any diverged touched span stale so its decorations dim until the re-parse lands.
|
|
181
|
+
const text = update.state.doc.toString();
|
|
182
|
+
const effects = update.state
|
|
183
|
+
.field(posAnalysisField)
|
|
184
|
+
.filter((span) => !span.stale && overlapsEdit(span) && spanDiverged(text, span))
|
|
185
|
+
.map((span) => markStale.of({ from: span.from, to: span.to }));
|
|
186
|
+
if (effects.length > 0) {
|
|
187
|
+
update.view.dispatch({ effects });
|
|
188
|
+
}
|
|
189
|
+
run(update.view);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return [initial, onEdit];
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Part-of-speech decoration extension. Renders per-word UPOS marks from analysis state held in a
|
|
197
|
+
* span-oriented state field. State can be set externally via `setAnalysis`/`clearAnalysis`, or — when
|
|
198
|
+
* `options.parse` is supplied — self-driven on idle (reactive mode).
|
|
199
|
+
*/
|
|
200
|
+
export const pos = (options: PosOptions = {}): Extension => {
|
|
201
|
+
const extensions: Extension[] = [posAnalysisField, posDecorations, posTheme()];
|
|
202
|
+
if (options.parse) {
|
|
203
|
+
extensions.push(reactiveDriver(options.parse, options.debounceMs ?? 500));
|
|
204
|
+
}
|
|
205
|
+
return extensions;
|
|
206
|
+
};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { StateEffect } from '@codemirror/state';
|
|
6
6
|
import { EditorView, ViewPlugin } from '@codemirror/view';
|
|
7
7
|
|
|
8
|
-
import { addEventListener, combine, throttle } from '@dxos/async';
|
|
8
|
+
import { addEventListener, combine, debounceAndThrottle, throttle } from '@dxos/async';
|
|
9
9
|
import { Domino } from '@dxos/ui';
|
|
10
10
|
import { getSize } from '@dxos/ui-theme';
|
|
11
11
|
|
|
@@ -176,7 +176,10 @@ export const autoScroll = ({ scrollOnResize = true }: AutoScrollProps = {}) => {
|
|
|
176
176
|
// Re-pin check is throttled so the listener doesn't thrash while scrolling, but
|
|
177
177
|
// unpinning must be immediate — otherwise content arriving during the throttle
|
|
178
178
|
// window re-applies the crawl effect and yanks the viewport back to the bottom.
|
|
179
|
-
|
|
179
|
+
// `debounceAndThrottle` (not bare `throttle`) guarantees a trailing call after the
|
|
180
|
+
// gesture stops; a leading-only throttle drops the final at-bottom sample, leaving the
|
|
181
|
+
// scroll-to-bottom button visible even though the viewport settled at the bottom.
|
|
182
|
+
const onUserScroll = debounceAndThrottle(() => {
|
|
180
183
|
requestAnimationFrame(() => {
|
|
181
184
|
const { scrollTop, scrollHeight, clientHeight } = view.scrollDOM;
|
|
182
185
|
const delta = scrollHeight - scrollTop - clientHeight;
|
|
@@ -208,13 +211,14 @@ export const autoScroll = ({ scrollOnResize = true }: AutoScrollProps = {}) => {
|
|
|
208
211
|
ViewPlugin.fromClass(
|
|
209
212
|
class {
|
|
210
213
|
constructor(view: EditorView) {
|
|
211
|
-
const icon = Domino.of('dx-icon' as any)
|
|
212
|
-
.classNames(getSize(4))
|
|
213
|
-
.attributes({ icon: 'ph--arrow-down--regular' });
|
|
214
214
|
const button = Domino.of('button')
|
|
215
|
-
.classNames('dx-button bg-accent-bg')
|
|
216
|
-
.attributes({ 'data-density': '
|
|
217
|
-
.append(
|
|
215
|
+
.classNames('dx-button bg-accent-bg aspect-square')
|
|
216
|
+
.attributes({ 'data-density': 'sm' })
|
|
217
|
+
.append(
|
|
218
|
+
Domino.of('dx-icon' as any)
|
|
219
|
+
.classNames(getSize(4))
|
|
220
|
+
.attributes({ icon: 'ph--arrow-down--regular' }),
|
|
221
|
+
)
|
|
218
222
|
.on('click', () => {
|
|
219
223
|
setPinned(true);
|
|
220
224
|
view.dispatch({
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Schema from 'effect/Schema';
|
|
6
|
+
import { describe, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { EditorSelectionStateSchema } from './selection';
|
|
9
|
+
|
|
10
|
+
describe('EditorSelectionStateSchema', () => {
|
|
11
|
+
test('encode/decode preserves the legacy serialized shape', ({ expect }) => {
|
|
12
|
+
const value = { scrollTo: 42, selection: { anchor: 3, head: 9 } };
|
|
13
|
+
const encoded = Schema.encodeSync(EditorSelectionStateSchema)(value);
|
|
14
|
+
expect(encoded).toEqual(value);
|
|
15
|
+
expect(Schema.decodeUnknownSync(EditorSelectionStateSchema)(JSON.parse(JSON.stringify(encoded)))).toEqual(value);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('accepts an empty state', ({ expect }) => {
|
|
19
|
+
expect(Schema.decodeUnknownSync(EditorSelectionStateSchema)({})).toEqual({});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('accepts selection without head', ({ expect }) => {
|
|
23
|
+
const value = { selection: { anchor: 5 } };
|
|
24
|
+
expect(Schema.decodeUnknownSync(EditorSelectionStateSchema)(value)).toEqual(value);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
import { type Extension, Transaction, type TransactionSpec } from '@codemirror/state';
|
|
6
6
|
import { EditorView, keymap } from '@codemirror/view';
|
|
7
|
+
import * as Schema from 'effect/Schema';
|
|
7
8
|
|
|
8
9
|
import { debounce } from '@dxos/async';
|
|
9
|
-
import { invariant } from '@dxos/invariant';
|
|
10
10
|
import { isTruthy } from '@dxos/util';
|
|
11
11
|
|
|
12
12
|
import { singleValueFacet } from '../util';
|
|
@@ -26,6 +26,16 @@ export type EditorSelectionState = {
|
|
|
26
26
|
selection?: EditorSelection;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
export const EditorSelectionSchema = Schema.Struct({
|
|
30
|
+
anchor: Schema.Number,
|
|
31
|
+
head: Schema.optional(Schema.Number),
|
|
32
|
+
}).pipe(Schema.mutable);
|
|
33
|
+
|
|
34
|
+
export const EditorSelectionStateSchema = Schema.Struct({
|
|
35
|
+
scrollTo: Schema.optional(Schema.Number),
|
|
36
|
+
selection: Schema.optional(EditorSelectionSchema),
|
|
37
|
+
}).pipe(Schema.mutable);
|
|
38
|
+
|
|
29
39
|
export type EditorStateStore = {
|
|
30
40
|
setState: (id: string, state: EditorSelectionState) => void;
|
|
31
41
|
getState: (id: string) => EditorSelectionState | undefined;
|
|
@@ -42,19 +52,6 @@ export const createEditorStateTransaction = ({ scrollTo, selection }: EditorSele
|
|
|
42
52
|
};
|
|
43
53
|
};
|
|
44
54
|
|
|
45
|
-
export const createEditorStateStore = (keyPrefix: string): EditorStateStore => ({
|
|
46
|
-
getState: (id) => {
|
|
47
|
-
invariant(id);
|
|
48
|
-
const state = localStorage.getItem(`${keyPrefix}/${id}`);
|
|
49
|
-
return state ? JSON.parse(state) : undefined;
|
|
50
|
-
},
|
|
51
|
-
|
|
52
|
-
setState: (id, state) => {
|
|
53
|
-
invariant(id);
|
|
54
|
-
localStorage.setItem(`${keyPrefix}/${id}`, JSON.stringify(state));
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
|
|
58
55
|
/**
|
|
59
56
|
* Track scrolling and selection state to be restored when switching to document.
|
|
60
57
|
*/
|
|
@@ -89,7 +86,9 @@ export const selectionState = ({ getState, setState }: Partial<EditorStateStore>
|
|
|
89
86
|
key: 'Ctrl-r', // TODO(burdon): Setting to jump back to selection.
|
|
90
87
|
run: (view) => {
|
|
91
88
|
const state = getState(view.state.facet(documentId));
|
|
92
|
-
|
|
89
|
+
// Only restore when something was actually stored; a store may return an empty state
|
|
90
|
+
// (no scroll/selection) for an unseen document, which would otherwise dispatch a no-op.
|
|
91
|
+
if (state && (state.scrollTo != null || state.selection)) {
|
|
93
92
|
view.dispatch(createEditorStateTransaction(state));
|
|
94
93
|
}
|
|
95
94
|
return true;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Widget Portal Mechanism
|
|
2
|
+
|
|
3
|
+
React components are rendered into CodeMirror editor positions via a single unified mechanism:
|
|
4
|
+
a `WidgetType.toDOM()` creates a DOM placeholder, and React portals render content into it.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## `xmlTags` / `StubWidget`
|
|
9
|
+
|
|
10
|
+
**Source:** `packages/ui/ui-editor/src/extensions/tags/xml-tags.ts`
|
|
11
|
+
|
|
12
|
+
**How it works:**
|
|
13
|
+
|
|
14
|
+
1. A `XmlWidgetRegistry` maps tag names and URL scheme prefixes to `XmlWidgetDef` entries.
|
|
15
|
+
2. `xmlTags({ registry, setWidgets })` is added to a CodeMirror extension list.
|
|
16
|
+
3. On each document change, `buildDecorations` walks the syntax tree and handles two node types:
|
|
17
|
+
- **`Element` nodes** — matched by XML tag name (e.g. `<surface>`, `<toolCall>`).
|
|
18
|
+
- **`Link`/`Image` nodes** — matched when the URL starts with a declared `urlSchemes` prefix
|
|
19
|
+
(e.g. `dxn:`, `echo:`). Block widgets are created for `Image`; inline for `Link`.
|
|
20
|
+
4. For each match, a `factory` returns a native `WidgetType`, or a `StubWidget` is created for
|
|
21
|
+
`Component`-backed entries.
|
|
22
|
+
5. `StubWidget.toDOM()` creates a `<div class="min-h-[24px]">` and calls
|
|
23
|
+
`notifier.mounted({ id, root, props, Component })`.
|
|
24
|
+
6. The `setWidgets` callback (owned by the React host) receives the updated widget list.
|
|
25
|
+
7. The React host renders `createPortal(<Component {...props} />, root)` for each widget.
|
|
26
|
+
8. `StubWidget.destroy()` calls `notifier.unmounted(id)`; the host removes the portal.
|
|
27
|
+
|
|
28
|
+
**Extra capabilities:**
|
|
29
|
+
|
|
30
|
+
- Widget state updated imperatively via `xmlTagUpdateEffect`.
|
|
31
|
+
- Context propagated to all widgets via `xmlTagContextEffect`.
|
|
32
|
+
- Full reset via `xmlTagResetEffect`.
|
|
33
|
+
- Streaming tag support (unclosed opening tags while content streams in).
|
|
34
|
+
- Keyboard navigation bookmarks (`navigatePreviousEffect` / `navigateNextEffect`).
|
|
35
|
+
|
|
36
|
+
**Call sites:**
|
|
37
|
+
|
|
38
|
+
| File | Role |
|
|
39
|
+
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
40
|
+
| `packages/ui/ui-editor/src/extensions/tags/xml-tags.ts` | Definition |
|
|
41
|
+
| `packages/ui/react-ui-markdown/src/MarkdownStream/MarkdownStream.tsx` | Chat thread renderer — XML tags + dxn:/echo: url-scheme widgets |
|
|
42
|
+
| `packages/plugins/plugin-assistant/src/components/ChatThread/registry.tsx` | Defines component registry (summary, surface, toolCall, toolResult, toolkit, json, inline factories) |
|
|
43
|
+
| `packages/plugins/plugin-markdown/src/hooks/useExtensions.tsx` | Editor — registers `dxn-preview` (block) and `link-preview` (inline) url-scheme widgets |
|
|
44
|
+
| `packages/plugins/plugin-inbox/src/components/MarkdownViewer/MarkdownViewer.tsx` | Read-only viewer — inline anchor chips via url-scheme registry |
|
|
45
|
+
| `packages/ui/react-ui-transcription/src/components/Transcription/Transcription.tsx` | Transcription viewer — inline anchor chips via url-scheme registry |
|
|
46
|
+
| `packages/ui/react-ui-editor/src/stories/Widgets.stories.tsx` | Stories: `XmlTags` (XML tag registry) and `Preview` (url-scheme registry with `EditorPreviewProvider`) |
|
|
47
|
+
| `packages/ui/ui-editor/src/extensions/tags/xml-util.test.ts` | Unit tests |
|
|
48
|
+
| `packages/ui/react-ui-markdown/src/MarkdownStream/MarkdownStream.stories.tsx` | Story defining dom-widget (factory) and react-widget (Component) |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Inline anchor chip
|
|
53
|
+
|
|
54
|
+
`Link` nodes that match a url-scheme entry with `block: false` use a `factory` that returns an
|
|
55
|
+
`AnchorInlineWidget` — a native `WidgetType` that renders a `<dx-anchor>` Lit web component.
|
|
56
|
+
No React portal is involved. On click, `<dx-anchor>` dispatches a `DxAnchorActivate` custom event
|
|
57
|
+
that either `EditorPreviewProvider` (standalone/stories) or `PreviewPlugin` (Composer) handles to
|
|
58
|
+
open a popover.
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
export * from './extended-markdown';
|
|
6
|
-
export * from './
|
|
7
|
-
export * from './typewriter';
|
|
6
|
+
export * from './stub';
|
|
8
7
|
export * from './xml-block-decoration';
|
|
9
8
|
export * from './xml-formatting';
|
|
10
9
|
export * from './xml-tags';
|
|
10
|
+
export * from './widgets';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorView, WidgetType } from '@codemirror/view';
|
|
6
|
+
import { type FunctionComponent } from 'react';
|
|
7
|
+
|
|
8
|
+
import { invariant } from '@dxos/invariant';
|
|
9
|
+
import { Domino } from '@dxos/ui';
|
|
10
|
+
|
|
11
|
+
import { type XmlWidgetProps, type XmlWidgetState } from './xml-tags';
|
|
12
|
+
|
|
13
|
+
export interface XmlWidgetNotifier {
|
|
14
|
+
mounted(widget: XmlWidgetState): void;
|
|
15
|
+
unmounted(id: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* Drop any mounted widgets whose id is not in `liveIds`. Needed because CM reuses a widget's DOM
|
|
18
|
+
* via `updateDOM` (without calling `destroy`) when a decoration's widget changes in place, so an
|
|
19
|
+
* id that is no longer present — e.g. a position-keyed id after an edit shifted the node — would
|
|
20
|
+
* otherwise leak a stale portal.
|
|
21
|
+
*/
|
|
22
|
+
reconcile(liveIds: Set<string>): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Mounts a DOM placeholder that React portals render into.
|
|
27
|
+
* Uses a block <div> for block widgets and an inline <span> for inline widgets.
|
|
28
|
+
*/
|
|
29
|
+
export class StubWidget<TProps extends XmlWidgetProps> extends WidgetType {
|
|
30
|
+
#root: HTMLElement | null = null;
|
|
31
|
+
#view: EditorView | undefined;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
readonly id: string,
|
|
35
|
+
readonly Component: FunctionComponent<TProps>,
|
|
36
|
+
readonly props: TProps,
|
|
37
|
+
readonly notifier: XmlWidgetNotifier,
|
|
38
|
+
readonly streaming?: boolean,
|
|
39
|
+
readonly block?: boolean,
|
|
40
|
+
) {
|
|
41
|
+
super();
|
|
42
|
+
invariant(id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get root(): HTMLElement | null {
|
|
46
|
+
return this.#root;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
override eq(other: this) {
|
|
50
|
+
if (this.streaming) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return this.id === other.id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
override ignoreEvent() {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
override toDOM(view: EditorView) {
|
|
61
|
+
this.#view = view;
|
|
62
|
+
this.#root = this.block ? Domino.of('div').classNames('min-h-[24px]').root : Domino.of('span').root;
|
|
63
|
+
const props = Object.assign({}, this.props, { view }) as TProps;
|
|
64
|
+
this.notifier.mounted({ id: this.id, root: this.#root, props, Component: this.Component });
|
|
65
|
+
return this.#root;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
override updateDOM(dom: HTMLElement) {
|
|
69
|
+
this.#root = dom;
|
|
70
|
+
const props = Object.assign({}, this.props, { view: this.#view }) as TProps;
|
|
71
|
+
this.notifier.mounted({ id: this.id, root: this.#root, props, Component: this.Component });
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
override destroy(_dom: HTMLElement) {
|
|
76
|
+
this.notifier.unmounted(this.id);
|
|
77
|
+
this.#root = null;
|
|
78
|
+
this.#view = undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorView, WidgetType } from '@codemirror/view';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Inline widget for echo/dxn links (e.g., `[Label](echo:/123)`).
|
|
9
|
+
* The <dx-anchor> tag is a web component that renders a link chip and popover.
|
|
10
|
+
*/
|
|
11
|
+
export class AnchorWidget extends WidgetType {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly _label: string,
|
|
14
|
+
readonly _dxn: string,
|
|
15
|
+
) {
|
|
16
|
+
super();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override eq(other: this) {
|
|
20
|
+
return this._dxn === other._dxn && this._label === other._label;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
override toDOM(_view: EditorView) {
|
|
24
|
+
const root = document.createElement('dx-anchor');
|
|
25
|
+
root.classList.add('dx-tag--anchor');
|
|
26
|
+
root.textContent = this._label;
|
|
27
|
+
root.setAttribute('dxn', this._dxn);
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
}
|