@dxos/react-ui-editor 0.3.11-main.00a28cb → 0.3.11-main.0999ea0
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/browser/index.mjs +1304 -1092
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +12 -17
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +13 -5
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
- package/dist/types/src/components/Toolbar/Toolbar.d.ts +9 -0
- package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -0
- package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +14 -0
- package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
- package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.spec.d.ts +2 -0
- package/dist/types/src/extensions/automerge/automerge.spec.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/automerge.test.d.ts +2 -0
- package/dist/types/src/extensions/automerge/automerge.test.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/defs.d.ts +8 -10
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/semaphore.d.ts +7 -6
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
- package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/awareness.d.ts +2 -2
- package/dist/types/src/extensions/awareness.d.ts.map +1 -1
- package/dist/types/src/extensions/basic.d.ts +4 -5
- package/dist/types/src/extensions/basic.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +23 -15
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/extensions/cursor.d.ts +3 -3
- package/dist/types/src/extensions/cursor.d.ts.map +1 -1
- package/dist/types/src/extensions/index.d.ts +0 -7
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/bundle.d.ts +3 -6
- package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/code.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/formatting.d.ts +11 -0
- package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/heading.d.ts +6 -0
- package/dist/types/src/extensions/markdown/heading.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/hr.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/image.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/index.d.ts +8 -0
- package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/link.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/table.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/tasklist.d.ts +3 -0
- package/dist/types/src/extensions/markdown/tasklist.d.ts.map +1 -0
- package/dist/types/src/extensions/mention.d.ts.map +1 -1
- package/dist/types/src/hooks/awareness-provider.d.ts +7 -4
- package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -1
- package/dist/types/src/hooks/defs.d.ts +25 -0
- package/dist/types/src/hooks/defs.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +3 -2
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useActionHandler.d.ts +4 -0
- package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -0
- package/dist/types/src/hooks/useTextEditor.d.ts +17 -0
- package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
- package/dist/types/src/hooks/useTextModel.d.ts +2 -27
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/dist/types/src/styles/markdown.d.ts +2 -2
- package/dist/types/src/styles/markdown.d.ts.map +1 -1
- package/dist/types/src/themes/default.d.ts.map +1 -1
- package/dist/types/src/util.d.ts +2 -0
- package/dist/types/src/util.d.ts.map +1 -1
- package/package.json +45 -36
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +64 -75
- package/src/components/TextEditor/TextEditor.tsx +103 -71
- package/src/components/TextEditor/comments.stories.tsx +46 -44
- package/src/components/Toolbar/Toolbar.stories.tsx +86 -0
- package/src/components/Toolbar/Toolbar.tsx +115 -0
- package/src/extensions/autocomplete.ts +6 -3
- package/src/extensions/automerge/automerge.spec.tsx +76 -0
- package/src/extensions/automerge/automerge.test.ts +13 -0
- package/src/extensions/automerge/automerge.ts +50 -39
- package/src/extensions/automerge/cursor.ts +3 -3
- package/src/extensions/automerge/defs.ts +11 -36
- package/src/extensions/automerge/semaphore.ts +31 -28
- package/src/extensions/automerge/update-automerge.ts +14 -8
- package/src/extensions/automerge/update-codemirror.ts +5 -5
- package/src/extensions/awareness.ts +17 -17
- package/src/extensions/basic.ts +22 -17
- package/src/extensions/comments.ts +247 -179
- package/src/extensions/cursor.ts +7 -3
- package/src/extensions/index.ts +0 -7
- package/src/extensions/markdown/bundle.ts +20 -30
- package/src/extensions/markdown/code.ts +142 -0
- package/src/extensions/markdown/formatting.ts +159 -0
- package/src/extensions/markdown/heading.ts +59 -0
- package/src/extensions/markdown/highlight.ts +9 -15
- package/src/extensions/markdown/hr.ts +71 -0
- package/src/extensions/markdown/index.ts +8 -0
- package/src/extensions/{link.ts → markdown/link.ts} +5 -2
- package/src/extensions/{tasklist.ts → markdown/tasklist.ts} +48 -34
- package/src/extensions/mention.ts +9 -2
- package/src/hooks/awareness-provider.ts +24 -20
- package/src/hooks/defs.ts +42 -0
- package/src/hooks/index.ts +3 -2
- package/src/hooks/useActionHandler.ts +45 -0
- package/src/hooks/useTextEditor.ts +29 -0
- package/src/hooks/useTextModel.ts +49 -55
- package/src/styles/markdown.ts +9 -10
- package/src/themes/default.ts +1 -3
- package/src/util.ts +26 -3
- package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
- package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
- package/dist/types/src/components/TextEditor/yjs.stories.d.ts +0 -12
- package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
- package/dist/types/src/extensions/code.d.ts.map +0 -1
- package/dist/types/src/extensions/hr.d.ts.map +0 -1
- package/dist/types/src/extensions/image.d.ts.map +0 -1
- package/dist/types/src/extensions/link.d.ts.map +0 -1
- package/dist/types/src/extensions/table.d.ts.map +0 -1
- package/dist/types/src/extensions/tasklist.d.ts +0 -7
- package/dist/types/src/extensions/tasklist.d.ts.map +0 -1
- package/dist/types/src/extensions/yjs/cursor.d.ts +0 -4
- package/dist/types/src/extensions/yjs/cursor.d.ts.map +0 -1
- package/dist/types/src/extensions/yjs/index.d.ts +0 -2
- package/dist/types/src/extensions/yjs/index.d.ts.map +0 -1
- package/dist/types/src/extensions/yjs/yjs.d.ts +0 -4
- package/dist/types/src/extensions/yjs/yjs.d.ts.map +0 -1
- package/dist/types/src/extensions/yjs/yjs.test.d.ts +0 -2
- package/dist/types/src/extensions/yjs/yjs.test.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge.d.ts +0 -3
- package/dist/types/src/hooks/automerge.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs.d.ts +0 -27
- package/dist/types/src/hooks/yjs.d.ts.map +0 -1
- package/dist/types/src/testing/index.d.ts +0 -3
- package/dist/types/src/testing/index.d.ts.map +0 -1
- package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
- package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
- package/dist/types/src/testing/proto/index.d.ts +0 -2
- package/dist/types/src/testing/proto/index.d.ts.map +0 -1
- package/dist/types/src/testing/replicator.d.ts +0 -45
- package/dist/types/src/testing/replicator.d.ts.map +0 -1
- package/src/components/TextEditor/codemirror.stories.ts +0 -115
- package/src/components/TextEditor/yjs.stories.tsx +0 -56
- package/src/extensions/code.ts +0 -104
- package/src/extensions/hr.ts +0 -72
- package/src/extensions/yjs/cursor.ts +0 -22
- package/src/extensions/yjs/index.ts +0 -5
- package/src/extensions/yjs/yjs.test.ts +0 -35
- package/src/extensions/yjs/yjs.ts +0 -16
- package/src/hooks/automerge.ts +0 -52
- package/src/hooks/yjs.ts +0 -156
- package/src/testing/index.ts +0 -6
- package/src/testing/proto/gen/schema.ts +0 -49
- package/src/testing/proto/index.ts +0 -5
- package/src/testing/proto/schema.proto +0 -15
- package/src/testing/replicator.ts +0 -184
- /package/dist/types/src/extensions/{code.d.ts → markdown/code.d.ts} +0 -0
- /package/dist/types/src/extensions/{hr.d.ts → markdown/hr.d.ts} +0 -0
- /package/dist/types/src/extensions/{image.d.ts → markdown/image.d.ts} +0 -0
- /package/dist/types/src/extensions/{link.d.ts → markdown/link.d.ts} +0 -0
- /package/dist/types/src/extensions/{table.d.ts → markdown/table.d.ts} +0 -0
- /package/src/extensions/{image.ts → markdown/image.ts} +0 -0
- /package/src/extensions/{table.ts → markdown/table.ts} +0 -0
|
@@ -17,8 +17,8 @@ import { fixedInsetFlexLayout, mx } from '@dxos/react-ui-theme';
|
|
|
17
17
|
import { withTheme } from '@dxos/storybook-utils';
|
|
18
18
|
|
|
19
19
|
import { MarkdownEditor, TextEditor } from './TextEditor';
|
|
20
|
-
import { comments, type CommentsOptions,
|
|
21
|
-
import { type
|
|
20
|
+
import { comments, type CommentsOptions, setFocus, useComments } from '../../extensions';
|
|
21
|
+
import { type Comment, type Range, useTextModel } from '../../hooks';
|
|
22
22
|
|
|
23
23
|
faker.seed(101);
|
|
24
24
|
|
|
@@ -28,31 +28,34 @@ faker.seed(101);
|
|
|
28
28
|
|
|
29
29
|
const Editor: FC<{
|
|
30
30
|
item: { text: TextObject };
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
comments: Comment[];
|
|
32
|
+
selected?: string;
|
|
33
33
|
onCreateComment: CommentsOptions['onCreate'];
|
|
34
34
|
onDeleteComment: CommentsOptions['onDelete'];
|
|
35
35
|
onUpdateComment: CommentsOptions['onUpdate'];
|
|
36
36
|
onSelectComment: CommentsOptions['onSelect'];
|
|
37
|
-
}> = ({
|
|
37
|
+
}> = ({
|
|
38
|
+
item,
|
|
39
|
+
selected: selectedValue,
|
|
40
|
+
comments: commentRanges,
|
|
41
|
+
onCreateComment,
|
|
42
|
+
onDeleteComment,
|
|
43
|
+
onUpdateComment,
|
|
44
|
+
onSelectComment,
|
|
45
|
+
}) => {
|
|
38
46
|
const model = useTextModel({ text: item.text });
|
|
39
|
-
const
|
|
47
|
+
const view = useRef<EditorView>(null);
|
|
40
48
|
const [selected, setSelected] = useState<string>();
|
|
41
49
|
useEffect(() => {
|
|
42
|
-
if (!
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
const range = Cursor.getRangeFromCursor(editorRef.current!.state.facet(Cursor.converter), cursor);
|
|
47
|
-
if (range) {
|
|
48
|
-
// TODO(burdon): Scroll selection to center of screen?
|
|
49
|
-
editorRef.current?.dispatch({ selection: { anchor: range.from }, scrollIntoView: true });
|
|
50
|
-
}
|
|
50
|
+
if (!view.current?.hasFocus && selectedValue !== selected) {
|
|
51
|
+
setSelected(selectedValue);
|
|
52
|
+
if (selectedValue) {
|
|
53
|
+
setFocus(view.current!, selectedValue);
|
|
51
54
|
}
|
|
52
|
-
|
|
53
|
-
setSelected(commentSelected);
|
|
54
55
|
}
|
|
55
|
-
}, [selected, commentRanges,
|
|
56
|
+
}, [selected, commentRanges, selectedValue]);
|
|
57
|
+
|
|
58
|
+
useComments(view.current, commentRanges);
|
|
56
59
|
|
|
57
60
|
const extensions = useMemo(() => {
|
|
58
61
|
return [
|
|
@@ -72,7 +75,7 @@ const Editor: FC<{
|
|
|
72
75
|
// TODO(burdon): Highlight currently selected comment.
|
|
73
76
|
return (
|
|
74
77
|
<div className='flex grow overflow-y-scroll'>
|
|
75
|
-
<MarkdownEditor ref={
|
|
78
|
+
<MarkdownEditor ref={view} model={model} extensions={extensions} />
|
|
76
79
|
</div>
|
|
77
80
|
);
|
|
78
81
|
};
|
|
@@ -83,11 +86,10 @@ const Editor: FC<{
|
|
|
83
86
|
|
|
84
87
|
type CommentThread = {
|
|
85
88
|
id: string;
|
|
86
|
-
|
|
89
|
+
cursor?: string;
|
|
90
|
+
range?: Range;
|
|
87
91
|
yPos?: number;
|
|
88
|
-
selection?: Range;
|
|
89
92
|
messages: TextObject[];
|
|
90
|
-
deleted?: boolean;
|
|
91
93
|
};
|
|
92
94
|
|
|
93
95
|
const Thread: FC<{
|
|
@@ -129,16 +131,16 @@ const Thread: FC<{
|
|
|
129
131
|
className={mx(
|
|
130
132
|
'flex flex-col m-1 rounded shadow divide-y bg-white',
|
|
131
133
|
selected && 'ring',
|
|
132
|
-
thread.
|
|
134
|
+
!thread.cursor && 'opacity-50',
|
|
133
135
|
)}
|
|
134
136
|
>
|
|
135
137
|
<div className='flex p-2 gap-2 items-center text-xs font-mono text-neutral-500 font-thin'>
|
|
136
138
|
<span>id:{thread.id.slice(0, 4)}</span>
|
|
137
|
-
<span>from:{thread.
|
|
138
|
-
<span>to:{thread.
|
|
139
|
+
<span>from:{thread.range?.from}</span>
|
|
140
|
+
<span>to:{thread.range?.to}</span>
|
|
139
141
|
<span>y:{thread.yPos}</span>
|
|
140
142
|
<span className='grow' />
|
|
141
|
-
{thread.
|
|
143
|
+
{!thread.cursor && <Trash />}
|
|
142
144
|
</div>
|
|
143
145
|
|
|
144
146
|
{thread.messages.map((message, i) => (
|
|
@@ -148,13 +150,13 @@ const Thread: FC<{
|
|
|
148
150
|
</div>
|
|
149
151
|
))}
|
|
150
152
|
|
|
151
|
-
<div ref={containerRef} onClick={() => onSelect()}
|
|
153
|
+
<div ref={containerRef} className='flex' onClick={() => onSelect()}>
|
|
152
154
|
<TextEditor
|
|
153
155
|
ref={editorRef}
|
|
154
|
-
|
|
156
|
+
autoFocus={focus}
|
|
155
157
|
model={model}
|
|
156
158
|
placeholder={'Enter comment...'}
|
|
157
|
-
slots={{ root: { className: 'grow
|
|
159
|
+
slots={{ root: { className: 'grow rounded-b' } }}
|
|
158
160
|
extensions={[
|
|
159
161
|
keymap.of([
|
|
160
162
|
{
|
|
@@ -218,9 +220,10 @@ type StoryProps = {
|
|
|
218
220
|
const Story = ({ text, autoCreate }: StoryProps) => {
|
|
219
221
|
const [item] = useState({ text: new TextObject(text) });
|
|
220
222
|
const [threads, setThreads] = useState<CommentThread[]>([]);
|
|
221
|
-
const commentRanges = useMemo(() => threads.map((thread) => thread.range), [threads]);
|
|
222
223
|
const [selected, setSelected] = useState<string>();
|
|
223
224
|
|
|
225
|
+
const comments = useMemo<Comment[]>(() => threads.map(({ id, cursor }) => ({ id, cursor })), [threads]);
|
|
226
|
+
|
|
224
227
|
// Filter by visibility.
|
|
225
228
|
const visibleThreads = useMemo(() => threads.filter((thread) => thread.yPos !== undefined), [threads]);
|
|
226
229
|
|
|
@@ -231,14 +234,14 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
231
234
|
...threads,
|
|
232
235
|
{
|
|
233
236
|
id,
|
|
234
|
-
|
|
237
|
+
cursor,
|
|
235
238
|
yPos: location ? Math.floor(location.top) : undefined,
|
|
236
239
|
messages: autoCreate
|
|
237
240
|
? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 1, max: 3 } })
|
|
238
241
|
: [],
|
|
239
242
|
},
|
|
240
243
|
]);
|
|
241
|
-
setSelected(id);
|
|
244
|
+
setSelected(id); // TODO(burdon): Not required.
|
|
242
245
|
return id;
|
|
243
246
|
};
|
|
244
247
|
|
|
@@ -247,7 +250,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
247
250
|
setThreads((threads) =>
|
|
248
251
|
threads.map((thread) => {
|
|
249
252
|
if (thread.id === id) {
|
|
250
|
-
thread.
|
|
253
|
+
thread.cursor = undefined;
|
|
251
254
|
}
|
|
252
255
|
|
|
253
256
|
return thread;
|
|
@@ -262,8 +265,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
262
265
|
setThreads((threads) =>
|
|
263
266
|
threads.map((thread) => {
|
|
264
267
|
if (thread.id === id) {
|
|
265
|
-
thread.
|
|
266
|
-
delete thread.deleted;
|
|
268
|
+
thread.cursor = cursor;
|
|
267
269
|
}
|
|
268
270
|
|
|
269
271
|
return thread;
|
|
@@ -271,21 +273,21 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
271
273
|
);
|
|
272
274
|
};
|
|
273
275
|
|
|
274
|
-
const handleSelectComment: CommentsOptions['onSelect'] = ({
|
|
275
|
-
log.info('select', { active:
|
|
276
|
+
const handleSelectComment: CommentsOptions['onSelect'] = ({ comments, selection: { current, closest } }) => {
|
|
277
|
+
log.info('select', { active: current?.slice(0, 4), closest: closest?.slice(0, 4) });
|
|
276
278
|
setThreads((threads) =>
|
|
277
279
|
threads.map((thread) => {
|
|
278
|
-
const
|
|
279
|
-
if (
|
|
280
|
-
thread.yPos =
|
|
281
|
-
thread.
|
|
280
|
+
const comment = comments.find(({ comment }) => comment.id === thread.id);
|
|
281
|
+
if (comment) {
|
|
282
|
+
thread.yPos = comment.location ? Math.floor(comment.location.top) : undefined;
|
|
283
|
+
thread.range = { from: comment.range.from, to: comment.range.to };
|
|
282
284
|
}
|
|
283
285
|
|
|
284
286
|
return thread;
|
|
285
287
|
}),
|
|
286
288
|
);
|
|
287
289
|
|
|
288
|
-
setSelected(
|
|
290
|
+
setSelected(current ?? closest);
|
|
289
291
|
};
|
|
290
292
|
|
|
291
293
|
const handleSelectThread = (id: string) => {
|
|
@@ -303,8 +305,8 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
303
305
|
<div className='flex flex-col h-full w-[600px]'>
|
|
304
306
|
<Editor
|
|
305
307
|
item={item}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
+
selected={selected}
|
|
309
|
+
comments={comments}
|
|
308
310
|
onCreateComment={handleCreateComment}
|
|
309
311
|
onDeleteComment={handleDeleteComment}
|
|
310
312
|
onUpdateComment={handleUpdateComment}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxosTheme';
|
|
6
|
+
|
|
7
|
+
import { faker } from '@faker-js/faker';
|
|
8
|
+
import React, { type FC, useMemo, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { TextObject } from '@dxos/echo-schema';
|
|
11
|
+
import { PublicKey } from '@dxos/keys';
|
|
12
|
+
import { fixedInsetFlexLayout, groupSurface, mx } from '@dxos/react-ui-theme';
|
|
13
|
+
import { withTheme } from '@dxos/storybook-utils';
|
|
14
|
+
|
|
15
|
+
import { Toolbar } from './Toolbar';
|
|
16
|
+
import { code, comments, formatting, heading, image, table, tasklist, useComments } from '../../extensions';
|
|
17
|
+
import { type Comment, useActionHandler, useTextEditor, useTextModel } from '../../hooks';
|
|
18
|
+
import { MarkdownEditor } from '../TextEditor';
|
|
19
|
+
|
|
20
|
+
faker.seed(101);
|
|
21
|
+
|
|
22
|
+
const Story: FC<{ content: string }> = ({ content }) => {
|
|
23
|
+
const [item] = useState({ text: new TextObject(content) });
|
|
24
|
+
const [_comments, setComments] = useState<Comment[]>([]);
|
|
25
|
+
const [editorRef, editorView] = useTextEditor();
|
|
26
|
+
const model = useTextModel({ text: item.text });
|
|
27
|
+
const extensions = useMemo(
|
|
28
|
+
() => [
|
|
29
|
+
code(),
|
|
30
|
+
comments({
|
|
31
|
+
onCreate: (cursor) => {
|
|
32
|
+
const id = PublicKey.random().toHex();
|
|
33
|
+
setComments((comments) => [...comments, { id, cursor }]);
|
|
34
|
+
return id;
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
formatting(),
|
|
38
|
+
heading(),
|
|
39
|
+
image(),
|
|
40
|
+
table(),
|
|
41
|
+
tasklist(),
|
|
42
|
+
],
|
|
43
|
+
[],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
useComments(editorView, _comments);
|
|
47
|
+
const handleAction = useActionHandler(editorView);
|
|
48
|
+
|
|
49
|
+
if (!model) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={mx(fixedInsetFlexLayout, groupSurface)}>
|
|
55
|
+
<div className='flex h-full justify-center'>
|
|
56
|
+
<div className='flex flex-col h-full w-[800px]'>
|
|
57
|
+
<Toolbar onAction={handleAction} />
|
|
58
|
+
<MarkdownEditor ref={editorRef} model={model} extensions={extensions} />
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export default {
|
|
66
|
+
title: 'react-ui-editor/Toolbar',
|
|
67
|
+
component: Toolbar,
|
|
68
|
+
render: (args: any) => <Story {...args} />,
|
|
69
|
+
decorators: [withTheme],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const content = [
|
|
73
|
+
'Demo',
|
|
74
|
+
'',
|
|
75
|
+
'The editor supports **Markdown** styles.',
|
|
76
|
+
'',
|
|
77
|
+
faker.lorem.paragraph({ min: 5, max: 8 }),
|
|
78
|
+
'',
|
|
79
|
+
'',
|
|
80
|
+
].join('\n');
|
|
81
|
+
|
|
82
|
+
export const Default = {
|
|
83
|
+
args: {
|
|
84
|
+
content,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Icon,
|
|
7
|
+
At,
|
|
8
|
+
Brain,
|
|
9
|
+
CaretRight,
|
|
10
|
+
ChatText,
|
|
11
|
+
Code,
|
|
12
|
+
Image,
|
|
13
|
+
Link,
|
|
14
|
+
ListBullets,
|
|
15
|
+
ListChecks,
|
|
16
|
+
ListNumbers,
|
|
17
|
+
TextStrikethrough,
|
|
18
|
+
Table,
|
|
19
|
+
TextB,
|
|
20
|
+
TextItalic,
|
|
21
|
+
} from '@phosphor-icons/react';
|
|
22
|
+
import React, { type FC } from 'react';
|
|
23
|
+
|
|
24
|
+
import { Button, type ButtonProps, DensityProvider, Select } from '@dxos/react-ui';
|
|
25
|
+
import { getSize } from '@dxos/react-ui-theme';
|
|
26
|
+
|
|
27
|
+
export type Action =
|
|
28
|
+
| 'blockquote'
|
|
29
|
+
| 'bold'
|
|
30
|
+
| 'code'
|
|
31
|
+
| 'comment'
|
|
32
|
+
| 'heading'
|
|
33
|
+
| 'image'
|
|
34
|
+
| 'italic'
|
|
35
|
+
| 'link'
|
|
36
|
+
| 'list'
|
|
37
|
+
| 'list-ordered'
|
|
38
|
+
| 'list-tasks'
|
|
39
|
+
| 'mention'
|
|
40
|
+
| 'prompt'
|
|
41
|
+
| 'strikethrough'
|
|
42
|
+
| 'table';
|
|
43
|
+
|
|
44
|
+
export type ToolbarProps = {
|
|
45
|
+
onAction?: (action: { type: Action; data?: any }) => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const IconButton: FC<{ Icon: Icon; title: string } & NonNullable<Pick<ButtonProps, 'onClick'>>> = ({
|
|
49
|
+
Icon,
|
|
50
|
+
title,
|
|
51
|
+
onClick,
|
|
52
|
+
}) => (
|
|
53
|
+
<Button variant='ghost' classNames='p-2' onClick={onClick} title={title}>
|
|
54
|
+
<Icon className={getSize(5)} />
|
|
55
|
+
</Button>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// TODO(burdon): Update state based on current selection?
|
|
59
|
+
export const Toolbar = ({ onAction }: ToolbarProps) => {
|
|
60
|
+
return (
|
|
61
|
+
<DensityProvider density='fine'>
|
|
62
|
+
<div role='toolbar' className='flex w-full p-2 items-center gap-4'>
|
|
63
|
+
<div>
|
|
64
|
+
<Select.Root
|
|
65
|
+
defaultValue='0'
|
|
66
|
+
onValueChange={(value) => onAction?.({ type: 'heading', data: parseInt(value) })}
|
|
67
|
+
>
|
|
68
|
+
<Select.TriggerButton classNames='w-[8rem]' />
|
|
69
|
+
<Select.Portal>
|
|
70
|
+
<Select.Content>
|
|
71
|
+
<Select.Viewport>
|
|
72
|
+
<Select.Option value='0'>Paragraph</Select.Option>
|
|
73
|
+
{[1, 2, 3, 4, 5, 6].map((level) => (
|
|
74
|
+
<Select.Option key={level} value={String(level)}>
|
|
75
|
+
Heading {level}
|
|
76
|
+
</Select.Option>
|
|
77
|
+
))}
|
|
78
|
+
</Select.Viewport>
|
|
79
|
+
</Select.Content>
|
|
80
|
+
</Select.Portal>
|
|
81
|
+
</Select.Root>
|
|
82
|
+
</div>
|
|
83
|
+
<div>
|
|
84
|
+
<IconButton Icon={TextB} title='Bold' onClick={() => onAction?.({ type: 'bold' })} />
|
|
85
|
+
<IconButton Icon={TextItalic} title='Italic' onClick={() => onAction?.({ type: 'italic' })} />
|
|
86
|
+
<IconButton
|
|
87
|
+
Icon={TextStrikethrough}
|
|
88
|
+
title='Strike-through'
|
|
89
|
+
onClick={() => onAction?.({ type: 'strikethrough' })}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
<div>
|
|
93
|
+
<IconButton Icon={ListBullets} title='Bullet list' onClick={() => onAction?.({ type: 'list' })} />
|
|
94
|
+
<IconButton Icon={ListNumbers} title='Numbered list' onClick={() => onAction?.({ type: 'list-ordered' })} />
|
|
95
|
+
<IconButton Icon={ListChecks} title='Task list' onClick={() => onAction?.({ type: 'list-tasks' })} />
|
|
96
|
+
</div>
|
|
97
|
+
<div>
|
|
98
|
+
<IconButton Icon={CaretRight} title='Block quite' onClick={() => onAction?.({ type: 'blockquote' })} />
|
|
99
|
+
<IconButton Icon={Code} title='Code block' onClick={() => onAction?.({ type: 'code' })} />
|
|
100
|
+
<IconButton Icon={Table} title='Table' onClick={() => onAction?.({ type: 'table' })} />
|
|
101
|
+
</div>
|
|
102
|
+
<div>
|
|
103
|
+
<IconButton Icon={Link} title='Link' onClick={() => onAction?.({ type: 'link' })} />
|
|
104
|
+
<IconButton Icon={At} title='Mention' onClick={() => onAction?.({ type: 'mention' })} />
|
|
105
|
+
<IconButton Icon={Image} title='Image' onClick={() => onAction?.({ type: 'image' })} />
|
|
106
|
+
</div>
|
|
107
|
+
<div className='grow' />
|
|
108
|
+
<div>
|
|
109
|
+
<IconButton Icon={Brain} title='AI prompt' onClick={() => onAction?.({ type: 'prompt' })} />
|
|
110
|
+
<IconButton Icon={ChatText} title='Create comment' onClick={() => onAction?.({ type: 'comment' })} />
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</DensityProvider>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
@@ -35,10 +35,13 @@ export const autocomplete = ({ onSearch }: AutocompleteOptions) => {
|
|
|
35
35
|
// https://codemirror.net/examples/autocompletion
|
|
36
36
|
// https://codemirror.net/docs/ref/#autocomplete.autocompletion
|
|
37
37
|
autocompletion({
|
|
38
|
-
|
|
38
|
+
activateOnTyping: true,
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
|
|
40
|
+
// closeOnBlur: false,
|
|
41
|
+
// defaultKeymap: false,
|
|
42
|
+
|
|
43
|
+
// TODO(burdon): Styles/fragments.
|
|
44
|
+
tooltipClass: () => 'shadow rounded',
|
|
42
45
|
}),
|
|
43
46
|
|
|
44
47
|
// TODO(burdon): Option to create new page?
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorState } from '@codemirror/state';
|
|
6
|
+
import { EditorView } from '@codemirror/view';
|
|
7
|
+
import { render, screen } from '@testing-library/react';
|
|
8
|
+
import chai, { expect } from 'chai';
|
|
9
|
+
import chaiDom from 'chai-dom';
|
|
10
|
+
import get from 'lodash.get';
|
|
11
|
+
import React, { type FC, useEffect, useRef, useState } from 'react';
|
|
12
|
+
import { describe, test } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import { type DocHandle, Repo } from '@dxos/automerge/automerge-repo';
|
|
15
|
+
|
|
16
|
+
import { automerge } from './automerge';
|
|
17
|
+
|
|
18
|
+
type TestObject = {
|
|
19
|
+
text: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const path = ['text'];
|
|
23
|
+
|
|
24
|
+
class Generator {
|
|
25
|
+
constructor(private readonly _handle: DocHandle<TestObject>) {}
|
|
26
|
+
update(text: string) {
|
|
27
|
+
this._handle.change((doc: TestObject) => {
|
|
28
|
+
doc.text = text;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const Test: FC<{ handle: DocHandle<TestObject>; generator: Generator }> = ({ handle, generator }) => {
|
|
34
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
35
|
+
const [view, setView] = useState<EditorView>();
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const extensions = [
|
|
38
|
+
automerge({ handle, path }),
|
|
39
|
+
EditorView.updateListener.of((update) => {
|
|
40
|
+
if (view.state.doc.toString() === 'hello!') {
|
|
41
|
+
// Update editor.
|
|
42
|
+
update.view.dispatch({ changes: { from: view.state.doc.length - 1, insert: ' world' } });
|
|
43
|
+
}
|
|
44
|
+
}),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const view = new EditorView({
|
|
48
|
+
state: EditorState.create({ doc: get(handle.docSync()!, path), extensions }),
|
|
49
|
+
parent: ref.current!,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
setView(view);
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {}, [view]);
|
|
56
|
+
|
|
57
|
+
return <div ref={ref} />;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
chai.use(chaiDom);
|
|
61
|
+
|
|
62
|
+
describe('Automerge', () => {
|
|
63
|
+
test('basic sync', () => {
|
|
64
|
+
const repo = new Repo({ network: [] });
|
|
65
|
+
const handle = repo.create<TestObject>();
|
|
66
|
+
const generator = new Generator(handle);
|
|
67
|
+
render(<Test handle={handle} generator={generator} />);
|
|
68
|
+
expect(screen.getByRole('textbox')).to.have.text('');
|
|
69
|
+
|
|
70
|
+
generator.update('hello!');
|
|
71
|
+
expect(screen.getByRole('textbox')).to.have.text('hello world!');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// TODO(burdon): Test history/undo.
|
|
75
|
+
// TODO(burdon): https://testing-library.com/docs/react-testing-library/example-intro/
|
|
76
|
+
});
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
// Ref: https://github.com/automerge/automerge-codemirror
|
|
5
5
|
//
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { invertedEffects } from '@codemirror/commands';
|
|
8
|
+
import { StateField, type Extension, type StateEffect } from '@codemirror/state';
|
|
9
|
+
import { EditorView, ViewPlugin } from '@codemirror/view';
|
|
9
10
|
|
|
10
|
-
import { next as A } from '@dxos/automerge/automerge';
|
|
11
|
-
import { type Prop } from '@dxos/automerge/automerge';
|
|
11
|
+
import { next as A, type Prop } from '@dxos/automerge/automerge';
|
|
12
12
|
|
|
13
13
|
import { cursorConverter } from './cursor';
|
|
14
|
-
import {
|
|
14
|
+
import { updateHeadsEffect, type IDocHandle, isReconcile, type State } from './defs';
|
|
15
15
|
import { PatchSemaphore } from './semaphore';
|
|
16
16
|
import { Cursor } from '../cursor';
|
|
17
17
|
|
|
@@ -21,23 +21,23 @@ export type AutomergeOptions = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
24
|
-
const
|
|
24
|
+
const syncState = StateField.define<State>({
|
|
25
25
|
create: () => ({
|
|
26
|
+
path: path.slice(),
|
|
26
27
|
lastHeads: A.getHeads(handle.docSync()!),
|
|
27
28
|
unreconciledTransactions: [],
|
|
28
|
-
path: path.slice(),
|
|
29
29
|
}),
|
|
30
30
|
|
|
31
|
-
update: (value
|
|
32
|
-
const result:
|
|
31
|
+
update: (value, tr) => {
|
|
32
|
+
const result: State = {
|
|
33
|
+
path: path.slice(),
|
|
33
34
|
lastHeads: value.lastHeads,
|
|
34
35
|
unreconciledTransactions: value.unreconciledTransactions.slice(),
|
|
35
|
-
path: path.slice(),
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
let clearUnreconciled = false;
|
|
39
39
|
for (const effect of tr.effects) {
|
|
40
|
-
if (effect.is(
|
|
40
|
+
if (effect.is(updateHeadsEffect)) {
|
|
41
41
|
result.lastHeads = effect.value.newHeads;
|
|
42
42
|
clearUnreconciled = true;
|
|
43
43
|
}
|
|
@@ -46,7 +46,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
46
46
|
if (clearUnreconciled) {
|
|
47
47
|
result.unreconciledTransactions = [];
|
|
48
48
|
} else {
|
|
49
|
-
if (!
|
|
49
|
+
if (!isReconcile(tr)) {
|
|
50
50
|
result.unreconciledTransactions.push(tr);
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -55,38 +55,49 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
55
55
|
},
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
const semaphore = new PatchSemaphore(handle,
|
|
58
|
+
const semaphore = new PatchSemaphore(handle, syncState);
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
return [
|
|
61
|
+
Cursor.converter.of(cursorConverter(handle, path)),
|
|
62
|
+
syncState,
|
|
63
|
+
|
|
64
|
+
// Reconcile external updates.
|
|
65
|
+
ViewPlugin.fromClass(
|
|
66
|
+
class {
|
|
67
|
+
constructor(private readonly _view: EditorView) {
|
|
68
|
+
handle.addListener('change', this._handleChange.bind(this));
|
|
69
|
+
}
|
|
65
70
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
queueMicrotask(() => {
|
|
69
|
-
this._view.state.facet(semaphoreFacet).reconcile(this._view);
|
|
70
|
-
});
|
|
71
|
+
destroy() {
|
|
72
|
+
handle.removeListener('change', this._handleChange.bind(this));
|
|
71
73
|
}
|
|
74
|
+
|
|
75
|
+
_handleChange() {
|
|
76
|
+
semaphore.reconcile(this._view);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
|
|
81
|
+
// Reconcile local updates.
|
|
82
|
+
EditorView.updateListener.of(({ view, changes }) => {
|
|
83
|
+
if (!changes.empty) {
|
|
84
|
+
semaphore.reconcile(view);
|
|
72
85
|
}
|
|
86
|
+
}),
|
|
73
87
|
|
|
74
|
-
|
|
75
|
-
|
|
88
|
+
// TODO(burdon): Record undo transactions.
|
|
89
|
+
// See https://github.com/yjs/y-codemirror.next/tree/main
|
|
90
|
+
// https://codemirror.net/examples/inverted-effect
|
|
91
|
+
invertedEffects.of((tr) => {
|
|
92
|
+
// Each change results it three transactions: insert, delete, insert.
|
|
93
|
+
const effects: StateEffect<any>[] = [];
|
|
94
|
+
if (!tr.changes.empty) {
|
|
95
|
+
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
|
96
|
+
// effects.push(effectType.of({});
|
|
97
|
+
});
|
|
76
98
|
}
|
|
77
99
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
},
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
extension: [
|
|
86
|
-
Cursor.converter.of(cursorConverter(handle, path)),
|
|
87
|
-
semaphoreFacet.of(semaphore),
|
|
88
|
-
stateField,
|
|
89
|
-
viewPlugin,
|
|
90
|
-
],
|
|
91
|
-
};
|
|
100
|
+
return effects;
|
|
101
|
+
}),
|
|
102
|
+
];
|
|
92
103
|
};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import get from 'lodash.get';
|
|
6
6
|
|
|
7
7
|
import type { Prop } from '@dxos/automerge/automerge';
|
|
8
|
-
import { next as
|
|
8
|
+
import { next as A } from '@dxos/automerge/automerge';
|
|
9
9
|
|
|
10
10
|
import { type IDocHandle } from './defs';
|
|
11
11
|
import { type CursorConverter } from '../cursor';
|
|
@@ -24,7 +24,7 @@ export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConvert
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// NOTE: Slice is needed because getCursor mutates the array.
|
|
27
|
-
return
|
|
27
|
+
return A.getCursor(doc, path.slice(), pos);
|
|
28
28
|
},
|
|
29
29
|
|
|
30
30
|
fromCursor: (cursor) => {
|
|
@@ -47,6 +47,6 @@ export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConvert
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// NOTE: Slice is needed because getCursor mutates the array.
|
|
50
|
-
return
|
|
50
|
+
return A.getCursorPosition(doc, path.slice(), cursor);
|
|
51
51
|
},
|
|
52
52
|
});
|