@dxos/react-ui-editor 0.8.4-main.406dc2a → 0.8.4-main.548089c

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 (108) hide show
  1. package/dist/lib/browser/index.mjs +1379 -1139
  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 +1379 -1139
  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/components/Editor/Editor.stories.d.ts +0 -3
  8. package/dist/types/src/components/Editor/Editor.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts +17 -2
  10. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
  11. package/dist/types/src/components/EditorToolbar/headings.d.ts.map +1 -1
  12. package/dist/types/src/components/EditorToolbar/util.d.ts +5 -19
  13. package/dist/types/src/components/EditorToolbar/util.d.ts.map +1 -1
  14. package/dist/types/src/extensions/automerge/automerge.d.ts +1 -1
  15. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  16. package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
  17. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  18. package/dist/types/src/extensions/automerge/sync.d.ts +1 -1
  19. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  20. package/dist/types/src/extensions/automerge/update-automerge.d.ts +1 -1
  21. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  22. package/dist/types/src/extensions/autoscroll.d.ts +14 -4
  23. package/dist/types/src/extensions/autoscroll.d.ts.map +1 -1
  24. package/dist/types/src/extensions/awareness/awareness-provider.d.ts +1 -1
  25. package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -1
  26. package/dist/types/src/extensions/blocks.d.ts +2 -0
  27. package/dist/types/src/extensions/blocks.d.ts.map +1 -0
  28. package/dist/types/src/extensions/bookmarks.d.ts +12 -0
  29. package/dist/types/src/extensions/bookmarks.d.ts.map +1 -0
  30. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  31. package/dist/types/src/extensions/factories.d.ts +4 -4
  32. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  33. package/dist/types/src/extensions/folding.d.ts.map +1 -1
  34. package/dist/types/src/extensions/index.d.ts +4 -0
  35. package/dist/types/src/extensions/index.d.ts.map +1 -1
  36. package/dist/types/src/extensions/listener.d.ts +8 -6
  37. package/dist/types/src/extensions/listener.d.ts.map +1 -1
  38. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  39. package/dist/types/src/extensions/markdown/formatting.d.ts +1 -2
  40. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  41. package/dist/types/src/extensions/popover/PopoverMenuProvider.d.ts +1 -1
  42. package/dist/types/src/extensions/popover/PopoverMenuProvider.d.ts.map +1 -1
  43. package/dist/types/src/extensions/popover/popover.d.ts.map +1 -1
  44. package/dist/types/src/extensions/preview/preview.d.ts +6 -2
  45. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  46. package/dist/types/src/extensions/replacer.d.ts +21 -0
  47. package/dist/types/src/extensions/replacer.d.ts.map +1 -0
  48. package/dist/types/src/extensions/replacer.test.d.ts +2 -0
  49. package/dist/types/src/extensions/replacer.test.d.ts.map +1 -0
  50. package/dist/types/src/extensions/scrolling.d.ts +78 -0
  51. package/dist/types/src/extensions/scrolling.d.ts.map +1 -0
  52. package/dist/types/src/extensions/tags/xml-tags.d.ts +41 -16
  53. package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -1
  54. package/dist/types/src/stories/CommandDialog.stories.d.ts.map +1 -1
  55. package/dist/types/src/stories/EditorToolbar.stories.d.ts.map +1 -1
  56. package/dist/types/src/stories/Popover.stories.d.ts.map +1 -1
  57. package/dist/types/src/stories/Preview.stories.d.ts.map +1 -1
  58. package/dist/types/src/stories/Tags.stories.d.ts.map +1 -1
  59. package/dist/types/src/stories/components/EditorStory.d.ts.map +1 -1
  60. package/dist/types/src/stories/components/util.d.ts.map +1 -1
  61. package/dist/types/tsconfig.tsbuildinfo +1 -1
  62. package/package.json +41 -38
  63. package/src/components/Editor/Editor.stories.tsx +4 -7
  64. package/src/components/EditorToolbar/EditorToolbar.tsx +90 -90
  65. package/src/components/EditorToolbar/headings.ts +6 -4
  66. package/src/components/EditorToolbar/util.ts +4 -20
  67. package/src/extensions/autocomplete/autocomplete.ts +5 -5
  68. package/src/extensions/automerge/automerge.stories.tsx +1 -1
  69. package/src/extensions/automerge/automerge.ts +1 -1
  70. package/src/extensions/automerge/cursor.ts +1 -1
  71. package/src/extensions/automerge/sync.ts +1 -1
  72. package/src/extensions/automerge/update-automerge.ts +1 -1
  73. package/src/extensions/autoscroll.ts +74 -68
  74. package/src/extensions/awareness/awareness-provider.ts +2 -2
  75. package/src/extensions/blocks.ts +131 -0
  76. package/src/extensions/bookmarks.ts +75 -0
  77. package/src/extensions/comments.ts +2 -1
  78. package/src/extensions/factories.ts +6 -4
  79. package/src/extensions/folding.tsx +1 -2
  80. package/src/extensions/index.ts +4 -0
  81. package/src/extensions/listener.ts +14 -20
  82. package/src/extensions/markdown/bundle.ts +12 -2
  83. package/src/extensions/markdown/decorate.ts +8 -8
  84. package/src/extensions/markdown/formatting.ts +8 -8
  85. package/src/extensions/markdown/highlight.ts +1 -1
  86. package/src/extensions/markdown/image.ts +2 -2
  87. package/src/extensions/markdown/table.ts +6 -6
  88. package/src/extensions/popover/PopoverMenuProvider.tsx +2 -3
  89. package/src/extensions/popover/popover.ts +0 -4
  90. package/src/extensions/preview/preview.ts +14 -9
  91. package/src/extensions/replacer.test.ts +75 -0
  92. package/src/extensions/replacer.ts +93 -0
  93. package/src/extensions/scrolling.ts +189 -0
  94. package/src/extensions/selection.ts +1 -1
  95. package/src/extensions/tags/extended-markdown.test.ts +2 -1
  96. package/src/extensions/tags/xml-tags.ts +310 -203
  97. package/src/extensions/typewriter.ts +1 -1
  98. package/src/stories/CommandDialog.stories.tsx +9 -4
  99. package/src/stories/Comments.stories.tsx +1 -1
  100. package/src/stories/EditorToolbar.stories.tsx +4 -5
  101. package/src/stories/Popover.stories.tsx +4 -6
  102. package/src/stories/Preview.stories.tsx +15 -8
  103. package/src/stories/Tags.stories.tsx +19 -5
  104. package/src/stories/TextEditor.stories.tsx +2 -2
  105. package/src/stories/components/EditorStory.tsx +3 -3
  106. package/src/stories/components/util.tsx +39 -6
  107. package/src/styles/markdown.ts +1 -1
  108. package/src/styles/theme.ts +1 -1
