@dxos/react-ui-editor 0.3.11-main.08776d7 → 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 (160) hide show
  1. package/dist/lib/browser/index.mjs +1211 -1005
  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 -7
  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 +6 -6
  21. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  22. package/dist/types/src/extensions/automerge/semaphore.d.ts +6 -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 +11 -3
  31. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  32. package/dist/types/src/extensions/index.d.ts +0 -8
  33. package/dist/types/src/extensions/index.d.ts.map +1 -1
  34. package/dist/types/src/extensions/markdown/bundle.d.ts +3 -6
  35. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  36. package/dist/types/src/extensions/markdown/code.d.ts.map +1 -0
  37. package/dist/types/src/extensions/markdown/formatting.d.ts +11 -0
  38. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
  39. package/dist/types/src/extensions/markdown/heading.d.ts +6 -0
  40. package/dist/types/src/extensions/markdown/heading.d.ts.map +1 -0
  41. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  42. package/dist/types/src/extensions/markdown/hr.d.ts.map +1 -0
  43. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -0
  44. package/dist/types/src/extensions/markdown/index.d.ts +8 -0
  45. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  46. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -0
  47. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -0
  48. package/dist/types/src/extensions/markdown/tasklist.d.ts +3 -0
  49. package/dist/types/src/extensions/markdown/tasklist.d.ts.map +1 -0
  50. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  51. package/dist/types/src/hooks/awareness-provider.d.ts +7 -4
  52. package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -1
  53. package/dist/types/src/hooks/defs.d.ts +1 -4
  54. package/dist/types/src/hooks/defs.d.ts.map +1 -1
  55. package/dist/types/src/hooks/index.d.ts +2 -2
  56. package/dist/types/src/hooks/index.d.ts.map +1 -1
  57. package/dist/types/src/hooks/useActionHandler.d.ts +4 -0
  58. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -0
  59. package/dist/types/src/hooks/useTextEditor.d.ts +17 -0
  60. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
  61. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  62. package/dist/types/src/styles/markdown.d.ts +2 -2
  63. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  64. package/dist/types/src/themes/default.d.ts.map +1 -1
  65. package/dist/types/src/util.d.ts +2 -0
  66. package/dist/types/src/util.d.ts.map +1 -1
  67. package/package.json +45 -36
  68. package/src/components/TextEditor/MarkdownEditor.stories.tsx +61 -72
  69. package/src/components/TextEditor/TextEditor.tsx +101 -77
  70. package/src/components/TextEditor/comments.stories.tsx +16 -21
  71. package/src/components/Toolbar/Toolbar.stories.tsx +86 -0
  72. package/src/components/Toolbar/Toolbar.tsx +115 -0
  73. package/src/extensions/autocomplete.ts +6 -3
  74. package/src/extensions/automerge/automerge.spec.tsx +76 -0
  75. package/src/extensions/automerge/automerge.test.ts +13 -0
  76. package/src/extensions/automerge/automerge.ts +53 -13
  77. package/src/extensions/automerge/cursor.ts +3 -3
  78. package/src/extensions/automerge/defs.ts +9 -29
  79. package/src/extensions/automerge/semaphore.ts +24 -28
  80. package/src/extensions/automerge/update-automerge.ts +14 -8
  81. package/src/extensions/automerge/update-codemirror.ts +5 -5
  82. package/src/extensions/awareness.ts +17 -17
  83. package/src/extensions/basic.ts +22 -17
  84. package/src/extensions/comments.ts +149 -92
  85. package/src/extensions/index.ts +0 -8
  86. package/src/extensions/markdown/bundle.ts +20 -30
  87. package/src/extensions/markdown/code.ts +142 -0
  88. package/src/extensions/markdown/formatting.ts +159 -0
  89. package/src/extensions/markdown/heading.ts +59 -0
  90. package/src/extensions/markdown/highlight.ts +9 -15
  91. package/src/extensions/markdown/hr.ts +71 -0
  92. package/src/extensions/markdown/index.ts +8 -0
  93. package/src/extensions/{link.ts → markdown/link.ts} +5 -2
  94. package/src/extensions/{tasklist.ts → markdown/tasklist.ts} +48 -34
  95. package/src/extensions/mention.ts +9 -2
  96. package/src/hooks/awareness-provider.ts +24 -20
  97. package/src/hooks/defs.ts +1 -6
  98. package/src/hooks/index.ts +2 -2
  99. package/src/hooks/useActionHandler.ts +45 -0
  100. package/src/hooks/useTextEditor.ts +29 -0
  101. package/src/hooks/useTextModel.ts +48 -11
  102. package/src/styles/markdown.ts +9 -10
  103. package/src/themes/default.ts +1 -3
  104. package/src/util.ts +26 -3
  105. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
  106. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
  107. package/dist/types/src/components/TextEditor/yjs.stories.d.ts +0 -12
  108. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  109. package/dist/types/src/extensions/code.d.ts.map +0 -1
  110. package/dist/types/src/extensions/hr.d.ts.map +0 -1
  111. package/dist/types/src/extensions/image.d.ts.map +0 -1
  112. package/dist/types/src/extensions/link.d.ts.map +0 -1
  113. package/dist/types/src/extensions/sync.d.ts +0 -7
  114. package/dist/types/src/extensions/sync.d.ts.map +0 -1
  115. package/dist/types/src/extensions/table.d.ts.map +0 -1
  116. package/dist/types/src/extensions/tasklist.d.ts +0 -7
  117. package/dist/types/src/extensions/tasklist.d.ts.map +0 -1
  118. package/dist/types/src/extensions/yjs/cursor.d.ts +0 -4
  119. package/dist/types/src/extensions/yjs/cursor.d.ts.map +0 -1
  120. package/dist/types/src/extensions/yjs/index.d.ts +0 -2
  121. package/dist/types/src/extensions/yjs/index.d.ts.map +0 -1
  122. package/dist/types/src/extensions/yjs/yjs.d.ts +0 -4
  123. package/dist/types/src/extensions/yjs/yjs.d.ts.map +0 -1
  124. package/dist/types/src/extensions/yjs/yjs.test.d.ts +0 -2
  125. package/dist/types/src/extensions/yjs/yjs.test.d.ts.map +0 -1
  126. package/dist/types/src/hooks/automerge.d.ts +0 -4
  127. package/dist/types/src/hooks/automerge.d.ts.map +0 -1
  128. package/dist/types/src/hooks/yjs.d.ts +0 -28
  129. package/dist/types/src/hooks/yjs.d.ts.map +0 -1
  130. package/dist/types/src/testing/index.d.ts +0 -3
  131. package/dist/types/src/testing/index.d.ts.map +0 -1
  132. package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
  133. package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
  134. package/dist/types/src/testing/proto/index.d.ts +0 -2
  135. package/dist/types/src/testing/proto/index.d.ts.map +0 -1
  136. package/dist/types/src/testing/replicator.d.ts +0 -45
  137. package/dist/types/src/testing/replicator.d.ts.map +0 -1
  138. package/src/components/TextEditor/codemirror.stories.ts +0 -115
  139. package/src/components/TextEditor/yjs.stories.tsx +0 -56
  140. package/src/extensions/code.ts +0 -104
  141. package/src/extensions/hr.ts +0 -72
  142. package/src/extensions/sync.ts +0 -15
  143. package/src/extensions/yjs/cursor.ts +0 -22
  144. package/src/extensions/yjs/index.ts +0 -5
  145. package/src/extensions/yjs/yjs.test.ts +0 -35
  146. package/src/extensions/yjs/yjs.ts +0 -16
  147. package/src/hooks/automerge.ts +0 -53
  148. package/src/hooks/yjs.ts +0 -157
  149. package/src/testing/index.ts +0 -6
  150. package/src/testing/proto/gen/schema.ts +0 -49
  151. package/src/testing/proto/index.ts +0 -5
  152. package/src/testing/proto/schema.proto +0 -15
  153. package/src/testing/replicator.ts +0 -184
  154. /package/dist/types/src/extensions/{code.d.ts → markdown/code.d.ts} +0 -0
  155. /package/dist/types/src/extensions/{hr.d.ts → markdown/hr.d.ts} +0 -0
  156. /package/dist/types/src/extensions/{image.d.ts → markdown/image.d.ts} +0 -0
  157. /package/dist/types/src/extensions/{link.d.ts → markdown/link.d.ts} +0 -0
  158. /package/dist/types/src/extensions/{table.d.ts → markdown/table.d.ts} +0 -0
  159. /package/src/extensions/{image.ts → markdown/image.ts} +0 -0
  160. /package/src/extensions/{table.ts → markdown/table.ts} +0 -0
