@dxos/react-ui-editor 0.10.0 → 0.11.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 (113) hide show
  1. package/dist/lib/index.mjs +1477 -0
  2. package/dist/lib/index.mjs.map +1 -0
  3. package/dist/lib/translations.mjs +32 -0
  4. package/dist/lib/translations.mjs.map +1 -0
  5. package/dist/types/src/components/Editor/Editor.d.ts +22 -17
  6. package/dist/types/src/components/Editor/Editor.d.ts.map +1 -1
  7. package/dist/types/src/components/Editor/EditorContext.d.ts +20 -0
  8. package/dist/types/src/components/Editor/EditorContext.d.ts.map +1 -0
  9. package/dist/types/src/components/Editor/EditorView.d.ts +1 -2
  10. package/dist/types/src/components/Editor/EditorView.d.ts.map +1 -1
  11. package/dist/types/src/components/Editor/index.d.ts +2 -0
  12. package/dist/types/src/components/Editor/index.d.ts.map +1 -1
  13. package/dist/types/src/components/EditorMenuProvider/EditorMenuProvider.d.ts +7 -1
  14. package/dist/types/src/components/EditorMenuProvider/EditorMenuProvider.d.ts.map +1 -1
  15. package/dist/types/src/components/EditorMenuProvider/menu.d.ts +4 -2
  16. package/dist/types/src/components/EditorMenuProvider/menu.d.ts.map +1 -1
  17. package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts +7 -2
  18. package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts.map +1 -1
  19. package/dist/types/src/components/EditorPreviewProvider/EditorPreviewContext.d.ts +18 -0
  20. package/dist/types/src/components/EditorPreviewProvider/EditorPreviewContext.d.ts.map +1 -0
  21. package/dist/types/src/components/EditorPreviewProvider/EditorPreviewProvider.d.ts +0 -6
  22. package/dist/types/src/components/EditorPreviewProvider/EditorPreviewProvider.d.ts.map +1 -1
  23. package/dist/types/src/components/EditorPreviewProvider/index.d.ts +1 -0
  24. package/dist/types/src/components/EditorPreviewProvider/index.d.ts.map +1 -1
  25. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts +4 -1
  26. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
  27. package/dist/types/src/components/EditorToolbar/index.d.ts +1 -0
  28. package/dist/types/src/components/EditorToolbar/index.d.ts.map +1 -1
  29. package/dist/types/src/components/EditorToolbar/lists.d.ts.map +1 -1
  30. package/dist/types/src/components/EditorToolbar/view-mode.d.ts +27 -2
  31. package/dist/types/src/components/EditorToolbar/view-mode.d.ts.map +1 -1
  32. package/dist/types/src/components/index.d.ts +1 -0
  33. package/dist/types/src/components/index.d.ts.map +1 -1
  34. package/dist/types/src/index.d.ts +0 -1
  35. package/dist/types/src/index.d.ts.map +1 -1
  36. package/dist/types/src/stories/Assistant.stories.d.ts.map +1 -0
  37. package/dist/types/src/stories/Automerge.stories.d.ts +16 -39
  38. package/dist/types/src/stories/Automerge.stories.d.ts.map +1 -1
  39. package/dist/types/src/stories/Blocks.stories.d.ts +10 -0
  40. package/dist/types/src/stories/Blocks.stories.d.ts.map +1 -0
  41. package/dist/types/src/stories/Comments.stories.d.ts +53 -14
  42. package/dist/types/src/stories/Comments.stories.d.ts.map +1 -1
  43. package/dist/types/src/stories/Diff.stories.d.ts +32 -0
  44. package/dist/types/src/stories/Diff.stories.d.ts.map +1 -0
  45. package/dist/types/src/stories/Experimental.stories.d.ts +1 -0
  46. package/dist/types/src/stories/Experimental.stories.d.ts.map +1 -1
  47. package/dist/types/src/stories/Folding.stories.d.ts +18 -0
  48. package/dist/types/src/stories/Folding.stories.d.ts.map +1 -0
  49. package/dist/types/src/stories/Outliner.stories.d.ts +4 -4
  50. package/dist/types/src/stories/Outliner.stories.d.ts.map +1 -1
  51. package/dist/types/src/stories/Popover.stories.d.ts +3 -3
  52. package/dist/types/src/stories/Popover.stories.d.ts.map +1 -1
  53. package/dist/types/src/stories/Suggest.stories.d.ts +27 -0
  54. package/dist/types/src/stories/Suggest.stories.d.ts.map +1 -0
  55. package/dist/types/src/stories/TrackChanges.stories.d.ts +44 -0
  56. package/dist/types/src/stories/TrackChanges.stories.d.ts.map +1 -0
  57. package/dist/types/src/stories/Widgets.stories.d.ts +12 -0
  58. package/dist/types/src/stories/Widgets.stories.d.ts.map +1 -1
  59. package/dist/types/src/stories/components/EditorStory.d.ts.map +1 -1
  60. package/dist/types/src/stories/components/util.d.ts +16 -0
  61. package/dist/types/src/stories/components/util.d.ts.map +1 -1
  62. package/dist/types/tsconfig.tsbuildinfo +1 -1
  63. package/package.json +40 -83
  64. package/src/components/Editor/Editor.tsx +142 -35
  65. package/src/components/Editor/EditorContext.ts +34 -0
  66. package/src/components/Editor/EditorView.tsx +9 -6
  67. package/src/components/Editor/index.ts +3 -0
  68. package/src/components/EditorMenuProvider/EditorMenuProvider.tsx +98 -8
  69. package/src/components/EditorMenuProvider/menu.ts +4 -2
  70. package/src/components/EditorMenuProvider/useEditorMenu.ts +114 -29
  71. package/src/components/EditorPreviewProvider/EditorPreviewContext.ts +21 -0
  72. package/src/components/EditorPreviewProvider/EditorPreviewProvider.tsx +1 -10
  73. package/src/components/EditorPreviewProvider/index.ts +2 -0
  74. package/src/components/EditorToolbar/EditorToolbar.tsx +6 -3
  75. package/src/components/EditorToolbar/index.ts +1 -0
  76. package/src/components/EditorToolbar/lists.ts +2 -6
  77. package/src/components/EditorToolbar/view-mode.ts +41 -12
  78. package/src/components/index.ts +1 -0
  79. package/src/index.ts +0 -1
  80. package/src/{extensions → stories}/Assistant.stories.tsx +7 -2
  81. package/src/stories/Automerge.stories.tsx +99 -78
  82. package/src/stories/Blocks.stories.tsx +83 -0
  83. package/src/stories/Comments.stories.tsx +273 -74
  84. package/src/stories/Diff.stories.tsx +48 -0
  85. package/src/stories/EditorToolbar.stories.tsx +2 -2
  86. package/src/stories/Experimental.stories.tsx +13 -2
  87. package/src/stories/Folding.stories.tsx +120 -0
  88. package/src/stories/Markdown.stories.tsx +1 -1
  89. package/src/stories/Outliner.stories.tsx +18 -7
  90. package/src/stories/Popover.stories.tsx +27 -5
  91. package/src/stories/Suggest.stories.tsx +199 -0
  92. package/src/stories/TrackChanges.stories.tsx +226 -0
  93. package/src/stories/Widgets.stories.tsx +162 -10
  94. package/src/stories/components/EditorStory.tsx +15 -6
  95. package/src/stories/components/util.tsx +40 -2
  96. package/dist/lib/browser/index.mjs +0 -1676
  97. package/dist/lib/browser/index.mjs.map +0 -7
  98. package/dist/lib/browser/meta.json +0 -1
  99. package/dist/lib/browser/translations.mjs +0 -39
  100. package/dist/lib/browser/translations.mjs.map +0 -7
  101. package/dist/lib/node-esm/index.mjs +0 -1678
  102. package/dist/lib/node-esm/index.mjs.map +0 -7
  103. package/dist/lib/node-esm/meta.json +0 -1
  104. package/dist/lib/node-esm/translations.mjs +0 -41
  105. package/dist/lib/node-esm/translations.mjs.map +0 -7
  106. package/dist/types/src/extensions/Assistant.stories.d.ts.map +0 -1
  107. package/dist/types/src/extensions/assistant-extension.d.ts +0 -24
  108. package/dist/types/src/extensions/assistant-extension.d.ts.map +0 -1
  109. package/dist/types/src/extensions/index.d.ts +0 -2
  110. package/dist/types/src/extensions/index.d.ts.map +0 -1
  111. package/src/extensions/assistant-extension.tsx +0 -223
  112. package/src/extensions/index.ts +0 -5
  113. /package/dist/types/src/{extensions → stories}/Assistant.stories.d.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-editor",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Text editor components.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -19,14 +19,12 @@
