@dxos/react-ui-editor 0.14.0 → 0.16.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.
Files changed (25) hide show
  1. package/dist/lib/index.mjs +82 -70
  2. package/dist/lib/index.mjs.map +1 -1
  3. package/dist/types/src/components/EditorMenuProvider/menu.d.ts +2 -0
  4. package/dist/types/src/components/EditorMenuProvider/menu.d.ts.map +1 -1
  5. package/dist/types/src/components/EditorMenuProvider/popover.d.ts +7 -1
  6. package/dist/types/src/components/EditorMenuProvider/popover.d.ts.map +1 -1
  7. package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts +2 -2
  8. package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts.map +1 -1
  9. package/dist/types/src/components/EditorMenuProvider/useEditorMenu.test.d.ts +2 -0
  10. package/dist/types/src/components/EditorMenuProvider/useEditorMenu.test.d.ts.map +1 -0
  11. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  12. package/dist/types/src/stories/Comments.stories.d.ts.map +1 -1
  13. package/dist/types/src/stories/Widgets.stories.d.ts +5 -0
  14. package/dist/types/src/stories/Widgets.stories.d.ts.map +1 -1
  15. package/dist/types/tsconfig.tsbuildinfo +1 -1
  16. package/package.json +33 -33
  17. package/src/components/EditorMenuProvider/EditorMenuProvider.tsx +1 -1
  18. package/src/components/EditorMenuProvider/menu.ts +2 -0
  19. package/src/components/EditorMenuProvider/popover.ts +12 -3
  20. package/src/components/EditorMenuProvider/useEditorMenu.test.tsx +125 -0
  21. package/src/components/EditorMenuProvider/useEditorMenu.ts +79 -56
  22. package/src/hooks/useTextEditor.test.tsx +45 -4
  23. package/src/hooks/useTextEditor.ts +37 -9
  24. package/src/stories/Comments.stories.tsx +25 -21
  25. package/src/stories/Widgets.stories.tsx +59 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-editor",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Text editor components.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -40,55 +40,55 @@
40
40
  "@codemirror/search": "^6.7.2",
41
41
  "@codemirror/state": "^6.7.6",
42
42
  "@codemirror/view": "^6.43.13",
43
- "@dxos/app-graph": "0.14.0",
44
- "@dxos/async": "0.14.0",
45
- "@dxos/invariant": "0.14.0",
46
- "@dxos/log": "0.14.0",
47
- "@dxos/react-hooks": "0.14.0",
48
- "@dxos/react-ui-menu": "0.14.0",
49
- "@dxos/ui-editor": "0.14.0",
50
- "@dxos/ui-types": "0.14.0",
51
- "@dxos/util": "0.14.0"
43
+ "@dxos/app-graph": "0.16.0",
44
+ "@dxos/async": "0.16.0",
45
+ "@dxos/invariant": "0.16.0",
46
+ "@dxos/log": "0.16.0",
47
+ "@dxos/react-hooks": "0.16.0",
48
+ "@dxos/react-ui-menu": "0.16.0",
49
+ "@dxos/ui-editor": "0.16.0",
50
+ "@dxos/ui-types": "0.16.0",
51
+ "@dxos/util": "0.16.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@codemirror/autocomplete": "^6.20.3",
55
55
  "@codemirror/lang-javascript": "^6.2.5",
56
56
  "@codemirror/lang-markdown": "6.5.2",
57
- "@effect/atom-react": "4.0.0",
57
+ "@effect/atom-react": "4.0.4",
58
58
  "@storybook/react-vite": "^10.6.0",
59
59
  "@types/lodash.defaultsdeep": "^4.6.6",
60
60
  "@types/react": "~19.2.17",
61
61
  "@types/react-dom": "~19.2.3",
62
- "effect": "4.0.0",
62
+ "effect": "4.0.4",
63
63
  "happy-dom": "^20.0.0",
64
64
  "lodash.defaultsdeep": "^4.6.1",
65
65
  "react": "~19.2.7",
66
66
  "react-dom": "~19.2.7",
