@dxos/ui-editor 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/dist/lib/chunk-diff.mjs +14 -3
  2. package/dist/lib/chunk-diff.mjs.map +1 -1
  3. package/dist/lib/headless.mjs +1 -2
  4. package/dist/lib/index.mjs +146 -23
  5. package/dist/lib/index.mjs.map +1 -1
  6. package/dist/types/src/extensions/language/index.d.ts +1 -0
  7. package/dist/types/src/extensions/language/index.d.ts.map +1 -1
  8. package/dist/types/src/extensions/language/markdown/styles.d.ts.map +1 -1
  9. package/dist/types/src/extensions/language/turtle/index.d.ts +2 -0
  10. package/dist/types/src/extensions/language/turtle/index.d.ts.map +1 -0
  11. package/dist/types/src/extensions/language/turtle/turtle.d.ts +45 -0
  12. package/dist/types/src/extensions/language/turtle/turtle.d.ts.map +1 -0
  13. package/dist/types/src/extensions/language/turtle/turtle.test.d.ts +2 -0
  14. package/dist/types/src/extensions/language/turtle/turtle.test.d.ts.map +1 -0
  15. package/dist/types/src/extensions/state/selection.d.ts.map +1 -1
  16. package/dist/types/src/extensions/widgets/widgets.d.ts.map +1 -1
  17. package/dist/types/tsconfig.tsbuildinfo +1 -1
  18. package/package.json +24 -24
  19. package/src/extensions/language/index.ts +1 -0
  20. package/src/extensions/language/markdown/styles.ts +2 -0
  21. package/src/extensions/language/turtle/index.ts +5 -0
  22. package/src/extensions/language/turtle/turtle.test.ts +106 -0
  23. package/src/extensions/language/turtle/turtle.ts +183 -0
  24. package/src/extensions/state/selection.test.ts +70 -2
  25. package/src/extensions/state/selection.ts +19 -4
  26. package/src/extensions/widgets/object-links.test.ts +17 -1
  27. package/src/extensions/widgets/widgets.ts +18 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/ui-editor",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Text editor components.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -61,40 +61,40 @@
61
61
  "diff": "^8.0.3",
62
62
  "lodash.defaultsdeep": "^4.6.1",
63
63
  "lodash.sortby": "^4.7.0",
64
- "@dxos/app-graph": "0.14.0",
65
- "@dxos/async": "0.14.0",
66
- "@dxos/client": "0.14.0",
67
- "@dxos/context": "0.14.0",
68
- "@dxos/display-name": "0.14.0",
69
- "@dxos/echo": "0.14.0",
70
- "@dxos/echo-client": "0.14.0",
71
- "@dxos/echo-doc": "0.14.0",
72
- "@dxos/invariant": "0.14.0",
73
- "@dxos/keys": "0.14.0",
74
- "@dxos/log": "0.14.0",
75
- "@dxos/nlp": "0.14.0",
76
- "@dxos/protocols": "0.14.0",
77
- "@dxos/ui": "0.14.0",
78
- "@dxos/ui-theme": "0.14.0",
79
- "@dxos/ui-types": "0.14.0",
80
- "@dxos/util": "0.14.0"
64
+ "@dxos/app-graph": "0.16.0",
65
+ "@dxos/async": "0.16.0",
66
+ "@dxos/client": "0.16.0",
67
+ "@dxos/context": "0.16.0",
68
+ "@dxos/display-name": "0.16.0",
69
+ "@dxos/echo": "0.16.0",
70
+ "@dxos/echo-client": "0.16.0",
71
+ "@dxos/echo-doc": "0.16.0",
72
+ "@dxos/invariant": "0.16.0",
73
+ "@dxos/keys": "0.16.0",
74
+ "@dxos/log": "0.16.0",
75
+ "@dxos/nlp": "0.16.0",
76
+ "@dxos/protocols": "0.16.0",
77
+ "@dxos/ui": "0.16.0",
78
+ "@dxos/ui-theme": "0.16.0",
79
+ "@dxos/ui-types": "0.16.0",
80
+ "@dxos/util": "0.16.0"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@automerge/automerge": "3.5.0",
84
84
  "@types/lodash.defaultsdeep": "^4.6.6",
85
85
  "@types/lodash.sortby": "^4.7.7",
