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

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 (83) hide show
  1. package/dist/lib/browser/index.mjs +936 -274
  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 +981 -314
  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 +936 -274
  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/util.d.ts +2 -2
  11. package/dist/types/src/components/Popover/CommandMenu.d.ts +34 -0
  12. package/dist/types/src/components/Popover/CommandMenu.d.ts.map +1 -0
  13. package/dist/types/src/components/Popover/RefPopover.d.ts +19 -6
  14. package/dist/types/src/components/Popover/RefPopover.d.ts.map +1 -1
  15. package/dist/types/src/components/Popover/index.d.ts +1 -0
  16. package/dist/types/src/components/Popover/index.d.ts.map +1 -1
  17. package/dist/types/src/defaults.d.ts.map +1 -1
  18. package/dist/types/src/extensions/command/menu.d.ts +40 -0
  19. package/dist/types/src/extensions/command/menu.d.ts.map +1 -1
  20. package/dist/types/src/extensions/factories.d.ts +1 -0
  21. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  22. package/dist/types/src/extensions/hashtag.d.ts +3 -0
  23. package/dist/types/src/extensions/hashtag.d.ts.map +1 -0
  24. package/dist/types/src/extensions/index.d.ts +2 -0
  25. package/dist/types/src/extensions/index.d.ts.map +1 -1
  26. package/dist/types/src/extensions/json.d.ts.map +1 -1
  27. package/dist/types/src/extensions/markdown/debug.d.ts +2 -2
  28. package/dist/types/src/extensions/markdown/debug.d.ts.map +1 -1
  29. package/dist/types/src/extensions/outliner/outliner.d.ts +1 -3
  30. package/dist/types/src/extensions/outliner/outliner.d.ts.map +1 -1
  31. package/dist/types/src/extensions/placeholder.d.ts +4 -0
  32. package/dist/types/src/extensions/placeholder.d.ts.map +1 -0
  33. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  34. package/dist/types/src/hooks/useTextEditor.d.ts +8 -9
  35. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  36. package/dist/types/src/stories/Command.stories.d.ts +1 -1
  37. package/dist/types/src/stories/Command.stories.d.ts.map +1 -1
  38. package/dist/types/src/stories/CommandMenu.stories.d.ts +12 -0
  39. package/dist/types/src/stories/CommandMenu.stories.d.ts.map +1 -0
  40. package/dist/types/src/stories/Comments.stories.d.ts +1 -1
  41. package/dist/types/src/stories/Comments.stories.d.ts.map +1 -1
  42. package/dist/types/src/stories/Experimental.stories.d.ts +1 -1
  43. package/dist/types/src/stories/Experimental.stories.d.ts.map +1 -1
  44. package/dist/types/src/stories/Markdown.stories.d.ts +1 -1
  45. package/dist/types/src/stories/Markdown.stories.d.ts.map +1 -1
  46. package/dist/types/src/stories/Outliner.stories.d.ts.map +1 -1
  47. package/dist/types/src/stories/Preview.stories.d.ts +1 -1
  48. package/dist/types/src/stories/Preview.stories.d.ts.map +1 -1
  49. package/dist/types/src/stories/TextEditor.stories.d.ts +1 -1
  50. package/dist/types/src/stories/TextEditor.stories.d.ts.map +1 -1
  51. package/dist/types/src/stories/components/EditorStory.d.ts +43 -0
  52. package/dist/types/src/stories/components/EditorStory.d.ts.map +1 -0
  53. package/dist/types/src/stories/components/index.d.ts +3 -0
  54. package/dist/types/src/stories/components/index.d.ts.map +1 -0
  55. package/dist/types/src/stories/{util.d.ts → components/util.d.ts} +3 -18
  56. package/dist/types/src/stories/components/util.d.ts.map +1 -0
  57. package/package.json +31 -27
  58. package/src/components/Popover/CommandMenu.tsx +279 -0
  59. package/src/components/Popover/RefPopover.tsx +44 -22
  60. package/src/components/Popover/index.ts +1 -0
  61. package/src/defaults.ts +1 -0
  62. package/src/extensions/command/menu.ts +334 -23
  63. package/src/extensions/factories.ts +4 -1
  64. package/src/extensions/hashtag.tsx +68 -0
  65. package/src/extensions/index.ts +2 -0
  66. package/src/extensions/json.ts +2 -1
  67. package/src/extensions/markdown/debug.ts +2 -2
  68. package/src/extensions/outliner/outliner.ts +6 -8
  69. package/src/extensions/placeholder.ts +82 -0
  70. package/src/extensions/preview/preview.ts +3 -6
  71. package/src/hooks/useTextEditor.ts +11 -12
  72. package/src/stories/Command.stories.tsx +1 -1
  73. package/src/stories/CommandMenu.stories.tsx +143 -0
  74. package/src/stories/Comments.stories.tsx +2 -2
  75. package/src/stories/Experimental.stories.tsx +2 -2
  76. package/src/stories/Markdown.stories.tsx +2 -2
  77. package/src/stories/Outliner.stories.tsx +19 -7
  78. package/src/stories/Preview.stories.tsx +34 -32
  79. package/src/stories/TextEditor.stories.tsx +3 -3
  80. package/src/stories/components/EditorStory.tsx +135 -0
  81. package/src/stories/components/index.ts +6 -0
  82. package/src/stories/{util.tsx → components/util.tsx} +5 -100
  83. package/dist/types/src/stories/util.d.ts.map +0 -1
