@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.
- package/dist/lib/index.mjs +1477 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/translations.mjs +32 -0
- package/dist/lib/translations.mjs.map +1 -0
- package/dist/types/src/components/Editor/Editor.d.ts +22 -17
- package/dist/types/src/components/Editor/Editor.d.ts.map +1 -1
- package/dist/types/src/components/Editor/EditorContext.d.ts +20 -0
- package/dist/types/src/components/Editor/EditorContext.d.ts.map +1 -0
- package/dist/types/src/components/Editor/EditorView.d.ts +1 -2
- package/dist/types/src/components/Editor/EditorView.d.ts.map +1 -1
- package/dist/types/src/components/Editor/index.d.ts +2 -0
- package/dist/types/src/components/Editor/index.d.ts.map +1 -1
- package/dist/types/src/components/EditorMenuProvider/EditorMenuProvider.d.ts +7 -1
- package/dist/types/src/components/EditorMenuProvider/EditorMenuProvider.d.ts.map +1 -1
- package/dist/types/src/components/EditorMenuProvider/menu.d.ts +4 -2
- package/dist/types/src/components/EditorMenuProvider/menu.d.ts.map +1 -1
- package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts +7 -2
- package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts.map +1 -1
- package/dist/types/src/components/EditorPreviewProvider/EditorPreviewContext.d.ts +18 -0
- package/dist/types/src/components/EditorPreviewProvider/EditorPreviewContext.d.ts.map +1 -0
- package/dist/types/src/components/EditorPreviewProvider/EditorPreviewProvider.d.ts +0 -6
- package/dist/types/src/components/EditorPreviewProvider/EditorPreviewProvider.d.ts.map +1 -1
- package/dist/types/src/components/EditorPreviewProvider/index.d.ts +1 -0
- package/dist/types/src/components/EditorPreviewProvider/index.d.ts.map +1 -1
- package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts +4 -1
- package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
- package/dist/types/src/components/EditorToolbar/index.d.ts +1 -0
- package/dist/types/src/components/EditorToolbar/index.d.ts.map +1 -1
- package/dist/types/src/components/EditorToolbar/lists.d.ts.map +1 -1
- package/dist/types/src/components/EditorToolbar/view-mode.d.ts +27 -2
- package/dist/types/src/components/EditorToolbar/view-mode.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +0 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/stories/Assistant.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Automerge.stories.d.ts +16 -39
- package/dist/types/src/stories/Automerge.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Blocks.stories.d.ts +10 -0
- package/dist/types/src/stories/Blocks.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Comments.stories.d.ts +53 -14
- package/dist/types/src/stories/Comments.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Diff.stories.d.ts +32 -0
- package/dist/types/src/stories/Diff.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Experimental.stories.d.ts +1 -0
- package/dist/types/src/stories/Experimental.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Folding.stories.d.ts +18 -0
- package/dist/types/src/stories/Folding.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Outliner.stories.d.ts +4 -4
- package/dist/types/src/stories/Outliner.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Popover.stories.d.ts +3 -3
- package/dist/types/src/stories/Popover.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Suggest.stories.d.ts +27 -0
- package/dist/types/src/stories/Suggest.stories.d.ts.map +1 -0
- package/dist/types/src/stories/TrackChanges.stories.d.ts +44 -0
- package/dist/types/src/stories/TrackChanges.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Widgets.stories.d.ts +12 -0
- package/dist/types/src/stories/Widgets.stories.d.ts.map +1 -1
- package/dist/types/src/stories/components/EditorStory.d.ts.map +1 -1
- package/dist/types/src/stories/components/util.d.ts +16 -0
- package/dist/types/src/stories/components/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +40 -83
- package/src/components/Editor/Editor.tsx +142 -35
- package/src/components/Editor/EditorContext.ts +34 -0
- package/src/components/Editor/EditorView.tsx +9 -6
- package/src/components/Editor/index.ts +3 -0
- package/src/components/EditorMenuProvider/EditorMenuProvider.tsx +98 -8
- package/src/components/EditorMenuProvider/menu.ts +4 -2
- package/src/components/EditorMenuProvider/useEditorMenu.ts +114 -29
- package/src/components/EditorPreviewProvider/EditorPreviewContext.ts +21 -0
- package/src/components/EditorPreviewProvider/EditorPreviewProvider.tsx +1 -10
- package/src/components/EditorPreviewProvider/index.ts +2 -0
- package/src/components/EditorToolbar/EditorToolbar.tsx +6 -3
- package/src/components/EditorToolbar/index.ts +1 -0
- package/src/components/EditorToolbar/lists.ts +2 -6
- package/src/components/EditorToolbar/view-mode.ts +41 -12
- package/src/components/index.ts +1 -0
- package/src/index.ts +0 -1
- package/src/{extensions → stories}/Assistant.stories.tsx +7 -2
- package/src/stories/Automerge.stories.tsx +99 -78
- package/src/stories/Blocks.stories.tsx +83 -0
- package/src/stories/Comments.stories.tsx +273 -74
- package/src/stories/Diff.stories.tsx +48 -0
- package/src/stories/EditorToolbar.stories.tsx +2 -2
- package/src/stories/Experimental.stories.tsx +13 -2
- package/src/stories/Folding.stories.tsx +120 -0
- package/src/stories/Markdown.stories.tsx +1 -1
- package/src/stories/Outliner.stories.tsx +18 -7
- package/src/stories/Popover.stories.tsx +27 -5
- package/src/stories/Suggest.stories.tsx +199 -0
- package/src/stories/TrackChanges.stories.tsx +226 -0
- package/src/stories/Widgets.stories.tsx +162 -10
- package/src/stories/components/EditorStory.tsx +15 -6
- package/src/stories/components/util.tsx +40 -2
- package/dist/lib/browser/index.mjs +0 -1676
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/translations.mjs +0 -39
- package/dist/lib/browser/translations.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -1678
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/translations.mjs +0 -41
- package/dist/lib/node-esm/translations.mjs.map +0 -7
- package/dist/types/src/extensions/Assistant.stories.d.ts.map +0 -1
- package/dist/types/src/extensions/assistant-extension.d.ts +0 -24
- package/dist/types/src/extensions/assistant-extension.d.ts.map +0 -1
- package/dist/types/src/extensions/index.d.ts +0 -2
- package/dist/types/src/extensions/index.d.ts.map +0 -1
- package/src/extensions/assistant-extension.tsx +0 -223
- package/src/extensions/index.ts +0 -5
- /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, {
|
|
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 {
|
|
14
|
-
|
|
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 {
|
|
18
|
-
import { EditorStory, content, longText } from './components';
|
|
34
|
+
import { translations } from '#translations';
|
|
19
35
|
|
|
20
|
-
|
|
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
|
-
|
|
38
|
+
random.seed(123);
|
|
30
39
|
|
|
31
|
-
|
|
40
|
+
const DOCUMENT_ID = 'test';
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
type StoryArgs = {
|
|
43
|
+
content?: string;
|
|
44
|
+
comments?: Comment[];
|
|
45
|
+
};
|
|
36
46
|
|
|
37
|
-
const
|
|
47
|
+
const DefaultStory = ({ content, comments: commentsProp = [] }: StoryArgs) => {
|
|
48
|
+
const { themeMode } = useThemeContext();
|
|
38
49
|
const registry = useContext(RegistryContext);
|
|
39
|
-
const
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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='
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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: '
|
|
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 {
|
|
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 = (
|
|
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
|
-
|
|
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',
|