@dxos/react-ui-editor 0.8.3-main.672df60 → 0.8.3-staging.0fa589b

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 +981 -377
  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 +1025 -420
  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 +981 -377
  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/EditorToolbar/util.d.ts +2 -2
  12. package/dist/types/src/components/Popover/CommandMenu.d.ts +34 -0
  13. package/dist/types/src/components/Popover/CommandMenu.d.ts.map +1 -0
  14. package/dist/types/src/components/Popover/RefDropdownMenu.d.ts.map +1 -1
  15. package/dist/types/src/components/Popover/RefPopover.d.ts +19 -6
  16. package/dist/types/src/components/Popover/RefPopover.d.ts.map +1 -1
  17. package/dist/types/src/components/Popover/index.d.ts +1 -0
  18. package/dist/types/src/components/Popover/index.d.ts.map +1 -1
  19. package/dist/types/src/defaults.d.ts +0 -1
  20. package/dist/types/src/defaults.d.ts.map +1 -1
  21. package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
  22. package/dist/types/src/extensions/command/action.d.ts.map +1 -1
  23. package/dist/types/src/extensions/command/command-menu.d.ts +20 -0
  24. package/dist/types/src/extensions/command/command-menu.d.ts.map +1 -0
  25. package/dist/types/src/extensions/command/command.d.ts.map +1 -1
  26. package/dist/types/src/extensions/command/{menu.d.ts → floating-menu.d.ts} +1 -1
  27. package/dist/types/src/extensions/command/floating-menu.d.ts.map +1 -0
  28. package/dist/types/src/extensions/command/hint.d.ts +5 -2
  29. package/dist/types/src/extensions/command/hint.d.ts.map +1 -1
  30. package/dist/types/src/extensions/command/index.d.ts +3 -1
  31. package/dist/types/src/extensions/command/index.d.ts.map +1 -1
  32. package/dist/types/src/extensions/command/placeholder.d.ts +10 -0
  33. package/dist/types/src/extensions/command/placeholder.d.ts.map +1 -0
  34. package/dist/types/src/extensions/command/state.d.ts +1 -1
  35. package/dist/types/src/extensions/command/state.d.ts.map +1 -1
  36. package/dist/types/src/extensions/command/useCommandMenu.d.ts +26 -0
  37. package/dist/types/src/extensions/command/useCommandMenu.d.ts.map +1 -0
  38. package/dist/types/src/extensions/factories.d.ts +1 -0
  39. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  40. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  41. package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -1
  42. package/dist/types/src/extensions/preview/preview.d.ts +12 -19
  43. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  44. package/dist/types/src/hooks/useTextEditor.d.ts +8 -9
  45. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  46. package/dist/types/src/stories/CommandMenu.stories.d.ts +13 -0
  47. package/dist/types/src/stories/CommandMenu.stories.d.ts.map +1 -0
  48. package/dist/types/src/stories/Preview.stories.d.ts.map +1 -1
  49. package/dist/types/src/util/dom.d.ts +5 -0
  50. package/dist/types/src/util/dom.d.ts.map +1 -1
  51. package/dist/types/src/util/react.d.ts +2 -4
  52. package/dist/types/src/util/react.d.ts.map +1 -1
  53. package/package.json +31 -28
  54. package/src/components/EditorToolbar/EditorToolbar.tsx +5 -9
  55. package/src/components/Popover/CommandMenu.tsx +279 -0
  56. package/src/components/Popover/RefDropdownMenu.tsx +5 -3
  57. package/src/components/Popover/RefPopover.tsx +46 -22
  58. package/src/components/Popover/index.ts +1 -0
  59. package/src/defaults.ts +1 -6
  60. package/src/extensions/automerge/automerge.stories.tsx +5 -5
  61. package/src/extensions/command/action.ts +9 -2
  62. package/src/extensions/command/command-menu.ts +210 -0
  63. package/src/extensions/command/command.ts +8 -8
  64. package/src/extensions/command/{menu.ts → floating-menu.ts} +0 -4
  65. package/src/extensions/command/hint.ts +29 -9
  66. package/src/extensions/command/index.ts +3 -1
  67. package/src/extensions/command/placeholder.ts +113 -0
  68. package/src/extensions/command/state.ts +1 -2
  69. package/src/extensions/command/useCommandMenu.ts +118 -0
  70. package/src/extensions/factories.ts +4 -1
  71. package/src/extensions/markdown/bundle.ts +0 -2
  72. package/src/extensions/outliner/outliner.ts +0 -3
  73. package/src/extensions/outliner/tree.test.ts +13 -10
  74. package/src/extensions/outliner/tree.ts +5 -3
  75. package/src/extensions/preview/preview.ts +11 -89
  76. package/src/hooks/useTextEditor.ts +11 -12
  77. package/src/stories/Command.stories.tsx +1 -1
  78. package/src/stories/CommandMenu.stories.tsx +159 -0
  79. package/src/stories/Preview.stories.tsx +157 -78
  80. package/src/stories/components/util.tsx +2 -2
  81. package/src/util/dom.ts +20 -0
  82. package/src/util/react.tsx +3 -20
  83. package/dist/types/src/extensions/command/menu.d.ts.map +0 -1
