@dxos/react-ui-editor 0.8.3-main.7f5a14c → 0.8.3

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 (76) hide show
  1. package/dist/lib/browser/index.mjs +371 -375
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +502 -511
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +371 -375
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
  11. package/dist/types/src/components/Popover/RefDropdownMenu.d.ts.map +1 -1
  12. package/dist/types/src/components/Popover/RefPopover.d.ts.map +1 -1
  13. package/dist/types/src/defaults.d.ts +0 -1
  14. package/dist/types/src/defaults.d.ts.map +1 -1
  15. package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
  16. package/dist/types/src/extensions/command/action.d.ts.map +1 -1
  17. package/dist/types/src/extensions/command/command-menu.d.ts +20 -0
  18. package/dist/types/src/extensions/command/command-menu.d.ts.map +1 -0
  19. package/dist/types/src/extensions/command/command.d.ts.map +1 -1
  20. package/dist/types/src/extensions/command/floating-menu.d.ts +7 -0
  21. package/dist/types/src/extensions/command/floating-menu.d.ts.map +1 -0
  22. package/dist/types/src/extensions/command/hint.d.ts +5 -2
  23. package/dist/types/src/extensions/command/hint.d.ts.map +1 -1
  24. package/dist/types/src/extensions/command/index.d.ts +3 -1
  25. package/dist/types/src/extensions/command/index.d.ts.map +1 -1
  26. package/dist/types/src/extensions/command/placeholder.d.ts +10 -0
  27. package/dist/types/src/extensions/command/placeholder.d.ts.map +1 -0
  28. package/dist/types/src/extensions/command/state.d.ts +1 -1
  29. package/dist/types/src/extensions/command/state.d.ts.map +1 -1
  30. package/dist/types/src/extensions/command/useCommandMenu.d.ts +26 -0
  31. package/dist/types/src/extensions/command/useCommandMenu.d.ts.map +1 -0
  32. package/dist/types/src/extensions/index.d.ts +0 -1
  33. package/dist/types/src/extensions/index.d.ts.map +1 -1
  34. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  35. package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -1
  36. package/dist/types/src/extensions/preview/preview.d.ts +12 -19
  37. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  38. package/dist/types/src/stories/CommandMenu.stories.d.ts +5 -4
  39. package/dist/types/src/stories/CommandMenu.stories.d.ts.map +1 -1
  40. package/dist/types/src/stories/Preview.stories.d.ts.map +1 -1
  41. package/dist/types/src/util/dom.d.ts +5 -0
  42. package/dist/types/src/util/dom.d.ts.map +1 -1
  43. package/dist/types/src/util/react.d.ts +2 -4
  44. package/dist/types/src/util/react.d.ts.map +1 -1
  45. package/package.json +31 -31
  46. package/src/components/EditorToolbar/EditorToolbar.tsx +5 -9
  47. package/src/components/Popover/RefDropdownMenu.tsx +5 -3
  48. package/src/components/Popover/RefPopover.tsx +5 -3
  49. package/src/defaults.ts +0 -6
  50. package/src/extensions/automerge/automerge.stories.tsx +5 -5
  51. package/src/extensions/command/action.ts +9 -2
  52. package/src/extensions/command/command-menu.ts +210 -0
  53. package/src/extensions/command/command.ts +8 -8
  54. package/src/extensions/command/floating-menu.ts +133 -0
  55. package/src/extensions/command/hint.ts +29 -9
  56. package/src/extensions/command/index.ts +3 -1
  57. package/src/extensions/command/placeholder.ts +113 -0
  58. package/src/extensions/command/state.ts +1 -2
  59. package/src/extensions/command/useCommandMenu.ts +118 -0
  60. package/src/extensions/index.ts +0 -1
  61. package/src/extensions/markdown/bundle.ts +0 -2
  62. package/src/extensions/outliner/tree.test.ts +13 -10
  63. package/src/extensions/outliner/tree.ts +5 -3
  64. package/src/extensions/preview/preview.ts +11 -86
  65. package/src/stories/Command.stories.tsx +1 -1
  66. package/src/stories/CommandMenu.stories.tsx +35 -19
  67. package/src/stories/Preview.stories.tsx +134 -57
  68. package/src/stories/components/util.tsx +2 -2
  69. package/src/util/dom.ts +20 -0
  70. package/src/util/react.tsx +3 -20
  71. package/dist/types/src/extensions/command/menu.d.ts +0 -47
  72. package/dist/types/src/extensions/command/menu.d.ts.map +0 -1
  73. package/dist/types/src/extensions/placeholder.d.ts +0 -4
  74. package/dist/types/src/extensions/placeholder.d.ts.map +0 -1
  75. package/src/extensions/command/menu.ts +0 -439
  76. package/src/extensions/placeholder.ts +0 -82