@@ -2,9 +2,18 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
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';
6
12
 
7
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';
8
17
 
9
18
  export type FloatingMenuOptions = {
10
19
  icon?: string;
@@ -17,7 +26,8 @@ export const floatingMenu = (options: FloatingMenuOptions = {}) => [
17
26
  class {
18
27
  view: EditorView;
19
28
  tag: HTMLElement;
20
- rafId: number | null = null;
29
+ rafId?: number | null;
30
+ cleanup?: CleanupFn;
21
31
 
22
32
  constructor(view: EditorView) {
23
33
  this.view = view;
@@ -28,23 +38,34 @@ export const floatingMenu = (options: FloatingMenuOptions = {}) => [
28
38
  container.style.position = 'relative';
29
39
  }
30
40
 
31
- const icon = document.createElement('dx-icon');
32
- icon.setAttribute('icon', options.icon ?? 'ph--dots-three-outline--regular');
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);
33
47
 
34
- const button = document.createElement('button');
35
- button.appendChild(icon);
48
+ this.tag = document.createElement('dx-ref-tag');
49
+ this.tag.classList.add('cm-ref-tag');
50
+ this.tag.appendChild(button);
51
+ }
36
52
 
37
- // TODO(burdon): Custom tag/styles?
38
- this.tag = document.createElement('dx-ref-tag');
39
- this.tag.classList.add('cm-ref-tag');
40
- this.tag.appendChild(button);
41
53
  container.appendChild(this.tag);
42
54
 
43
55
  // Listen for scroll events.
44
- container.addEventListener('scroll', this.scheduleUpdate.bind(this));
56
+ const handler = () => this.scheduleUpdate();
57
+ this.cleanup = addEventListener(container, 'scroll', handler);
45
58
  this.scheduleUpdate();
46
59
  }
47
60
 
61
+ destroy() {
62
+ this.cleanup?.();
63
+ this.tag.remove();
64
+ if (this.rafId != null) {
65
+ cancelAnimationFrame(this.rafId);
66
+ }
67
+ }
68
+
48
69
  update(update: ViewUpdate) {
49
70
  this.tag.dataset.focused = update.view.hasFocus ? 'true' : 'false';
50
71
  if (!update.view.hasFocus) {
@@ -96,13 +117,6 @@ export const floatingMenu = (options: FloatingMenuOptions = {}) => [
96
117
 
97
118
  this.rafId = requestAnimationFrame(this.updateButtonPosition.bind(this));
98
119
  }
99
-
100
- destroy() {
101
- this.tag.remove();
102
- if (this.rafId != null) {
103
- cancelAnimationFrame(this.rafId);
104
- }
105
- }
106
120
  },
107
121
  ),
108
122
 
@@ -111,8 +125,10 @@ export const floatingMenu = (options: FloatingMenuOptions = {}) => [
111
125
  position: 'fixed',
112
126
  padding: '0',
113
127
  border: 'none',
114
- transition: 'opacity 0.3s ease-in-out',
115
- opacity: 0.1,
128
+ opacity: '0',
129
+ },
130
+ '[data-has-focus] & .cm-ref-tag': {
131
+ opacity: '1',
116
132
  },
117
133
  '.cm-ref-tag button': {
118
134
  display: 'grid',
@@ -121,8 +137,303 @@ export const floatingMenu = (options: FloatingMenuOptions = {}) => [
121
137
  width: '2rem',
122
138
  height: '2rem',
123
139
  },
124
- '.cm-ref-tag[data-focused="true"]': {
125
- opacity: 1,
126
- },
127
140
  }),