86
- "effect": "4.0.0",
86
+ "effect": "4.0.4",
87
87
  "happy-dom": "^20.0.0",
88
88
  "jsdom": "^29.1.1",
89
- "vite": "^8.2.2",
89
+ "vite": "^8.3.4",
90
90
  "vite-plugin-top-level-await": "^1.6.0",
91
91
  "vite-plugin-wasm": "^3.6.0",
92
- "@dxos/echo": "0.14.0",
93
- "@dxos/ui-theme": "0.14.0"
92
+ "@dxos/echo": "0.16.0",
93
+ "@dxos/ui-theme": "0.16.0"
94
94
  },
95
95
  "peerDependencies": {
96
- "effect": "4.0.0",
97
- "@dxos/ui-theme": "^0.14.0"
96
+ "effect": "4.0.4",
97
+ "@dxos/ui-theme": "^0.16.0"
98
98
  },
99
99
  "publishConfig": {
100
100
  "access": "public"
@@ -6,4 +6,5 @@ export * from './datalog/index.ts';
6
6
  export * from './json/index.ts';
7
7
  export * from './markdown/index.ts';
8
8
  export * from './mermaid/index.ts';
9
+ export * from './turtle/index.ts';
9
10
  export * from './xml/index.ts';
@@ -85,6 +85,8 @@ export const formattingStyles = EditorView.theme({
85
85
  // so constrain them to the target 24px height.
86
86
  '& .cm-code-inline': {
87
87
  fontFamily: fontMono,
88
+ // A step below the body text: a monospace face runs wider and taller than the prose at the same size.
89
+ fontSize: '0.9em',
88
90
  height: '24px',
89
91
  alignItems: 'center',
90
92
  overflow: 'hidden',
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './turtle.ts';
@@ -0,0 +1,106 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { highlightTree, tagHighlighter, tags } from '@lezer/highlight';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import { highlightTurtle, turtleLanguage } from './turtle.ts';
9
+
10
+ /** Class names per standard tag, so a token's colour is asserted by the tag the editor's theme would style. */
11
+ const highlighter = tagHighlighter([
12
+ { tag: tags.lineComment, class: 'comment' },
13
+ { tag: tags.keyword, class: 'keyword' },
14
+ { tag: tags.url, class: 'iri' },
15
+ { tag: tags.namespace, class: 'prefix' },
16
+ { tag: tags.propertyName, class: 'name' },
17
+ { tag: tags.special(tags.variableName), class: 'blank' },
18
+ { tag: tags.string, class: 'string' },
19
+ { tag: tags.annotation, class: 'language' },
20
+ { tag: tags.typeOperator, class: 'datatype' },
21
+ { tag: tags.number, class: 'number' },
22
+ { tag: tags.bool, class: 'bool' },
23
+ { tag: tags.punctuation, class: 'terminator' },
24
+ { tag: tags.separator, class: 'separator' },
25
+ { tag: tags.squareBracket, class: 'bracket' },
26
+ { tag: tags.brace, class: 'brace' },
27
+ { tag: tags.invalid, class: 'invalid' },
28
+ ]);
29
+
30
+ /** `[text, class]` for every highlighted span of `source`. */
31
+ const tokenize = (source: string): [string, string][] => {
32
+ const spans: [string, string][] = [];
33
+ highlightTree(turtleLanguage.parser.parse(source), highlighter, (from, to, classes) => {
34
+ spans.push([source.slice(from, to), classes]);
35
+ });
36
+ return spans;
37
+ };
38
+
39
+ describe('turtle language', () => {
40
+ test('prefix declaration: keyword, prefix and IRI', ({ expect }) => {
41
+ expect(tokenize('@prefix sx: <https://dxos.org/semantic#> .')).toEqual([
42
+ ['@prefix', 'keyword'],
43
+ ['sx:', 'prefix'],
44
+ ['<https://dxos.org/semantic#>', 'iri'],
45
+ ['.', 'terminator'],
46
+ ]);
47
+ });
48
+
49
+ test('statement: prefixed names (a digit-led local name included), `a`, literals and separators', ({ expect }) => {
50
+ expect(tokenize('fact:3f2a a sx:Fact ; sx:quote "I own it"@en , 42 ; sx:ok true .')).toEqual([
51
+ ['fact:', 'prefix'],
52
+ ['3f2a', 'name'],
53
+ ['a', 'keyword'],
54
+ ['sx:', 'prefix'],
55
+ ['Fact', 'name'],
56
+ [';', 'separator'],
57
+ ['sx:', 'prefix'],
58
+ ['quote', 'name'],
59
+ ['"I own it"', 'string'],
60
+ ['@en', 'language'],
61
+ [',', 'separator'],
62
+ ['42', 'number'],
63
+ [';', 'separator'],
64
+ ['sx:', 'prefix'],
65
+ ['ok', 'name'],
66
+ ['true', 'bool'],
67
+ ['.', 'terminator'],
68
+ ]);
69
+ });
70
+
71
+ test('TriG graph block, typed literal, blank node and comment', ({ expect }) => {
72
+ expect(tokenize('g1:graph { _:b1 prov:at "2026"^^xsd:dateTime . } # done')).toEqual([
73
+ ['g1:', 'prefix'],
74
+ ['graph', 'name'],
75
+ ['{', 'brace'],
76
+ ['_:b1', 'blank'],
77
+ ['prov:', 'prefix'],
78
+ ['at', 'name'],
79
+ ['"2026"', 'string'],
80
+ ['^^', 'datatype'],
81
+ ['xsd:', 'prefix'],
82
+ ['dateTime', 'name'],
83
+ ['.', 'terminator'],
84
+ ['}', 'brace'],
85
+ ['# done', 'comment'],
86
+ ]);
87
+ });
88
+
89
+ test('a long string spans lines', ({ expect }) => {
90
+ expect(tokenize('sx:q """one\ntwo""" .')).toEqual([
91
+ ['sx:', 'prefix'],
92
+ ['q', 'name'],
93
+ ['"""one', 'string'],
94
+ ['two"""', 'string'],
95
+ ['.', 'terminator'],
96
+ ]);
97
+ });
98
+
99
+ test('highlightTurtle splits spans by line and keeps every character', ({ expect }) => {
100
+ const source = 'fact:f1 {\n entity:a dx:b "c" .\n}';
101
+ const lines = highlightTurtle(source, highlighter);
102
+ expect(lines).toHaveLength(3);
103
+ expect(lines.map((spans) => spans.map((span) => span.text).join('')).join('\n')).toBe(source);
104
+ expect(lines[1]).toContainEqual({ text: '"c"', classes: 'string' });
105
+ });
106
+ });
@@ -0,0 +1,183 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { LanguageSupport, StreamLanguage, type StringStream } from '@codemirror/language';
6
+ import { type Highlighter, type Tag, highlightCode, tags } from '@lezer/highlight';
7
+
8
+ /** Token names emitted by the tokenizer, mapped to standard tags so the editor's highlight style colours them. */
9
+ const tokenTable = {
10
+ comment: tags.lineComment,
11
+ // `@prefix`, `@base`, `PREFIX`, `BASE`, `GRAPH` and `a` (`rdf:type`).
12
+ keyword: tags.keyword,
13
+ iri: tags.url,
14
+ // The `sx` of `sx:quote`; the local name after the colon is the `name`.
15
+ prefix: tags.namespace,
16
+ name: tags.propertyName,
17
+ blank: tags.special(tags.variableName),
18
+ string: tags.string,
19
+ language: tags.annotation,
20
+ datatype: tags.typeOperator,
21
+ number: tags.number,
22
+ bool: tags.bool,
23
+ terminator: tags.punctuation,
24
+ separator: tags.separator,
25
+ bracket: tags.squareBracket,
26
+ paren: tags.paren,
27
+ // TriG's graph blocks.
28
+ brace: tags.brace,
29
+ invalid: tags.invalid,
30
+ } satisfies Record<string, Tag>;
31
+
32
+ export type TurtleToken = keyof typeof tokenTable;
33
+
34
+ type TurtleState = {
35
+ /** The delimiter of a long string (`"""` or `'''`) that spans lines. */
36
+ long?: string;
37
+ /** The last token was a prefix, so a local name follows directly, even one a number would otherwise claim. */
38
+ local: boolean;
39
+ };
40
+
41
+ const PUNCTUATION: Record<string, TurtleToken | undefined> = {
42
+ '.': 'terminator',
43
+ ';': 'separator',
44
+ ',': 'separator',
45
+ '[': 'bracket',
46
+ ']': 'bracket',
47
+ '(': 'paren',
48
+ ')': 'paren',
49
+ '{': 'brace',
50
+ '}': 'brace',
51
+ };
52
+
53
+ const KEYWORDS = /^(?:PREFIX|BASE|GRAPH)\b/i;
54
+ // PN_PREFIX ':' PN_LOCAL, loosely: a local name may hold dots and dashes but does not end in a dot.
55
+ const PREFIXED_NAME = /^([A-Za-z][\w.-]*)?:/;
56
+ const LOCAL_NAME = /^(?:[\w\-%]|\.(?=[\w\-%]))+/;
57
+
58
+ const skipLongString = (stream: StringStream, state: TurtleState): TurtleToken => {
59
+ const delimiter = state.long ?? '"""';
60
+ if (stream.skipTo(delimiter)) {
61
+ stream.pos += delimiter.length;
62
+ state.long = undefined;
63
+ } else {
64
+ stream.skipToEnd();
65
+ }
66
+ return 'string';
67
+ };
68
+
69
+ const token = (stream: StringStream, state: TurtleState): TurtleToken | null => {
70
+ if (state.long) {
71
+ return skipLongString(stream, state);
72
+ }
73
+ if (state.local) {
74
+ state.local = false;
75
+ if (stream.match(LOCAL_NAME)) {
76
+ return 'name';
77
+ }
78
+ }
79
+ if (stream.eatSpace()) {
80
+ return null;
81
+ }
82
+ if (stream.match('#')) {
83
+ stream.skipToEnd();
84
+ return 'comment';
85
+ }
86
+
87
+ const long = stream.match('"""') ? '"""' : stream.match("'''") ? "'''" : undefined;
88
+ if (long) {
89
+ state.long = long;
90
+ return skipLongString(stream, state);
91
+ }
92
+ const quote = stream.peek();
93
+ if (quote === '"' || quote === "'") {
94
+ stream.next();
95
+ let escaped = false;
96
+ let char: string | void;
97
+ while ((char = stream.next()) != null) {
98
+ if (char === quote && !escaped) {
99
+ return 'string';
100
+ }
101
+ escaped = !escaped && char === '\\';
102
+ }
103
+ return 'invalid';
104
+ }
105
+
106
+ if (stream.match(/^<[^<>"{}|^`\\\s]*>/)) {
107
+ return 'iri';
108
+ }
109
+ if (stream.match(/^@(?:prefix|base)\b/)) {
110
+ return 'keyword';
111
+ }
112
+ // A language tag follows its string directly (`"Founder"@en`).
113
+ if (stream.match(/^@[A-Za-z]+(?:-[A-Za-z0-9]+)*/)) {
114
+ return 'language';
115
+ }
116
+ if (stream.match('^^')) {
117
+ return 'datatype';
118
+ }
119
+ if (stream.match(/^_:[\w.-]*\w/)) {
120
+ return 'blank';
121
+ }
122
+ if (stream.match(/^[+-]?(?:\d+\.?\d*(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)/)) {
123
+ return 'number';
124
+ }
125
+ if (stream.match(/^(?:true|false)\b/)) {
126
+ return 'bool';
127
+ }
128
+ if (stream.match(KEYWORDS)) {
129
+ return 'keyword';
130
+ }
131
+ if (stream.match(/^a(?=[\s<[(_"'])/)) {
132
+ return 'keyword';
133
+ }
134
+ if (stream.match(PREFIXED_NAME)) {
135
+ // The prefix and its colon; the local name, if any, is the next token.
136
+ state.local = true;
137
+ return 'prefix';
138
+ }
139
+ if (stream.match(LOCAL_NAME)) {
140
+ return 'name';
141
+ }
142
+
143
+ const char = stream.next();
144
+ return (char ? PUNCTUATION[char] : undefined) ?? 'invalid';
145
+ };
146
+
147
+ /** RDF 1.1 Turtle, and TriG's `{ … }` graph blocks, which Turtle's tokens cover otherwise. */
148
+ export const turtleLanguage = StreamLanguage.define<TurtleState>({
149
+ name: 'turtle',
150
+ startState: () => ({ local: false }),
151
+ copyState: (state) => ({ ...state }),
152
+ token,
153
+ tokenTable,
154
+ languageData: {
155
+ commentTokens: { line: '#' },
156
+ closeBrackets: { brackets: ['(', '[', '{', '"', "'", '<'] },
157
+ },
158
+ });
159
+
160
+ /** Turtle (and TriG) language support; pair with the theme's syntax highlighting to colour it. */
161
+ export const turtle = (): LanguageSupport => new LanguageSupport(turtleLanguage);
162
+
163
+ /** A run of source text and the classes the highlighter gave it (empty when unstyled). */
164
+ export type HighlightedSpan = { text: string; classes: string };
165
+
166
+ /**
167
+ * Highlights Turtle or TriG without an editor, one array of spans per line, for read-only snippets where mounting
168
+ * a CodeMirror view per snippet would cost too much.
169
+ */
170
+ export const highlightTurtle = (
171
+ source: string,
172
+ highlighter: Highlighter | readonly Highlighter[],
173
+ ): HighlightedSpan[][] => {
174
+ const lines: HighlightedSpan[][] = [[]];
175
+ highlightCode(
176
+ source,
177
+ turtleLanguage.parser.parse(source),
178
+ highlighter,
179
+ (text, classes) => lines[lines.length - 1].push({ text, classes }),
180
+ () => lines.push([]),
181
+ );
182
+ return lines;
183
+ };
@@ -2,10 +2,78 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
+ import { EditorState } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
5
7
  import * as Schema from 'effect/Schema';
6
- import { describe, test } from 'vitest';
8
+ import { describe, onTestFinished, test } from 'vitest';
7
9
 
8
- import { EditorSelectionStateSchema } from './selection.ts';
10
+ import {
11
+ type EditorSelectionState,
12
+ EditorSelectionStateSchema,
13
+ documentId,
14
+ restoreEditorState,
15
+ selectionState,
16
+ } from './selection.ts';
17
+
18
+ const DOC_ID = 'doc';
19
+
20
+ /** Mounts a view whose recorder writes into `store`. */
21
+ const createRecordingView = (store: Map<string, EditorSelectionState>) => {
22
+ const parent = document.createElement('div');
23
+ document.body.append(parent);
24
+ const view = new EditorView({
25
+ parent,
26
+ state: EditorState.create({
27
+ doc: 'line one\nline two\nline three\n',
28
+ extensions: [
29
+ documentId.of(DOC_ID),
30
+ selectionState({ getState: (id) => store.get(id), setState: (id, state) => store.set(id, state) }),
31
+ ],
32
+ }),
33
+ });
34
+ // happy-dom has no layout, so pin the top-of-viewport position the recorder measures.
35
+ view.posAtCoords = () => 0;
36
+ onTestFinished(() => {
37
+ view.destroy();
38
+ parent.remove();
39
+ });
40
+ return view;
41
+ };
42
+
43
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
44
+
45
+ describe('selectionState', () => {
46
+ const restored: EditorSelectionState = { scrollTo: 12, scrollOffset: 3, selection: { anchor: 15, head: 15 } };
47
+
48
+ test('records the caret after a change', async ({ expect }) => {
49
+ const store = new Map<string, EditorSelectionState>();
50
+ const view = createRecordingView(store);
51
+ view.dispatch({ selection: { anchor: 4 } });
52
+ await sleep(1_200);
53
+ expect(store.get(DOC_ID)?.selection).toEqual({ anchor: 4, head: 4 });
54
+ });
55
+
56
+ // The content fill at mount records the top of the document; that write must not land over the
57
+ // state the restore just applied, or the next visit opens at the top.
58
+ test('a restore cancels a write queued before it', async ({ expect }) => {
59
+ const store = new Map<string, EditorSelectionState>([[DOC_ID, restored]]);
60
+ const view = createRecordingView(store);
61
+ view.dispatch({ selection: { anchor: 2 } });
62
+ await sleep(50);
63
+ restoreEditorState(view, restored);
64
+ await sleep(1_200);
65
+ expect(store.get(DOC_ID)).toEqual(restored);
66
+ });
67
+
68
+ test('a measurement requested before a restore is not written after it', async ({ expect }) => {
69
+ const store = new Map<string, EditorSelectionState>([[DOC_ID, restored]]);
70
+ const view = createRecordingView(store);
71
+ view.dispatch({ selection: { anchor: 2 } });
72
+ restoreEditorState(view, restored);
73
+ await sleep(1_200);
74
+ expect(store.get(DOC_ID)).toEqual(restored);
75
+ });
76
+ });
9
77
 
10
78
  describe('EditorSelectionStateSchema', () => {
11
79
  test('encode/decode preserves the legacy serialized shape', ({ expect }) => {
@@ -7,7 +7,6 @@ import { EditorView, keymap } from '@codemirror/view';
7
7
  import * as Schema from 'effect/Schema';
8
8
  import * as Struct from 'effect/Struct';
9
9
 
10
- import { debounce } from '@dxos/async';
11
10
  import { isTruthy } from '@dxos/util';
12
11
 
13
12
  import { singleValueFacet } from '../../util/index.ts';
@@ -50,6 +49,9 @@ const stateRestoreAnnotation = 'org.dxos.cm.state-restore';
50
49
  /** Window after a restore during which scroll events are not recorded. */
51
50
  const RESTORE_SUPPRESS_MS = 500;
52
51
 
52
+ /** Delay before a recorded state is written to the store. */
53
+ const RECORD_DELAY_MS = 1_000;
54
+
53
55
  /** Dedupes the recorder's measure requests: CodeMirror keeps one request per key until its measure phase runs. */
54
56
  const recordKey = {};
55
57
 
@@ -105,7 +107,13 @@ export const restoreEditorState = (view: EditorView, state: EditorSelectionState
105
107
  * Track scrolling and selection state to be restored when switching to document.
106
108
  */
107
109
  export const selectionState = ({ getState, setState }: Partial<EditorStateStore> = {}): Extension => {
108
- const setStateDebounced = debounce(setState!, 1_000);
110
+ // Debounced by hand so a restore can cancel a write queued before it: the binding's content fill at
111
+ // mount records the top-of-document position, which would otherwise overwrite the stored state.
112
+ let timer: ReturnType<typeof setTimeout> | undefined;
113
+ const cancelPending = () => {
114
+ clearTimeout(timer);
115
+ timer = undefined;
116
+ };
109
117
 
110
118
  // A restore dispatches its own scroll, which would immediately record a position measured
111
119
  // before the document has finished laying out; ignore scroll events for a beat afterwards.
@@ -132,9 +140,15 @@ export const selectionState = ({ getState, setState }: Partial<EditorStateStore>
132
140
  return { scrollTo: pos, scrollOffset, selection: { anchor, head } };
133
141
  },
134
142
  write: (state: EditorSelectionState | undefined, view: EditorView) => {
143
+ // CodeMirror reads measure requests before applying a pending scroll target, so a request queued
144
+ // before a restore measures the pre-restore viewport.
145
+ if (performance.now() < suppressUntil) {
146
+ return;
147
+ }
135
148
  const id = view.state.facet(documentId);
136
- if (state && id) {
137
- setStateDebounced(id, state);
149
+ if (state && id && setState) {
150
+ cancelPending();
151
+ timer = setTimeout(() => setState(id, state), RECORD_DELAY_MS);
138
152
  }
139
153
  },
140
154
  };
@@ -156,6 +170,7 @@ export const selectionState = ({ getState, setState }: Partial<EditorStateStore>
156
170
  EditorView.updateListener.of(({ view, transactions }) => {
157
171
  if (transactions.some((tr) => tr.isUserEvent(stateRestoreAnnotation))) {
158
172
  suppressUntil = performance.now() + RESTORE_SUPPRESS_MS;
173
+ cancelPending();
159
174
  return;
160
175
  }
161
176
 
@@ -2,7 +2,7 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import { forceParsing } from '@codemirror/language';
5
+ import { forceParsing, syntaxTree } from '@codemirror/language';
6
6
  import { EditorState } from '@codemirror/state';
7
7
  import { EditorView, WidgetType } from '@codemirror/view';
8
8
  import { describe, test } from 'vitest';
@@ -134,6 +134,22 @@ describe('objectLinks', () => {
134
134
  view.destroy();
135
135
  });
136
136
 
137
+ // Background parsing extends the tree in steps with no document change, and a parse that runs out
138
+ // of time fills skipped ranges in as they scroll into view; each step must decorate what it parsed.
139
+ test('widgets follow a parse that advances without a document change', async ({ expect }) => {
140
+ const doc = Array.from({ length: 4_000 }, (_, i) => `See [x](eid:${i}) ${'and more text '.repeat(15)}\n\n`).join(
141
+ '',
142
+ );
143
+ const view = createView(doc, { link: recording });
144
+ const initial = decorations(view).length;
145
+ const parsed = syntaxTree(view.state).length;
146
+ await expect.poll(() => syntaxTree(view.state).length, { timeout: 10_000 }).toBeGreaterThan(parsed);
147
+ await flush();
148
+ expect(syntaxTree(view.state).length).toBeLessThan(doc.length);
149
+ expect(decorations(view).length).toBeGreaterThan(initial);
150
+ view.destroy();
151
+ });
152
+
137
153
  // The Component (StubWidget) branch the app uses — asserts the portal host mounts too.
138
154
  test('component-backed block widget builds and mounts on first render', async ({ expect }) => {
139
155
  let widgets: WidgetState[] = [];
@@ -8,7 +8,7 @@
8
8
  // host renders portals from, widget state and context effects, and bookmark navigation. What is
9
9
  // matched is a matcher's business: `xmlTags` claims XML elements, `objectLinks` object links.
10
10
 
11
- import { ensureSyntaxTree, syntaxTree, syntaxTreeAvailable } from '@codemirror/language';
11
+ import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
12
12
  import {
13
13
  type EditorState,
14
14
  type Extension,
@@ -435,7 +435,7 @@ type WidgetDecorationSet = {
435
435
  notifier: WidgetNotifier;
436
436
  };
437
437
 
438
- /** Ceiling on parsing ahead of the background parser; past it `parseCompletionPlugin` rebuilds. */
438
+ /** Ceiling on parsing ahead of the background parser; past it `parseProgressPlugin` rebuilds. */
439
439
  const PARSE_BUDGET = 50;
440
440
 
441
441
  /**
@@ -637,25 +637,26 @@ const scrollJumpMonitor = (() => {
637
637
  })();
638
638
 
639
639
  /**
640
- * Re-decorate once the whole document has parsed. `StateField.create` (and incremental rebuilds) only
641
- * see the syntax tree parsed so far, so a block past CodeMirror's initial background-parse window is
642
- * not decorated until some later transaction — previously it appeared only after the first edit. This
643
- * dispatches a one-shot rebuild when the full tree becomes available (re-armed on each edit).
640
+ * Re-decorate whenever the syntax tree changes. `StateField.create` (and incremental rebuilds) see
641
+ * only the tree parsed so far: background parsing extends it later, and a parse that runs out of time
642
+ * skips ranges outside the viewport and parses them as they scroll in, neither with a document change.
643
+ * Edits rebuild too: the incremental merge duplicates a widget appended after the last one (see the
644
+ * `xml-tags` invariant tests), and this rebuild settles it. Rebuilds within one task coalesce.
644
645
  */
645
- const parseCompletionPlugin = ViewPlugin.fromClass(
646
+ const parseProgressPlugin = ViewPlugin.fromClass(
646
647
  class {
647
- #rebuiltForLength = -1;
648
+ #pending = false;
648
649
 
649
650
  update(update: ViewUpdate) {
650
- if (update.docChanged) {
651
- this.#rebuiltForLength = -1;
652
- }
653
- const length = update.state.doc.length;
654
- if (this.#rebuiltForLength !== length && syntaxTreeAvailable(update.state, length)) {
655
- this.#rebuiltForLength = length;
656
- // Defer: cannot dispatch during an update.
657
- queueMicrotask(() => update.view.dispatch({ effects: widgetRebuildEffect.of(null) }));
651
+ if (this.#pending || syntaxTree(update.startState) === syntaxTree(update.state)) {
652
+ return;
658
653
  }
654
+ this.#pending = true;
655
+ // Defer: cannot dispatch during an update.
656
+ queueMicrotask(() => {
657
+ this.#pending = false;
658
+ update.view.dispatch({ effects: widgetRebuildEffect.of(null) });
659
+ });
659
660
  }
660
661
  },
661
662
  );
@@ -798,7 +799,7 @@ export const widgetsCore: Extension = [
798
799
  widgetStateMapStateField,
799
800
  widgetDecorationsField,
800
801
  widgetUpdatePlugin,
801
- parseCompletionPlugin,
802
+ parseProgressPlugin,
802
803
  navigationEffectPlugin,
803
804
  keyHandlers,
804
805
  scrollJumpMonitor,