67
- "vite": "^8.2.2",
68
- "@dxos/ai": "0.14.0",
69
- "@dxos/echo": "0.14.0",
70
- "@dxos/echo-client": "0.14.0",
71
- "@dxos/echo-doc": "0.14.0",
72
- "@dxos/echo-react": "0.14.0",
73
- "@dxos/keys": "0.14.0",
74
- "@dxos/protocols": "0.14.0",
75
- "@dxos/random": "0.14.0",
76
- "@dxos/react-client": "0.14.0",
77
- "@dxos/react-ui": "0.14.0",
78
- "@dxos/react-ui-attention": "0.14.0",
79
- "@dxos/react-ui-list": "0.14.0",
80
- "@dxos/react-ui-syntax-highlighter": "0.14.0",
81
- "@dxos/schema": "0.14.0",
82
- "@dxos/ui": "0.14.0",
83
- "@dxos/ui-theme": "0.14.0"
67
+ "vite": "^8.3.4",
68
+ "@dxos/ai": "0.16.0",
69
+ "@dxos/echo": "0.16.0",
70
+ "@dxos/echo-client": "0.16.0",
71
+ "@dxos/echo-doc": "0.16.0",
72
+ "@dxos/echo-react": "0.16.0",
73
+ "@dxos/keys": "0.16.0",
74
+ "@dxos/protocols": "0.16.0",
75
+ "@dxos/random": "0.16.0",
76
+ "@dxos/react-client": "0.16.0",
77
+ "@dxos/react-ui": "0.16.0",
78
+ "@dxos/react-ui-attention": "0.16.0",
79
+ "@dxos/react-ui-list": "0.16.0",
80
+ "@dxos/react-ui-syntax-highlighter": "0.16.0",
81
+ "@dxos/schema": "0.16.0",
82
+ "@dxos/ui": "0.16.0",
83
+ "@dxos/ui-theme": "0.16.0"
84
84
  },
85
85
  "peerDependencies": {
86
- "@effect/atom-react": "4.0.0",
87
- "effect": "4.0.0",
86
+ "@effect/atom-react": "4.0.4",
87
+ "effect": "4.0.4",
88
88
  "react": "~19.2.7",
89
89
  "react-dom": "~19.2.7",
90
- "@dxos/react-ui": "^0.14.0",
91
- "@dxos/ui-theme": "^0.14.0"
90
+ "@dxos/react-ui": "^0.16.0",
91
+ "@dxos/ui-theme": "^0.16.0"
92
92
  },