@@ -3,24 +3,34 @@
3
3
  //
4
4
 
5
5
  import { syntaxTree } from '@codemirror/language';
6
- import {
7
- type EditorState,
8
- type Extension,
9
- RangeSetBuilder,
10
- StateEffect,
11
- StateField,
12
- Transaction,
13
- } from '@codemirror/state';
14
- import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
6
+ import { Prec } from '@codemirror/state';
7
+ import { type EditorState, type Extension, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
8
+ import { keymap } from '@codemirror/view';
9
+ import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate, WidgetType } from '@codemirror/view';
15
10
  import { type ComponentType, type FC } from 'react';
16
11
 
17
12
  import { invariant } from '@dxos/invariant';
18
13
  import { log } from '@dxos/log';
19
14
 
15
+ import { type Range } from '../../types';
20
16
  import { decorationSetToArray } from '../../util';
17
+ import { scrollToLineEffect } from '../scrolling';
21
18
 
22
19
  import { nodeToJson } from './xml-util';
23
20
 
21
+ /**
22
+ * StateEffect for navigating to previous bookmark.
23
+ */
24
+ export const navigatePreviousEffect = StateEffect.define<void>();
25
+
26
+ /**
27
+ * StateEffect for navigating to next bookmark.
28
+ */
29
+ export const navigateNextEffect = StateEffect.define<void>();
30
+
31
+ /**
32
+ * Dispatch function for updating state.
33
+ */
24
34
  export type StateDispatch<T> = T | ((state: T) => T);
