@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.
Files changed (161) hide show
  1. package/dist/lib/browser/index.mjs +1304 -1092
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +12 -17
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +13 -5
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +1 -1
  9. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/Toolbar/Toolbar.d.ts +9 -0
  11. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -0
  12. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +14 -0
  13. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
  14. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  15. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  16. package/dist/types/src/extensions/automerge/automerge.spec.d.ts +2 -0
  17. package/dist/types/src/extensions/automerge/automerge.spec.d.ts.map +1 -0
  18. package/dist/types/src/extensions/automerge/automerge.test.d.ts +2 -0
  19. package/dist/types/src/extensions/automerge/automerge.test.d.ts.map +1 -0
  20. package/dist/types/src/extensions/automerge/defs.d.ts +8 -10
  21. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  22. package/dist/types/src/extensions/automerge/semaphore.d.ts +7 -6
  23. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  24. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  25. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  26. package/dist/types/src/extensions/awareness.d.ts +2 -2
  27. package/dist/types/src/extensions/awareness.d.ts.map +1 -1
  28. package/dist/types/src/extensions/basic.d.ts +4 -5
  29. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  30. package/dist/types/src/extensions/comments.d.ts +23 -15
  31. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  32. package/dist/types/src/extensions/cursor.d.ts +3 -3
  33. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  34. package/dist/types/src/extensions/index.d.ts +0 -7
  35. package/dist/types/src/extensions/index.d.ts.map +1 -1
  36. package/dist/types/src/extensions/markdown/bundle.d.ts +3 -6
  37. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  38. package/dist/types/src/extensions/markdown/code.d.ts.map +1 -0
  39. package/dist/types/src/extensions/markdown/formatting.d.ts +11 -0
  40. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
  41. package/dist/types/src/extensions/markdown/heading.d.ts +6 -0
  42. package/dist/types/src/extensions/markdown/heading.d.ts.map +1 -0
  43. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  44. package/dist/types/src/extensions/markdown/hr.d.ts.map +1 -0
  45. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -0
  46. package/dist/types/src/extensions/markdown/index.d.ts +8 -0
  47. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  48. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -0
  49. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -0
  50. package/dist/types/src/extensions/markdown/tasklist.d.ts +3 -0
  51. package/dist/types/src/extensions/markdown/tasklist.d.ts.map +1 -0
  52. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  53. package/dist/types/src/hooks/awareness-provider.d.ts +7 -4
  54. package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -1
  55. package/dist/types/src/hooks/defs.d.ts +25 -0
  56. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  57. package/dist/types/src/hooks/index.d.ts +3 -2
  58. package/dist/types/src/hooks/index.d.ts.map +1 -1
  59. package/dist/types/src/hooks/useActionHandler.d.ts +4 -0
  60. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -0
  61. package/dist/types/src/hooks/useTextEditor.d.ts +17 -0
  62. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
  63. package/dist/types/src/hooks/useTextModel.d.ts +2 -27
  64. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  65. package/dist/types/src/styles/markdown.d.ts +2 -2
  66. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  67. package/dist/types/src/themes/default.d.ts.map +1 -1
  68. package/dist/types/src/util.d.ts +2 -0
  69. package/dist/types/src/util.d.ts.map +1 -1
  70. package/package.json +45 -36
  71. package/src/components/TextEditor/MarkdownEditor.stories.tsx +64 -75
  72. package/src/components/TextEditor/TextEditor.tsx +103 -71
  73. package/src/components/TextEditor/comments.stories.tsx +46 -44
  74. package/src/components/Toolbar/Toolbar.stories.tsx +86 -0
  75. package/src/components/Toolbar/Toolbar.tsx +115 -0
  76. package/src/extensions/autocomplete.ts +6 -3
  77. package/src/extensions/automerge/automerge.spec.tsx +76 -0
  78. package/src/extensions/automerge/automerge.test.ts +13 -0
  79. package/src/extensions/automerge/automerge.ts +50 -39
  80. package/src/extensions/automerge/cursor.ts +3 -3
  81. package/src/extensions/automerge/defs.ts +11 -36
  82. package/src/extensions/automerge/semaphore.ts +31 -28
  83. package/src/extensions/automerge/update-automerge.ts +14 -8
  84. package/src/extensions/automerge/update-codemirror.ts +5 -5
  85. package/src/extensions/awareness.ts +17 -17
  86. package/src/extensions/basic.ts +22 -17
  87. package/src/extensions/comments.ts +247 -179
  88. package/src/extensions/cursor.ts +7 -3
  89. package/src/extensions/index.ts +0 -7
  90. package/src/extensions/markdown/bundle.ts +20 -30
  91. package/src/extensions/markdown/code.ts +142 -0
  92. package/src/extensions/markdown/formatting.ts +159 -0
  93. package/src/extensions/markdown/heading.ts +59 -0
  94. package/src/extensions/markdown/highlight.ts +9 -15
  95. package/src/extensions/markdown/hr.ts +71 -0
  96. package/src/extensions/markdown/index.ts +8 -0
  97. package/src/extensions/{link.ts → markdown/link.ts} +5 -2
  98. package/src/extensions/{tasklist.ts → markdown/tasklist.ts} +48 -34
  99. package/src/extensions/mention.ts +9 -2
  100. package/src/hooks/awareness-provider.ts +24 -20
  101. package/src/hooks/defs.ts +42 -0
  102. package/src/hooks/index.ts +3 -2
  103. package/src/hooks/useActionHandler.ts +45 -0
  104. package/src/hooks/useTextEditor.ts +29 -0
  105. package/src/hooks/useTextModel.ts +49 -55
  106. package/src/styles/markdown.ts +9 -10
  107. package/src/themes/default.ts +1 -3
  108. package/src/util.ts +26 -3
  109. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
  110. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
  111. package/dist/types/src/components/TextEditor/yjs.stories.d.ts +0 -12
  112. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  113. package/dist/types/src/extensions/code.d.ts.map +0 -1
  114. package/dist/types/src/extensions/hr.d.ts.map +0 -1
  115. package/dist/types/src/extensions/image.d.ts.map +0 -1
  116. package/dist/types/src/extensions/link.d.ts.map +0 -1
  117. package/dist/types/src/extensions/table.d.ts.map +0 -1
  118. package/dist/types/src/extensions/tasklist.d.ts +0 -7
  119. package/dist/types/src/extensions/tasklist.d.ts.map +0 -1
  120. package/dist/types/src/extensions/yjs/cursor.d.ts +0 -4
  121. package/dist/types/src/extensions/yjs/cursor.d.ts.map +0 -1
  122. package/dist/types/src/extensions/yjs/index.d.ts +0 -2
  123. package/dist/types/src/extensions/yjs/index.d.ts.map +0 -1
  124. package/dist/types/src/extensions/yjs/yjs.d.ts +0 -4
  125. package/dist/types/src/extensions/yjs/yjs.d.ts.map +0 -1
  126. package/dist/types/src/extensions/yjs/yjs.test.d.ts +0 -2
  127. package/dist/types/src/extensions/yjs/yjs.test.d.ts.map +0 -1
  128. package/dist/types/src/hooks/automerge.d.ts +0 -3
  129. package/dist/types/src/hooks/automerge.d.ts.map +0 -1
  130. package/dist/types/src/hooks/yjs.d.ts +0 -27
  131. package/dist/types/src/hooks/yjs.d.ts.map +0 -1
  132. package/dist/types/src/testing/index.d.ts +0 -3
  133. package/dist/types/src/testing/index.d.ts.map +0 -1
  134. package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
  135. package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
  136. package/dist/types/src/testing/proto/index.d.ts +0 -2
  137. package/dist/types/src/testing/proto/index.d.ts.map +0 -1
  138. package/dist/types/src/testing/replicator.d.ts +0 -45
  139. package/dist/types/src/testing/replicator.d.ts.map +0 -1
  140. package/src/components/TextEditor/codemirror.stories.ts +0 -115
  141. package/src/components/TextEditor/yjs.stories.tsx +0 -56
  142. package/src/extensions/code.ts +0 -104
  143. package/src/extensions/hr.ts +0 -72
  144. package/src/extensions/yjs/cursor.ts +0 -22
  145. package/src/extensions/yjs/index.ts +0 -5
  146. package/src/extensions/yjs/yjs.test.ts +0 -35
  147. package/src/extensions/yjs/yjs.ts +0 -16
  148. package/src/hooks/automerge.ts +0 -52
  149. package/src/hooks/yjs.ts +0 -156
  150. package/src/testing/index.ts +0 -6
  151. package/src/testing/proto/gen/schema.ts +0 -49
  152. package/src/testing/proto/index.ts +0 -5
  153. package/src/testing/proto/schema.proto +0 -15
  154. package/src/testing/replicator.ts +0 -184
  155. /package/dist/types/src/extensions/{code.d.ts → markdown/code.d.ts} +0 -0
  156. /package/dist/types/src/extensions/{hr.d.ts → markdown/hr.d.ts} +0 -0
  157. /package/dist/types/src/extensions/{image.d.ts → markdown/image.d.ts} +0 -0
  158. /package/dist/types/src/extensions/{link.d.ts → markdown/link.d.ts} +0 -0
  159. /package/dist/types/src/extensions/{table.d.ts → markdown/table.d.ts} +0 -0
  160. /package/src/extensions/{image.ts → markdown/image.ts} +0 -0
  161. /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, Cursor } from '../../extensions';