19
19
  ".": {
20
20
  "source": "./src/index.ts",
21
21
  "types": "./dist/types/src/index.d.ts",
22
- "browser": "./dist/lib/browser/index.mjs",
23
- "node": "./dist/lib/node-esm/index.mjs"
22
+ "import": "./dist/lib/index.mjs"
24
23
  },
25
24
  "./translations": {
26
25
  "source": "./src/translations.ts",
27
26
  "types": "./dist/types/src/translations.d.ts",
28
- "browser": "./dist/lib/browser/translations.mjs",
29
- "node": "./dist/lib/node-esm/translations.mjs"
27
+ "import": "./dist/lib/translations.mjs"
30
28
  }
31
29
  },
32
30
  "types": "dist/types/src/index.d.ts",
@@ -35,103 +33,62 @@
35
33
  "src"
36
34
  ],
37
35
  "dependencies": {
38
- "@automerge/automerge": "3.3.0-fragments.2",
39
- "@codemirror/autocomplete": "^6.20.2",
40
- "@codemirror/commands": "^6.10.3",
41
- "@codemirror/lang-html": "^6.4.11",
42
- "@codemirror/lang-javascript": "^6.2.5",
43
- "@codemirror/lang-json": "^6.0.2",
44
- "@codemirror/lang-markdown": "6.5.0",
45
- "@codemirror/lang-xml": "^6.1.0",
46
- "@codemirror/lang-yaml": "^6.1.3",
47
- "@codemirror/language": "^6.12.3",
48
- "@codemirror/language-data": "^6.5.2",
49
- "@codemirror/lint": "6.9.7",
50
- "@codemirror/search": "^6.7.0",
51
- "@codemirror/state": "^6.6.0",
52
- "@codemirror/theme-one-dark": "^6.1.3",
53
- "@codemirror/view": "^6.43.0",
54
- "@fluentui/react-tabster": "9.26.11",
55
- "@lezer/common": "^1.5.2",
56
- "@lezer/generator": "^1.8.0",
57
- "@lezer/highlight": "^1.2.3",
58
- "@lezer/markdown": "^1.6.3",
36
+ "@codemirror/search": "^6.7.1",
37
+ "@codemirror/state": "^6.7.1",
38
+ "@codemirror/view": "^6.43.6",
39
+ "@effect-atom/atom": "^0.5.3",
59
40
  "@radix-ui/react-context": "1.1.1",
60
41
  "@radix-ui/react-use-controllable-state": "1.1.0",
61
- "@replit/codemirror-vim": "^6.3.0",
62
- "@replit/codemirror-vscode-keymap": "^6.0.2",
63
- "@uiw/codemirror-theme-vscode": "^4.25.2",
64
- "ajv": "^8.18.0",
65
- "bind-event-listener": "^3.0.0",
66
- "codemirror": "^6.0.2",
67
- "lib0": "^0.2.65",
68
- "lodash.defaultsdeep": "^4.6.1",
69
- "lodash.merge": "^4.6.2",
70
- "lodash.sortby": "^4.7.0",
71
- "style-mod": "^4.1.0",
72
- "@dxos/app-graph": "0.10.0",
73
- "@dxos/async": "0.10.0",
74
- "@dxos/context": "0.10.0",
75
- "@dxos/debug": "0.10.0",
76
- "@dxos/echo": "0.10.0",
77
- "@dxos/echo-client": "0.10.0",
78
- "@dxos/client": "0.10.0",
79
- "@dxos/echo-doc": "0.10.0",
80
- "@dxos/invariant": "0.10.0",
81
- "@dxos/effect": "0.10.0",
82
- "@dxos/log": "0.10.0",
83
- "@dxos/lit-ui": "0.10.0",
84
- "@dxos/keys": "0.10.0",
85
- "@dxos/protocols": "0.10.0",
86
- "@dxos/react-hooks": "0.10.0",
87
- "@dxos/display-name": "0.10.0",
88
- "@dxos/react-ui-menu": "0.10.0",
89
- "@dxos/react-ui-mosaic": "0.10.0",
90
- "@dxos/ui": "0.10.0",
91
- "@dxos/ui-editor": "0.10.0",
92
- "@dxos/util": "0.10.0",
93
- "@dxos/ui-theme": "0.10.0"
42
+ "@dxos/app-graph": "0.11.0",
43
+ "@dxos/async": "0.11.0",
44
+ "@dxos/echo-react": "0.11.0",
45
+ "@dxos/log": "0.11.0",
46
+ "@dxos/invariant": "0.11.0",
47
+ "@dxos/react-ui-menu": "0.11.0",
48
+ "@dxos/ui-editor": "0.11.0",
49
+ "@dxos/util": "0.11.0"
94
50
  },