@@ -17,7 +17,7 @@ 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';
20
+ import { comments, type CommentsOptions, setFocus, useComments } from '../../extensions';
21
21
  import { type Comment, type Range, useTextModel } from '../../hooks';
22
22
 
23
23
  faker.seed(101);
@@ -44,24 +44,19 @@ const Editor: FC<{
44
44
  onSelectComment,
45
45
  }) => {
46
46
  const model = useTextModel({ text: item.text });
47
- const editorRef = useRef<EditorView>(null);
47
+ const view = useRef<EditorView>(null);
48
48
  const [selected, setSelected] = useState<string>();
49
49
  useEffect(() => {
50
- if (!editorRef.current?.hasFocus && selectedValue !== selected) {
51
- const thread = commentRanges.find((range) => range.id === selectedValue);
52
- if (thread) {
53
- const { cursor } = thread;
54
- const range = cursor && Cursor.getRangeFromCursor(editorRef.current!.state, cursor);
55
- if (range) {
56
- // TODO(burdon): Scroll selection to center of screen?
57
- editorRef.current?.dispatch({ selection: { anchor: range.from }, scrollIntoView: true });
58
- }
59
- }
60
-
50
+ if (!view.current?.hasFocus && selectedValue !== selected) {
61
51
  setSelected(selectedValue);
52
+ if (selectedValue) {
53
+ setFocus(view.current!, selectedValue);
54
+ }
62
55
  }
63
56
  }, [selected, commentRanges, selectedValue]);
64
57
 
58
+ useComments(view.current, commentRanges);
59
+
65
60
  const extensions = useMemo(() => {
66
61
  return [
67
62
  comments({
@@ -80,7 +75,7 @@ const Editor: FC<{
80
75
  // TODO(burdon): Highlight currently selected comment.
81
76
  return (
82
77
  <div className='flex grow overflow-y-scroll'>
83
- <MarkdownEditor ref={editorRef} model={model} comments={commentRanges} extensions={extensions} />
78
+ <MarkdownEditor ref={view} model={model} extensions={extensions} />
84
79
  </div>
85
80
  );
86
81
  };
@@ -155,13 +150,13 @@ const Thread: FC<{
155
150
  </div>
156
151
  ))}
157
152
 
158
- <div ref={containerRef} onClick={() => onSelect()} className='flex'>
153
+ <div ref={containerRef} className='flex' onClick={() => onSelect()}>
159
154
  <TextEditor
160
155
  ref={editorRef}
161
- autofocus={focus}
156
+ autoFocus={focus}
162
157
  model={model}
163
158
  placeholder={'Enter comment...'}
164
- slots={{ root: { className: 'grow p-2 rounded-b' } }}
159
+ slots={{ root: { className: 'grow rounded-b' } }}
165
160
  extensions={[
166
161
  keymap.of([
167
162
  {
@@ -246,7 +241,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
246
241
  : [],
247
242
  },
248
243
  ]);
249
- setSelected(id);
244
+ setSelected(id); // TODO(burdon): Not required.
250
245
  return id;
251
246
  };
252
247
 
@@ -278,8 +273,8 @@ const Story = ({ text, autoCreate }: StoryProps) => {
278
273
  );
279
274
  };
280
275
 
281
- const handleSelectComment: CommentsOptions['onSelect'] = ({ comments, selection: { active, closest } }) => {
282
- 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) });
283
278
  setThreads((threads) =>
284
279
  threads.map((thread) => {
285
280
  const comment = comments.find(({ comment }) => comment.id === thread.id);
@@ -292,7 +287,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
292
287
  }),
293
288
  );
294
289
 
295
- setSelected(active ?? closest);
290
+ setSelected(current ?? closest);
296
291
  };
297
292
 
298
293
  const handleSelectThread = (id: string) => {
@@ -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,15 +4,16 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
- import { StateField, type Extension } from '@codemirror/state';
7
+ import { invertedEffects } from '@codemirror/commands';
8
+ import { StateField, type Extension, type StateEffect } from '@codemirror/state';
9
+ import { EditorView, ViewPlugin } from '@codemirror/view';
8
10
 
9
- import { type Prop, next as A } from '@dxos/automerge/automerge';
11
+ import { next as A, type Prop } from '@dxos/automerge/automerge';
10
12
 
11
13
  import { cursorConverter } from './cursor';
12
- import { effectType, type IDocHandle, isReconcileTx, type Value } from './defs';
14
+ import { updateHeadsEffect, type IDocHandle, isReconcile, type State } from './defs';
13
15
  import { PatchSemaphore } from './semaphore';
14
16
  import { Cursor } from '../cursor';
15
- import { syncFacet } from '../sync';
16
17
 
17
18
  export type AutomergeOptions = {
18
19
  handle: IDocHandle;
@@ -20,8 +21,7 @@ export type AutomergeOptions = {
20
21
  };
21
22
 
22
23
  export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
23
- // TODO(burdon): Rename and comment.
24
- const stateField = StateField.define<Value>({
24
+ const syncState = StateField.define<State>({
25
25
  create: () => ({
26
26
  path: path.slice(),
27
27
  lastHeads: A.getHeads(handle.docSync()!),
@@ -29,7 +29,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
29
29
  }),
30
30
 
31
31
  update: (value, tr) => {
32
- const result: Value = {
32
+ const result: State = {
33
33
  path: path.slice(),
34
34
  lastHeads: value.lastHeads,
35
35
  unreconciledTransactions: value.unreconciledTransactions.slice(),
@@ -37,7 +37,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
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,9 +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
- return {
61
- extension: [Cursor.converter.of(cursorConverter(handle, path)), syncFacet.of(semaphore), stateField],
62
- };
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
+ }
70
+
71
+ destroy() {
72
+ handle.removeListener('change', this._handleChange.bind(this));
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);
85
+ }
86
+ }),
87
+
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
+ });
98
+ }
99
+
100
+ return effects;
101
+ }),
102
+ ];
63
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
  });