25
35
 
26
36
  /**
@@ -35,9 +45,11 @@ export type XmlEventHandler<TEvent = any> = (event: TEvent) => void;
35
45
  /**
36
46
  * Widget component.
37
47
  */
38
- export type XmlWidgetProps<TContext = any, TProps = any> = TProps & {
48
+ export type XmlWidgetProps<TProps = any, TContext = any> = TProps & {
39
49
  _tag: string;
40
- context: TContext;
50
+ context?: TContext;
51
+ range?: { from: number; to: number };
52
+ view?: EditorView;
41
53
  onEvent?: XmlEventHandler;
42
54
  };
43
55
 
@@ -50,10 +62,13 @@ export type XmlWidgetFactory = (props: XmlWidgetProps, onEvent?: XmlEventHandler
50
62
  * Widget registry definition.
51
63
  */
52
64
  export type XmlWidgetDef = {
65
+ /** Block widget. */
53
66
  block?: boolean;
54
- /** Native widget. */
67
+
68
+ /** Native widget (rendered inline). */
55
69
  factory?: XmlWidgetFactory;
56
- /** React widget. */
70
+
71
+ /** React widget (rendered in portals outside of the editor). */
57
72
  Component?: FC<XmlWidgetProps>;
58
73
  };
59
74
 
@@ -70,14 +85,14 @@ export const getXmlTextChild = (children: any[]): string | null => {
70
85
  export const xmlTagContextEffect = StateEffect.define<any>();
71
86
 
72
87
  /**
73
- * Update widget.
88
+ * Reset all state.
74
89
  */
75
- export const xmlTagUpdateEffect = StateEffect.define<{ id: string; value: any }>();
90
+ export const xmlTagResetEffect = StateEffect.define();
76
91
 
77
92
  /**
78
- * Reset all state.
93
+ * Update widget.
79
94
  */
80
- export const xmlTagResetEffect = StateEffect.define();
95
+ export const xmlTagUpdateEffect = StateEffect.define<{ id: string; value: any }>();
81
96
 
82
97
  type WidgetDecorationSet = {
83
98
  from: number;
@@ -88,9 +103,9 @@ type XmlWidgetStateMap = Record<string, any>;
88
103
 
89
104
  export type XmlWidgetState = {
90
105
  id: string;
91
- props: any;
92
106
  root: HTMLElement;
93
- Component: ComponentType<any>;
107
+ props: any;
108
+ Component: ComponentType<XmlWidgetProps>;
94
109
  };
95
110
 
96
111
  export interface XmlWidgetNotifier {
@@ -98,145 +113,267 @@ export interface XmlWidgetNotifier {
98
113
  unmounted(id: string): void;
99
114
  }
100
115
 
116
+ /**
117
+ * Context state.
118
+ */
119
+ const widgetContextStateField = StateField.define<any>({
120
+ create: () => undefined,
121
+ update: (value, tr) => {
122
+ for (const effect of tr.effects) {
123
+ if (effect.is(xmlTagContextEffect)) {
124
+ return effect.value;
125
+ }
126
+ }
127
+
128
+ return value;
129
+ },
130
+ });
131
+
132
+ /**
133
+ * Widget state management.
134
+ */
135
+ const widgetStateMapStateField = StateField.define<XmlWidgetStateMap>({
136
+ create: () => ({}),
137
+ update: (map, tr) => {
138
+ for (const effect of tr.effects) {
139
+ if (effect.is(xmlTagResetEffect)) {
140
+ return {};
141
+ }
142
+
143
+ if (effect.is(xmlTagUpdateEffect)) {
144
+ // Update accumulated widget props by id.
145
+ const { id, value } = effect.value;
146
+ log('widget updated', { id, value });
147
+ const state = typeof value === 'function' ? value(map[id]) : value;
148
+ return { ...map, [id]: state };
149
+ }
150
+ }
151
+
152
+ return map;
153
+ },
154
+ });
155
+
101
156
  export type XmlTagsOptions = {
157
+ /** Tag registry. */
102
158
  registry?: XmlWidgetRegistry;
103
- /**
104
- * Called when a widget is mounted or unmounted.
105
- */
159
+
160
+ /** Called when widgets are mounted or unmounted. */
106
161
  setWidgets?: (widgets: XmlWidgetState[]) => void;
162
+
163
+ /** Tags to bookmark. */
164
+ bookmarks?: string[];
107
165
  };
108
166
 
109
167
  /**
110
- * Extension that adds thread-related functionality including XML tag decorations.
168
+ * Implements custom XML tags via CodeMirror-native Widgets and portaled React components.
169
+ *
170
+ * Basic mechanism:
171
+ * - Decorations are created from XML tags that matched the provided Widget registry.
172
+ * - Native widgets are rendered inline.
173
+ * - React widgets are rendered in portals outside of the editor via the PlaceholderWidget.
174
+ * - Widget state can be update via effects.
175
+ * - NOTE: Widget state may be updated BEFORE the widget is mounted.
111
176
  */
112
- export const xmlTags = (options: XmlTagsOptions = {}): Extension => {
113
- //
114
- // Context state.
115
- //
116
- const contextState = StateField.define<any>({
117
- create: () => undefined,
118
- update: (value, tr) => {
119
- for (const effect of tr.effects) {
120
- if (effect.is(xmlTagContextEffect)) {
121
- return effect.value;
122
- }
123
- }
124
-
125
- return value;
126
- },
127
- });
177
+ export const xmlTags = ({ registry, setWidgets, bookmarks }: XmlTagsOptions): Extension => {
178
+ const notifier = createWidgetMap(setWidgets);
179
+ const widgetDecorationsField = createWidgetDecorationsField(registry, notifier);
180
+ return [
181
+ widgetContextStateField,
182
+ widgetStateMapStateField,
183
+ widgetDecorationsField,
184
+ createWidgetUpdatePlugin(widgetDecorationsField, notifier),
185
+ createNavigationEffectPlugin(widgetDecorationsField, bookmarks),
186
+ bookmarks?.length ? Prec.highest(keyHandlers) : [],
187
+ ];
188
+ };
128
189
 
129
- //
130
- // Active widgets.
131
- //
190
+ /**
191
+ * Manages the collection of widgets.
192
+ */
193
+ const createWidgetMap = (setWidgets?: (widgets: XmlWidgetState[]) => void): XmlWidgetNotifier => {
132
194
  const widgets = new Map<string, XmlWidgetState>();
195
+
196
+ // TODO(burdon): Batch updates?
133
197
  const notifier = {
134
- mounted: (widget: XmlWidgetState) => {
135
- widgets.set(widget.id, widget);
136
- options.setWidgets?.([...widgets.values()]);
198
+ mounted: (state: XmlWidgetState) => {
199
+ log('widget mounted', { id: state.id, tag: state.props._tag });
200
+ widgets.set(state.id, state);
201
+ setWidgets?.([...widgets.values()]);
137
202
  },
138
203
  unmounted: (id: string) => {
204
+ const state = widgets.get(id);
205
+ log('widget unmounted', { id, tag: state?.props._tag });
139
206
  widgets.delete(id);
140
- options.setWidgets?.([...widgets.values()]);
207
+ setWidgets?.([...widgets.values()]);
141
208
  },
142
209
  } satisfies XmlWidgetNotifier;
143
210
 
144
- //
145
- // Widget decorations.
146
- //
147
- const decorationsState = StateField.define<WidgetDecorationSet>({
148
- create: (state) => {
149
- return buildDecorations(
150
- state,
151
- 0,
152
- state.doc.length,
153
- state.field(contextState),
154
- state.field(widgetState),
155
- options,
156
- notifier,
157
- );
211
+ return notifier;
212
+ };
213
+
214
+ /**
215
+ * Navigation keys.
216
+ */
217
+ const keyHandlers = keymap.of([
218
+ {
219
+ key: 'Mod-ArrowUp',
220
+ run: (view) => {
221
+ view.dispatch({ effects: navigatePreviousEffect.of() });
222
+ return true;
158
223
  },
159
- update: ({ from, decorations }, tr) => {
160
- for (const effect of tr.effects) {
161
- if (effect.is(xmlTagResetEffect)) {
162
- return { from: 0, decorations: Decoration.none };
163
- }
164
- }
224
+ },
225
+ {
226
+ key: 'Mod-ArrowDown',
227
+ run: (view) => {
228
+ view.dispatch({ effects: navigateNextEffect.of() });
229
+ return true;
230
+ },
231
+ },
232
+ ]);
165
233
 
166
- // Check if user pressed Backspace or Delete and remove adjacent decorations if present.
167
- const userEvent = tr.annotation(Transaction.userEvent);
168
- if (userEvent === 'delete.backward' || userEvent === 'delete.forward') {
169
- const { state } = tr;
170
- const decorationArray = decorationSetToArray(decorations);
171
- const filteredDecorations = [];
234
+ /**
235
+ * Effect processing plugin for navigation.
236
+ * Handles navigation up/down effects.
237
+ */
238
+ const createNavigationEffectPlugin = (
239
+ widgetDecorationsField: StateField<WidgetDecorationSet>,
240
+ bookmarks?: string[],
241
+ ) => {
242
+ return EditorView.updateListener.of((update) => {
243
+ update.transactions.forEach((transaction) => {
244
+ for (const effect of transaction.effects) {
245
+ if (effect.is(navigatePreviousEffect)) {
246
+ const view = update.view;
247
+ const cursorPos = view.state.doc.lineAt(view.state.selection.main.head).from;
248
+ let widget: { from: number; to: number; tag: string } | null = null;
249
+ const { decorations } = view.state.field(widgetDecorationsField);
250
+ for (const range of decorationSetToArray(decorations)) {
251
+ if (range.from < cursorPos) {
252
+ const tag = range.value.spec.tag;
253
+ if (bookmarks?.includes(tag)) {
254
+ if (!widget || range.from > widget.from) {
255
+ widget = { from: range.from, to: range.to, tag };
256
+ }
257
+ }
258
+ }
259
+ }
172
260
 
173
- // Get cursor position after the change.
174
- const cursorPos = state.selection.main.head;
261
+ const line = view.state.doc.lineAt(widget?.from ?? 0);
262
+ view.dispatch({
263
+ selection: { anchor: line.from, head: line.from },
264
+ effects: scrollToLineEffect.of({ line: line.number, options: { offset: -16 } }),
265
+ });
175
266
 
176
- for (const range of decorationArray) {
177
- let shouldKeep = true;
267
+ continue;
268
+ }
178
269
 
179
- // For Backspace (delete.backward), check if decoration is immediately after cursor.
180
- if (userEvent === 'delete.backward' && range.from === cursorPos) {
181
- shouldKeep = false;
270
+ if (effect.is(navigateNextEffect)) {
271
+ const view = update.view;
272
+ const cursorPos = view.state.doc.lineAt(view.state.selection.main.head).to;
273
+ let widget: { from: number; to: number; tag: string } | null = null;
274
+ const { decorations } = view.state.field(widgetDecorationsField);
275
+ for (const range of decorationSetToArray(decorations)) {
276
+ if (range.from > cursorPos) {
277
+ const tag = range.value.spec.tag;
278
+ if (bookmarks?.includes(tag)) {
279
+ if (!widget || range.from < widget.from) {
280
+ widget = { from: range.from, to: range.to, tag };
281
+ }
282
+ }
283
+ }
182
284
  }
183
285
 
184
- // For Delete (delete.forward), check if decoration is immediately before cursor.
185
- if (userEvent === 'delete.forward' && range.to === cursorPos) {
186
- shouldKeep = false;
286
+ if (widget) {
287
+ const line = view.state.doc.lineAt(widget?.from);
288
+ view.dispatch({
289
+ selection: { anchor: line.to, head: line.to },
290
+ effects: scrollToLineEffect.of({ line: line.number, options: { offset: -16 } }),
291
+ });
292
+ } else {
293
+ const line = view.state.doc.lineAt(view.state.doc.length);
294
+ view.dispatch({
295
+ selection: { anchor: line.to, head: line.to },
296
+ effects: scrollToLineEffect.of({ line: line.number, options: { position: 'end' } }),
297
+ });
187
298
  }
188
299
 
189
- if (shouldKeep) {
190
- // Map the decoration position through the transaction changes.
191
- const mappedFrom = tr.changes.mapPos(range.from, -1);
192
- const mappedTo = tr.changes.mapPos(range.to, 1);
193
-
194
- // Only keep the decoration if mapping was successful and positions are valid.
195
- if (mappedFrom >= 0 && mappedTo >= mappedFrom && mappedTo <= state.doc.length) {
196
- filteredDecorations.push({
197
- from: mappedFrom,
198
- to: mappedTo,
199
- value: range.value,
200
- });
300
+ continue;
301
+ }
302
+ }
303
+ });
304
+ });
305
+ };
306
+
307
+ /**
308
+ * Handles effect that updates widget state.
309
+ */
310
+ const createWidgetUpdatePlugin = (
311
+ widgetDecorationsField: StateField<WidgetDecorationSet>,
312
+ notifier: XmlWidgetNotifier,
313
+ ) =>
314
+ ViewPlugin.fromClass(
315
+ class {
316
+ update(update: ViewUpdate) {
317
+ const widgetStateMap = update.state.field(widgetStateMapStateField);
318
+ const { decorations } = update.state.field(widgetDecorationsField);
319
+
320
+ // Check for widget update effects and re-render widgets.
321
+ for (const effect of update.transactions.flatMap((tr) => tr.effects)) {
322
+ if (effect.is(xmlTagUpdateEffect)) {
323
+ const widgetState = widgetStateMap[effect.value.id];
324
+
325
+ // Find and render widget.
326
+ for (const range of decorationSetToArray(decorations)) {
327
+ const deco = range.value;
328
+ const widget = deco?.spec?.widget;
329
+
330
+ // NOTE: If the widget has not yet been mounted, then the root will be null.
331
+ if (widget && widget instanceof PlaceholderWidget && widget.id === effect.value.id && widget.root) {
332
+ const props = { ...widget.props, ...widgetState };
333
+ notifier.mounted({ id: widget.id, props, root: widget.root, Component: widget.Component });
334
+ }
201
335
  }
202
336
  }
203
337
  }
338
+ }
339
+ },
340
+ );
204
341
 
205
- // Return updated decorations with adjacent ones removed and positions mapped.
206
- return {
207
- from,
208
- decorations: Decoration.set(filteredDecorations),
209
- };
342
+ /**
343
+ * Builds and maintains decorations for XML widgets.
344
+ * Must be a StateField because block decorations cannot be provided via ViewPlugin.
345
+ */
346
+ const createWidgetDecorationsField = (registry: XmlWidgetRegistry = {}, notifier: XmlWidgetNotifier) =>
347
+ StateField.define<WidgetDecorationSet>({
348
+ create: (state) => {
349
+ return buildDecorations(state, { from: 0, to: state.doc.length }, registry, notifier);
350
+ },
351
+ update: ({ from, decorations }, tr) => {
352
+ // Check for reset effect.
353
+ for (const effect of tr.effects) {
354
+ if (effect.is(xmlTagResetEffect)) {
355
+ return { from: 0, decorations: Decoration.none };
356
+ }
210
357
  }
211
358
 
212
359
  if (tr.docChanged) {
213
360
  const { state } = tr;
214
-
215
361
  // Flag if the transaction has modified the head of the document.
216
- // (i.e., any changes that touch before the current `from` position).
217
362
  const reset = tr.changes.touchesRange(0, from);
218
-
219
- // Since append-only, rebuild decorations from after the last widget.
220
- const result = buildDecorations(
221
- state,
222
- reset ? 0 : from,
223
- state.doc.length,
224
- state.field(contextState),
225
- state.field(widgetState),
226
- options,
227
- notifier,
228
- );
229
-
230
- // Merge with existing decorations.
231
- return {
232
- from: result.from,
233
- decorations: decorations.update({ add: decorationSetToArray(result.decorations) }),
234
- };
363
+ if (reset) {
364
+ log('document reset', { from, to: state.doc.length });
365
+ // Full rebuild from start.
366
+ return buildDecorations(state, { from: 0, to: state.doc.length }, registry, notifier);
367
+ } else {
368
+ // Append-only: rebuild decorations from after the last widget and merge with existing decorations.
369
+ const result = buildDecorations(state, { from, to: state.doc.length }, registry, notifier);
370
+ return {
371
+ from: result.from,
372
+ decorations: decorations.update({ add: decorationSetToArray(result.decorations) }),
373
+ };
374
+ }
235
375
  }
236
376
 
237
- // No document changes: avoid mapping decorations through an empty ChangeSet,
238
- // which can throw when the decoration set was created for a different base length.
239
- // Simply return the existing decorations unchanged.
240
377
  return { from, decorations };
241
378
  },
242
379
  provide: (field) => [
@@ -245,97 +382,66 @@ export const xmlTags = (options: XmlTagsOptions = {}): Extension => {
245
382
  ],
246
383
  });
247
384
 
248
- //
249
- // Widget state management.
250
- //
251
- const widgetState = StateField.define<XmlWidgetStateMap>({
252
- create: () => ({}),
253
- update: (map, tr) => {
254
- for (const effect of tr.effects) {
255
- if (effect.is(xmlTagResetEffect)) {
256
- return {};
257
- }
258
-
259
- if (effect.is(xmlTagUpdateEffect)) {
260
- // Update accumulated widget props by id.
261
- const { id, value } = effect.value;
262
- const state = typeof value === 'function' ? value(map[id]) : value;
263
-
264
- // Find and render widget.
265
- const { decorations } = tr.state.field(decorationsState);
266
- for (const range of decorationSetToArray(decorations)) {
267
- const deco = range.value;
268
- const widget = deco?.spec?.widget;
269
- if (widget && widget instanceof PlaceholderWidget && widget.id === effect.value.id && widget.root) {
270
- const props = { ...widget.props, ...state };
271
- notifier.mounted({ id: widget.id, props, root: widget.root, Component: widget.Component });
272
- }
273
- }
274
-
275
- return { ...map, [id]: state };
276
- }
277
- }
278
-
279
- return map;
280
- },
281
- });
282
-
283
- return [contextState, decorationsState, widgetState];
284
- };
285
-
286
385
  /**
287
386
  * Creates widget decorations for XML tags in the document using the syntax tree.
288
387
  */
289
388
  const buildDecorations = (
290
389
  state: EditorState,
291
- from: number,
292
- to: number,
293
- context: any,
294
- widgetState: XmlWidgetStateMap,
295
- options: XmlTagsOptions,
390
+ range: Range,
391
+ registry: XmlWidgetRegistry,
296
392
  notifier: XmlWidgetNotifier,
297
393
  ): WidgetDecorationSet => {
394
+ const context = state.field(widgetContextStateField, false);
395
+ const widgetStateMap = state.field(widgetStateMapStateField, false) ?? {};
396
+
298
397
  const builder = new RangeSetBuilder<Decoration>();
299
398
  const tree = syntaxTree(state);
300
399
  if (!tree || (tree.type.name === 'Program' && tree.length === 0)) {
301
- return { from, decorations: Decoration.none };
400
+ return { from: range.from, decorations: Decoration.none };
302
401
  }
303
402
 
403
+ let last = range.from;
304
404
  tree.iterate({
305
- from,
306
- to,
405
+ from: range.from,
406
+ to: range.to,
307
407
  enter: (node) => {
308
408
  switch (node.type.name) {
309
409
  // XML Element.
310
410
  case 'Element': {
311
411
  try {
312
- if (options.registry) {
313
- const props = nodeToJson(state, node.node);
314
- if (props) {
315
- const def = options.registry[props._tag];
316
- if (def) {
317
- const { block, factory, Component } = def;
318
- const state = props.id ? widgetState[props.id] : undefined;
319
- const args = { context, ...props, ...state };
320
- const widget: WidgetType | undefined = factory
321
- ? factory(args)
322
- : Component
323
- ? props.id && new PlaceholderWidget(props.id, Component, args, notifier)
324
- : undefined;
325
-
326
- if (widget) {
327
- from = node.node.to;
328
- builder.add(
329
- node.node.from,
330
- node.node.to,
331
- Decoration.replace({
332
- widget,
333
- block,
334
- atomic: true,
335
- inclusive: true,
336
- }),
337
- );
338
- }
412
+ const args = nodeToJson(state, node.node);
413
+ if (args) {
414
+ const def = registry[args._tag];
415
+ if (def) {
416
+ // NOTE: The widget state may already have been updated before the widget is mounted.
417
+ const { block, factory, Component } = def;
418
+ const widgetState = args.id ? widgetStateMap[args.id] : undefined;
419
+ const nodeRange = { from: node.node.from, to: node.node.to };
420
+ const props = { context, range: nodeRange, ...args, ...widgetState } satisfies XmlWidgetProps;
421
+
422
+ // Create widget.
423
+ const widget: WidgetType | undefined = factory
424
+ ? factory(props)
425
+ : Component
426
+ ? args.id && new PlaceholderWidget(args.id, Component, props, notifier)
427
+ : undefined;
428
+
429
+ // Add decoration.
430
+ if (widget) {
431
+ builder.add(
432
+ nodeRange.from,
433
+ nodeRange.to,
434
+ Decoration.replace({
435
+ widget,
436
+ block,
437
+ atomic: true,
438
+ inclusive: true,
439
+ tag: args._tag,
440
+ }),
441
+ );
442
+
443
+ // Track last widget (NOTE: range is inclusive).
444
+ last = nodeRange.to - 1;
339
445
  }
340
446
  }
341
447
  }
@@ -343,19 +449,20 @@ const buildDecorations = (
343
449
  log.catch(err);
344
450
  }
345
451
 
346
- return false; // Don't descend into children.
452
+ // Don't descend into children.
453
+ return false;
347
454
  }
348
455
  }
349
456
  },
350
457
  });
351
458
 
352
- return { from, decorations: builder.finish() };
459
+ return { from: last, decorations: builder.finish() };
353
460
  };
354
461
 
355
462
  /**
356
463
  * Placeholder for React widgets.
357
464
  */
358
- class PlaceholderWidget<TProps = {}> extends WidgetType {
465
+ class PlaceholderWidget<TProps extends XmlWidgetProps> extends WidgetType {
359
466
  private _root: HTMLElement | null = null;
360
467
 
361
468
  constructor(
@@ -368,25 +475,25 @@ class PlaceholderWidget<TProps = {}> extends WidgetType {
368
475
  invariant(id);
369
476
  }
370
477
 
371
- get root() {
478
+ get root(): HTMLElement | null {
372
479
  return this._root;
373
480
  }
374
481
 
375
- override eq(other: WidgetType): boolean {
376
- return other instanceof PlaceholderWidget && this.id === other.id;
482
+ override eq(other: this) {
483
+ return this.id === other.id;
377
484
  }
378
485
 
379
486
  override ignoreEvent() {
380
487
  return true;
381
488
  }
382
489
 
383
- override toDOM(_view: EditorView): HTMLElement {
490
+ override toDOM(_view: EditorView) {
384
491
  this._root = document.createElement('span');
385
- this.notifier.mounted({ id: this.id, props: this.props, root: this._root, Component: this.Component });
492
+ this.notifier.mounted({ id: this.id, root: this._root, props: this.props, Component: this.Component });
386
493
  return this._root;
387
494
  }
388
495
 
389
- override destroy(_dom: HTMLElement): void {
496
+ override destroy(_dom: HTMLElement) {
390
497
  this.notifier.unmounted(this.id);
391
498
  this._root = null;
392
499
  }
@@ -35,7 +35,7 @@ export const typewriter = ({ delay = 75, items = defaultItems }: DemoOptions = {
35
35
  {
36
36
  // Next prompt.
37
37
  // TODO(burdon): Press 1-9 to select prompt?
38
- key: "shift-meta-'",
38
+ key: "Shift-Meta-'",
39
39
  run: (view) => {
40
40
  clearTimeout(t);
41
41
  // TODO(burdon): Add space if needed.