95
51
  "devDependencies": {
96
- "@automerge/automerge": "3.3.0-fragments.2",
97
- "@automerge/automerge-repo": "2.6.0-subduction.37",
98
- "@automerge/automerge-repo-network-broadcastchannel": "2.6.0-subduction.37",
52
+ "@codemirror/autocomplete": "^6.20.3",
53
+ "@codemirror/lang-javascript": "^6.2.5",
54
+ "@codemirror/lang-markdown": "6.5.1",
99
55
  "@effect-atom/atom-react": "^0.5.0",
100
56
  "@effect/ai": "0.36.0",
101
57
  "@effect/platform": "0.96.2",
102
58
  "@types/chai": "^4.2.15",
103
59
  "@types/chai-dom": "^1.11.0",
104
60
  "@types/lodash.defaultsdeep": "^4.6.6",
105
- "@types/lodash.merge": "^4.6.6",
106
- "@types/lodash.sortby": "^4.7.7",
107
61
  "@types/react": "~19.2.17",
108
62
  "@types/react-dom": "~19.2.3",
109
- "@types/react-test-renderer": "^17.0.2",
110
63
  "chai": "^4.4.1",
111
64
  "chai-dom": "^1.11.0",
112
65
  "effect": "3.21.4",
113
66
  "happy-dom": "^20.0.0",
114
67
  "jsdom": "^29.1.1",
68
+ "lodash.defaultsdeep": "^4.6.1",
115
69
  "mocha": "^10.6.0",
116
70
  "react": "~19.2.7",
117
71
  "react-dom": "~19.2.7",
118
- "react-test-renderer": "~19.2.7",
119
- "vite": "^8.0.16",
72
+ "vite": "^8.1.4",
120
73
  "vite-plugin-top-level-await": "^1.6.0",
121
74
  "vite-plugin-wasm": "^3.6.0",
122
- "@dxos/ai": "0.10.0",
123
- "@dxos/config": "0.10.0",
124
- "@dxos/keyboard": "0.10.0",
125
- "@dxos/echo": "0.10.0",
126
- "@dxos/random": "0.10.0",
127
- "@dxos/react-client": "0.10.0",
128
- "@dxos/react-ui-attention": "0.10.0",
129
- "@dxos/react-ui": "0.10.0",
130
- "@dxos/react-ui-syntax-highlighter": "0.10.0",
131
- "@dxos/schema": "0.10.0",
132
- "@dxos/storybook-utils": "0.10.0",
133
- "@dxos/ui-theme": "0.10.0",
134
- "@dxos/ui-types": "0.10.0"
75
+ "@dxos/ai": "0.11.0",
76
+ "@dxos/echo": "0.11.0",
77
+ "@dxos/echo-client": "0.11.0",
78
+ "@dxos/keys": "0.11.0",
79
+ "@dxos/protocols": "0.11.0",
80
+ "@dxos/echo-doc": "0.11.0",
81
+ "@dxos/random": "0.11.0",
82
+ "@dxos/react-client": "0.11.0",
83
+ "@dxos/react-ui": "0.11.0",
84
+ "@dxos/react-ui-attention": "0.11.0",
85
+ "@dxos/react-ui-list": "0.11.0",
86
+ "@dxos/react-ui-syntax-highlighter": "0.11.0",
87
+ "@dxos/schema": "0.11.0",
88
+ "@dxos/ui-theme": "0.11.0",
89
+ "@dxos/storybook-utils": "0.11.0",
90
+ "@dxos/ui": "0.11.0",
91
+ "@dxos/ui-types": "0.11.0"
135
92
  },
