@dxos/ui-editor 0.0.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.
- package/LICENSE +8 -0
- package/README.md +21 -0
- package/package.json +121 -0
- package/src/defaults.ts +34 -0
- package/src/extensions/annotations.ts +55 -0
- package/src/extensions/autocomplete/autocomplete.ts +151 -0
- package/src/extensions/autocomplete/index.ts +8 -0
- package/src/extensions/autocomplete/match.ts +46 -0
- package/src/extensions/autocomplete/placeholder.ts +117 -0
- package/src/extensions/autocomplete/typeahead.ts +87 -0
- package/src/extensions/automerge/automerge.test.tsx +76 -0
- package/src/extensions/automerge/automerge.ts +105 -0
- package/src/extensions/automerge/cursor.ts +28 -0
- package/src/extensions/automerge/defs.ts +31 -0
- package/src/extensions/automerge/index.ts +5 -0
- package/src/extensions/automerge/sync.ts +79 -0
- package/src/extensions/automerge/update-automerge.ts +50 -0
- package/src/extensions/automerge/update-codemirror.ts +115 -0
- package/src/extensions/autoscroll.ts +165 -0
- package/src/extensions/awareness/awareness-provider.ts +127 -0
- package/src/extensions/awareness/awareness.ts +315 -0
- package/src/extensions/awareness/index.ts +6 -0
- package/src/extensions/blast.ts +363 -0
- package/src/extensions/blocks.ts +131 -0
- package/src/extensions/bookmarks.ts +77 -0
- package/src/extensions/comments.ts +579 -0
- package/src/extensions/debug.ts +15 -0
- package/src/extensions/dnd.ts +39 -0
- package/src/extensions/factories.ts +284 -0
- package/src/extensions/focus.ts +36 -0
- package/src/extensions/folding.ts +63 -0
- package/src/extensions/hashtag.ts +68 -0
- package/src/extensions/index.ts +34 -0
- package/src/extensions/json.ts +57 -0
- package/src/extensions/listener.ts +32 -0
- package/src/extensions/markdown/action.ts +117 -0
- package/src/extensions/markdown/bundle.ts +105 -0
- package/src/extensions/markdown/changes.test.ts +26 -0
- package/src/extensions/markdown/changes.ts +149 -0
- package/src/extensions/markdown/debug.ts +44 -0
- package/src/extensions/markdown/decorate.ts +622 -0
- package/src/extensions/markdown/formatting.test.ts +498 -0
- package/src/extensions/markdown/formatting.ts +1265 -0
- package/src/extensions/markdown/highlight.ts +183 -0
- package/src/extensions/markdown/image.ts +118 -0
- package/src/extensions/markdown/index.ts +13 -0
- package/src/extensions/markdown/link.ts +50 -0
- package/src/extensions/markdown/parser.test.ts +75 -0
- package/src/extensions/markdown/styles.ts +135 -0
- package/src/extensions/markdown/table.ts +150 -0
- package/src/extensions/mention.ts +41 -0
- package/src/extensions/modal.ts +24 -0
- package/src/extensions/modes.ts +41 -0
- package/src/extensions/outliner/commands.ts +270 -0
- package/src/extensions/outliner/editor.test.ts +33 -0
- package/src/extensions/outliner/editor.ts +184 -0
- package/src/extensions/outliner/index.ts +7 -0
- package/src/extensions/outliner/menu.ts +128 -0
- package/src/extensions/outliner/outliner.test.ts +100 -0
- package/src/extensions/outliner/outliner.ts +167 -0
- package/src/extensions/outliner/selection.ts +50 -0
- package/src/extensions/outliner/tree.test.ts +168 -0
- package/src/extensions/outliner/tree.ts +317 -0
- package/src/extensions/preview/index.ts +5 -0
- package/src/extensions/preview/preview.ts +193 -0
- package/src/extensions/replacer.test.ts +75 -0
- package/src/extensions/replacer.ts +93 -0
- package/src/extensions/scrolling.ts +189 -0
- package/src/extensions/selection.ts +100 -0
- package/src/extensions/state.ts +7 -0
- package/src/extensions/submit.ts +62 -0
- package/src/extensions/tags/extended-markdown.test.ts +263 -0
- package/src/extensions/tags/extended-markdown.ts +78 -0
- package/src/extensions/tags/index.ts +7 -0
- package/src/extensions/tags/streamer.ts +243 -0
- package/src/extensions/tags/xml-tags.ts +507 -0
- package/src/extensions/tags/xml-util.test.ts +48 -0
- package/src/extensions/tags/xml-util.ts +93 -0
- package/src/extensions/typewriter.ts +68 -0
- package/src/index.ts +14 -0
- package/src/styles/index.ts +7 -0
- package/src/styles/markdown.ts +26 -0
- package/src/styles/theme.ts +293 -0
- package/src/styles/tokens.ts +17 -0
- package/src/types/index.ts +5 -0
- package/src/types/types.ts +32 -0
- package/src/util/cursor.ts +56 -0
- package/src/util/debug.ts +56 -0
- package/src/util/decorations.ts +21 -0
- package/src/util/dom.ts +36 -0
- package/src/util/facet.ts +13 -0
- package/src/util/index.ts +10 -0
- package/src/util/util.ts +29 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { syntaxTree } from '@codemirror/language';
|
|
6
|
+
import { type EditorState, type Extension, Prec, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
|
7
|
+
import {
|
|
8
|
+
Decoration,
|
|
9
|
+
type DecorationSet,
|
|
10
|
+
EditorView,
|
|
11
|
+
ViewPlugin,
|
|
12
|
+
type ViewUpdate,
|
|
13
|
+
WidgetType,
|
|
14
|
+
keymap,
|
|
15
|
+
} from '@codemirror/view';
|
|
16
|
+
// TODO(burdon): Factor out agnostic types (React/solid).
|
|
17
|
+
import { type FunctionComponent } from 'react';
|
|
18
|
+
|
|
19
|
+
import { invariant } from '@dxos/invariant';
|
|
20
|
+
import { log } from '@dxos/log';
|
|
21
|
+
|
|
22
|
+
import { type Range } from '../../types';
|
|
23
|
+
import { decorationSetToArray } from '../../util';
|
|
24
|
+
import { scrollToLineEffect } from '../scrolling';
|
|
25
|
+
|
|
26
|
+
import { nodeToJson } from './xml-util';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* StateEffect for navigating to previous bookmark.
|
|
30
|
+
*/
|
|
31
|
+
export const navigatePreviousEffect = StateEffect.define<void>();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* StateEffect for navigating to next bookmark.
|
|
35
|
+
*/
|
|
36
|
+
export const navigateNextEffect = StateEffect.define<void>();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Dispatch function for updating state.
|
|
40
|
+
*/
|
|
41
|
+
export type StateDispatch<T> = T | ((state: T) => T);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Manages widget state.
|
|
45
|
+
*/
|
|
46
|
+
export interface XmlWidgetStateManager {
|
|
47
|
+
updateWidget<T>(id: string, props: StateDispatch<T>): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type XmlEventHandler<TEvent = any> = (event: TEvent) => void;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Widget component.
|
|
54
|
+
*/
|
|
55
|
+
export type XmlWidgetProps<TProps = any, TContext = any> = TProps & {
|
|
56
|
+
_tag: string;
|
|
57
|
+
context?: TContext;
|
|
58
|
+
range?: { from: number; to: number };
|
|
59
|
+
view?: EditorView;
|
|
60
|
+
onEvent?: XmlEventHandler;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Factory for creating widgets.
|
|
65
|
+
*/
|
|
66
|
+
export type XmlWidgetFactory = (props: XmlWidgetProps, onEvent?: XmlEventHandler) => WidgetType | null;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Widget registry definition.
|
|
70
|
+
*/
|
|
71
|
+
export type XmlWidgetDef = {
|
|
72
|
+
/** Block widget. */
|
|
73
|
+
block?: boolean;
|
|
74
|
+
|
|
75
|
+
/** Native widget (rendered inline). */
|
|
76
|
+
factory?: XmlWidgetFactory;
|
|
77
|
+
|
|
78
|
+
/** React/Solid widget (rendered in portals outside of the editor). */
|
|
79
|
+
Component?: FunctionComponent<XmlWidgetProps>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type XmlWidgetRegistry = Record<string, XmlWidgetDef>;
|
|
83
|
+
|
|
84
|
+
export const getXmlTextChild = (children: any[]): string | null => {
|
|
85
|
+
const child = children?.[0];
|
|
86
|
+
return typeof child === 'string' ? child : null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Update context.
|
|
91
|
+
*/
|
|
92
|
+
export const xmlTagContextEffect = StateEffect.define<any>();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reset all state.
|
|
96
|
+
*/
|
|
97
|
+
export const xmlTagResetEffect = StateEffect.define();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Update widget.
|
|
101
|
+
*/
|
|
102
|
+
export const xmlTagUpdateEffect = StateEffect.define<{ id: string; value: any }>();
|
|
103
|
+
|
|
104
|
+
type WidgetDecorationSet = {
|
|
105
|
+
from: number;
|
|
106
|
+
decorations: DecorationSet;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type XmlWidgetStateMap = Record<string, any>;
|
|
110
|
+
|
|
111
|
+
export type XmlWidgetState = {
|
|
112
|
+
id: string;
|
|
113
|
+
root: HTMLElement;
|
|
114
|
+
props: any;
|
|
115
|
+
Component: FunctionComponent<XmlWidgetProps>;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export interface XmlWidgetNotifier {
|
|
119
|
+
mounted(widget: XmlWidgetState): void;
|
|
120
|
+
unmounted(id: string): void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Context state.
|
|
125
|
+
*/
|
|
126
|
+
const widgetContextStateField = StateField.define<any>({
|
|
127
|
+
create: () => undefined,
|
|
128
|
+
update: (value, tr) => {
|
|
129
|
+
for (const effect of tr.effects) {
|
|
130
|
+
if (effect.is(xmlTagContextEffect)) {
|
|
131
|
+
return effect.value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return value;
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Widget state management.
|
|
141
|
+
*/
|
|
142
|
+
const widgetStateMapStateField = StateField.define<XmlWidgetStateMap>({
|
|
143
|
+
create: () => ({}),
|
|
144
|
+
update: (map, tr) => {
|
|
145
|
+
for (const effect of tr.effects) {
|
|
146
|
+
if (effect.is(xmlTagResetEffect)) {
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (effect.is(xmlTagUpdateEffect)) {
|
|
151
|
+
// Update accumulated widget props by id.
|
|
152
|
+
const { id, value } = effect.value;
|
|
153
|
+
log('widget updated', { id, value });
|
|
154
|
+
const state = typeof value === 'function' ? value(map[id]) : value;
|
|
155
|
+
return { ...map, [id]: state };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return map;
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export type XmlTagsOptions = {
|
|
164
|
+
/** Tag registry. */
|
|
165
|
+
registry?: XmlWidgetRegistry;
|
|
166
|
+
|
|
167
|
+
/** Called when widgets are mounted or unmounted. */
|
|
168
|
+
setWidgets?: (widgets: XmlWidgetState[]) => void;
|
|
169
|
+
|
|
170
|
+
/** Tags to bookmark. */
|
|
171
|
+
bookmarks?: string[];
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Implements custom XML tags via CodeMirror-native Widgets and portaled React/Solid components.
|
|
176
|
+
*
|
|
177
|
+
* Basic mechanism:
|
|
178
|
+
* - Decorations are created from XML tags that matched the provided Widget registry.
|
|
179
|
+
* - Native widgets are rendered inline.
|
|
180
|
+
* - React/Solid widgets are rendered in portals outside of the editor via the PlaceholderWidget.
|
|
181
|
+
* - Widget state can be update via effects.
|
|
182
|
+
* - NOTE: Widget state may be updated BEFORE the widget is mounted.
|
|
183
|
+
*/
|
|
184
|
+
export const xmlTags = ({ registry, setWidgets, bookmarks }: XmlTagsOptions = {}): Extension => {
|
|
185
|
+
const notifier = createWidgetMap(setWidgets);
|
|
186
|
+
const widgetDecorationsField = createWidgetDecorationsField(registry, notifier);
|
|
187
|
+
return [
|
|
188
|
+
widgetContextStateField,
|
|
189
|
+
widgetStateMapStateField,
|
|
190
|
+
widgetDecorationsField,
|
|
191
|
+
createWidgetUpdatePlugin(widgetDecorationsField, notifier),
|
|
192
|
+
createNavigationEffectPlugin(widgetDecorationsField, bookmarks),
|
|
193
|
+
bookmarks?.length ? Prec.highest(keyHandlers) : [],
|
|
194
|
+
];
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Manages the collection of widgets.
|
|
199
|
+
*/
|
|
200
|
+
const createWidgetMap = (setWidgets?: (widgets: XmlWidgetState[]) => void): XmlWidgetNotifier => {
|
|
201
|
+
const widgets = new Map<string, XmlWidgetState>();
|
|
202
|
+
|
|
203
|
+
// TODO(burdon): Batch updates?
|
|
204
|
+
const notifier = {
|
|
205
|
+
mounted: (state: XmlWidgetState) => {
|
|
206
|
+
log('widget mounted', { id: state.id, tag: state.props._tag });
|
|
207
|
+
widgets.set(state.id, state);
|
|
208
|
+
setWidgets?.([...widgets.values()]);
|
|
209
|
+
},
|
|
210
|
+
unmounted: (id: string) => {
|
|
211
|
+
const state = widgets.get(id);
|
|
212
|
+
log('widget unmounted', { id, tag: state?.props._tag });
|
|
213
|
+
widgets.delete(id);
|
|
214
|
+
setWidgets?.([...widgets.values()]);
|
|
215
|
+
},
|
|
216
|
+
} satisfies XmlWidgetNotifier;
|
|
217
|
+
|
|
218
|
+
return notifier;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Navigation keys.
|
|
223
|
+
*/
|
|
224
|
+
const keyHandlers = keymap.of([
|
|
225
|
+
{
|
|
226
|
+
key: 'Mod-ArrowUp',
|
|
227
|
+
run: (view) => {
|
|
228
|
+
view.dispatch({ effects: navigatePreviousEffect.of() });
|
|
229
|
+
return true;
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
key: 'Mod-ArrowDown',
|
|
234
|
+
run: (view) => {
|
|
235
|
+
view.dispatch({ effects: navigateNextEffect.of() });
|
|
236
|
+
return true;
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Effect processing plugin for navigation.
|
|
243
|
+
* Handles navigation up/down effects.
|
|
244
|
+
*/
|
|
245
|
+
const createNavigationEffectPlugin = (
|
|
246
|
+
widgetDecorationsField: StateField<WidgetDecorationSet>,
|
|
247
|
+
bookmarks?: string[],
|
|
248
|
+
) => {
|
|
249
|
+
return EditorView.updateListener.of((update) => {
|
|
250
|
+
update.transactions.forEach((transaction) => {
|
|
251
|
+
for (const effect of transaction.effects) {
|
|
252
|
+
if (effect.is(navigatePreviousEffect)) {
|
|
253
|
+
const view = update.view;
|
|
254
|
+
const cursorPos = view.state.doc.lineAt(view.state.selection.main.head).from;
|
|
255
|
+
let widget: { from: number; to: number; tag: string } | null = null;
|
|
256
|
+
const { decorations } = view.state.field(widgetDecorationsField);
|
|
257
|
+
for (const range of decorationSetToArray(decorations)) {
|
|
258
|
+
if (range.from < cursorPos) {
|
|
259
|
+
const tag = range.value.spec.tag;
|
|
260
|
+
if (bookmarks?.includes(tag)) {
|
|
261
|
+
if (!widget || range.from > widget.from) {
|
|
262
|
+
widget = { from: range.from, to: range.to, tag };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const line = view.state.doc.lineAt(widget?.from ?? 0);
|
|
269
|
+
view.dispatch({
|
|
270
|
+
selection: { anchor: line.from, head: line.from },
|
|
271
|
+
effects: scrollToLineEffect.of({ line: line.number, options: { offset: -16 } }),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (effect.is(navigateNextEffect)) {
|
|
278
|
+
const view = update.view;
|
|
279
|
+
const cursorPos = view.state.doc.lineAt(view.state.selection.main.head).to;
|
|
280
|
+
let widget: { from: number; to: number; tag: string } | null = null;
|
|
281
|
+
const { decorations } = view.state.field(widgetDecorationsField);
|
|
282
|
+
for (const range of decorationSetToArray(decorations)) {
|
|
283
|
+
if (range.from > cursorPos) {
|
|
284
|
+
const tag = range.value.spec.tag;
|
|
285
|
+
if (bookmarks?.includes(tag)) {
|
|
286
|
+
if (!widget || range.from < widget.from) {
|
|
287
|
+
widget = { from: range.from, to: range.to, tag };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (widget) {
|
|
294
|
+
const line = view.state.doc.lineAt(widget?.from);
|
|
295
|
+
view.dispatch({
|
|
296
|
+
selection: { anchor: line.to, head: line.to },
|
|
297
|
+
effects: scrollToLineEffect.of({ line: line.number, options: { offset: -16 } }),
|
|
298
|
+
});
|
|
299
|
+
} else {
|
|
300
|
+
const line = view.state.doc.lineAt(view.state.doc.length);
|
|
301
|
+
view.dispatch({
|
|
302
|
+
selection: { anchor: line.to, head: line.to },
|
|
303
|
+
effects: scrollToLineEffect.of({ line: line.number, options: { position: 'end' } }),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Handles effect that updates widget state.
|
|
316
|
+
*/
|
|
317
|
+
const createWidgetUpdatePlugin = (
|
|
318
|
+
widgetDecorationsField: StateField<WidgetDecorationSet>,
|
|
319
|
+
notifier: XmlWidgetNotifier,
|
|
320
|
+
) =>
|
|
321
|
+
ViewPlugin.fromClass(
|
|
322
|
+
class {
|
|
323
|
+
update(update: ViewUpdate) {
|
|
324
|
+
const widgetStateMap = update.state.field(widgetStateMapStateField);
|
|
325
|
+
const { decorations } = update.state.field(widgetDecorationsField);
|
|
326
|
+
|
|
327
|
+
// Check for widget update effects and re-render widgets.
|
|
328
|
+
for (const effect of update.transactions.flatMap((tr) => tr.effects)) {
|
|
329
|
+
if (effect.is(xmlTagUpdateEffect)) {
|
|
330
|
+
const widgetState = widgetStateMap[effect.value.id];
|
|
331
|
+
|
|
332
|
+
// Find and render widget.
|
|
333
|
+
for (const range of decorationSetToArray(decorations)) {
|
|
334
|
+
const deco = range.value;
|
|
335
|
+
const widget = deco?.spec?.widget;
|
|
336
|
+
|
|
337
|
+
// NOTE: If the widget has not yet been mounted, then the root will be null.
|
|
338
|
+
if (widget && widget instanceof PlaceholderWidget && widget.id === effect.value.id && widget.root) {
|
|
339
|
+
const props = { ...widget.props, ...widgetState };
|
|
340
|
+
notifier.mounted({ id: widget.id, props, root: widget.root, Component: widget.Component });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Builds and maintains decorations for XML widgets.
|
|
351
|
+
* Must be a StateField because block decorations cannot be provided via ViewPlugin.
|
|
352
|
+
*/
|
|
353
|
+
const createWidgetDecorationsField = (registry: XmlWidgetRegistry = {}, notifier: XmlWidgetNotifier) =>
|
|
354
|
+
StateField.define<WidgetDecorationSet>({
|
|
355
|
+
create: (state) => {
|
|
356
|
+
return buildDecorations(state, { from: 0, to: state.doc.length }, registry, notifier);
|
|
357
|
+
},
|
|
358
|
+
update: ({ from, decorations }, tr) => {
|
|
359
|
+
// Check for reset effect.
|
|
360
|
+
for (const effect of tr.effects) {
|
|
361
|
+
if (effect.is(xmlTagResetEffect)) {
|
|
362
|
+
return { from: 0, decorations: Decoration.none };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (tr.docChanged) {
|
|
367
|
+
const { state } = tr;
|
|
368
|
+
// Flag if the transaction has modified the head of the document.
|
|
369
|
+
const reset = tr.changes.touchesRange(0, from);
|
|
370
|
+
if (reset) {
|
|
371
|
+
log('document reset', { from, to: state.doc.length });
|
|
372
|
+
// Full rebuild from start.
|
|
373
|
+
return buildDecorations(state, { from: 0, to: state.doc.length }, registry, notifier);
|
|
374
|
+
} else {
|
|
375
|
+
// Append-only: rebuild decorations from after the last widget and merge with existing decorations.
|
|
376
|
+
const result = buildDecorations(state, { from, to: state.doc.length }, registry, notifier);
|
|
377
|
+
return {
|
|
378
|
+
from: result.from,
|
|
379
|
+
decorations: decorations.update({ add: decorationSetToArray(result.decorations) }),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { from, decorations };
|
|
385
|
+
},
|
|
386
|
+
provide: (field) => [
|
|
387
|
+
EditorView.decorations.from(field, (v) => v.decorations),
|
|
388
|
+
EditorView.atomicRanges.of((view) => view.state.field(field).decorations || Decoration.none),
|
|
389
|
+
],
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Creates widget decorations for XML tags in the document using the syntax tree.
|
|
394
|
+
*/
|
|
395
|
+
const buildDecorations = (
|
|
396
|
+
state: EditorState,
|
|
397
|
+
range: Range,
|
|
398
|
+
registry: XmlWidgetRegistry,
|
|
399
|
+
notifier: XmlWidgetNotifier,
|
|
400
|
+
): WidgetDecorationSet => {
|
|
401
|
+
const context = state.field(widgetContextStateField, false);
|
|
402
|
+
const widgetStateMap = state.field(widgetStateMapStateField, false) ?? {};
|
|
403
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
404
|
+
|
|
405
|
+
const tree = syntaxTree(state);
|
|
406
|
+
if (!tree || (tree.type.name === 'Program' && tree.length === 0)) {
|
|
407
|
+
return { from: range.from, decorations: Decoration.none };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let last = range.from;
|
|
411
|
+
tree.iterate({
|
|
412
|
+
from: range.from,
|
|
413
|
+
to: range.to,
|
|
414
|
+
enter: (node) => {
|
|
415
|
+
switch (node.type.name) {
|
|
416
|
+
// XML Element.
|
|
417
|
+
case 'Element': {
|
|
418
|
+
try {
|
|
419
|
+
const args = nodeToJson(state, node.node);
|
|
420
|
+
if (args) {
|
|
421
|
+
const def = registry[args._tag];
|
|
422
|
+
if (def) {
|
|
423
|
+
// NOTE: The widget state may already have been updated before the widget is mounted.
|
|
424
|
+
const { block, factory, Component } = def;
|
|
425
|
+
const widgetState = args.id ? widgetStateMap[args.id] : undefined;
|
|
426
|
+
const nodeRange = { from: node.node.from, to: node.node.to };
|
|
427
|
+
const props = { context, range: nodeRange, ...args, ...widgetState } satisfies XmlWidgetProps;
|
|
428
|
+
|
|
429
|
+
// Create widget.
|
|
430
|
+
const widget: WidgetType | undefined = factory
|
|
431
|
+
? factory(props)
|
|
432
|
+
: Component
|
|
433
|
+
? args.id && new PlaceholderWidget(args.id, Component, props, notifier)
|
|
434
|
+
: undefined;
|
|
435
|
+
|
|
436
|
+
// Add decoration.
|
|
437
|
+
if (widget) {
|
|
438
|
+
builder.add(
|
|
439
|
+
nodeRange.from,
|
|
440
|
+
nodeRange.to,
|
|
441
|
+
Decoration.replace({
|
|
442
|
+
widget,
|
|
443
|
+
block,
|
|
444
|
+
atomic: true,
|
|
445
|
+
inclusive: true,
|
|
446
|
+
tag: args._tag,
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// Track last widget (NOTE: range is inclusive).
|
|
451
|
+
last = nodeRange.to - 1;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
} catch (err) {
|
|
456
|
+
log.catch(err);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Don't descend into children.
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
return { from: last, decorations: builder.finish() };
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Placeholder for widgets.
|
|
471
|
+
*/
|
|
472
|
+
class PlaceholderWidget<TProps extends XmlWidgetProps> extends WidgetType {
|
|
473
|
+
private _root: HTMLElement | null = null;
|
|
474
|
+
|
|
475
|
+
constructor(
|
|
476
|
+
public readonly id: string,
|
|
477
|
+
public readonly Component: FunctionComponent<TProps>,
|
|
478
|
+
public readonly props: TProps,
|
|
479
|
+
private readonly notifier: XmlWidgetNotifier,
|
|
480
|
+
) {
|
|
481
|
+
super();
|
|
482
|
+
invariant(id);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
get root(): HTMLElement | null {
|
|
486
|
+
return this._root;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
override eq(other: this) {
|
|
490
|
+
return this.id === other.id;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
override ignoreEvent() {
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
override toDOM(_view: EditorView) {
|
|
498
|
+
this._root = document.createElement('span');
|
|
499
|
+
this.notifier.mounted({ id: this.id, root: this._root, props: this.props, Component: this.Component });
|
|
500
|
+
return this._root;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
override destroy(_dom: HTMLElement) {
|
|
504
|
+
this.notifier.unmounted(this.id);
|
|
505
|
+
this._root = null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { syntaxTree } from '@codemirror/language';
|
|
6
|
+
import { EditorState } from '@codemirror/state';
|
|
7
|
+
import { describe, it } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { Trigger } from '@dxos/async';
|
|
10
|
+
import { trim } from '@dxos/util';
|
|
11
|
+
|
|
12
|
+
import { extendedMarkdown } from './extended-markdown';
|
|
13
|
+
import { xmlTags } from './xml-tags';
|
|
14
|
+
import { nodeToJson } from './xml-util';
|
|
15
|
+
|
|
16
|
+
describe('nodeToJson', () => {
|
|
17
|
+
it('should parse a simple element', async ({ expect }) => {
|
|
18
|
+
const xml = trim`
|
|
19
|
+
# Test
|
|
20
|
+
|
|
21
|
+
<test id="123" foo="100" />
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const state = EditorState.create({
|
|
25
|
+
doc: xml,
|
|
26
|
+
extensions: [extendedMarkdown(), xmlTags()],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const value = new Trigger<any>();
|
|
30
|
+
syntaxTree(state).iterate({
|
|
31
|
+
enter: (node) => {
|
|
32
|
+
switch (node.type.name) {
|
|
33
|
+
case 'Element': {
|
|
34
|
+
const args = nodeToJson(state, node.node);
|
|
35
|
+
value.wake(args);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(await value.wait()).toEqual({
|
|
43
|
+
_tag: 'test',
|
|
44
|
+
id: '123',
|
|
45
|
+
foo: '100',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type EditorState } from '@codemirror/state';
|
|
6
|
+
import { type SyntaxNode } from '@lezer/common';
|
|
7
|
+
|
|
8
|
+
import { invariant } from '@dxos/invariant';
|
|
9
|
+
|
|
10
|
+
export type Tag = Record<string, any> & {
|
|
11
|
+
_tag: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse XML Element.
|
|
16
|
+
*/
|
|
17
|
+
export const nodeToJson = (state: EditorState, node: SyntaxNode): Tag | undefined => {
|
|
18
|
+
invariant(node.type.name === 'Element', 'Node is not an Element');
|
|
19
|
+
|
|
20
|
+
// Find the opening tag.
|
|
21
|
+
const openTag = node.node.getChild('OpenTag') || node.node.getChild('SelfClosingTag');
|
|
22
|
+
if (openTag) {
|
|
23
|
+
// Extract tag name.
|
|
24
|
+
const tagName = openTag.getChild('TagName');
|
|
25
|
+
if (!tagName) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const tag: Tag = {
|
|
30
|
+
_tag: state.doc.sliceString(tagName.from, tagName.to),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Extract attributes.
|
|
34
|
+
let attributeNode = openTag.getChild('Attribute');
|
|
35
|
+
while (attributeNode) {
|
|
36
|
+
const attrName = attributeNode.getChild('AttributeName');
|
|
37
|
+
if (attrName) {
|
|
38
|
+
const attr = state.doc.sliceString(attrName.from, attrName.to);
|
|
39
|
+
|
|
40
|
+
// Default for attributes without values.
|
|
41
|
+
let value: any = undefined;
|
|
42
|
+
const attrValue = attributeNode.getChild('AttributeValue');
|
|
43
|
+
if (attrValue) {
|
|
44
|
+
const rawValue = state.doc.sliceString(attrValue.from, attrValue.to);
|
|
45
|
+
if (
|
|
46
|
+
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
|
|
47
|
+
(rawValue.startsWith("'") && rawValue.endsWith("'"))
|
|
48
|
+
) {
|
|
49
|
+
// Remove quotes if present.
|
|
50
|
+
value = rawValue.slice(1, -1);
|
|
51
|
+
} else {
|
|
52
|
+
value = rawValue;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
tag[attr] = value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Get next sibling attribute.
|
|
60
|
+
attributeNode = attributeNode.nextSibling;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Extract children for non-self-closing tags.
|
|
64
|
+
if (node.type.name === 'Element' && openTag.type.name !== 'SelfClosingTag') {
|
|
65
|
+
const children: any[] = [];
|
|
66
|
+
let child = node.node.firstChild;
|
|
67
|
+
|
|
68
|
+
while (child) {
|
|
69
|
+
// Skip the opening and closing tags.
|
|
70
|
+
if (child.type.name !== 'OpenTag' && child.type.name !== 'CloseTag') {
|
|
71
|
+
if (child.type.name === 'Text') {
|
|
72
|
+
const text = state.doc.sliceString(child.from, child.to).trim();
|
|
73
|
+
if (text) {
|
|
74
|
+
children.push(text);
|
|
75
|
+
}
|
|
76
|
+
} else if (child.type.name === 'Element') {
|
|
77
|
+
const data = nodeToJson(state, child);
|
|
78
|
+
if (data) {
|
|
79
|
+
children.push(data);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
child = child.nextSibling;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (children.length > 0) {
|
|
87
|
+
tag.children = children;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return tag;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Extension } from '@codemirror/state';
|
|
6
|
+
import { keymap } from '@codemirror/view';
|
|
7
|
+
|
|
8
|
+
// TODO(burdon): Review https://github.com/sergeche/codemirror-movie?tab=readme-ov-file
|
|
9
|
+
|
|
10
|
+
export type DemoOptions = {
|
|
11
|
+
delay?: number;
|
|
12
|
+
items?: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const defaultItems = ['hello world!', 'this is a test.', 'this is [DXOS](https://dxos.org)'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Configurable plugin that let's user cycle through pre-configured input script.
|
|
19
|
+
*/
|
|
20
|
+
export const typewriter = ({ delay = 75, items = defaultItems }: DemoOptions = {}): Extension => {
|
|
21
|
+
let t: any;
|
|
22
|
+
let idx = 0; // TODO(burdon): Make global.
|
|
23
|
+
|
|
24
|
+
return [
|
|
25
|
+
keymap.of([
|
|
26
|
+
{
|
|
27
|
+
// Reset.
|
|
28
|
+
key: "alt-meta-'",
|
|
29
|
+
run: () => {
|
|
30
|
+
clearTimeout(t);
|
|
31
|
+
idx = 0;
|
|
32
|
+
return true;
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
// Next prompt.
|
|
37
|
+
// TODO(burdon): Press 1-9 to select prompt?
|
|
38
|
+
key: "Shift-Meta-'",
|
|
39
|
+
run: (view) => {
|
|
40
|
+
clearTimeout(t);
|
|
41
|
+
// TODO(burdon): Add space if needed.
|
|
42
|
+
const text = items[idx++];
|
|
43
|
+
if (idx === items?.length) {
|
|
44
|
+
idx = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let i = 0;
|
|
48
|
+
const insert = (d = 0) => {
|
|
49
|
+
t = setTimeout(() => {
|
|
50
|
+
const pos = view.state.selection.main.head;
|
|
51
|
+
view.dispatch({
|
|
52
|
+
changes: { from: pos, insert: text[i++] },
|
|
53
|
+
selection: { anchor: pos + 1 },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (i < text.length) {
|
|
57
|
+
insert(Math.random() * delay * (text[i] === ' ' ? 2 : 1));
|
|
58
|
+
}
|
|
59
|
+
}, d);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
insert();
|
|
63
|
+
return true;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
]),
|
|
67
|
+
];
|
|
68
|
+
};
|