@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.
- package/dist/lib/index.mjs +82 -70
- package/dist/lib/index.mjs.map +1 -1
- package/dist/types/src/components/EditorMenuProvider/menu.d.ts +2 -0
- package/dist/types/src/components/EditorMenuProvider/menu.d.ts.map +1 -1
- package/dist/types/src/components/EditorMenuProvider/popover.d.ts +7 -1
- package/dist/types/src/components/EditorMenuProvider/popover.d.ts.map +1 -1
- package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts +2 -2
- package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts.map +1 -1
- package/dist/types/src/components/EditorMenuProvider/useEditorMenu.test.d.ts +2 -0
- package/dist/types/src/components/EditorMenuProvider/useEditorMenu.test.d.ts.map +1 -0
- package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
- package/dist/types/src/stories/Comments.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Widgets.stories.d.ts +5 -0
- package/dist/types/src/stories/Widgets.stories.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +33 -33
- package/src/components/EditorMenuProvider/EditorMenuProvider.tsx +1 -1
- package/src/components/EditorMenuProvider/menu.ts +2 -0
- package/src/components/EditorMenuProvider/popover.ts +12 -3
- package/src/components/EditorMenuProvider/useEditorMenu.test.tsx +125 -0
- package/src/components/EditorMenuProvider/useEditorMenu.ts +79 -56
- package/src/hooks/useTextEditor.test.tsx +45 -4
- package/src/hooks/useTextEditor.ts +37 -9
- package/src/stories/Comments.stories.tsx +25 -21
- 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.
|
|
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.
|
|
44
|
-
"@dxos/async": "0.
|
|
45
|
-
"@dxos/invariant": "0.
|
|
46
|
-
"@dxos/log": "0.
|
|
47
|
-
"@dxos/react-hooks": "0.
|
|
48
|
-
"@dxos/react-ui-menu": "0.
|
|
49
|
-
"@dxos/ui-editor": "0.
|
|
50
|
-
"@dxos/ui-types": "0.
|
|
51
|
-
"@dxos/util": "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.
|
|
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.
|
|
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.
|
|
68
|
-
"@dxos/ai": "0.
|
|
69
|
-
"@dxos/echo": "0.
|
|
70
|
-
"@dxos/echo-client": "0.
|
|
71
|
-
"@dxos/echo-doc": "0.
|
|
72
|
-
"@dxos/echo-react": "0.
|
|
73
|
-
"@dxos/keys": "0.
|
|
74
|
-
"@dxos/protocols": "0.
|
|
75
|
-
"@dxos/random": "0.
|
|
76
|
-
"@dxos/react-client": "0.
|
|
77
|
-
"@dxos/react-ui": "0.
|
|
78
|
-
"@dxos/react-ui-attention": "0.
|
|
79
|
-
"@dxos/react-ui-list": "0.
|
|
80
|
-
"@dxos/react-ui-syntax-highlighter": "0.
|
|
81
|
-
"@dxos/schema": "0.
|
|
82
|
-
"@dxos/ui": "0.
|
|
83
|
-
"@dxos/ui-theme": "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.
|
|
87
|
-
"effect": "4.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.
|
|
91
|
-
"@dxos/ui-theme": "^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
|
-
|
|
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
|
-
|
|
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<
|
|
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<
|
|
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
|
|
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
|
-
[
|
|
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
|
-
|
|
229
|
-
const
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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(() => {
|