93
93
  "publishConfig": {
94
94
  "access": "public"
@@ -306,7 +306,7 @@ const MenuItem = ({ item, current, onSelect }: MenuItemProps) => {
306
306
  return (
307
307
  // Menu row metrics without a Menu machine: the popover keeps focus in the editor, so `current` is the highlight.
308
308
  <li ref={listRef} className='dx-menu-item' data-highlighted={current ? '' : undefined} onClick={handleSelect}>
309
- {item.icon && <Icon.Icon icon={item.icon} />}
309
+ {item.icon && <Icon.Icon icon={item.icon} hue={item.iconHue} />}
310
310
  <span className='dx-menu-item-text'>{Theme.toLocalizedString(item.label, t)}</span>
311
311
  </li>
312
312
  );
@@ -6,6 +6,7 @@ import { type EditorView } from '@codemirror/view';
6
6
 
7
7
  import type * as Theme from '@dxos/react-ui/Theme';
8
8
  import { insertAtCursor } from '@dxos/ui-editor';
9
+ import { type ChromaticPalette } from '@dxos/ui-types';
9
10
  import { type MaybePromise } from '@dxos/util';
10
11
 
11
12
  export type EditorMenuGroup = {
@@ -18,6 +19,7 @@ export type EditorMenuItem = {
18
19
  id: string;
19
20
  label: Theme.Label;
20
21
  icon?: string;
22
+ iconHue?: ChromaticPalette;
21
23
  onSelect?: (event: { view: EditorView; head: number }) => MaybePromise<void>;
22
24
  };
23
25
 
@@ -22,7 +22,8 @@ const DELIMITERS = [' ', ':'];
22
22
  export type PopoverOptions = {
23
23
  trigger?: string | string[];
24
24
  triggerKey?: string;
25
- placeholder?: Partial<PlaceholderOptions>;
25
+ /** Overrides the trigger hint shown in an empty, focused editor; `false` omits the hint. */
26
+ placeholder?: Partial<PlaceholderOptions> | false;
26
27
  delimiters?: string[];
27
28
 
28
29
  /**
@@ -38,6 +39,12 @@ export type PopoverOptions = {
38
39
  */
39
40
  activateOnDelimiters?: string[];
40
41
 
42
+ /**
43
+ * Triggers whose query may contain spaces (e.g. multi-word names). A double space, a leading space or a
44
+ * line break still ends it, and Escape keeps the typed text.
45
+ */
46
+ multiWordTriggers?: string[];
47
+
41
48
  // Trigger update.
42
49
  onTextChange?: (event: { view: EditorView; pos: number; text: string; trigger?: string }) => void;
43
50
  onClose?: (event: { view: EditorView }) => void;
@@ -62,6 +69,7 @@ export const popover = (options: PopoverOptions = {}): Extension => {
62
69
  popoverAnchorDecoration(options),
63
70
  modalStateField,
64
71
  options.trigger &&
72
+ options.placeholder !== false &&
65
73
  placeholder({
66
74
  // TODO(burdon): Translations.
67
75
  content: `Press '${Array.isArray(options.trigger) ? options.trigger[0] : options.trigger}' for commands`,
@@ -82,12 +90,13 @@ const popoverTriggerListener = (options: PopoverOptions) =>
82
90
  }
83
91
 
84
92
  const text = view.state.doc.sliceString(activeRange.from, activeRange.to);
93
+ const query = trigger ? text.slice(1) : text;
85
94
  const selection = view.state.selection.main;
86
95
  const shouldClose =
87
96
  // Trigger deleted.
88
97
  (trigger ? trigger !== text[0] : false) ||
89
- // Whitespace in text.
90
- /\s/.test(trigger ? text.slice(1) : text) ||
98
+ // Whitespace in text; a multi-word query may hold single spaces between words.
99
+ (trigger && options.multiWordTriggers?.includes(trigger) ? /^\s|[^\S ]| {2}/.test(query) : /\s/.test(query)) ||
91
100
  // Cursor moved before the range.
92
101
  selection.head < activeRange.from ||
93
102
  // Cursor moved after the range (+1 to handle selection changing before doc).
@@ -0,0 +1,125 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type EditorView } from '@codemirror/view';
6
+ import React, { act, useEffect } from 'react';
7
+ import { createRoot } from 'react-dom/client';
8
+ import { beforeAll, describe, onTestFinished, test, vi } from 'vitest';
9
+
10
+ import { useTextEditor } from '../../hooks/index.ts';
11
+ import { type EditorMenuGroup, type EditorMenuItem, createMenuGroup } from './menu.ts';
12
+ import { popoverStateField } from './popover.ts';
13
+ import { type UseEditorMenuProps, useEditorMenu } from './useEditorMenu.ts';
14
+
15
+ const getMenu: UseEditorMenuProps['getMenu'] = ({ text }): EditorMenuGroup[] => [
16
+ { id: 'create', items: [{ id: 'create', label: 'Add object' }] },
17
+ createMenuGroup({ id: 'test', filter: text, items: ['Hello world', 'Hello DXOS', 'Goodbye world'] }),
18
+ ];
19
+
20
+ type Menu = { view: EditorView; labels: () => EditorMenuItem['label'][]; cancel: () => void };
21
+
22
+ const MenuEditor = ({ onReady }: { onReady: (menu: Menu) => void }) => {
23
+ const { groupsRef, extension, onCancel } = useEditorMenu({ trigger: '@', multiWordTriggers: ['@'], getMenu });
24
+ const { parentRef, view } = useTextEditor({ extensions: [extension] });
25
+ useEffect(() => {
26
+ if (view) {
27
+ onReady({
28
+ view,
29
+ labels: () => groupsRef.current.flatMap((group) => group.items.map((item) => item.label)),
30
+ cancel: () => onCancel?.({ view }),
31
+ });
32
+ }
33
+ }, [view, groupsRef, onCancel, onReady]);
34
+
35
+ return <div ref={parentRef} />;
36
+ };
37
+
38
+ const mountMenuEditor = async (): Promise<Menu> => {
39
+ const container = document.createElement('div');
40
+ document.body.append(container);
41
+ const root = createRoot(container);
42
+ let menu: Menu | undefined;
43
+ await act(async () => root.render(<MenuEditor onReady={(ready) => (menu = ready)} />));
44
+ onTestFinished(() => {
45
+ act(() => root.unmount());
46
+ container.remove();
47
+ });
48
+ return vi.waitFor(() => menu!);
49
+ };
50
+
51
+ /** Types as a user does: the trigger through its keymap, the rest as input. */
52
+ const type = (view: EditorView, text: string) => {
53
+ for (const char of text) {
54
+ const event = new KeyboardEvent('keydown', { key: char, bubbles: true, cancelable: true });
55
+ view.contentDOM.dispatchEvent(event);
56
+ if (!event.defaultPrevented) {
57
+ const head = view.state.selection.main.head;
58
+ view.dispatch({
59
+ changes: { from: head, insert: char },
60
+ selection: { anchor: head + 1 },
61
+ userEvent: 'input.type',
62
+ });
63
+ }
64
+ }
65
+ };
66
+
67
+ const press = (view: EditorView, key: string) =>
68
+ view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
69
+
70
+ describe('useEditorMenu', () => {
71
+ beforeAll(() => {
72
+ Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true);
73
+ });
74
+
75
+ test('a multi-word query stays open as the user types, matching or not', async ({ expect }) => {
76
+ const { view, labels } = await mountMenuEditor();
77
+ const isOpen = () => view.state.field(popoverStateField) !== null;
78
+
79
+ await act(async () => type(view, '@hello '));
80
+ await vi.waitFor(() => expect(labels()).toEqual(['Add object', 'Hello world', 'Hello DXOS']));
81
+
82
+ await act(async () => type(view, 'w'));
83
+ await vi.waitFor(() => expect(labels()).toEqual(['Add object', 'Hello world']));
84
+
85
+ await act(async () => type(view, 'x and more'));
86
+ await vi.waitFor(() => expect(labels()).toEqual(['Add object']));
87
+ expect(isOpen()).toBe(true);
88
+ });
89
+
90
+ test('Enter picks the highlighted row in place of a multi-word query', async ({ expect }) => {
91
+ const { view, labels } = await mountMenuEditor();
92
+ await act(async () => type(view, '@hello w'));
93
+ await vi.waitFor(() => expect(labels()).toEqual(['Add object', 'Hello world']));
94
+
95
+ await act(async () => press(view, 'ArrowDown'));
96
+ await act(async () => press(view, 'Enter'));
97
+ expect(view.state.doc.toString()).toBe('Hello world');
98
+ expect(view.state.field(popoverStateField)).toBeNull();
99
+ });
100
+
101
+ test('moving the cursor out of a multi-word query ends it', async ({ expect }) => {
102
+ const { view } = await mountMenuEditor();
103
+ await act(async () => type(view, 'x @hello world'));
104
+ expect(view.state.field(popoverStateField)).not.toBeNull();
105
+
106
+ await act(async () => view.dispatch({ selection: { anchor: 0 } }));
107
+ expect(view.state.field(popoverStateField)).toBeNull();
108
+ });
109
+
110
+ test('a double or leading space ends the query', async ({ expect }) => {
111
+ const { view } = await mountMenuEditor();
112
+ await act(async () => type(view, '@hello '));
113
+ expect(view.state.field(popoverStateField)).toBeNull();
114
+
115
+ await act(async () => type(view, '@ '));
116
+ expect(view.state.field(popoverStateField)).toBeNull();
117
+ });
118
+
119
+ test('cancel keeps a multi-word query in the document', async ({ expect }) => {
120
+ const { view, cancel } = await mountMenuEditor();
121
+ await act(async () => type(view, '@hello world'));
122
+ cancel();
123
+ expect(view.state.doc.toString()).toBe('@hello world');
124
+ });
125
+ });
@@ -5,7 +5,7 @@
5
5
  import { type Extension } from '@codemirror/state';
6
6
  import { type EditorState } from '@codemirror/state';
7
7
  import { type EditorView } from '@codemirror/view';
8
- import { type RefObject, useCallback, useMemo, useRef, useState } from 'react';
8
+ import { type RefObject, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
9
9
 
10
10
  import { invariant } from '@dxos/invariant';
11
11
  import { modalStateEffect } from '@dxos/ui-editor';
@@ -29,9 +29,22 @@ export type UseEditorMenuProps = {
29
29
  * Triggers whose menu is rendered as a combobox: the popover owns a search input and the query is
30
30
  * typed there rather than into the document.
31
31
  */
32
+ // TODO(wittjosiah): Remove? Prefer inline instead.
32
33
  searchTriggers?: string[];
33
34
  getMenu?: (context: GetMenuContext) => MaybePromise<EditorMenuGroup[]>;
34
- } & Pick<PopoverOptions, 'trigger' | 'triggerKey' | 'placeholder' | 'activateOnTyping' | 'activateOnDelimiters'>;
35
+ } & Pick<
36
+ PopoverOptions,
37
+ 'trigger' | 'triggerKey' | 'placeholder' | 'activateOnTyping' | 'activateOnDelimiters' | 'multiWordTriggers'
38
+ >;
39
+
40
+ type MenuContext = {
41
+ view: EditorView;
42
+ pos: number;
43
+ text: string;
44
+ trigger?: string;
45
+ /** The trigger's query may hold spaces; see {@link PopoverOptions.multiWordTriggers}. */
46
+ multiWord?: boolean;
47
+ };
35
48
 
36
49
  export type UseEditorMenu = {
37
50
  groupsRef: RefObject<EditorMenuGroup[]>;
@@ -67,6 +80,7 @@ export const useEditorMenu = ({
67
80
  placeholder,
68
81
  activateOnTyping,
69
82
  activateOnDelimiters,
83
+ multiWordTriggers,
70
84
  filter = true,
71
85
  searchTriggers,
72
86
  getMenu,
@@ -81,17 +95,24 @@ export const useEditorMenu = ({
81
95
 
82
96
  // The document range only supplies the trigger-relative prefix (e.g. the second "@" of a block
83
97
  // embed); in search mode the rest of the query comes from the popover's input.
84
- const contextRef = useRef<{ view: EditorView; pos: number; text: string; trigger?: string } | null>(null);
98
+ const contextRef = useRef<MenuContext | null>(null);
85
99
 
86
100
  // Monotonic token identifying the latest menu query; bumped per request and on close.
87
101
  const requestRef = useRef(0);
88
102
 
103
+ // The latest `getMenu`, read when the menu opens: it changes whenever the caller's data does, and the extension
104
+ // built from it would otherwise rebuild the view, blurring the editor mid-keystroke.
105
+ const getMenuRef = useRef(getMenu);
106
+ useLayoutEffect(() => {
107
+ getMenuRef.current = getMenu;
108
+ }, [getMenu]);
109
+
89
110
  /**
90
111
  * Get filtered options.
91
112
  */
92
113
  const getMenuOptions = useCallback<NonNullable<UseEditorMenuProps['getMenu']>>(
93
114
  async ({ text, trigger, ...props }) => {
94
- const groups = (await getMenu?.({ text, trigger, ...props })) ?? [];
115
+ const groups = (await getMenuRef.current?.({ text, trigger, ...props })) ?? [];
95
116
  // The "@" menu can use "@@" as syntax for block embeds, so it owns its own query filtering.
96
117
  return filter && trigger !== '@'
97
118
  ? filterMenuGroups(groups, (item) =>
@@ -99,37 +120,7 @@ export const useEditorMenu = ({
99
120
  )
100
121
  : groups;
101
122
  },
102
- [getMenu, filter],
103
- );
104
-
105
- /**
106
- * Recompute the menu and preselect the first item.
107
- */
108
- const updateGroups = useCallback(
109
- async ({ view, pos, text, trigger }: { view: EditorView; pos: number; text: string; trigger?: string }) => {
110
- const request = ++requestRef.current;
111
- const groups = (await getMenuOptions({ state: view.state, pos, text, trigger })) ?? [];
112
- // Queries resolve out of order, so a slower earlier one must not overwrite a newer result
113
- // (or repopulate the menu after it closed).
114
- if (request !== requestRef.current) {
115
- return;
116
- }
117
-
118
- groupsRef.current = groups;
119
- const firstItem = groupsRef.current.filter((group) => group.items.length > 0)[0]?.items[0];
120
- if (firstItem) {
121
- setCurrentItem(firstItem.id);
122
- currentRef.current = firstItem;
123
- } else {
124
- // No matches: clear the selection so Enter falls through instead of
125
- // selecting a stale item from a previous query.
126
- setCurrentItem(undefined);
127
- currentRef.current = null;
128
- }
129
-
130
- refresh({});
131
- },
132
- [getMenuOptions],
123
+ [filter],
133
124
  );
134
125
 
135
126
  const handleOpenChange = useCallback<NonNullable<UseEditorMenu['onOpenChange']>>(
@@ -161,6 +152,36 @@ export const useEditorMenu = ({
161
152
  [getMenuOptions],
162
153
  );
163
154
 
155
+ /**
156
+ * Recompute the menu and preselect the first item.
157
+ */
158
+ const updateGroups = useCallback(
159
+ async ({ view, pos, text, trigger }: MenuContext) => {
160
+ const request = ++requestRef.current;
161
+ const groups = (await getMenuOptions({ state: view.state, pos, text, trigger })) ?? [];
162
+ // Queries resolve out of order, so a slower earlier one must not overwrite a newer result
163
+ // (or repopulate the menu after it closed).
164
+ if (request !== requestRef.current) {
165
+ return;
166
+ }
167
+
168
+ groupsRef.current = groups;
169
+ const firstItem = groupsRef.current.filter((group) => group.items.length > 0)[0]?.items[0];
170
+ if (firstItem) {
171
+ setCurrentItem(firstItem.id);
172
+ currentRef.current = firstItem;
173
+ } else {
174
+ // No matches: clear the selection so Enter falls through instead of
175
+ // selecting a stale item from a previous query.
176
+ setCurrentItem(undefined);
177
+ currentRef.current = null;
178
+ }
179
+
180
+ refresh({});
181
+ },
182
+ [getMenuOptions],
183
+ );
184
+
164
185
  const handleActivate = useCallback<NonNullable<UseEditorMenu['onActivate']>>(
165
186
  async ({ view, trigger }) => {
166
187
  const item = getMenuItem(groupsRef.current, currentItem);
@@ -186,9 +207,9 @@ export const useEditorMenu = ({
186
207
  }, []);
187
208
 
188
209
  const handleCancel = useCallback<NonNullable<UseEditorMenu['onCancel']>>(({ view }) => {
189
- // Delete trigger.
210
+ // Delete trigger; a multi-word query may be a sentence the user wants to keep.
190
211
  const { range, trigger } = view.state.field(popoverStateField) ?? {};
191
- if (range && trigger) {
212
+ if (range && trigger && !contextRef.current?.multiWord) {
192
213
  view.dispatch({
193
214
  changes: { ...range, insert: '' },
194
215
  });
@@ -224,17 +245,21 @@ export const useEditorMenu = ({
224
245
 
225
246
  // Array props are compared by CONTENT, not identity: a caller writing `trigger={['#']}` inline
226
247
  // hands over a new array every render, and a changed extension destroys and recreates the view —
227
- // blurring the editor mid-keystroke.
228
- const serializedTrigger = Array.isArray(trigger) ? trigger.join(',') : trigger;
229
- const serializedSearchTriggers = searchTriggers?.join(',');
230
- const serializedDelimiters = activateOnDelimiters?.join(',');
248
+ // blurring the editor mid-keystroke. The memo reads them back from the key, so the React Compiler keys it the
249
+ // same way.
250
+ const arraysKey = JSON.stringify({ trigger, searchTriggers, activateOnDelimiters, multiWordTriggers });
231
251
  const extension = useMemo<Extension>(() => {
252
+ const arrays: Pick<
253
+ UseEditorMenuProps,
254
+ 'trigger' | 'searchTriggers' | 'activateOnDelimiters' | 'multiWordTriggers'
255
+ > = JSON.parse(arraysKey);
232
256
  return popover({
233
- trigger,
257
+ trigger: arrays.trigger,
234
258
  triggerKey,
235
259
  placeholder,
236
260
  activateOnTyping,
237
- activateOnDelimiters,
261
+ activateOnDelimiters: arrays.activateOnDelimiters,
262
+ multiWordTriggers: arrays.multiWordTriggers,
238
263
  onClose: ({ view }) => handleOpenChange({ view, open: false }),
239
264
  onEnter: ({ view }) => {
240
265
  if (currentRef.current) {
@@ -246,23 +271,21 @@ export const useEditorMenu = ({
246
271
  onArrowUp: () => handleNavigate('up'),
247
272
  onArrowDown: () => handleNavigate('down'),
248
273
  onTextChange: async ({ view, pos, text, trigger }) => {
249
- contextRef.current = { view, pos, text, trigger };
274
+ const context: MenuContext = {
275
+ view,
276
+ pos,
277
+ text,
278
+ trigger,
279
+ multiWord: !!trigger && !!arrays.multiWordTriggers?.includes(trigger),
280
+ };
281
+ contextRef.current = context;
250
282
  // The input is remounted per activation, so the query always starts empty.
251
- setSearch(!!trigger && !!searchTriggers?.includes(trigger));
283
+ setSearch(!!trigger && !!arrays.searchTriggers?.includes(trigger));
252
284
  setQuery('');
253
- await updateGroups({ view, pos, text, trigger });
285
+ await updateGroups(context);
254
286
  },
255
287
  });
256
- }, [
257
- handleOpenChange,
258
- handleNavigate,
259
- updateGroups,
260
- serializedTrigger,
261
- serializedSearchTriggers,
262
- serializedDelimiters,
263
- placeholder,
264
- activateOnTyping,
265
- ]);
288
+ }, [handleOpenChange, handleNavigate, updateGroups, arraysKey, triggerKey, placeholder, activateOnTyping]);
266
289
 
267
290
  return {
268
291
  groupsRef,
@@ -2,22 +2,34 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import React, { act } from 'react';
5
+ import React, { act, useEffect } from 'react';
6
6
  import { createRoot } from 'react-dom/client';
7
7
  import { beforeAll, describe, onTestFinished, test } from 'vitest';
8
8
 
9
- import { useTextEditor } from './useTextEditor.ts';
9
+ import { type EditorView } from '@dxos/ui-editor';
10
+
11
+ import { type UseTextEditorProps, useTextEditor } from './useTextEditor.ts';
10
12
 
11
13
  const AutoFocusEditor = () => {
12
14
  const { parentRef } = useTextEditor({ autoFocus: true });
13
15
  return <div ref={parentRef} />;
14
16
  };
15
17
 
16
- const mountAutoFocusEditor = async (): Promise<HTMLElement> => {
18
+ const RestoringEditor = ({ onView, ...props }: UseTextEditorProps & { onView: (view: EditorView) => void }) => {
19
+ const { parentRef, view } = useTextEditor(props);
20
+ useEffect(() => {
21
+ if (view) {
22
+ onView(view);
23
+ }
24
+ }, [view]);
25
+ return <div ref={parentRef} />;
26
+ };
27
+
28
+ const mount = async (element: React.ReactElement): Promise<HTMLElement> => {
17
29
  const container = document.createElement('div');
18
30
  document.body.append(container);
19
31
  const root = createRoot(container);
20
- await act(async () => root.render(<AutoFocusEditor />));
32
+ await act(async () => root.render(element));
21
33
  onTestFinished(() => {
22
34
  act(() => root.unmount());
23
35
  container.remove();
@@ -25,6 +37,8 @@ const mountAutoFocusEditor = async (): Promise<HTMLElement> => {
25
37
  return container;
26
38
  };
27
39
 
40
+ const mountAutoFocusEditor = () => mount(<AutoFocusEditor />);
41
+
28
42
  describe('useTextEditor', () => {
29
43
  beforeAll(() => {
30
44
  Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true);
@@ -46,4 +60,31 @@ describe('useTextEditor', () => {
46
60
  await mountAutoFocusEditor();
47
61
  expect(document.activeElement).toBe(menu);
48
62
  });
63
+
64
+ // A bound editor mounts empty and the binding fills it afterwards; the recorded caret must land
65
+ // once the content arrives rather than being dropped against the empty document.
66
+ test('restores the selection once the content arrives', async ({ expect }) => {
67
+ let view: EditorView | undefined;
68
+ await mount(<RestoringEditor id='doc' selection={{ anchor: 5 }} onView={(next) => (view = next)} />);
69
+ expect(view?.state.doc.length).toBe(0);
70
+
71
+ await act(async () => {
72
+ view?.dispatch({ changes: { from: 0, insert: 'hello world' } });
73
+ await Promise.resolve();
74
+ });
75
+ expect(view?.state.selection.main.anchor).toBe(5);
76
+ });
77
+
78
+ test('restores the selection immediately when the content is present', async ({ expect }) => {
79
+ let view: EditorView | undefined;
80
+ await mount(
81
+ <RestoringEditor
82
+ id='doc'
83
+ initialValue='hello world'
84
+ selection={{ anchor: 7 }}
85
+ onView={(next) => (view = next)}
86
+ />,
87
+ );
88
+ expect(view?.state.selection.main.anchor).toBe(7);
89
+ });
49
90
  });
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { EditorState, type EditorStateConfig, type Text } from '@codemirror/state';
5
+ import { EditorState, type EditorStateConfig, StateEffect, type Text } from '@codemirror/state';
6
6
  import { EditorView } from '@codemirror/view';
7
7
  import {
8
8
  type ComponentPropsWithoutRef,
@@ -20,6 +20,7 @@ import {
20
20
  import { log } from '@dxos/log';
21
21
  import {
22
22
  type EditorSelection,
23
+ type EditorSelectionState,
23
24
  debugDispatcher,
24
25
  documentId,
25
26
  modalStateField,
@@ -56,6 +57,10 @@ export type UseTextEditorProps = Pick<EditorStateConfig, 'extensions'> & {
56
57
  debug?: boolean;
57
58
  };
58
59
 
60
+ /** Whether every position in a recorded state lies within a document of `length`. */
61
+ const fitsDocument = ({ scrollTo, selection }: EditorSelectionState, length: number): boolean =>
62
+ (scrollTo ?? 0) <= length && (selection?.anchor ?? 0) <= length && (selection?.head ?? 0) <= length;
63
+
59
64
  /**
60
65
  * Creates codemirror text editor.
61
66
  */
@@ -130,16 +135,39 @@ export const useTextEditor = (
130
135
  }, deps);
131
136
 
132
137
  useEffect(() => {
133
- if (view) {
134
- if (scrollTo != null || selection) {
135
- if (selection && selection.anchor > view.state.doc.length) {
136
- log.warn('invalid selection', { length: view.state.doc.length, scrollTo, selection });
137
- return;
138
- }
138
+ if (!view || (scrollTo == null && !selection)) {
139
+ return;
140
+ }
139
141
 
140
- restoreEditorState(view, { scrollTo, scrollOffset, selection });
141
- }
142
+ const state: EditorSelectionState = { scrollTo, scrollOffset, selection };
143
+ if (fitsDocument(state, view.state.doc.length)) {
144
+ restoreEditorState(view, state);
145
+ return;
142
146
  }
147
+
148
+ // A bound editor is created before its content arrives (the automerge binding fills the document
149
+ // on attach), so a restore against the empty document would be dropped; restore on the fill instead.
150
+ let active = true;
151
+ let pending = true;
152
+ view.dispatch({
153
+ effects: StateEffect.appendConfig.of(
154
+ EditorView.updateListener.of((update) => {
155
+ if (pending && update.docChanged && fitsDocument(state, update.state.doc.length)) {
156
+ pending = false;
157
+ // A view cannot be dispatched to from within its own update.
158
+ queueMicrotask(() => {
159
+ if (active) {
160
+ restoreEditorState(update.view, state);
161
+ }
162
+ });
163
+ }
164
+ }),
165
+ ),
166
+ });
167
+
168
+ return () => {
169
+ active = false;
170
+ };
143
171
  }, [view, scrollTo, scrollOffset, selection]);
144
172
 
145
173
  useEffect(() => {