21
- import { type CommentRange, type Range, useTextModel } from '../../hooks';
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
- commentSelected?: string;
32
- commentRanges: CommentRange[];
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
- }> = ({ item, commentSelected, commentRanges, onCreateComment, onDeleteComment, onUpdateComment, onSelectComment }) => {
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 editorRef = useRef<EditorView>(null);
47
+ const view = useRef<EditorView>(null);
40
48
  const [selected, setSelected] = useState<string>();
41
49
  useEffect(() => {
42
- if (!editorRef.current?.hasFocus && commentSelected !== selected) {
43
- const thread = commentRanges.find((range) => range.id === commentSelected);
44
- if (thread) {
45
- const { cursor } = thread;
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, commentSelected]);
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={editorRef} model={model} comments={commentRanges} extensions={extensions} />
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
- range: CommentRange;
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.deleted && 'opacity-50',
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.selection?.from}</span>
138
- <span>to:{thread.selection?.to}</span>
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.deleted && <Trash />}
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()} className='flex'>
153
+ <div ref={containerRef} className='flex' onClick={() => onSelect()}>
152
154
  <TextEditor
153
155
  ref={editorRef}
154
- autofocus={focus}
156
+ autoFocus={focus}
155
157
  model={model}
156
158
  placeholder={'Enter comment...'}
