@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
@@ -2,105 +2,304 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { Atom, RegistryContext } from '@effect-atom/atom-react';
5
+ import { Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
6
  import { type Meta, type StoryObj } from '@storybook/react-vite';
7
- import React, { type FC, useContext, useMemo } from 'react';
7
+ import React, { useContext, useMemo, useRef, useState } from 'react';
8
+ import { expect, userEvent, waitFor } from 'storybook/test';
8
9
 
9
- import { keySymbols, parseShortcut } from '@dxos/keyboard';
10
10
  import { PublicKey } from '@dxos/keys';
11
11
  import { log } from '@dxos/log';
12
+ import { random } from '@dxos/random';
13
+ import { IconButton, useThemeContext } from '@dxos/react-ui';
14
+ import { useAttentionAttributes } from '@dxos/react-ui-attention';
15
+ import { withAttention } from '@dxos/react-ui-attention/testing';
16
+ import { Listbox } from '@dxos/react-ui-list';
17
+ import { createMenuAction } from '@dxos/react-ui-menu';
12
18
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
13
- import { withRegistry } from '@dxos/storybook-utils';
14
- import { annotations, comments, createExternalCommentSync } from '@dxos/ui-editor';
19
+ import {
20
+ Cursor,
21
+ EditorView,
22
+ comments,
23
+ createBasicExtensions,
24
+ createComment,
25
+ createMarkdownExtensions,
26
+ createThemeExtensions,
27
+ decorateMarkdown,
28
+ documentSlots,
29
+ formattingKeymap,
30
+ scrollCommentIntoView,
31
+ } from '@dxos/ui-editor';
15
32
  import { type Comment } from '@dxos/ui-editor/types';
16
33
 
17
- import { createRenderer, str } from '../util';
18
- import { EditorStory, content, longText } from './components';
34
+ import { translations } from '#translations';
19
35
 
20
- const meta = {
21
- title: 'ui/react-ui-editor/Comments',
22
- component: EditorStory,
23
- decorators: [withRegistry, withTheme(), withLayout({ layout: 'fullscreen' })],
24
- parameters: {
25
- layout: 'fullscreen',
26
- },
27
- } satisfies Meta<typeof EditorStory>;
36
+ import { Editor, type EditorController } from '../components';
28
37
 
29
- export default meta;
38
+ random.seed(123);
30
39
 
31
- type Story = StoryObj<typeof meta>;
40
+ const DOCUMENT_ID = 'test';
32
41
 
33
- //
34
- // Comments
35
- //
42
+ type StoryArgs = {
43
+ content?: string;
44
+ comments?: Comment[];
45
+ };
36
46
 
37
- const CommentsStory = () => {
47
+ const DefaultStory = ({ content, comments: commentsProp = [] }: StoryArgs) => {
48
+ const { themeMode } = useThemeContext();
38
49
  const registry = useContext(RegistryContext);
39
- const commentsAtom = useMemo(() => Atom.make<Comment[]>([]), []);
50
+ const editorRef = useRef<EditorController>(null);
51
+ const attentionAttrs = useAttentionAttributes(DOCUMENT_ID);
52
+ const commentsAtom = useMemo(() => Atom.make<Comment[]>(commentsProp), []);
53
+ const [activeComment, setActiveComment] = useState<string>();
54
+ // Bumped on every document edit so the comments list re-resolves each thread's range.
55
+ const [, setDocVersion] = useState(0);
40
56
 
41
- return (
42
- <EditorStory
43
- text={str('# Comments', '', content.paragraphs, content.footer)}
44
- extensions={[
45
- createExternalCommentSync(
46
- 'test',
47
- (sink) => registry.subscribe(commentsAtom, () => sink()),
48
- () => registry.get(commentsAtom),
49
- ),
50
- comments({
51
- id: 'test',
52
- renderTooltip: createRenderer(CommentTooltip),
53
- onCreate: ({ cursor }) => {
54
- const id = PublicKey.random().toHex();
55
- const current = registry.get(commentsAtom);
56
- registry.set(commentsAtom, [...current, { id, cursor }]);
57
- return id;
58
- },
59
- onSelect: (state) => {
60
- const debug = false;
61
- if (debug) {
62
- log.info('update', {
63
- comments: state.comments.length,
64
- active: state.selection.current?.slice(0, 8),
65
- closest: state.selection.closest?.slice(0, 8),
66
- });
57
+ const extensions = useMemo(
58
+ () => [
59
+ createBasicExtensions({ placeholder: 'Type here...', lineWrapping: true, search: true }),
60
+ createThemeExtensions({ themeMode, syntaxHighlighting: true, slots: documentSlots }),
61
+ createMarkdownExtensions(),
62
+ decorateMarkdown(),
63
+ formattingKeymap(),
64
+ EditorView.updateListener.of((update) => {
65
+ if (update.docChanged) {
66
+ setDocVersion((version) => version + 1);
67
+ }
68
+ }),
69
+ comments({
70
+ id: DOCUMENT_ID,
71
+ // Append the new thread to the atom; the extension re-reads it via `getComments`/`subscribe`.
72
+ onCreate: ({ cursor }) => {
73
+ registry.set(commentsAtom, [...registry.get(commentsAtom), { id: PublicKey.random().toHex(), cursor }]);
74
+ },
75
+ onSelect: ({ comments, selection }) => {
76
+ // Reflect the editor's current thread in the list selection.
77
+ setActiveComment(selection.current);
78
+ log.info('update', {
79
+ comments: comments.length,
80
+ active: selection.current?.slice(0, 8),
81
+ closest: selection.closest?.slice(0, 8),
82
+ });
83
+ },
84
+ // A deliberate click selects the thread in the editor (handled internally); reflect it in the
85
+ // list immediately (mirrors plugin-review revealing the companion on click).
86
+ onActivate: (id) => setActiveComment(id),
87
+ getComments: () => registry.get(commentsAtom),
88
+ subscribe: (sink) => {
89
+ sink();
90
+ return registry.subscribe(commentsAtom, () => sink());
91
+ },
92
+ }),
93
+ ],
94
+ [themeMode, registry, commentsAtom],
95
+ );
96
+
97
+ // Toolbar action that creates a comment on the current selection (mirrors the `meta-'` shortcut).
98
+ const customActions = useMemo(
99
+ () =>
100
+ Atom.make(() => {
101
+ const action = createMenuAction(
102
+ 'comment',
103
+ () => {
104
+ const view = editorRef.current?.view;
105
+ if (view) {
106
+ createComment(view);
67
107
  }
68
108
  },
69
- }),
70
- ]}
71
- />
109
+ {
110
+ label: 'Add comment',
111
+ icon: 'ph--chat-text--regular',
112
+ iconOnly: true,
113
+ },
114
+ );
115
+
116
+ return {
117
+ nodes: [action],
118
+ edges: [{ source: 'root', target: action.id, relation: 'child' as const }],
119
+ };
120
+ }),
121
+ [],
72
122
  );
73
- };
74
123
 
75
- export const Comments: Story = {
76
- render: () => <CommentsStory />,
124
+ return (
125
+ <Editor.Root ref={editorRef} extensions={extensions}>
126
+ <Editor.Content>
127
+ <Editor.Toolbar classNames='dx-document' attendableId={DOCUMENT_ID} customActions={customActions} />
128
+ <div className='dx-container dx-document dx-base-surface' {...attentionAttrs}>
129
+ <Editor.View initialValue={content} selectionEnd />
130
+ </div>
131
+ <CommentsList
132
+ commentsAtom={commentsAtom}
133
+ getView={() => editorRef.current?.view}
134
+ activeId={activeComment}
135
+ onActivate={setActiveComment}
136
+ />
137
+ </Editor.Content>
138
+ </Editor.Root>
139
+ );
77
140
  };
78
141
 
79
- const Key: FC<{ char: string }> = ({ char }) => (
80
- <span className='flex justify-center items-center w-[24px] h-[24px] rounded-sm text-xs bg-neutral-200 text-black'>
81
- {char}
82
- </span>
83
- );
142
+ /**
143
+ * Renders the current comments (reactively from the atom), labelling each with the text it anchors to
144
+ * (resolved from its relative cursor against the live editor state). Selecting a row scrolls to and
145
+ * activates that thread in the editor; the delete button removes it.
146
+ */
147
+ const CommentsList = ({
148
+ commentsAtom,
149
+ getView,
150
+ activeId,
151
+ onActivate,
152
+ }: {
153
+ commentsAtom: Atom.Writable<Comment[]>;
154
+ getView: () => EditorController['view'] | undefined;
155
+ activeId?: string;
156
+ onActivate: (id: string) => void;
157
+ }) => {
158
+ const registry = useContext(RegistryContext);
159
+ const items = useAtomValue(commentsAtom);
160
+
161
+ const view = getView();
162
+ // Resolve each comment's range once, then sort by document position (unresolved ranges sort last).
163
+ const resolved = items
164
+ .map((comment) => ({
165
+ comment,
166
+ range: view && comment.cursor ? Cursor.getRangeFromCursor(view.state, comment.cursor) : undefined,
167
+ }))
168
+ .sort((a, b) => (a.range?.from ?? Infinity) - (b.range?.from ?? Infinity));
169
+
170
+ const handleSelect = (id: string) => {
171
+ // Update the list selection directly: activating a thread in the editor doesn't re-fire `onSelect`
172
+ // (the effect already set it current), so the round-trip wouldn't move the list selection.
173
+ onActivate(id);
174
+ if (view) {
175
+ scrollCommentIntoView(view, id);
176
+ view.focus();
177
+ }
178
+ };
179
+
180
+ const handleDelete = (id: string) => {
181
+ registry.set(
182
+ commentsAtom,
183
+ registry.get(commentsAtom).filter((comment) => comment.id !== id),
184
+ );
185
+ };
84
186
 
85
- const CommentTooltip: FC<{ shortcut: string }> = ({ shortcut }) => {
86
187
  return (
87
- <div className='flex items-center gap-2 px-2 py-2 bg-neutral-700 text-white text-xs rounded-sm'>
88
- <div>Create comment</div>
89
- <div className='flex gap-1'>
90
- {keySymbols(parseShortcut(shortcut)).map((char) => (
91
- <Key key={char} char={char} />
92
- ))}
93
- </div>
188
+ <div className='border-bs border-subdued-separator overflow-y-auto max-h-48'>
189
+ <Listbox.Root value={activeId} onValueChange={handleSelect}>
190
+ <Listbox.Content aria-label='Comments' classNames='p-1'>
191
+ {resolved.map(({ comment, range }) => (
192
+ <Listbox.Item key={comment.id} id={comment.id} classNames='flex items-center gap-2'>
193
+ <Listbox.ItemContent
194
+ classNames='grow'
195
+ title={(range && view?.state.doc.sliceString(range.from, range.to)) || comment.cursor || comment.id}
196
+ description={range ? `${range.from}–${range.to}` : comment.cursor}
197
+ />
198
+ <IconButton
199
+ variant='ghost'
200
+ iconOnly
201
+ icon='ph--x--regular'
202
+ label='Delete comment'
203
+ onClick={(event) => {
204
+ // Keep the row from selecting when the delete button is pressed.
205
+ event.stopPropagation();
206
+ handleDelete(comment.id);
207
+ }}
208
+ />
209
+ </Listbox.Item>
210
+ ))}
211
+ </Listbox.Content>
212
+ </Listbox.Root>
94
213
  </div>
95
214
  );
96
215
  };
97
216
 
98
- //
99
- // Annotations
100
- //
217
+ const meta = {
218
+ title: 'ui/react-ui-editor/Comments',
219
+ render: (args) => <DefaultStory {...args} />,
220
+ decorators: [withAttention(DOCUMENT_ID), withTheme(), withLayout({ layout: 'column' })],
221
+ parameters: {
222
+ layout: 'fullscreen',
223
+ translations,
224
+ },
225
+ } satisfies Meta<typeof DefaultStory>;
226
+
227
+ export default meta;
101
228
 
102
- export const Annotations: Story = {
103
- render: () => (
104
- <EditorStory text={str('# Annotations', '', longText)} extensions={[annotations({ match: /volup/gi })]} />
105
- ),
229
+ type Story = StoryObj<StoryArgs>;
230
+
231
+ export const Default: Story = {
232
+ args: {
233
+ content: Array.from({ length: 3 })
234
+ .map(() => random.lorem.paragraph(3))
235
+ .join('\n\n'),
236
+ comments: [
237
+ { id: PublicKey.random().toHex(), cursor: '16:197' },
238
+ { id: PublicKey.random().toHex(), cursor: '402:420' },
239
+ ],
240
+ },
241
+ };
242
+
243
+ /**
244
+ * Regression: clicking INSIDE a comment must be detected. The highlight-layer rectangles are drawn over
245
+ * the comment text; if they intercept the click (no `pointer-events: none`), it never reaches
246
+ * `handleCommentClick` and the thread isn't selected. Asserts the clicked comment becomes current.
247
+ */
248
+ export const ClickSelectsComment: Story = {
249
+ args: {
250
+ content: 'The quick brown fox jumps over the lazy dog.',
251
+ comments: [{ id: PublicKey.random().toHex(), cursor: '4:19' }], // "quick brown fox"
252
+ },
253
+ play: async ({ canvasElement }) => {
254
+ await waitFor(() => expect(canvasElement.querySelector('.cm-comment')).not.toBeNull(), { timeout: 15_000 });
255
+ const comment = canvasElement.querySelector<HTMLElement>('.cm-comment');
256
+ if (!comment) {
257
+ throw new Error('comment mark not rendered');
258
+ }
259
+
260
+ // Click inside the comment: must reach handleCommentClick and select the thread (data-current="1").
261
+ await userEvent.click(comment);
262
+ await waitFor(() => expect(canvasElement.querySelector('.cm-comment[data-current="1"]')).not.toBeNull(), {
263
+ timeout: 5_000,
264
+ });
265
+ },
266
+ };
267
+
268
+ /**
269
+ * Regression (app repro): a click on a comment often lands on the `.cm-line` (an ANCESTOR of the
270
+ * `.cm-comment` span), because the highlight-layer rectangles sit over the text with
271
+ * `pointer-events: none` and pass the click through to the line. An upward DOM-target walk never finds
272
+ * the span's `data-comment-id`, so `handleCommentClick` must fall back to a `posAtCoords` hit-test.
273
+ * Dispatches the click ON the line element (target = line) at the comment's coordinates and asserts the
274
+ * thread is still selected — this is exactly the "first click ignored" case seen in the app.
275
+ */
276
+ export const ClickThroughLineSelectsComment: Story = {
277
+ args: {
278
+ content: 'The quick brown fox jumps over the lazy dog.',
279
+ comments: [{ id: PublicKey.random().toHex(), cursor: '4:19' }], // "quick brown fox"
280
+ },
281
+ play: async ({ canvasElement }) => {
282
+ await waitFor(() => expect(canvasElement.querySelector('.cm-comment')).not.toBeNull(), { timeout: 15_000 });
283
+ const comment = canvasElement.querySelector<HTMLElement>('.cm-comment');
284
+ const line = comment?.closest<HTMLElement>('.cm-line');
285
+ if (!comment || !line) {
286
+ throw new Error('comment mark or line not rendered');
287
+ }
288
+
289
+ // Dispatch the click on the LINE (not the span) at the comment's centre — reproduces the highlight
290
+ // rect passing the click through to `.cm-line`, which the old target-walk missed.
291
+ const rect = comment.getBoundingClientRect();
292
+ line.dispatchEvent(
293
+ new MouseEvent('click', {
294
+ bubbles: true,
295
+ cancelable: true,
296
+ view: window,
297
+ clientX: rect.left + rect.width / 2,
298
+ clientY: rect.top + rect.height / 2,
299
+ }),
300
+ );
301
+ await waitFor(() => expect(canvasElement.querySelector('.cm-comment[data-current="1"]')).not.toBeNull(), {
302
+ timeout: 5_000,
303
+ });
304
+ },
106
305
  };
@@ -0,0 +1,48 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React from 'react';
7
+
8
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
9
+ import { diffView } from '@dxos/ui-editor';
10
+
11
+ import { EditorStory } from './components';
12
+
13
+ // Two versions of a markdown document. The editor shows `modified` with insertions and deletions
14
+ // marked relative to `original` — exactly what the branch compare view renders (current branch vs
15
+ // another branch).
16
+ const original = ['# Release notes', '', '- Fixed a crash on startup.', '- Improved sync performance.', ''].join('\n');
17
+
18
+ const modified = [
19
+ '# Release notes',
20
+ '',
21
+ '- Fixed a crash on startup and on resume.',
22
+ '- Improved sync performance.',
23
+ '- Added dark mode.',
24
+ '',
25
+ ].join('\n');
26
+
27
+ const meta = {
28
+ title: 'ui/react-ui-editor/Diff',
29
+ component: EditorStory,
30
+ decorators: [withTheme(), withLayout({ layout: 'fullscreen' })],
31
+ parameters: {
32
+ layout: 'fullscreen',
33
+ controls: { disable: true },
34
+ },
35
+ } satisfies Meta<typeof EditorStory>;
36
+
37
+ export default meta;
38
+
39
+ type Story = StoryObj<typeof meta>;
40
+
41
+ /**
42
+ * Inline (unified) diff of two markdown versions — what the branch compare view renders. The editor
43
+ * stays editable: typing updates the diff against `original` live (you edit the current branch while
44
+ * viewing the diff to another branch).
45
+ */
46
+ export const Markdown: Story = {
47
+ render: () => <EditorStory text={modified} extensions={diffView({ original })} />,
48
+ };
@@ -52,7 +52,7 @@ const DefaultStory = ({ autoFocus, initialValue, placeholder, viewMode = 'source
52
52
  <Editor.Root extensions={extensions} viewMode={viewMode}>
53
53
  <Editor.Content>
54
54
  <Editor.Toolbar classNames='dx-document' />
55
- <div className='dx-container dx-document bg-base-surface'>
55
+ <div className='dx-container dx-document dx-base-surface'>
56
56
  <Editor.View autoFocus={autoFocus} initialValue={initialValue} selectionEnd />
57
57
  </div>
58
58
  </Editor.Content>
@@ -63,7 +63,7 @@ const DefaultStory = ({ autoFocus, initialValue, placeholder, viewMode = 'source
63
63
  const meta = {
64
64
  title: 'ui/react-ui-editor/EditorToolbar',
65
65
  render: DefaultStory,
66
- decorators: [withRegistry, withTheme(), withLayout({ layout: 'fullscreen', classNames: 'bg-sidebar-surface' })],
66
+ decorators: [withRegistry, withTheme(), withLayout({ layout: 'column' })],
67
67
  parameters: {
68
68
  layout: 'fullscreen',
69
69
  translations,
@@ -9,9 +9,10 @@ import React from 'react';
9
9
  import { log } from '@dxos/log';
10
10
  import { random } from '@dxos/random';
11
11
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
12
- import { blast, defaultOptions, dropFile, join, snippets } from '@dxos/ui-editor';
12
+ import { annotations, blast, defaultOptions, dropFile, join, snippets } from '@dxos/ui-editor';
13
13
 
14
- import { EditorStory, content } from './components';
14
+ import { str } from '../util';
15
+ import { EditorStory, content, longText } from './components';
15
16
 
16
17
  const meta = {
17
18
  title: 'ui/react-ui-editor/Experimental',
@@ -86,3 +87,13 @@ export const DND: Story = {
86
87
  />
87
88
  ),
88
89
  };
90
+
91
+ //
92
+ // Annotations
93
+ //
94
+
95
+ export const Annotations: Story = {
96
+ render: () => (
97
+ <EditorStory text={str('# Annotations', '', longText)} extensions={[annotations({ match: /volup/gi })]} />
98
+ ),
99
+ };
@@ -0,0 +1,120 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ /**
6
+ * Stories for turn folding: a chat transcript is treated as a sequence of turns, and each turn's
7
+ * response is a foldable region built on CodeMirror's native folding. The fold ranges are supplied
8
+ * by the story via a `TurnSource` — here, one that scans `<prompt>` boundaries (a user turn is a
9
+ * `<prompt>` element; the response is everything up to the next prompt).
10
+ */
11
+
12
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
13
+ import React, { useMemo, useState } from 'react';
14
+
15
+ import { random } from '@dxos/random';
16
+ import { Panel, SystemIconButton, Toolbar, useThemeContext } from '@dxos/react-ui';
17
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
18
+ import {
19
+ PROMPT_ELEMENT,
20
+ createBasicExtensions,
21
+ createThemeExtensions,
22
+ createTurnSource,
23
+ decorateMarkdown,
24
+ documentSlots,
25
+ extendedMarkdown,
26
+ foldTurns,
27
+ turnFolding,
28
+ unfoldTurns,
29
+ xmlBlockDecoration,
30
+ xmlFormatting,
31
+ } from '@dxos/ui-editor';
32
+
33
+ import { useTextEditor } from '../hooks';
34
+
35
+ const source = createTurnSource(PROMPT_ELEMENT);
36
+
37
+ /** Extensions shared by the stories: markdown + prompt bubbles + turn folding. */
38
+ const chatExtensions = (themeMode: 'dark' | 'light') => [
39
+ createThemeExtensions({ themeMode, slots: documentSlots }),
40
+ createBasicExtensions({ readOnly: true, lineWrapping: true }),
41
+ decorateMarkdown(),
42
+ extendedMarkdown({ registry: {} }),
43
+ xmlFormatting({ skip: ['prompt'] }),
44
+ xmlBlockDecoration({
45
+ tag: 'prompt',
46
+ contentClass: 'bg-green-600 text-black p-1',
47
+ hideTags: true,
48
+ }),
49
+ turnFolding({ source }),
50
+ ];
51
+
52
+ const PROMPTS = [
53
+ 'Hello',
54
+ 'What is ECHO and how does it relate to spaces?',
55
+ 'Show me how queries and replication work together.',
56
+ 'Summarize the tradeoffs of a local-first architecture.',
57
+ 'What comes next on the roadmap?',
58
+ ];
59
+
60
+ /** Builds a large, deterministic transcript: each prompt followed by several lorem paragraphs. */
61
+ const buildSampleText = (): string => {
62
+ random.seed(1234);
63
+ return PROMPTS.map((prompt) => {
64
+ const body = Array.from({ length: 4 }, () => random.lorem.paragraphs()).join('\n\n');
65
+ return [`<prompt>${prompt}</prompt>`, '', body].join('\n');
66
+ }).join('\n\n');
67
+ };
68
+
69
+ const sampleText = buildSampleText();
70
+
71
+ type StoryProps = { text: string };
72
+
73
+ const DefaultStory = ({ text }: StoryProps) => {
74
+ const { themeMode } = useThemeContext();
75
+ const [collapsed, setCollapsed] = useState(false);
76
+ const extensions = useMemo(() => chatExtensions(themeMode), [themeMode]);
77
+ const { parentRef, view } = useTextEditor({ initialValue: text, extensions });
78
+
79
+ return (
80
+ <Panel.Root>
81
+ <Panel.Toolbar>
82
+ <Toolbar.Root classNames='dx-document'>
83
+ <SystemIconButton.Expander
84
+ active={!collapsed}
85
+ label={collapsed ? 'Expand all' : 'Collapse all'}
86
+ onClick={() => {
87
+ if (!view) {
88
+ return;
89
+ }
90
+ const next = !collapsed;
91
+ setCollapsed(next);
92
+ if (next) {
93
+ foldTurns(view, source);
94
+ } else {
95
+ unfoldTurns(view, source);
96
+ }
97
+ }}
98
+ />
99
+ </Toolbar.Root>
100
+ </Panel.Toolbar>
101
+ <Panel.Content classNames='grid overflow-hidden'>
102
+ <div ref={parentRef} className='dx-expander' />
103
+ </Panel.Content>
104
+ </Panel.Root>
105
+ );
106
+ };
107
+
108
+ const meta = {
109
+ title: 'ui/react-ui-editor/Folding',
110
+ decorators: [withTheme(), withLayout({ layout: 'fullscreen' })],
111
+ parameters: { layout: 'fullscreen' },
112
+ } satisfies Meta;
113
+
114
+ export default meta;
115
+
116
+ type Story = StoryObj<typeof meta>;
117
+
118
+ export const Default: Story = {
119
+ render: () => <DefaultStory text={sampleText} />,
120
+ };
@@ -48,7 +48,7 @@ export const Headings: Story = {
48
48
 
49
49
  export const Links: Story = {
50
50
  render: () => (
51
- <EditorStory text={join(content.links, content.footer)} extensions={[linkTooltip(renderLinkTooltip)]} />
51
+ <EditorStory text={join(content.links, content.footer)} extensions={[linkTooltip({ render: renderLinkTooltip })]} />
52
52
  ),
53
53
  };
54
54
 
@@ -5,18 +5,17 @@
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
6
  import React, { useCallback, useMemo, useState } from 'react';
7
7
 
8
+ import { random } from '@dxos/random';
8
9
  import { withAttention } from '@dxos/react-ui-attention/testing';
9
10
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
11
  import { deleteItem, hashtag, join, listItemToString, outliner, treeFacet } from '@dxos/ui-editor';
11
12
 
12
13
  import { type EditorController, type EditorMenuGroup, EditorMenuProvider } from '../components';
13
- import { EditorStory } from './components';
14
+ import { EditorStory, EditorStoryProps, generateList } from './components';
14
15
 
15
- type StoryArgs = {
16
- text?: string;
17
- };
16
+ type StoryArgs = EditorStoryProps;
18
17
 
19
- const DefaultStory = ({ text }: StoryArgs) => {
18
+ const DefaultStory = (props: StoryArgs) => {
20
19
  const [controller, setController] = useState<EditorController | null>(null);
21
20
 
22
21
  const extensions = useMemo(() => [outliner(), hashtag()], []);
@@ -37,6 +36,7 @@ const DefaultStory = ({ text }: StoryArgs) => {
37
36
  ],
38
37
  [],
39
38
  );
39
+
40
40
  const getView = useCallback(() => controller?.view ?? null, [controller]);
41
41
 
42
42
  return (
@@ -51,9 +51,8 @@ const DefaultStory = ({ text }: StoryArgs) => {
51
51
  >
52
52
  <EditorStory
53
53
  ref={setController}
54
- text={text}
54
+ {...props}
55
55
  extensions={extensions}
56
- debug='raw+tree'
57
56
  debugCustom={(view) => {
58
57
  const tree = view.state.facet(treeFacet);
59
58
  const lines: string[] = [];
@@ -82,8 +81,19 @@ export const Empty: Story = {
82
81
  args: {},
83
82
  };
84
83
 
84
+ export const Paragraphs: Story = {
85
+ args: {
86
+ text: generateList({
87
+ depth: 3,
88
+ children: [2, 3],
89
+ content: () => random.lorem.paragraph(),
90
+ }),
91
+ },
92
+ };
93
+
85
94
  export const Basic: Story = {
86
95
  args: {
96
+ debug: 'raw+tree',
87
97
  text: join(
88
98
  //
89
99
  '- [ ] A',
@@ -99,6 +109,7 @@ export const Basic: Story = {
99
109
 
100
110
  export const Nested: Story = {
101
111
  args: {
112
+ // debug: 'raw+tree',
102
113
  text: join(
103
114
  //
104
115
  '- [ ] A',