@@ -0,0 +1,279 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type EditorView } from '@codemirror/view';
6
+ import React, { useCallback, useEffect, useRef } from 'react';
7
+
8
+ import { Icon, type Label, Popover, toLocalizedString, useThemeContext, useTranslation } from '@dxos/react-ui';
9
+ import { type MaybePromise } from '@dxos/util';
10
+
11
+ import { commandRangeEffect } from '../../extensions';
12
+
13
+ export type CommandMenuGroup = {
14
+ id: string;
15
+ label?: Label;
16
+ items: CommandMenuItem[];
17
+ };
18
+
19
+ export type CommandMenuItem = {
20
+ id: string;
21
+ label: Label;
22
+ icon?: string;
23
+ onSelect?: (view: EditorView, head: number) => MaybePromise<void>;
24
+ };
25
+
26
+ export type CommandMenuProps = {
27
+ groups: CommandMenuGroup[];
28
+ currentItem?: string;
29
+ onSelect: (item: CommandMenuItem) => void;
30
+ };
31
+
32
+ // NOTE: Not using DropdownMenu because the command menu needs to manage focus explicitly.
33
+ export const CommandMenu = ({ groups, currentItem, onSelect }: CommandMenuProps) => {
34
+ const { tx } = useThemeContext();
35
+ const groupsWithItems = groups.filter((group) => group.items.length > 0);
36
+ return (
37
+ <Popover.Portal>
38
+ <Popover.Content
39
+ align='start'
40
+ onOpenAutoFocus={(event) => event.preventDefault()}
41
+ classNames={tx('menu.content', 'menu--exotic-unfocusable', { elevation: 'positioned' }, [
42
+ 'max-h-[300px] overflow-y-auto',
43
+ ])}
44
+ >
45
+ <Popover.Viewport classNames={tx('menu.viewport', 'menu__viewport--exotic-unfocusable', {})}>
46
+ <ul>
47
+ {groupsWithItems.map((group, index) => (
48
+ <React.Fragment key={group.id}>
49
+ <CommandGroup group={group} currentItem={currentItem} onSelect={onSelect} />
50
+ {index < groupsWithItems.length - 1 && <div className={tx('menu.separator', 'menu__item', {})} />}
51
+ </React.Fragment>
52
+ ))}
53
+ </ul>
54
+ </Popover.Viewport>
55
+ </Popover.Content>
56
+ </Popover.Portal>
57
+ );
58
+ };
59
+
60
+ const CommandGroup = ({
61
+ group,
62
+ currentItem,
63
+ onSelect,
64
+ }: {
65
+ group: CommandMenuGroup;
66
+ currentItem?: string;
67
+ onSelect: (item: CommandMenuItem) => void;
68
+ }) => {
69
+ const { tx } = useThemeContext();
70
+ const { t } = useTranslation();
71
+ return (
72
+ <>
73
+ {group.label && (
74
+ <div className={tx('menu.groupLabel', 'menu__group__label', {})}>
75
+ <span>{toLocalizedString(group.label, t)}</span>
76
+ </div>
77
+ )}
78
+ {group.items.map((item) => (
79
+ <CommandItem key={item.id} item={item} current={currentItem === item.id} onSelect={onSelect} />
80
+ ))}
81
+ </>
82
+ );
83
+ };
84
+
85
+ const CommandItem = ({
86
+ item,
87
+ current,
88
+ onSelect,
89
+ }: {
90
+ item: CommandMenuItem;
91
+ current: boolean;
92
+ onSelect: (item: CommandMenuItem) => void;
93
+ }) => {
94
+ const ref = useRef<HTMLLIElement>(null);
95
+ const { tx } = useThemeContext();
96
+ const { t } = useTranslation();
97
+ const handleSelect = useCallback(() => onSelect(item), [item, onSelect]);
98
+
99
+ useEffect(() => {
100
+ if (current && ref.current) {
101
+ ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
102
+ }
103
+ }, [current]);
104
+
105
+ return (
106
+ <li
107
+ ref={ref}
108
+ className={tx('menu.item', 'menu__item--exotic-unfocusable', {}, [current && 'bg-hoverSurface'])}
109
+ onClick={handleSelect}
110
+ >
111
+ {item.icon && <Icon icon={item.icon} size={5} />}
112
+ <span className='grow truncate'>{toLocalizedString(item.label, t)}</span>
113
+ </li>
114
+ );
115
+ };
116
+
117
+ // TODO(wittjosiah): Factor out into a separate file.
118
+
119
+ //
120
+ // Helpers
121
+ //
122
+
123
+ export const getItem = (groups: CommandMenuGroup[], id?: string): CommandMenuItem | undefined => {
124
+ return groups.flatMap((group) => group.items).find((item) => item.id === id);
125
+ };
126
+
127
+ export const getNextItem = (groups: CommandMenuGroup[], id?: string): CommandMenuItem => {
128
+ const items = groups.flatMap((group) => group.items);
129
+ const index = items.findIndex((item) => item.id === id);
130
+ return items[(index + 1) % items.length];
131
+ };
132
+
133
+ export const getPreviousItem = (groups: CommandMenuGroup[], id?: string): CommandMenuItem => {
134
+ const items = groups.flatMap((group) => group.items);
135
+ const index = items.findIndex((item) => item.id === id);
136
+ return items[(index - 1 + items.length) % items.length];
137
+ };
138
+
139
+ export const filterItems = (
140
+ groups: CommandMenuGroup[],
141
+ filter: (item: CommandMenuItem) => boolean,
142
+ ): CommandMenuGroup[] => {
143
+ return groups.map((group) => ({
144
+ ...group,
145
+ items: group.items.filter(filter),
146
+ }));
147
+ };
148
+
149
+ export const insertAtCursor = (view: EditorView, head: number, insert: string) => {
150
+ view.dispatch({
151
+ changes: { from: head, to: head, insert },
152
+ selection: { anchor: head + insert.length, head: head + insert.length },
153
+ });
154
+ };
155
+
156
+ /**
157
+ * If the cursor is at the start of a line, insert the text at the cursor.
158
+ * Otherwise, insert the text on a new line.
159
+ */
160
+ export const insertAtLineStart = (view: EditorView, head: number, insert: string) => {
161
+ const line = view.state.doc.lineAt(head);
162
+ if (line.from === head) {
163
+ insertAtCursor(view, head, insert);
164
+ } else {
165
+ insert = '\n' + insert;
166
+ view.dispatch({
167
+ changes: { from: line.to, to: line.to, insert },
168
+ selection: { anchor: line.to + insert.length, head: line.to + insert.length },
169
+ });
170
+ }
171
+ };
172
+
173
+ export const coreSlashCommands: CommandMenuGroup = {
174
+ id: 'markdown',
175
+ label: 'Markdown',
176
+ items: [
177
+ {
178
+ id: 'heading-1',
179
+ label: 'Heading 1',
180
+ icon: 'ph--text-h-one--regular',
181
+ onSelect: (view, head) => insertAtLineStart(view, head, '# '),
182
+ },
183
+ {
184
+ id: 'heading-2',
185
+ label: 'Heading 2',
186
+ icon: 'ph--text-h-two--regular',
187
+ onSelect: (view, head) => insertAtLineStart(view, head, '## '),
188
+ },
189
+ {
190
+ id: 'heading-3',
191
+ label: 'Heading 3',
192
+ icon: 'ph--text-h-three--regular',
193
+ onSelect: (view, head) => insertAtLineStart(view, head, '### '),
194
+ },
195
+ {
196
+ id: 'heading-4',
197
+ label: 'Heading 4',
198
+ icon: 'ph--text-h-four--regular',
199
+ onSelect: (view, head) => insertAtLineStart(view, head, '#### '),
200
+ },
201
+ {
202
+ id: 'heading-5',
203
+ label: 'Heading 5',
204
+ icon: 'ph--text-h-five--regular',
205
+ onSelect: (view, head) => insertAtLineStart(view, head, '##### '),
206
+ },
207
+ {
208
+ id: 'heading-6',
209
+ label: 'Heading 6',
210
+ icon: 'ph--text-h-six--regular',
211
+ onSelect: (view, head) => insertAtLineStart(view, head, '###### '),
212
+ },
213
+ {
214
+ id: 'bullet-list',
215
+ label: 'Bullet List',
216
+ icon: 'ph--list-bullets--regular',
217
+ onSelect: (view, head) => insertAtLineStart(view, head, '- '),
218
+ },
219
+ {
220
+ id: 'numbered-list',
221
+ label: 'Numbered List',
222
+ icon: 'ph--list-numbers--regular',
223
+ onSelect: (view, head) => insertAtLineStart(view, head, '1. '),
224
+ },
225
+ {
226
+ id: 'task-list',
227
+ label: 'Task List',
228
+ icon: 'ph--list-checks--regular',
229
+ onSelect: (view, head) => insertAtLineStart(view, head, '- [ ] '),
230
+ },
231
+ {
232
+ id: 'quote',
233
+ label: 'Quote',
234
+ icon: 'ph--quotes--regular',
235
+ onSelect: (view, head) => insertAtLineStart(view, head, '> '),
236
+ },
237
+ {
238
+ id: 'code-block',
239
+ label: 'Code Block',
240
+ icon: 'ph--code-block--regular',
241
+ onSelect: (view, head) => insertAtLineStart(view, head, '```\n\n```'),
242
+ },
243
+ {
244
+ id: 'table',
245
+ label: 'Table',
246
+ icon: 'ph--table--regular',
247
+ onSelect: (view, head) => insertAtLineStart(view, head, '| | | |\n|---|---|---|\n| | | |'),
248
+ },
249
+ ],
250
+ };
251
+
252
+ export const linkSlashCommands: CommandMenuGroup = {
253
+ id: 'link',
254
+ label: 'Link',
255
+ items: [
256
+ {
257
+ id: 'inline-link',
258
+ label: 'Inline link',
259
+ icon: 'ph--link--regular',
260
+ onSelect: (view, head) =>
261
+ view.dispatch({
262
+ changes: { from: head, insert: '@' },
263
+ selection: { anchor: head + 1, head: head + 1 },
264
+ effects: commandRangeEffect.of({ trigger: '@', range: { from: head, to: head + 1 } }),
265
+ }),
266
+ },
267
+ {
268
+ id: 'block-embed',
269
+ label: 'Block embed',
270
+ icon: 'ph--lego--regular',
271
+ onSelect: (view, head) =>
272
+ view.dispatch({
273
+ changes: { from: head, insert: '@@' },
274
+ selection: { anchor: head + 2, head: head + 2 },
275
+ effects: commandRangeEffect.of({ trigger: '@', range: { from: head, to: head + 2 } }),
276
+ }),
277
+ },
278
+ ],
279
+ };
@@ -51,9 +51,11 @@ const RefDropdownMenuProvider = ({ children, onLookup }: RefDropdownMenuProvider
51
51
  );