157
- slots={{ root: { className: 'grow p-2 rounded-b' } }}
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
- range: { id, cursor },
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.deleted = true;
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.range.cursor = cursor;
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'] = ({ active, closest, ranges }) => {
275
- log.info('select', { active: active?.slice(0, 4), closest: closest?.slice(0, 4) });
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 range = ranges.find((range) => range.id === thread.range.id);
279
- if (range) {
280
- thread.yPos = range.location ? Math.floor(range.location.top) : undefined;
281
- thread.selection = { from: range.from, to: range.to };
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(active ?? closest);
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
- commentSelected={selected}
307
- commentRanges={commentRanges}
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
- defaultKeymap: false,
38
+ activateOnTyping: true,
39
39
 
40
- // Don't start unless key press.
41
- activateOnTyping: false,
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
+ });
@@ -0,0 +1,13 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { expect } from 'chai';
6
+
7
+ import { describe, test } from '@dxos/test';
8
+
9
+ describe('Test', () => {
10
+ test('checks sanity', async () => {
11
+ expect(true).to.be.true;
12
+ });
13
+ });
@@ -4,14 +4,14 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
- import { StateField, type Extension, type Transaction } from '@codemirror/state';
8
- import { ViewPlugin, type EditorView, type PluginValue, type ViewUpdate } from '@codemirror/view';
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 { effectType, type IDocHandle, isReconcileTx, semaphoreFacet, type Value } from './defs';
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 stateField: StateField<Value> = StateField.define({
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: Value, tr: Transaction) => {
32
- const result: Value = {
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(effectType)) {
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 (!isReconcileTx(tr)) {
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, stateField);
58
+ const semaphore = new PatchSemaphore(handle, syncState);
59
59
 
60
- const viewPlugin = ViewPlugin.fromClass(
61
- class AutomergeViewPlugin implements PluginValue {
62
- constructor(private readonly _view: EditorView) {
63
- handle.addListener('change', this._handleChange);
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
- update(update: ViewUpdate) {
67
- if (update.transactions.length > 0 && update.transactions.some((tr) => !isReconcileTx(tr))) {
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
- destroy() {
75
- handle.addListener('change', this._handleChange);
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
- private _handleChange = () => {
79
- this._view.state.facet(semaphoreFacet).reconcile(this._view);
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 automerge } from '@dxos/automerge/automerge';
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 automerge.getCursor(doc, path.slice(), pos);
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 automerge.getCursorPosition(doc, path.slice(), cursor);
50
+ return A.getCursorPosition(doc, path.slice(), cursor);
51
51
  },
52
52
  });