@@ -8,48 +8,28 @@ import { Annotation, StateEffect, type StateField, type EditorState, type Transa
8
8
 
9
9
  import { type ChangeFn, type ChangeOptions, type Doc, type Heads, type Prop } from '@dxos/automerge/automerge';
10
10
 
11
- // TODO(burdon): Rename and document.
12
- export type Value = {
11
+ export type State = {
13
12
  path: Prop[];
14
13
  lastHeads: Heads;
15
14
  unreconciledTransactions: Transaction[];
16
15
  };
17
16
 
17
+ export const getPath = (state: EditorState, field: StateField<State>): Prop[] => state.field(field).path;
18
+ export const getLastHeads = (state: EditorState, field: StateField<State>): Heads => state.field(field).lastHeads;
19
+
18
20
  export type UpdateHeads = {
19
21
  newHeads: Heads;
20
22
  };
21
23
 
22
- export const effectType = StateEffect.define<UpdateHeads>({});
23
-
24
- export const reconcileAnnotationType = Annotation.define<unknown>();
25
-
26
- export const getPath = (state: EditorState, field: StateField<Value>): Prop[] => state.field(field).path;
27
-
28
- export const getLastHeads = (state: EditorState, field: StateField<Value>): Heads => state.field(field).lastHeads;
29
-
30
- export const updateHeads = (newHeads: Heads): StateEffect<UpdateHeads> => effectType.of({ newHeads });
24
+ export const updateHeadsEffect = StateEffect.define<UpdateHeads>({});
31
25
 
32
- export const isReconcileTx = (tr: Transaction): boolean => !!tr.annotation(reconcileAnnotationType);
26
+ export const updateHeads = (newHeads: Heads): StateEffect<UpdateHeads> => updateHeadsEffect.of({ newHeads });
33
27
 
34
- // TODO(burdon): Remove?
35
- /*
36
- export const makeReconcile = (tr: TransactionSpec) => {
37
- if (tr.annotations != null) {
38
- if (tr.annotations instanceof Array) {
39
- tr.annotations = [...tr.annotations, reconcileAnnotationType.of({})];
40
- } else {
41
- tr.annotations = [tr.annotations, reconcileAnnotationType.of({})];
42
- }
43
- } else {
44
- tr.annotations = [reconcileAnnotationType.of({})];
45
- }
28
+ export const reconcileAnnotation = Annotation.define<boolean>();
46
29
 
47
- // return {
48
- // ...tr,
49
- // annotations: reconcileAnnotationType.of({})
50
- // }
30
+ export const isReconcile = (tr: Transaction): boolean => {
31
+ return !!tr.annotation(reconcileAnnotation);
51
32
  };
52
- */
53
33
 
54
34
  export type IDocHandle<T = any> = {
55
35
  docSync(): Doc<T> | undefined;