@@ -1,439 +0,0 @@
1
- //
2
- // Copyright 2024 DXOS.org
3
- //
4
-
5
- import { RangeSetBuilder, StateField, StateEffect, Prec } from '@codemirror/state';
6
- import { EditorView, ViewPlugin, type ViewUpdate, Decoration, keymap, type DecorationSet } from '@codemirror/view';
7
- import { type RefObject, useCallback, useMemo, useRef, useState } from 'react';
8
-
9
- import { type CleanupFn, addEventListener } from '@dxos/async';
10
- import { type DxRefTag, type DxRefTagActivate } from '@dxos/lit-ui';
11
- import { type MaybePromise } from '@dxos/util';
12
-
13
- import { closeEffect, openEffect } from './action';
14
- import { getItem, getNextItem, getPreviousItem, type CommandMenuGroup, type CommandMenuItem } from '../../components';
15
- import { type Range } from '../../types';
16
- import { multilinePlaceholder } from '../placeholder';
17
-
18
- export type FloatingMenuOptions = {
19
- icon?: string;
20
- height?: number;
21
- padding?: number;
22
- };
23
-
24
- export const floatingMenu = (options: FloatingMenuOptions = {}) => [
25
- ViewPlugin.fromClass(
26
- class {
27
- view: EditorView;
28
- tag: HTMLElement;
29
- rafId?: number | null;
30
- cleanup?: CleanupFn;
31
-
32
- constructor(view: EditorView) {
33
- this.view = view;
34
-
35
- // Position context.
36
- const container = view.scrollDOM;
37
- if (getComputedStyle(container).position === 'static') {
38
- container.style.position = 'relative';
39
- }
40
-
41
- {
42
- const icon = document.createElement('dx-icon');
43
- icon.setAttribute('icon', options.icon ?? 'ph--dots-three-vertical--regular');
44
-
45
- const button = document.createElement('button');
46
- button.appendChild(icon);
47
-
48
- this.tag = document.createElement('dx-ref-tag');
49
- this.tag.classList.add('cm-ref-tag');
50
- this.tag.appendChild(button);
51
- }
52
-
53
- container.appendChild(this.tag);
54
-
55
- // Listen for scroll events.
56
- const handler = () => this.scheduleUpdate();
57
- this.cleanup = addEventListener(container, 'scroll', handler);
58
- this.scheduleUpdate();
59
- }
60
-
61
- destroy() {
62
- this.cleanup?.();
63
- this.tag.remove();
64
- if (this.rafId != null) {
65
- cancelAnimationFrame(this.rafId);
66
- }
67
- }
68
-
69
- update(update: ViewUpdate) {
70
- this.tag.dataset.focused = update.view.hasFocus ? 'true' : 'false';
71
- if (!update.view.hasFocus) {
72
- return;
73
- }
74
-
75
- // TODO(burdon): Timer to fade in/out.
76
- if (update.transactions.some((tr) => tr.effects.some((effect) => effect.is(openEffect)))) {
77
- this.tag.style.display = 'none';
78
- this.tag.classList.add('opacity-10');
79
- } else if (update.transactions.some((tr) => tr.effects.some((effect) => effect.is(closeEffect)))) {
80
- this.tag.style.display = 'block';
81
- } else if (
82
- update.docChanged ||
83
- update.focusChanged ||
84
- update.geometryChanged ||
85
- update.selectionSet ||
86
- update.viewportChanged
87
- ) {
88
- this.scheduleUpdate();
89
- }
90
- }
91
-
92
- updateButtonPosition() {
93
- const { x, width } = this.view.contentDOM.getBoundingClientRect();
94
-
95
- const pos = this.view.state.selection.main.head;
96
- const line = this.view.lineBlockAt(pos);
97
- const coords = this.view.coordsAtPos(line.from);
98
- if (!coords) {
99
- return;
100
- }
101
-
102
- const lineHeight = coords.bottom - coords.top;
103
- const dy = (lineHeight - (options.height ?? 32)) / 2;
104
-
105
- const offsetTop = coords.top + dy;
106
- const offsetLeft = x + width + (options.padding ?? 8);
107
-
108
- this.tag.style.top = `${offsetTop}px`;
109
- this.tag.style.left = `${offsetLeft}px`;
110
- this.tag.style.display = 'block';
111
- }
112
-
113
- scheduleUpdate() {
114
- if (this.rafId != null) {
115
- cancelAnimationFrame(this.rafId);
116
- }
117
-
118
- this.rafId = requestAnimationFrame(this.updateButtonPosition.bind(this));
119
- }
120
- },
121
- ),
122
-
123
- EditorView.theme({
124
- '.cm-ref-tag': {
125
- position: 'fixed',
126
- padding: '0',
127
- border: 'none',
128
- opacity: '0',
129
- },
130
- '[data-has-focus] & .cm-ref-tag': {
131
- opacity: '1',
132
- },
133
- '.cm-ref-tag button': {
134
- display: 'grid',
135
- alignItems: 'center',
136
- justifyContent: 'center',
137
- width: '2rem',
138
- height: '2rem',
139
- },
140
- }),
141
- ];
142
-
143
- type CommandState = {
144
- trigger: string;
145
- range: Range;
146
- };
147
-
148
- // State effects for managing command menu state.
149
- export const commandRangeEffect = StateEffect.define<CommandState | null>();
150
-
151
- // State field to track the active command menu range.
152
- const commandMenuState = StateField.define<CommandState | null>({
153
- create: () => null,
154
- update: (value, tr) => {
155
- let newValue = value;
156
-
157
- for (const effect of tr.effects) {
158
- if (effect.is(commandRangeEffect)) {
159
- newValue = effect.value;
160
- }
161
- }
162
-
163
- return newValue;
164
- },
165
- });
166
-
167
- export type CommandMenuOptions = {
168
- trigger: string | string[];
169
- placeholder?: Parameters<typeof multilinePlaceholder>[0];
170
- onArrowDown?: () => void;
171
- onArrowUp?: () => void;
172
- onDeactivate?: () => void;
173
- onEnter?: () => void;
174
- onTextChange?: (trigger: string, text: string) => void;
175
- };
176
-
177
- export const commandMenu = (options: CommandMenuOptions) => {
178
- const commandMenuPlugin = ViewPlugin.fromClass(
179
- class {
180
- decorations: DecorationSet = Decoration.none;
181
-
182
- constructor(readonly view: EditorView) {}
183
-
184
- // TODO(wittjosiah): The decorations are repainted on every update, this occasionally causes menu to flicker.
185
- update(update: ViewUpdate) {
186
- const builder = new RangeSetBuilder<Decoration>();
187
- const selection = update.view.state.selection.main;
188
- const { range: activeRange, trigger } = update.view.state.field(commandMenuState) ?? {};
189
-
190
- // Check if we should show the widget - only if cursor is within the active command range.
191
- const shouldShowWidget = activeRange && selection.head >= activeRange.from && selection.head <= activeRange.to;
192
- if (shouldShowWidget) {
193
- // Create mark decoration that wraps the entire line content in a dx-ref-tag.
194
- builder.add(
195
- activeRange.from,
196
- activeRange.to,
197
- Decoration.mark({
198
- tagName: 'dx-ref-tag',
199
- class: 'cm-ref-tag',
200
- attributes: {
201
- 'data-auto-trigger': 'true',
202
- 'data-trigger': trigger!,
203
- },
204
- }),
205
- );
206
- }
207
-
208
- const activeRangeChanged = update.transactions.some((tr) =>
209
- tr.effects.some((effect) => effect.is(commandRangeEffect)),
210
- );
211
- if (activeRange && activeRangeChanged && trigger) {
212
- const content = update.view.state.sliceDoc(
213
- activeRange.from + 1, // Skip the trigger character.
214
- activeRange.to,
215
- );
216
- options.onTextChange?.(trigger, content);
217
- }
218
-
219
- this.decorations = builder.finish();
220
- }
221
- },
222
- {
223
- decorations: (v) => v.decorations,
224
- },
225
- );
226
-
227
- const triggers = Array.isArray(options.trigger) ? options.trigger : [options.trigger];
228
- const commandKeymap = keymap.of([
229
- ...triggers.map((trigger) => ({
230
- key: trigger,
231
- run: (view: EditorView) => {
232
- const selection = view.state.selection.main;
233
- const line = view.state.doc.lineAt(selection.head);
234
-
235
- // Check if we should trigger the command menu:
236
- // 1. Empty lines or at the beginning of a line
237
- // 2. When there's a preceding space
238
- const shouldTrigger =
239
- line.text.trim() === '' ||
240
- selection.head === line.from ||
241
- (selection.head > line.from && line.text[selection.head - line.from - 1] === ' ');
242
-
243
- if (shouldTrigger) {
244
- view.dispatch({
245
- changes: { from: selection.head, insert: trigger },
246
- selection: { anchor: selection.head + 1, head: selection.head + 1 },
247
- effects: commandRangeEffect.of({ trigger, range: { from: selection.head, to: selection.head + 1 } }),
248
- });
249
- return true;
250
- }
251
-
252
- return false;
253
- },
254
- })),
255
- {
256
- key: 'Enter',
257
- run: (view) => {
258
- const activeRange = view.state.field(commandMenuState)?.range;
259
- if (activeRange) {
260
- view.dispatch({ changes: { from: activeRange.from, to: activeRange.to, insert: '' } });
261
- options.onEnter?.();
262
- return true;
263
- }
264
-
265
- return false;
266
- },
267
- },
268
- {
269
- key: 'ArrowDown',
270
- run: (view) => {
271
- const activeRange = view.state.field(commandMenuState)?.range;
272
- if (activeRange) {
273
- options.onArrowDown?.();
274
- return true;
275
- }
276
-
277
- return false;
278
- },
279
- },
280
- {
281
- key: 'ArrowUp',
282
- run: (view) => {
283
- const activeRange = view.state.field(commandMenuState)?.range;
284
- if (activeRange) {
285
- options.onArrowUp?.();
286
- return true;
287
- }
288
-
289
- return false;
290
- },
291
- },
292
- ]);
293
-
294
- // Listen for selection and document changes to clean up the command menu.
295
- const updateListener = EditorView.updateListener.of((update) => {
296
- const { trigger, range: activeRange } = update.view.state.field(commandMenuState) ?? {};
297
- if (!activeRange || !trigger) {
298
- return;
299
- }
300
-
301
- const selection = update.view.state.selection.main;
302
- const firstChar = update.view.state.doc.sliceString(activeRange.from, activeRange.from + 1);
303
- const shouldRemove =
304
- firstChar !== trigger || // Trigger deleted.
305
- selection.head < activeRange.from || // Cursor moved before the range.
306
- selection.head > activeRange.to + 1; // Cursor moved after the range (+1 to handle selection changing before doc).
307
-
308
- const nextRange = shouldRemove
309
- ? null
310
- : update.docChanged
311
- ? { from: activeRange.from, to: selection.head }
312
- : activeRange;
313
- if (nextRange !== activeRange) {
314
- update.view.dispatch({ effects: commandRangeEffect.of(nextRange ? { trigger, range: nextRange } : null) });
315
- }
316
-
317
- if (shouldRemove) {
318
- options.onDeactivate?.();
319
- }
320
- });
321
-
322
- return [
323
- multilinePlaceholder(
324
- options.placeholder ??
325
- `Press '${Array.isArray(options.trigger) ? options.trigger[0] : options.trigger}' for commands`,
326
- ),
327
- Prec.highest(commandKeymap),
328
- updateListener,
329
- commandMenuState,
330
- commandMenuPlugin,
331
- ];
332
- };
333
-
334
- export type UseCommandMenuOptions = {
335
- viewRef: RefObject<EditorView | undefined>;
336
- trigger: string | string[];
337
- placeholder?: Parameters<typeof multilinePlaceholder>[0];
338
- getGroups: (trigger: string, query?: string) => MaybePromise<CommandMenuGroup[]>;
339
- };
340
-
341
- export const useCommandMenu = ({ viewRef, trigger, placeholder, getGroups }: UseCommandMenuOptions) => {
342
- const triggerRef = useRef<DxRefTag | null>(null);
343
- const currentRef = useRef<CommandMenuItem | null>(null);
344
- const groupsRef = useRef<CommandMenuGroup[]>([]);
345
- const [currentItem, setCurrentItem] = useState<string>();
346
- const [open, setOpen] = useState(false);
347
- const [_, update] = useState({});
348
-
349
- const handleOpenChange = useCallback(
350
- async (open: boolean, trigger?: string) => {
351
- if (open && trigger) {
352
- groupsRef.current = await getGroups(trigger);
353
- }
354
- setOpen(open);
355
- if (!open) {
356
- triggerRef.current = null;
357
- setCurrentItem(undefined);
358
- viewRef.current?.dispatch({ effects: [commandRangeEffect.of(null)] });
359
- }
360
- },
361
- [getGroups],
362
- );
363
-
364
- const handleActivate = useCallback(
365
- async (event: DxRefTagActivate) => {
366
- const item = getItem(groupsRef.current, currentItem);
367
- if (item) {
368
- currentRef.current = item;
369
- }
370
-
371
- triggerRef.current = event.trigger;
372
- const triggerKey = event.trigger.getAttribute('data-trigger');
373
- if (!open && triggerKey) {
374
- await handleOpenChange(true, triggerKey);
375
- }
376
- },
377
- [open, handleOpenChange],
378
- );
379
-
380
- const handleSelect = useCallback((item: CommandMenuItem) => {
381
- const view = viewRef.current;
382
- if (!view) {
383
- return;
384
- }
385
-
386
- const selection = view.state.selection.main;
387
- void item.onSelect?.(view, selection.head);
388
- }, []);
389
-
390
- const serializedTrigger = Array.isArray(trigger) ? trigger.join(',') : trigger;
391
- const _commandMenu = useMemo(
392
- () =>
393
- commandMenu({
394
- trigger,
395
- placeholder,
396
- onArrowDown: () => {
397
- setCurrentItem((currentItem) => {
398
- const next = getNextItem(groupsRef.current, currentItem);
399
- currentRef.current = next;
400
- return next.id;
401
- });
402
- },
403
- onArrowUp: () => {
404
- setCurrentItem((currentItem) => {
405
- const previous = getPreviousItem(groupsRef.current, currentItem);
406
- currentRef.current = previous;
407
- return previous.id;
408
- });
409
- },
410
- onDeactivate: () => handleOpenChange(false),
411
- onEnter: () => {
412
- if (currentRef.current) {
413
- handleSelect(currentRef.current);
414
- }
415
- },
416
- onTextChange: async (trigger, text) => {
417
- groupsRef.current = await getGroups(trigger, text);
418
- const firstItem = groupsRef.current.filter((group) => group.items.length > 0)[0]?.items[0];
419
- if (firstItem) {
420
- setCurrentItem(firstItem.id);
421
- currentRef.current = firstItem;
422
- }
423
- update({});
424
- },
425
- }),
426
- [handleOpenChange, getGroups, serializedTrigger, placeholder],
427
- );
428
-
429
- return {
430
- commandMenu: _commandMenu,
431
- currentItem,
432
- groupsRef,
433
- ref: triggerRef,
434
- open,
435
- onActivate: handleActivate,
436
- onOpenChange: setOpen,
437
- onSelect: handleSelect,
438
- };
439
- };
@@ -1,82 +0,0 @@
1
- //
2
- // Copyright 2025 DXOS.org
3
- //
4
-
5
- // Based on https://github.com/codemirror/view/blob/main/src/placeholder.ts
6
-
7
- import { type Extension } from '@codemirror/state';
8
- import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view';
9
-
10
- import { clientRectsFor, flattenRect } from '../util';
11
-
12
- class Placeholder extends WidgetType {
13
- constructor(readonly content: string | HTMLElement | ((view: EditorView) => HTMLElement)) {
14
- super();
15
- }
16
-
17
- toDOM(view: EditorView) {
18
- const wrap = document.createElement('span');
19
- wrap.className = 'cm-placeholder';
20
- wrap.style.pointerEvents = 'none';
21
- wrap.appendChild(
22
- typeof this.content === 'string'
23
- ? document.createTextNode(this.content)
24
- : typeof this.content === 'function'
25
- ? this.content(view)
26
- : this.content.cloneNode(true),
27
- );
28
- wrap.setAttribute('aria-hidden', 'true');
29
- return wrap;
30
- }
31
-
32
- override coordsAt(dom: HTMLElement) {
33
- const rects = dom.firstChild ? clientRectsFor(dom.firstChild) : [];
34
- if (!rects.length) {
35
- return null;
36
- }
37
- const style = window.getComputedStyle(dom.parentNode as HTMLElement);
38
- const rect = flattenRect(rects[0], style.direction !== 'rtl');
39
- const lineHeight = parseInt(style.lineHeight);
40
- if (rect.bottom - rect.top > lineHeight * 1.5) {
41
- return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.top + lineHeight };
42
- }
43
- return rect;
44
- }
45
-
46
- override ignoreEvent() {
47
- return false;
48
- }
49
- }
50
-
51
- export function multilinePlaceholder(content: string | HTMLElement | ((view: EditorView) => HTMLElement)): Extension {
52
- const plugin = ViewPlugin.fromClass(
53
- class {
54
- constructor(readonly view: EditorView) {}
55
-
56
- declare update: () => void; // Kludge to convince TypeScript that this is a plugin value
57
-
58
- get decorations() {
59
- // Check if the active line (where cursor is) is empty
60
- const activeLine = this.view.state.doc.lineAt(this.view.state.selection.main.head);
61
- const isEmpty = activeLine.text.trim() === '';
62
-
63
- if (!isEmpty || !content) {
64
- return Decoration.none;
65
- }
66
-
67
- // Create widget decoration at the start of the current line
68
- const lineStart = activeLine.from;
69
- return Decoration.set([
70
- Decoration.widget({
71
- widget: new Placeholder(content),
72
- side: 1,
73
- }).range(lineStart),
74
- ]);
75
- }
76
- },
77
- { decorations: (v) => v.decorations },
78
- );
79
- return typeof content === 'string'
80
- ? [plugin, EditorView.contentAttributes.of({ 'aria-placeholder': content })]
81
- : plugin;
82
- }