52
52
 
53
53
  useEffect(() => {
54
- return rootRef
55
- ? addEventListener(rootRef, 'dx-ref-tag-activate', handleDxRefTagActivate, customEventOptions)
56
- : undefined;
54
+ if (!rootRef) {
55
+ return;
56
+ }
57
+
58
+ return addEventListener(rootRef, 'dx-ref-tag-activate' as any, handleDxRefTagActivate, customEventOptions);
57
59
  }, [rootRef]);
58
60
 
59
61
  return (
@@ -3,7 +3,15 @@
3
3
  //
4
4
 
5
5
  import { createContext } from '@radix-ui/react-context';
6
- import React, { type PropsWithChildren, useRef, useState, useEffect, useCallback, type RefObject } from 'react';
6
+ import React, {
7
+ type PropsWithChildren,
8
+ useRef,
9
+ useState,
10
+ useEffect,
11
+ useCallback,
12
+ type RefObject,
13
+ forwardRef,
14
+ } from 'react';
7
15
 
8
16
  import { addEventListener } from '@dxos/async';
9
17
  import { type DxRefTag, type DxRefTagActivate } from '@dxos/lit-ui';
@@ -13,18 +21,47 @@ import { type PreviewLinkRef, type PreviewLinkTarget, type PreviewLookup } from
13
21
 
14
22
  const customEventOptions = { capture: true, passive: false };
15
23
 
24
+ export type RefPopoverProps = PropsWithChildren<{
25
+ modal?: boolean;
26
+ open?: boolean;
27
+ onOpenChange?: (open: boolean) => void;
28
+ onActivate?: (event: DxRefTagActivate) => void;
29
+ }>;
30
+
31
+ export const RefPopover = forwardRef<DxRefTag | null, RefPopoverProps>(
32
+ ({ children, open, onOpenChange, modal, onActivate }, ref) => {
33
+ const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
34
+
35
+ useEffect(() => {
36
+ if (!rootRef || !onActivate) {
37
+ return;
38
+ }
39
+
40
+ return addEventListener(rootRef, 'dx-ref-tag-activate' as any, onActivate, customEventOptions);
41
+ }, [rootRef, onActivate]);
42
+
43
+ return (
44
+ <Popover.Root open={open} onOpenChange={onOpenChange} modal={modal}>
45
+ <Popover.VirtualTrigger virtualRef={ref as unknown as RefObject<HTMLButtonElement>} />
46
+ <div role='none' className='contents' ref={setRootRef}>
47
+ {children}
48
+ </div>
49
+ </Popover.Root>
50
+ );
51
+ },
52
+ );
53
+
16
54
  // Create a context for the dxn value.
17
55
  type RefPopoverValue = Partial<{ link: PreviewLinkRef; target: PreviewLinkTarget; pending: boolean }>;
18
56
 
19
57
  const REF_POPOVER = 'RefPopover';
20
58
  const [RefPopoverContextProvider, useRefPopover] = createContext<RefPopoverValue>(REF_POPOVER, {});
21
59
 
22
- type RefPopoverProviderProps = PropsWithChildren<{ onLookup?: PreviewLookup }>;
60
+ type PreviewProviderProps = PropsWithChildren<{ onLookup?: PreviewLookup }>;
23
61
 
24
- const RefPopoverProvider = ({ children, onLookup }: RefPopoverProviderProps) => {
62
+ const PreviewProvider = ({ children, onLookup }: PreviewProviderProps) => {
25
63
  const trigger = useRef<DxRefTag | null>(null);
26
64
  const [value, setValue] = useState<RefPopoverValue>({});
27
- const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
28
65
  const [open, setOpen] = useState(false);
29
66
 
30
67
  const handleDxRefTagActivate = useCallback(
@@ -48,28 +85,15 @@ const RefPopoverProvider = ({ children, onLookup }: RefPopoverProviderProps) =>
48
85
  [onLookup],
49
86
  );
50
87
 
51
- useEffect(() => {
52
- return rootRef
53
- ? addEventListener(rootRef, 'dx-ref-tag-activate', handleDxRefTagActivate, customEventOptions)
54
- : undefined;
55
- }, [rootRef]);
56
-
57
88
  return (
58
89
  <RefPopoverContextProvider pending={value.pending} link={value.link} target={value.target}>
59
- <Popover.Root open={open} onOpenChange={setOpen}>
60
- <Popover.VirtualTrigger virtualRef={trigger as unknown as RefObject<HTMLButtonElement>} />
61
- <div role='none' className='contents' ref={setRootRef}>
62
- {children}
63
- </div>
64
- </Popover.Root>
90
+ <RefPopover ref={trigger} open={open} onOpenChange={setOpen} onActivate={handleDxRefTagActivate}>
91
+ {children}
92
+ </RefPopover>
65
93
  </RefPopoverContextProvider>
66
94
  );
67
95
  };
68
96
 
69
- export const RefPopover = {
70
- Provider: RefPopoverProvider,
71
- };
72
-
73
- export { useRefPopover };
97
+ export { PreviewProvider, useRefPopover };
74
98
 
75
- export type { RefPopoverProviderProps, RefPopoverValue };
99
+ export type { PreviewProviderProps, RefPopoverValue };
@@ -2,5 +2,6 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
+ export * from './CommandMenu';
5
6
  export * from './RefPopover';
6
7
  export * from './RefDropdownMenu';
package/src/defaults.ts CHANGED
@@ -28,6 +28,7 @@ export const editorSlots: ThemeExtensionsOptions['slots'] = {
28
28
 
29
29
  export const editorGutter = EditorView.theme({
30
30
  '.cm-gutters': {
31
+ background: 'var(--dx-baseSurface)',
31
32
  paddingRight: '1rem',
32
33
  },
33
34
  });
@@ -46,9 +47,3 @@ export const stackItemContentEditorClassNames = (role?: string) =>
46
47
  'attention-surface dx-focus-ring-inset data-[toolbar=disabled]:pbs-2',
47
48
  role === 'section' ? '[&_.cm-scroller]:overflow-hidden [&_.cm-scroller]:min-bs-24' : 'min-bs-0',
48
49
  );
49
-
50
- export const stackItemContentToolbarClassNames = (role?: string) =>
51
- mx(
52
- 'relative z-[1] flex is-full bg-toolbarSurface border-be border-subduedSeparator',
53
- role === 'section' && 'sticky block-start-0 -mbe-px min-is-0',
54
- );
@@ -10,8 +10,8 @@ import { Repo } from '@automerge/automerge-repo';
10
10
  import { BroadcastChannelNetworkAdapter } from '@automerge/automerge-repo-network-broadcastchannel';
11
11
  import React, { useEffect, useState } from 'react';
12
12
 
13
- import { Expando } from '@dxos/echo-schema';
14
- import { DocAccessor, live, createDocAccessor, useQuery, useSpace, type Space, Query } from '@dxos/react-client/echo';
13
+ import { Obj, Ref, Type } from '@dxos/echo';
14
+ import { DocAccessor, createDocAccessor, useQuery, useSpace, type Space, Query } from '@dxos/react-client/echo';
15
15
  import { useIdentity, type Identity } from '@dxos/react-client/halo';
16
16
  import { ClientRepeater, type ClientRepeatedComponentProps } from '@dxos/react-client/testing';
17
17
  import { useThemeContext } from '@dxos/react-ui';
@@ -100,7 +100,7 @@ const EchoStory = ({ spaceKey }: ClientRepeatedComponentProps) => {
100
100
  const identity = useIdentity();
101
101
  const space = useSpace(spaceKey);
102
102
  const [source, setSource] = useState<DocAccessor>();
103
- const objects = useQuery(space, Query.type(Expando, { type: 'test' }));
103
+ const objects = useQuery(space, Query.type(Type.Expando, { type: 'test' }));
104
104
 
105
105
  useEffect(() => {
106
106
  if (!source && objects.length) {
@@ -128,9 +128,9 @@ export const WithEcho = {
128
128
  createSpace
129
129
  onSpaceCreated={async ({ space }) => {
130
130
  space.db.add(
131
- live({
131
+ Obj.make(Type.Expando, {
132
132
  type: 'test',
133
- content: live(Expando, { content: initialContent }),
133
+ content: Ref.make(Obj.make(Type.Expando, { content: initialContent })),
134
134
  }),
135
135
  );
136
136
  }}
@@ -44,6 +44,13 @@ export const closeCommand: Command = (view: EditorView) => {
44
44
  };
45
45
 
46
46
  export const commandKeyBindings: readonly KeyBinding[] = [
47
- { key: '/', run: openCommand },
48
- { key: 'Escape', run: closeCommand },
47
+ {
48
+ key: '/',
49
+ preventDefault: true,
50
+ run: openCommand,
51
+ },
52
+ {
53
+ key: 'Escape',
54
+ run: closeCommand,
55
+ },
49
56
  ];
@@ -0,0 +1,210 @@
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
+
8
+ import { placeholder, type PlaceholderOptions } from './placeholder';
9
+ import { type Range } from '../../types';
10
+
11
+ export type CommandMenuOptions = {
12
+ trigger: string | string[];
13
+ placeholder?: Partial<PlaceholderOptions>;
14
+
15
+ // TODO(burdon): Replace with onKey?
16
+ onClose?: () => void;
17
+ onArrowDown?: () => void;
18
+ onArrowUp?: () => void;
19
+ onEnter?: () => void;
20
+
21
+ onTextChange?: (trigger: string, text: string) => void;
22
+ };
23
+
24
+ export const commandMenu = (options: CommandMenuOptions) => {
25
+ const commandMenuPlugin = ViewPlugin.fromClass(
26
+ class {
27
+ decorations: DecorationSet = Decoration.none;
28
+
29
+ constructor(readonly view: EditorView) {}
30
+
31
+ // TODO(wittjosiah): The decorations are repainted on every update, this occasionally causes menu to flicker.
32
+ update(update: ViewUpdate) {
33
+ const builder = new RangeSetBuilder<Decoration>();
34
+ const selection = update.view.state.selection.main;
35
+ const { range: activeRange, trigger } = update.view.state.field(commandMenuState) ?? {};
36
+
37
+ // Check if we should show the widget - only if cursor is within the active command range.
38
+ const shouldShowWidget = activeRange && selection.head >= activeRange.from && selection.head <= activeRange.to;
39
+ if (shouldShowWidget) {
40
+ // Create mark decoration that wraps the entire line content in a dx-ref-tag.
41
+ builder.add(
42
+ activeRange.from,
43
+ activeRange.to,
44
+ Decoration.mark({
45
+ tagName: 'dx-ref-tag',
46
+ class: 'cm-ref-tag',
47
+ attributes: {
48
+ 'data-auto-trigger': 'true',
49
+ 'data-trigger': trigger!,
50
+ },
51
+ }),
52
+ );
53
+ }
54
+
55
+ const activeRangeChanged = update.transactions.some((tr) =>
56
+ tr.effects.some((effect) => effect.is(commandRangeEffect)),
57
+ );
58
+ if (activeRange && activeRangeChanged && trigger) {
59
+ const content = update.view.state.sliceDoc(
60
+ activeRange.from + 1, // Skip the trigger character.
61
+ activeRange.to,
62
+ );
63
+ options.onTextChange?.(trigger, content);
64
+ }
65
+
66
+ this.decorations = builder.finish();
67
+ }
68
+ },
69
+ {
70
+ decorations: (v) => v.decorations,
71
+ },
72
+ );
73
+
74
+ const triggers = Array.isArray(options.trigger) ? options.trigger : [options.trigger];
75
+
76
+ const commandKeymap = keymap.of([
77
+ ...triggers.map((trigger) => ({
78
+ key: trigger,
79
+ preventDefault: true,
80
+ run: (view: EditorView) => {
81
+ const selection = view.state.selection.main;
82
+ const line = view.state.doc.lineAt(selection.head);
83
+
84
+ // Check if we should trigger the command menu:
85
+ // 1. Empty lines or at the beginning of a line
86
+ // 2. When there's a preceding space
87
+ if (
88
+ line.text.trim() === '' ||
89
+ selection.head === line.from ||
90
+ (selection.head > line.from && line.text[selection.head - line.from - 1] === ' ')
91
+ ) {
92
+ // Insert and select the trigger.
93
+ view.dispatch({
94
+ changes: { from: selection.head, insert: trigger },
95
+ selection: { anchor: selection.head + 1, head: selection.head + 1 },
96
+ effects: commandRangeEffect.of({ trigger, range: { from: selection.head, to: selection.head + 1 } }),
97
+ });
98
+
99
+ return true;
100
+ }
101
+
102
+ return false;
103
+ },
104
+ })),
105
+ {
106
+ key: 'Enter',
107
+ run: (view) => {
108
+ const activeRange = view.state.field(commandMenuState)?.range;
109
+ if (activeRange) {
110
+ view.dispatch({ changes: { from: activeRange.from, to: activeRange.to, insert: '' } });
111
+ options.onEnter?.();
112
+ return true;
113
+ }
114
+
115
+ return false;
116
+ },
117
+ },
118
+ {
119
+ key: 'ArrowDown',
120
+ run: (view) => {
121
+ const activeRange = view.state.field(commandMenuState)?.range;
122
+ if (activeRange) {
123
+ options.onArrowDown?.();
124
+ return true;
125
+ }
126
+
127
+ return false;
128
+ },
129
+ },
130
+ {
131
+ key: 'ArrowUp',
132
+ run: (view) => {
133
+ const activeRange = view.state.field(commandMenuState)?.range;
134
+ if (activeRange) {
135
+ options.onArrowUp?.();
136
+ return true;
137
+ }
138
+
139
+ return false;
140
+ },
141
+ },
142
+ ]);
143
+
144
+ // Listen for selection and document changes to clean up the command menu.
145
+ const updateListener = EditorView.updateListener.of((update) => {
146
+ const { trigger, range: activeRange } = update.view.state.field(commandMenuState) ?? {};
147
+ if (!activeRange || !trigger) {
148
+ return;
149
+ }
150
+
151
+ const selection = update.view.state.selection.main;
152
+ const firstChar = update.view.state.doc.sliceString(activeRange.from, activeRange.from + 1);
153
+ const shouldRemove =
154
+ firstChar !== trigger || // Trigger deleted.
155
+ selection.head < activeRange.from || // Cursor moved before the range.
156
+ selection.head > activeRange.to + 1; // Cursor moved after the range (+1 to handle selection changing before doc).
157
+
158
+ const nextRange = shouldRemove
159
+ ? null
160
+ : update.docChanged
161
+ ? { from: activeRange.from, to: selection.head }
162
+ : activeRange;
163
+ if (nextRange !== activeRange) {
164
+ update.view.dispatch({ effects: commandRangeEffect.of(nextRange ? { trigger, range: nextRange } : null) });
165
+ }
166
+
167
+ // TODO(burdon): Should delete if user presses escape? How else to insert the trigger character?
168
+ if (shouldRemove) {
169
+ options.onClose?.();
170
+ }
171
+ });
172
+
173
+ return [
174
+ Prec.highest(commandKeymap),
175
+ placeholder(
176
+ Object.assign(
177
+ {
178
+ content: `Press '${Array.isArray(options.trigger) ? options.trigger[0] : options.trigger}' for commands`,
179
+ },
180
+ options.placeholder,
181
+ ),
182
+ ),
183
+ updateListener,
184
+ commandMenuState,
185
+ commandMenuPlugin,
186
+ ];
187
+ };
188
+
189
+ type CommandState = {
190
+ trigger: string;
191
+ range: Range;
192
+ };
193
+
194
+ // State effects for managing command menu state.
195
+ export const commandRangeEffect = StateEffect.define<CommandState | null>();
196
+
197
+ // State field to track the active command menu range.
198
+ const commandMenuState = StateField.define<CommandState | null>({
199
+ create: () => null,
200
+ update: (value, tr) => {
201
+ let newValue = value;
202
+ for (const effect of tr.effects) {
203
+ if (effect.is(commandRangeEffect)) {
204
+ newValue = effect.value;
205
+ }
206
+ }
207
+
208
+ return newValue;
209
+ },
210
+ });