128
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
+ };
@@ -34,6 +34,7 @@ import { hexToHue, isNotFalsy } from '@dxos/util';
34
34
  import { automerge } from './automerge';
35
35
  import { SpaceAwarenessProvider, awareness } from './awareness';
36
36
  import { focus } from './focus';
37
+ import { editorGutter, editorMonospace } from '../defaults';
37
38
  import { type ThemeStyles, defaultTheme } from '../styles';
38
39
 
39
40
  //
@@ -62,6 +63,7 @@ export type BasicExtensionsOptions = {
62
63
  lineNumbers?: boolean;
63
64
  /** If false then do not set a max-width or side margin on the editor. */
64
65
  lineWrapping?: boolean;
66
+ monospace?: boolean;
65
67
  placeholder?: string;
66
68
  /** If true user cannot edit the text, but they can still select and copy it. */
67
69
  readOnly?: boolean;
@@ -107,8 +109,9 @@ export const createBasicExtensions = (_props?: BasicExtensionsOptions): Extensio
107
109
  props.focus && focus,
108
110
  props.highlightActiveLine && highlightActiveLine(),
109
111
  props.history && history(),
110
- props.lineNumbers && lineNumbers(),
112
+ props.lineNumbers && [lineNumbers(), editorGutter],
111
113
  props.lineWrapping && EditorView.lineWrapping,
114
+ props.monospace && editorMonospace,
112
115
  props.placeholder && placeholder(props.placeholder),
113
116
  props.readOnly !== undefined && EditorState.readOnly.of(props.readOnly),
114
117
  props.scrollPastEnd && scrollPastEnd(),
@@ -0,0 +1,68 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import {
7
+ Decoration,
8
+ type DecorationSet,
9
+ EditorView,
10
+ MatchDecorator,
11
+ ViewPlugin,
12
+ type ViewUpdate,
13
+ WidgetType,
14
+ } from '@codemirror/view';
15
+
16
+ import { getHashColor, mx } from '@dxos/react-ui-theme';
17
+
18
+ class TagWidget extends WidgetType {
19
+ constructor(private _text: string) {
20
+ super();
21
+ }
22
+
23
+ toDOM(): HTMLSpanElement {
24
+ const span = document.createElement('span');
25
+ span.className = mx('cm-tag', getHashColor(this._text).tag);
26
+ span.textContent = this._text;
27
+ return span;
28
+ }
29
+ }
30
+
31
+ const tagMatcher = new MatchDecorator({
32
+ regexp: /#(\w+)\W/g,
33
+ decoration: (match) =>
34
+ Decoration.replace({
35
+ widget: new TagWidget(match[1]),
36
+ }),
37
+ });
38
+
39
+ // TODO(burdon): Autocomplete from existing tags?
40
+ export const hashtag = (): Extension => [
41
+ ViewPlugin.fromClass(
42
+ class {
43
+ tags: DecorationSet;
44
+ constructor(view: EditorView) {
45
+ this.tags = tagMatcher.createDeco(view);
46
+ }
47
+
48
+ update(update: ViewUpdate) {
49
+ this.tags = tagMatcher.updateDeco(update, this.tags);
50
+ }
51
+ },
52
+ {
53
+ decorations: (instance) => instance.tags,
54
+ provide: (plugin) =>
55
+ EditorView.atomicRanges.of((view) => {
56
+ return view.plugin(plugin)?.tags || Decoration.none;
57
+ }),
58
+ },
59
+ ),
60
+
61
+ EditorView.theme({
62
+ '.cm-tag': {
63
+ borderRadius: '4px',
64
+ marginRight: '6px',
65
+ padding: '2px 6px',
66
+ },
67
+ }),
68
+ ];
@@ -14,12 +14,14 @@ export * from './dnd';
14
14
  export * from './factories';
15
15
  export * from './focus';
16
16
  export * from './folding';
17
+ export * from './hashtag';
17
18
  export * from './json';
18
19
  export * from './listener';
19
20
  export * from './markdown';
20
21
  export * from './mention';
21
22
  export * from './modes';
22
23
  export * from './outliner';
24
+ export * from './placeholder';
23
25
  export * from './preview';
24
26
  export * from './selection';
25
27
  export * from './typewriter';
@@ -16,7 +16,8 @@ export type JsonExtensionsOptions = {
16
16
  export const createJsonExtensions = ({ schema }: JsonExtensionsOptions = {}): Extension => {
17
17
  let lintSource: LintSource = jsonParseLinter();
18
18
  if (schema) {
19
- const ajv = new Ajv({ allErrors: false });
19
+ // NOTE: Relaxing strict mode to allow additional custom schema properties.
20
+ const ajv = new Ajv({ allErrors: false, strict: false });
20
21
  const validate = ajv.compile(schema);
21
22
  lintSource = schemaLinter(validate);
22
23
  }
@@ -3,10 +3,10 @@
3
3
  //
4
4
 
5
5
  import { syntaxTree } from '@codemirror/language';
6
- import { type EditorState, StateField } from '@codemirror/state';
6
+ import { type EditorState, type Extension, StateField } from '@codemirror/state';
7
7
  import { type TreeCursor } from '@lezer/common';
8
8
 
9
- export const debugTree = (cb: (tree: DebugNode) => void) =>
9
+ export const debugTree = (cb: (tree: DebugNode) => void): Extension =>
10
10
  StateField.define({
11
11
  create: (state) => cb(convertTreeToJson(state)),
12
12
  update: (value, tr) => cb(convertTreeToJson(tr.state)),
@@ -28,9 +28,7 @@ import { decorateMarkdown } from '../markdown';
28
28
  // TODO(burdon): Smart Cut-and-paste.
29
29
  // TODO(burdon): DND.
30
30
 
31
- export type OutlinerProps = {
32
- showSelected?: boolean;
33
- };
31
+ export type OutlinerProps = {};
34
32
 
35
33
  /**
36
34
  * Outliner extension.
@@ -56,7 +54,7 @@ export const outliner = (options: OutlinerProps = {}): Extension => [
56
54
  floatingMenu(),
57
55
 
58
56
  // Line decorations.
59
- decorations(options),
57
+ decorations(),
60
58
 
61
59
  // Default markdown decorations.
62
60
  decorateMarkdown({ listPaddingLeft: 8 }),
@@ -68,7 +66,7 @@ export const outliner = (options: OutlinerProps = {}): Extension => [
68
66
  /**
69
67
  * Line decorations (for border and selection).
70
68
  */
71
- const decorations = (options: OutlinerProps) => [
69
+ const decorations = () => [
72
70
  ViewPlugin.fromClass(
73
71
  class {
74
72
  decorations: DecorationSet = Decoration.none;
@@ -157,12 +155,12 @@ const decorations = (options: OutlinerProps) => [
157
155
  marginBottom: '2px',
158
156
  },
159
157
 
160
- '.cm-list-item-selected': {
161
- borderColor: options.showSelected ? 'var(--dx-separator)' : undefined,
162
- },
163
158
  '.cm-list-item-focused': {
164
159
  borderColor: 'var(--dx-accentFocusIndicator)',
165
160
  },
161
+ '[data-has-focus] & .cm-list-item-selected': {
162
+ borderColor: 'var(--dx-separator)',
163
+ },
166
164
  }),
167
165
  ),
168
166
  ];
@@ -0,0 +1,82 @@
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
+ }
@@ -77,12 +77,9 @@ export const preview = (options: PreviewOptions = {}): Extension => {
77
77
 
78
78
  EditorView.theme({
79
79
  '.cm-preview-block': {
80
- marginLeft: '-1rem',
81
- marginRight: '-1rem',
82
- padding: '1rem',
83
- borderRadius: '0.5rem',
84
- background: 'var(--dx-modalSurface)',
85
- border: '1px solid var(--dx-separator)',
80
+ '--dx-card-spacing-inline': 'var(--dx-trimMd)',
81
+ '--dx-card-spacing-block': 'var(--dx-trimMd)',
82
+ marginInline: 'calc(-1*var(--dx-trimMd))',
86
83
  },
87
84
  }),
88
85
  ];