136
93
  "peerDependencies": {
137
94
  "@effect-atom/atom-react": "^0.5.0",
@@ -139,9 +96,9 @@
139
96
  "effect": "3.21.4",
140
97
  "react": "~19.2.7",
141
98
  "react-dom": "~19.2.7",
142
- "@dxos/react-client": "0.10.0",
143
- "@dxos/ui-theme": "0.10.0",
144
- "@dxos/react-ui": "0.10.0"
99
+ "@dxos/react-client": "^0.11.0",
100
+ "@dxos/react-ui": "^0.11.0",
101
+ "@dxos/ui-theme": "^0.11.0"
145
102
  },
146
103
  "publishConfig": {
147
104
  "access": "public"
@@ -2,10 +2,19 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { type Extension } from '@codemirror/state';
6
- import { Atom } from '@effect-atom/atom-react';
7
- import { createContext } from '@radix-ui/react-context';
8
- import React, { type PropsWithChildren, forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react';
5
+ import { type EditorState, type Extension } from '@codemirror/state';
6
+ import { Atom } from '@effect-atom/atom';
7
+ import React, {
8
+ type PropsWithChildren,
9
+ forwardRef,
10
+ useCallback,
11
+ useEffect,
12
+ useImperativeHandle,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from 'react';
17
+ import { createPortal } from 'react-dom';
9
18
 
10
19
  import { invariant } from '@dxos/invariant';
11
20
  import { type ThemedClassName } from '@dxos/react-ui';
@@ -23,40 +32,18 @@ import {
23
32
  EditorToolbar as NaturalEditorToolbar,
24
33
  type EditorToolbarProps as NaturalEditorToolbarProps,
25
34
  } from '../EditorToolbar';
26
- import {
27
- type EditorController,
28
- EditorView as NaturalEditorContent,
29
- type EditorViewProps as NaturalEditorContentProps,
30
- createEditorController,
31
- noopController,
32
- } from './EditorView';
33
-
34
- //
35
- // Context
36
- //
37
-
38
- type EditorContextValue = {
39
- controller?: EditorController;
40
- setController: (controller: EditorController) => void;
41
- extensions?: Extension[];
42
- state: Atom.Writable<EditorToolbarState>;
43
- };
44
-
45
- const [EditorContextProvider, useEditorContext] = createContext<EditorContextValue>('Editor');
46
-
47
- /**
48
- * Access the editor context. Must be used within `Editor.Root`.
49
- */
50
- export { useEditorContext };
35
+ import { type EditorController, noopController } from './controller';
36
+ import { EditorContextProvider, type EditorContextValue, useEditorContext } from './EditorContext';
37
+ import { EditorView as NaturalEditorContent, type EditorViewProps as NaturalEditorContentProps } from './EditorView';
51
38
 
52
39
  //
53
40
  // Root
54
41
  //
55
42
 
56
43
  type EditorRootProps = PropsWithChildren<
57
- Pick<EditorContextValue, 'extensions'> &
44
+ Pick<EditorContextValue, 'extensions' | 'widgets'> &
58
45
  Omit<UseEditorMenuProps, 'viewRef'> &
59
- Pick<EditorMenuProviderProps, 'numItems'> & {
46
+ Pick<EditorMenuProviderProps, 'numItems' | 'searchPlaceholder'> & {
60
47
  viewMode?: EditorToolbarState['viewMode'];
61
48
  }
62
49
  >;
@@ -66,7 +53,10 @@ type EditorRootProps = PropsWithChildren<
66
53
  * Provides context for all child components and manages the editor controller state.
67
54
  */
68
55
  const EditorRoot = forwardRef<EditorController | null, EditorRootProps>(
69
- ({ children, extensions: extensionsProp, viewMode, numItems, ...props }, forwardedRef) => {
56
+ (
57
+ { children, extensions: extensionsProp, widgets, viewMode, numItems, searchPlaceholder, ...props },
58
+ forwardedRef,
59
+ ) => {
70
60
  // TODO(wittjosiah): Including initialState in the deps causes reactivity issues.
71
61
  const state = useMemo(() => Atom.make<EditorToolbarState>({ viewMode }), [viewMode]);
72
62
 
@@ -87,9 +77,16 @@ const EditorRoot = forwardRef<EditorController | null, EditorRootProps>(
87
77
  controller={controller}
88
78
  setController={setController}
89
79
  extensions={extensions}
80
+ widgets={widgets}
90
81
  state={state}
91
82
  >
92
- <EditorMenuProvider getView={getView} groups={groupsRef.current} numItems={numItems} {...menuProps}>
83
+ <EditorMenuProvider
84
+ getView={getView}
85
+ groups={groupsRef.current}
86
+ numItems={numItems}
87
+ searchPlaceholder={searchPlaceholder}
88
+ {...menuProps}
89
+ >
93
90
  {children}
94
91
  </EditorMenuProvider>
95
92
  </EditorContextProvider>
@@ -164,6 +161,116 @@ const EditorToolbar = (props: EditorToolbarProps) => {
164
161
 
165
162
  EditorToolbar.displayName = EDITOR_TOOLBAR_NAME;
166
163
 
164
+ //
165
+ // Blocks
166
+ //
167
+
168
+ const EDITOR_BLOCKS_NAME = 'Editor.Blocks';
169
+
170
+ /**
171
+ * Renders the xmlTags widget portals (embedded blocks) contributed via the editor's `setWidgets`
172
+ * callback. Place inside `Editor.Root`; the widgets come from context (pass them to `Editor.Root`).
173
+ */
174
+ const EditorBlocks = () => {
175
+ const { widgets = [] } = useEditorContext(EDITOR_BLOCKS_NAME);
176
+ return (
177
+ <>
178
+ {widgets.map(({ id, root, Component, props }) => (
179
+ <div key={id}>{createPortal(<Component {...props} />, root)}</div>
180
+ ))}
181
+ </>
182
+ );
183
+ };
184
+
185
+ EditorBlocks.displayName = EDITOR_BLOCKS_NAME;
186
+
187
+ //
188
+ // Diagnostics
189
+ //
190
+
191
+ const EDITOR_DIAGNOSTICS_NAME = 'Editor.Diagnostics';
192
+
193
+ /** Rough leaf count of a (recursively nested) CodeMirror extension tree. */
194
+ const countExtensions = (extension: Extension | undefined): number =>
195
+ extension == null
196
+ ? 0
197
+ : Array.isArray(extension)
198
+ ? extension.reduce((sum, child) => sum + countExtensions(child), 0)
199
+ : 1;
200
+
201
+ type EditorDiagnosticsProps = ThemedClassName<{}>;
202
+
203
+ /**
204
+ * Developer panel showing live editor state read from the CodeMirror `EditorState` (document size,
205
+ * selection, viewport, configured-extension count). A debugging aid — gate it behind a dev/debug
206
+ * setting; unrelated to `@codemirror/lint` diagnostics. Place inside `Editor.Root`, below the content.
207
+ */
208
+ const EditorDiagnostics = ({ classNames }: EditorDiagnosticsProps) => {
209
+ const { controller, extensions } = useEditorContext(EDITOR_DIAGNOSTICS_NAME);
210
+ const view = controller?.view ?? null;
211
+ // `EditorState` is immutable (a new object per transaction), so a reference change signals an update.
212
+ // Poll on animation frames while mounted — self-contained (no injected updateListener) and re-renders
213
+ // only when the state actually changes; catches programmatic transactions too.
214
+ const [, setRevision] = useState(0);
215
+ const lastState = useRef<EditorState | null>(null);
216
+ useEffect(() => {
217
+ if (!view) {
218
+ return;
219
+ }
220
+ let frame = 0;
221
+ const tick = () => {
222
+ if (view.state !== lastState.current) {
223
+ lastState.current = view.state;
224
+ setRevision((revision) => revision + 1);
225
+ }
226
+ frame = requestAnimationFrame(tick);
227
+ };
228
+ frame = requestAnimationFrame(tick);
229
+ return () => cancelAnimationFrame(frame);
230
+ }, [view]);
231
+
232
+ if (!view) {
233
+ return null;
234
+ }
235
+
236
+ const { state } = view;
237
+ const { doc, selection } = state;
238
+ const { main } = selection;
239
+ const head = doc.lineAt(main.head);
240
+ // The context holds the extensions passed to `Root`; count leaves (CodeMirror does not expose the
241
+ // resolved extension set at runtime, so this is the configured, not the active, count).
242
+ const extensionCount = countExtensions(extensions);
243
+ const item = (label: string, value: string | number) => (
244
+ <span>
245
+ <span className='text-subdued'>{label} </span>
246
+ {value}
247
+ </span>
248
+ );
249
+
250
+ return (
251
+ <div
252
+ className={mx(
253
+ 'flex flex-wrap gap-x-4 gap-y-0.5 border-bs border-separator px-2 py-1 text-xs font-mono text-description',
254
+ classNames,
255
+ )}
256
+ data-testid='editor.diagnostics'
257
+ >
258
+ {item('chars', doc.length)}
259
+ {item('lines', doc.lines)}
260
+ {item(
261
+ 'cursor',
262
+ main.empty ? `${head.number}:${main.head - head.from + 1}` : `${main.from}–${main.to} (${main.to - main.from})`,
263
+ )}
264
+ {selection.ranges.length > 1 && item('ranges', selection.ranges.length)}
265
+ {item('viewport', `${view.viewport.from}–${view.viewport.to}`)}
266
+ {item('mode', state.readOnly ? 'read-only' : 'editable')}
267
+ {item('extensions', extensionCount)}
268
+ </div>
269
+ );
270
+ };
271
+
272
+ EditorDiagnostics.displayName = EDITOR_DIAGNOSTICS_NAME;
273
+
167
274
  //
168
275
  // Editor
169
276
  //
@@ -173,6 +280,8 @@ export const Editor = {
173
280
  Toolbar: EditorToolbar,
174
281
  Content: EditorContent,
175
282
  View: EditorView,
283
+ Blocks: EditorBlocks,
284
+ Diagnostics: EditorDiagnostics,
176
285
  };
177
286
 
178
287
  export type {
@@ -183,5 +292,3 @@ export type {
183
292
  EditorToolbarState,
184
293
  EditorViewProps,
185
294
  };
186
-
187
- export { createEditorController };
@@ -0,0 +1,34 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { type Atom } from '@effect-atom/atom-react';
7
+ import { createContext } from '@radix-ui/react-context';
8
+
9
+ import { type XmlWidgetState } from '@dxos/ui-editor';
10
+
11
+ import { type EditorToolbarState } from '../EditorToolbar/types';
12
+ import { type EditorController } from './controller';
13
+
14
+ // Kept out of `Editor.tsx`: react-refresh only fast-refreshes a module whose exports are all
15
+ // components, so a context and its hook exported beside them force a full page reload on every edit.
16
+
17
+ //
18
+ // Context
19
+ //
20
+
21
+ export type EditorContextValue = {
22
+ controller?: EditorController;
23
+ setController: (controller: EditorController) => void;
24
+ extensions?: Extension[];
25
+ /** xmlTags widget portals (embedded blocks); rendered by `Editor.Blocks`, fed via `setWidgets`. */
26
+ widgets?: XmlWidgetState[];
27
+ state: Atom.Writable<EditorToolbarState>;
28
+ };
29
+
30
+ export const [EditorContextProvider, useEditorContext] = createContext<EditorContextValue>('Editor');
31
+
32
+ /**
33
+ * Access the editor context. Must be used within `Editor.Root`.
34
+ */
@@ -11,7 +11,7 @@ import { initialSync } from '@dxos/ui-editor';
11
11
  import { mx } from '@dxos/ui-theme';
12
12
 
13
13
  import { type UseTextEditorProps, useTextEditor } from '../../hooks';
14
- import { type EditorController, createEditorController, noopController } from './controller';
14
+ import { type EditorController, createEditorController } from './controller';
15
15
 
16
16
  export type EditorViewProps = ThemedClassName<
17
17
  {
@@ -62,11 +62,15 @@ export const EditorView = forwardRef<EditorController, EditorViewProps>(
62
62
  // disagree. After internal typing the prop will already match the editor's
63
63
  // doc, and dispatching anyway would race fast keystrokes — a stale rAF
64
64
  // closure can replace doc content with an older value, dropping characters.
65
+ //
66
+ // `undefined` means uncontrolled: the doc was seeded once from `value` at creation and the caller
67
+ // isn't driving it, so leave it be (coalescing undefined→'' here would wipe that seed on mount). A
68
+ // controlled caller clears the doc by passing '' explicitly.
65
69
  useEffect(() => {
66
- if (!view) {
70
+ if (!view || value === undefined) {
67
71
  return;
68
72
  }
69
- const next = value ?? '';
73
+ const next = value;
70
74
  if (view.state.doc.toString() === next) {
71
75
  return;
72
76
  }
@@ -86,14 +90,13 @@ export const EditorView = forwardRef<EditorController, EditorViewProps>(
86
90
  });
87
91
  }, [view, value, selectionEnd]);
88
92
 
93
+ // Focus chrome (border/ring) is the caller's responsibility (e.g. `dx-input`).
89
94
  return (
90
95
  <div
91
- className={mx('w-full outline-hidden focus:border-accent-bg focus-within:border-focus-ring-subtle', classNames)}
96
+ className={mx('w-full outline-hidden', classNames)}
92
97
  {...(focusable ? focusAttributes : {})}
93
98
  ref={parentRef}
94
99
  />
95
100
  );
96
101
  },
97
102
  );
98
-
99
- export { type EditorController, createEditorController, noopController };
@@ -2,4 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ export { type EditorController, createEditorController, noopController } from './controller';
6
+ export { useEditorContext } from './EditorContext';
7
+
5
8
  export * from './Editor';
@@ -4,7 +4,15 @@
4
4
 
5
5
  import { type EditorView } from '@codemirror/view';
6
6
  import { useControllableState } from '@radix-ui/react-use-controllable-state';
7
- import React, { Fragment, type PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react';
7
+ import React, {
8
+ Fragment,
9
+ type KeyboardEvent,
10
+ type PropsWithChildren,
11
+ useCallback,
12
+ useEffect,
13
+ useRef,
14
+ useState,
15
+ } from 'react';
8
16
 
9
17
  import { addEventListener } from '@dxos/async';
10
18
  import { invariant } from '@dxos/invariant';
@@ -12,6 +20,7 @@ import {
12
20
  DX_ANCHOR_ACTIVATE,
13
21
  type DxAnchorActivate,
14
22
  Icon,
23
+ Input,
15
24
  Popover,
16
25
  ScrollArea,
17
26
  toLocalizedString,
@@ -20,7 +29,9 @@ import {
20
29
  useTranslation,
21
30
  } from '@dxos/react-ui';
22
31
 
23
- import { type EditorMenuGroup, type EditorMenuItem } from './menu';
32
+ import { translationKey } from '#translations';
33
+
34
+ import { type EditorMenuGroup, type EditorMenuItem, getMenuItem } from './menu';
24
35
 
25
36
  export type EditorMenuProviderProps = PropsWithChildren<{
26
37
  // Provided as a getter (not a value prop) so the live `EditorView` is never carried in a React prop that
@@ -31,10 +42,16 @@ export type EditorMenuProviderProps = PropsWithChildren<{
31
42
  open?: boolean;
32
43
  defaultOpen?: boolean;
33
44
  numItems?: number;
45
+ /** Render the menu as a combobox: the popover owns the query input and takes focus from the editor. */
46
+ search?: boolean;
47
+ query?: string;
48
+ searchPlaceholder?: string;
34
49
  onOpenChange?: (event: { view: EditorView; open: boolean; trigger?: string }) => void;
35
50
  onActivate?: (event: { view: EditorView; trigger?: string }) => void;
36
51
  onSelect?: (event: { view: EditorView; item: EditorMenuItem }) => void;
37
52
  onCancel?: (event: { view: EditorView }) => void;
53
+ onQueryChange?: (query: string) => void;
54
+ onNavigate?: (direction: 'up' | 'down') => void;
38
55
  }>;
39
56
 
40
57
  /**
@@ -50,12 +67,17 @@ export const EditorMenuProvider = ({
50
67
  open: openProp,
51
68
  defaultOpen,
52
69
  numItems = 8,
70
+ search,
71
+ query = '',
72
+ searchPlaceholder,
53
73
  onOpenChange,
54
74
  onActivate,
55
75
  onSelect,
56
76
  onCancel,
77
+ onQueryChange,
78
+ onNavigate,
57
79
  }: EditorMenuProviderProps) => {
58
- const { tx } = useThemeContext();
80
+ const { t } = useTranslation(translationKey);
59
81
  const triggerRef = useRef<HTMLButtonElement | null>(null);
60
82
 
61
83
  // Hold the latest `getView` so callbacks/effects always read the current view without re-subscribing.
@@ -106,12 +128,59 @@ export const EditorMenuProvider = ({
106
128
  const view = getViewRef.current?.();
107
129
  invariant(view);
108
130
  onSelect?.({ view, item });
131
+ // Close the popover after acting (e.g. Delete) and return focus to the editor; otherwise the menu
132
+ // lingers open over the now-changed document.
133
+ setOpen(false);
134
+ view.focus();
109
135
  },
110
- [getViewRef, onSelect],
136
+ [getViewRef, onSelect, setOpen],
111
137
  );
112
138
 
113
139
  const menuGroups = groups?.filter((group) => group.items.length > 0) ?? [];
114
140
 
141
+ // Handing off from the slash menu refocuses the editor and reopens the popover a frame later, so
142
+ // neither `autoFocus` nor `onOpenAutoFocus` lands on the input. Claim focus a frame after the
143
+ // modal-state dispatch `handleOpenChange` schedules, which otherwise hands focus back to CodeMirror.
144
+ const searchInputRef = useRef<HTMLInputElement>(null);
145
+ useEffect(() => {
146
+ if (!search || !open) {
147
+ return;
148
+ }
149
+
150
+ let inner: number;
151
+ const outer = requestAnimationFrame(() => {
152
+ inner = requestAnimationFrame(() => searchInputRef.current?.focus());
153
+ });
154
+
155
+ return () => {
156
+ cancelAnimationFrame(outer);
157
+ cancelAnimationFrame(inner);
158
+ };
159
+ }, [search, open]);
160
+
161
+ const handleSearchKeyDown = useCallback(
162
+ (event: KeyboardEvent<HTMLInputElement>) => {
163
+ switch (event.key) {
164
+ case 'ArrowUp':
165
+ case 'ArrowDown': {
166
+ event.preventDefault();
167
+ onNavigate?.(event.key === 'ArrowUp' ? 'up' : 'down');
168
+ break;
169
+ }
170
+
171
+ case 'Enter': {
172
+ const item = getMenuItem(menuGroups, currentItem);
173
+ if (item) {
174
+ event.preventDefault();
175
+ handleSelect(item);
176
+ }
177
+ break;
178
+ }
179
+ }
180
+ },
181
+ [menuGroups, currentItem, handleSelect, onNavigate],
182
+ );
183
+
115
184
  return (
116
185
  <Popover.Root modal={false} open={open} onOpenChange={setOpen}>
117
186
  <Popover.VirtualTrigger virtualRef={triggerRef} />
@@ -120,9 +189,10 @@ export const EditorMenuProvider = ({
120
189
  <Popover.Portal>
121
190
  <Popover.Content
122
191
  align='start'
123
- classNames={['flex flex-col', !menuGroups.length && 'hidden']}
192
+ classNames={['flex flex-col', !search && !menuGroups.length && 'hidden']}
124
193
  style={{
125
- maxBlockSize: 36 * numItems + 10,
194
+ // The search input shares the box, so `numItems` keeps meaning "items visible".
195
+ maxBlockSize: 36 * numItems + 10 + (search ? 36 : 0),
126
196
  }}
127
197
  // NOTE: We keep the focus in the editor, but Radix routes escape key.
128
198
  onEscapeKeyDown={() => {
@@ -131,8 +201,26 @@ export const EditorMenuProvider = ({
131
201
  onCancel?.({ view: currentView });
132
202
  }
133
203
  }}
134
- onOpenAutoFocus={(event) => event.preventDefault()}
204
+ // In search mode the query is typed into the popover's own input, so it must take focus.
205
+ onOpenAutoFocus={search ? undefined : (event) => event.preventDefault()}
135
206
  >
207
+ {search && (
208
+ <Input.Root>
209
+ <Input.TextInput
210
+ ref={searchInputRef}
211
+ density='sm'
212
+ variant='subdued'
213
+ classNames='shrink-0 mbe-1'
214
+ value={query}
215
+ placeholder={searchPlaceholder}
216
+ // Placeholder text is not a persistent accessible name, and it is optional — fall
217
+ // back to the generic label so the input is never anonymous.
218
+ aria-label={searchPlaceholder ?? t('search.label')}
219
+ onChange={(event) => onQueryChange?.(event.target.value)}
220
+ onKeyDown={handleSearchKeyDown}
221
+ />
222
+ </Input.Root>
223
+ )}
136
224
  <Popover.Viewport asChild classNames='dx-container'>
137
225
  <ScrollArea.Root thin>
138
226
  <ScrollArea.Viewport>
@@ -219,7 +307,9 @@ const MenuItem = ({ item, current, onSelect }: MenuItemProps) => {
219
307
  const listRef = useRef<HTMLLIElement>(null);
220
308
  useEffect(() => {
221
309
  if (current && listRef.current) {
222
- listRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
310
+ // Instant, not smooth: held arrow keys retarget faster than a smooth scroll can settle, and each
311
+ // new animation supersedes the last, so the list stalls then jumps.
312
+ listRef.current.scrollIntoView({ behavior: 'instant', block: 'nearest' });
223
313
  }
224
314
  }, [current]);
225
315