@dxos/react-ui-editor 0.3.11-main.00a28cb → 0.3.11-main.08776d7

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 (45) hide show
  1. package/dist/lib/browser/index.mjs +258 -252
  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/TextEditor.d.ts +4 -2
  5. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
  7. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  8. package/dist/types/src/extensions/automerge/defs.d.ts +5 -7
  9. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  10. package/dist/types/src/extensions/automerge/semaphore.d.ts +5 -4
  11. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  12. package/dist/types/src/extensions/comments.d.ts +12 -12
  13. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  14. package/dist/types/src/extensions/cursor.d.ts +3 -3
  15. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  16. package/dist/types/src/extensions/index.d.ts +1 -0
  17. package/dist/types/src/extensions/index.d.ts.map +1 -1
  18. package/dist/types/src/extensions/sync.d.ts +7 -0
  19. package/dist/types/src/extensions/sync.d.ts.map +1 -0
  20. package/dist/types/src/hooks/automerge.d.ts +2 -1
  21. package/dist/types/src/hooks/automerge.d.ts.map +1 -1
  22. package/dist/types/src/hooks/defs.d.ts +28 -0
  23. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  24. package/dist/types/src/hooks/index.d.ts +1 -0
  25. package/dist/types/src/hooks/index.d.ts.map +1 -1
  26. package/dist/types/src/hooks/useTextModel.d.ts +2 -27
  27. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  28. package/dist/types/src/hooks/yjs.d.ts +2 -1
  29. package/dist/types/src/hooks/yjs.d.ts.map +1 -1
  30. package/package.json +23 -23
  31. package/src/components/TextEditor/MarkdownEditor.stories.tsx +5 -5
  32. package/src/components/TextEditor/TextEditor.tsx +46 -38
  33. package/src/components/TextEditor/comments.stories.tsx +35 -28
  34. package/src/extensions/automerge/automerge.ts +10 -39
  35. package/src/extensions/automerge/defs.ts +8 -13
  36. package/src/extensions/automerge/semaphore.ts +14 -7
  37. package/src/extensions/comments.ts +137 -126
  38. package/src/extensions/cursor.ts +7 -3
  39. package/src/extensions/index.ts +1 -0
  40. package/src/extensions/sync.ts +15 -0
  41. package/src/hooks/automerge.ts +2 -1
  42. package/src/hooks/defs.ts +47 -0
  43. package/src/hooks/index.ts +1 -0
  44. package/src/hooks/useTextModel.ts +2 -45
  45. package/src/hooks/yjs.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-editor",
3
- "version": "0.3.11-main.00a28cb",
3
+ "version": "0.3.11-main.08776d7",
4
4
  "description": "Document editing experience within a DXOS shell.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -38,20 +38,20 @@
38
38
  "y-codemirror.next": "^0.3.2",
39
39
  "y-protocols": "^1.0.5",
40
40
  "yjs": "^13.6.10",
41
- "@dxos/async": "0.3.11-main.00a28cb",
42
- "@dxos/context": "0.3.11-main.00a28cb",
43
- "@dxos/automerge": "0.3.11-main.00a28cb",
44
- "@dxos/display-name": "0.3.11-main.00a28cb",
45
- "@dxos/echo-schema": "0.3.11-main.00a28cb",
46
- "@dxos/invariant": "0.3.11-main.00a28cb",
47
- "@dxos/debug": "0.3.11-main.00a28cb",
48
- "@dxos/log": "0.3.11-main.00a28cb",
49
- "@dxos/protocols": "0.3.11-main.00a28cb",
50
- "@dxos/react-async": "0.3.11-main.00a28cb",
51
- "@dxos/react-ui": "0.3.11-main.00a28cb",
52
- "@dxos/react-ui-theme": "0.3.11-main.00a28cb",
53
- "@dxos/text-model": "0.3.11-main.00a28cb",
54
- "@dxos/util": "0.3.11-main.00a28cb"
41
+ "@dxos/async": "0.3.11-main.08776d7",
42
+ "@dxos/context": "0.3.11-main.08776d7",
43
+ "@dxos/debug": "0.3.11-main.08776d7",
44
+ "@dxos/automerge": "0.3.11-main.08776d7",
45
+ "@dxos/display-name": "0.3.11-main.08776d7",
46
+ "@dxos/echo-schema": "0.3.11-main.08776d7",
47
+ "@dxos/invariant": "0.3.11-main.08776d7",
48
+ "@dxos/log": "0.3.11-main.08776d7",
49
+ "@dxos/protocols": "0.3.11-main.08776d7",
50
+ "@dxos/react-async": "0.3.11-main.08776d7",
51
+ "@dxos/text-model": "0.3.11-main.08776d7",
52
+ "@dxos/react-ui": "0.3.11-main.08776d7",
53
+ "@dxos/react-ui-theme": "0.3.11-main.08776d7",
54
+ "@dxos/util": "0.3.11-main.08776d7"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@automerge/automerge-repo-network-broadcastchannel": "^1.0.19",
@@ -67,19 +67,19 @@
67
67
  "deepsignal": "1.4.0-shallow.0",
68
68
  "react": "^18.2.0",
69
69
  "react-dom": "^18.2.0",
70
- "@dxos/config": "0.3.11-main.00a28cb",
71
- "@dxos/echo-signals": "0.3.11-main.00a28cb",
72
- "@dxos/echo-typegen": "0.3.11-main.00a28cb",
73
- "@dxos/keyboard": "0.3.11-main.00a28cb",
74
- "@dxos/react-client": "0.3.11-main.00a28cb",
75
- "@dxos/react-ui": "0.3.11-main.00a28cb",
76
- "@dxos/storybook-utils": "0.3.11-main.00a28cb"
70
+ "@dxos/config": "0.3.11-main.08776d7",
71
+ "@dxos/echo-signals": "0.3.11-main.08776d7",
72
+ "@dxos/echo-typegen": "0.3.11-main.08776d7",
73
+ "@dxos/react-ui": "0.3.11-main.08776d7",
74
+ "@dxos/react-client": "0.3.11-main.08776d7",
75
+ "@dxos/keyboard": "0.3.11-main.08776d7",
76
+ "@dxos/storybook-utils": "0.3.11-main.08776d7"
77
77
  },
78
78
  "peerDependencies": {
79
79
  "@phosphor-icons/react": "^2.0.5",
80
80
  "react": "^18.2.0",
81
81
  "react-dom": "^18.2.0",
82
- "@dxos/react-client": "0.3.11-main.00a28cb"
82
+ "@dxos/react-client": "0.3.11-main.08776d7"
83
83
  },
84
84
  "publishConfig": {
85
85
  "access": "public"
@@ -35,7 +35,7 @@ import {
35
35
  type LinkOptions,
36
36
  outliner,
37
37
  } from '../../extensions';
38
- import { type CommentRange, useTextModel } from '../../hooks';
38
+ import { type Comment, useTextModel } from '../../hooks';
39
39
 
40
40
  // Extensions:
41
41
  // TODO(burdon): Table of contents.
@@ -347,7 +347,7 @@ export const Mention = {
347
347
 
348
348
  export const Comments = {
349
349
  render: () => {
350
- const [commentRanges, setCommentRanges] = useState<CommentRange[]>([]);
350
+ const [commentRanges, setCommentRanges] = useState<Comment[]>([]);
351
351
 
352
352
  return (
353
353
  <Story
@@ -367,9 +367,9 @@ export const Comments = {
367
367
  console.log(
368
368
  'update',
369
369
  JSON.stringify({
370
- active: state.active?.slice(0, 8),
371
- closest: state.closest?.slice(0, 8),
372
- ranges: state.ranges.length,
370
+ comments: state.comments.length,
371
+ active: state.selection.active?.slice(0, 8),
372
+ closest: state.selection.closest?.slice(0, 8),
373
373
  }),
374
374
  );
375
375
  }
@@ -22,8 +22,8 @@ import { generateName } from '@dxos/display-name';
22
22
  import { useThemeContext } from '@dxos/react-ui';
23
23
  import { getColorForValue, inputSurface, mx } from '@dxos/react-ui-theme';
24
24
 
25
- import { basicBundle, markdownBundle, setCommentRange } from '../../extensions';
26
- import { type CommentRange, type EditorModel } from '../../hooks';
25
+ import { basicBundle, markdownBundle, setComments, syncFacet } from '../../extensions';
26
+ import { type Comment, type EditorModel } from '../../hooks';
27
27
  import { type ThemeStyles } from '../../styles';
28
28
  import { defaultTheme, markdownTheme, textTheme } from '../../themes';
29
29
 
@@ -41,9 +41,6 @@ export type CursorInfo = {
41
41
 
42
42
  export type TextEditorSlots = {
43
43
  root?: Omit<ComponentProps<'div'>, 'ref'>;
44
- // editor?: {
45
- // className?: string;
46
- // };
47
44
  };
48
45
 
49
46
  // TODO(burdon): Spellcheck?
@@ -52,7 +49,7 @@ export type TextEditorProps = {
52
49
  autofocus?: boolean;
53
50
  readonly?: boolean; // TODO(burdon): Move into model.
54
51
  selection?: { anchor: number; head?: number };
55
- comments?: CommentRange[]; // TODO(burdon): Move into extension?
52
+ comments?: Comment[]; // TODO(burdon): Move into extension?
56
53
  extensions?: Extension[];
57
54
  editorMode?: EditorMode;
58
55
  placeholder?: string;
@@ -60,6 +57,31 @@ export type TextEditorProps = {
60
57
  slots?: TextEditorSlots;
61
58
  };
62
59
 
60
+ // TODO(burdon): Factor out?
61
+ export const useComments = (view?: EditorView | null, comments?: Comment[]) => {
62
+ useEffect(() => {
63
+ if (view && comments !== undefined) {
64
+ view.dispatch({
65
+ effects: setComments.of(comments),
66
+ });
67
+ }
68
+ }, [view, comments]);
69
+ };
70
+
71
+ // TODO(burdon): Factor out?
72
+ export const useAwareness = ({ awareness, peer }: EditorModel) => {
73
+ const { themeMode } = useThemeContext();
74
+ useEffect(() => {
75
+ if (awareness && peer) {
76
+ awareness.setLocalStateField('user', {
77
+ name: peer.name ?? generateName(peer.id),
78
+ color: getColorForValue({ value: peer.id, type: 'color' }),
79
+ colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
80
+ });
81
+ }
82
+ }, [awareness, peer, themeMode]);
83
+ };
84
+
63
85
  /**
64
86
  * Base text editor.
65
87
  */
@@ -77,32 +99,18 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
77
99
  // NOTE: This does not cause the parent to re-render, so the ref is not available immediately.
78
100
  useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
79
101
 
102
+ // Focus.
80
103
  useEffect(() => {
81
104
  if (autofocus) {
82
105
  view?.focus();
83
106
  }
84
- }, [autofocus, view]);
107
+ }, [view, autofocus]);
85
108
 
86
109
  // TODO(burdon): Factor out as extension/hook.
87
- const { awareness, peer } = model;
88
- useEffect(() => {
89
- if (awareness && peer) {
90
- awareness.setLocalStateField('user', {
91
- name: peer.name ?? generateName(peer.id),
92
- color: getColorForValue({ value: peer.id, type: 'color' }),
93
- colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
94
- });
95
- }
96
- }, [awareness, peer, themeMode]);
110
+ useAwareness(model);
97
111
 
98
112
  // TODO(burdon): Factor out as extension/hook.
99
- useEffect(() => {
100
- if (view && comments !== undefined) {
101
- view.dispatch({
102
- effects: setCommentRange.of({ model, comments }),
103
- });
104
- }
105
- }, [view, comments]);
113
+ useComments(view, comments);
106
114
 
107
115
  useEffect(() => {
108
116
  if (!model || !rootRef.current) {
@@ -117,16 +125,16 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
117
125
  extensions: [
118
126
  readonly && EditorState.readOnly.of(readonly),
119
127
 
120
- // TODO(burdon): Factor out VIM mode? (manage via MarkdownPlugin).
121
- editorMode === 'vim' && vim(),
122
-
123
128
  // Theme.
124
129
  // TODO(burdon): Make theme configurable.
125
130
  EditorView.baseTheme(defaultTheme),
126
131
  EditorView.theme(theme ?? {}),
127
132
  EditorView.darkTheme.of(themeMode === 'dark'),
128
133
 
129
- // Storage and replication.
134
+ // TODO(burdon): Factor out VIM mode? (manage via MarkdownPlugin).
135
+ editorMode === 'vim' && vim(),
136
+
137
+ // Storage and replication (NOTE: must come before other extensions).
130
138
  model.extension,
131
139
 
132
140
  // Custom.
@@ -143,9 +151,12 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
143
151
  state,
144
152
  // NOTE: Uncomment to spy on all transactions.
145
153
  // https://codemirror.net/docs/ref/#view.EditorView.dispatch
146
- // dispatch: (transaction, view) => {
147
- // view.update([transaction]);
148
- // },
154
+ dispatch: (transaction, view) => {
155
+ view.update([transaction]);
156
+
157
+ // TODO(burdon): Is there a place to register this?
158
+ view.state.facet(syncFacet)?.reconcile(view);
159
+ },
149
160
  });
150
161
 
151
162
  setView(newView);
@@ -155,18 +166,15 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
155
166
  };
156
167
  }, [rootRef, model, readonly, editorMode, themeMode]);
157
168
 
169
+ // TODO(burdon): Replace with keymap?
158
170
  const handleKeyUp = useCallback(
159
171
  (event: KeyboardEvent) => {
160
172
  const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
161
173
  switch (key) {
162
- // TODO(burdon): Is this required (for vim mode?)
163
- // case 'Enter': {
164
- // view?.contentDOM.focus();
165
- // break;
166
- // }
167
-
168
174
  case 'Escape': {
169
- editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey) && rootRef.current?.focus();
175
+ if (editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)) {
176
+ rootRef.current?.focus();
177
+ }
170
178
  break;
171
179
  }
172
180
  }
@@ -18,7 +18,7 @@ import { withTheme } from '@dxos/storybook-utils';
18
18
 
19
19
  import { MarkdownEditor, TextEditor } from './TextEditor';
20
20
  import { comments, type CommentsOptions, Cursor } from '../../extensions';
21
- import { type CommentRange, type Range, useTextModel } from '../../hooks';
21
+ import { type Comment, type Range, useTextModel } from '../../hooks';
22
22
 
23
23
  faker.seed(101);
24
24
 
@@ -28,31 +28,39 @@ 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
47
  const editorRef = 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);
50
+ if (!editorRef.current?.hasFocus && selectedValue !== selected) {
51
+ const thread = commentRanges.find((range) => range.id === selectedValue);
44
52
  if (thread) {
45
53
  const { cursor } = thread;
46
- const range = Cursor.getRangeFromCursor(editorRef.current!.state.facet(Cursor.converter), cursor);
54
+ const range = cursor && Cursor.getRangeFromCursor(editorRef.current!.state, cursor);
47
55
  if (range) {
48
56
  // TODO(burdon): Scroll selection to center of screen?
49
57
  editorRef.current?.dispatch({ selection: { anchor: range.from }, scrollIntoView: true });
50
58
  }
51
59
  }
52
60
 
53
- setSelected(commentSelected);
61
+ setSelected(selectedValue);
54
62
  }
55
- }, [selected, commentRanges, commentSelected]);
63
+ }, [selected, commentRanges, selectedValue]);
56
64
 
57
65
  const extensions = useMemo(() => {
58
66
  return [
@@ -83,11 +91,10 @@ const Editor: FC<{
83
91
 
84
92
  type CommentThread = {
85
93
  id: string;
86
- range: CommentRange;
94
+ cursor?: string;
95
+ range?: Range;
87
96
  yPos?: number;
88
- selection?: Range;
89
97
  messages: TextObject[];
90
- deleted?: boolean;
91
98
  };
92
99
 
93
100
  const Thread: FC<{
@@ -129,16 +136,16 @@ const Thread: FC<{
129
136
  className={mx(
130
137
  'flex flex-col m-1 rounded shadow divide-y bg-white',
131
138
  selected && 'ring',
132
- thread.deleted && 'opacity-50',
139
+ !thread.cursor && 'opacity-50',
133
140
  )}
134
141
  >
135
142
  <div className='flex p-2 gap-2 items-center text-xs font-mono text-neutral-500 font-thin'>
136
143
  <span>id:{thread.id.slice(0, 4)}</span>
137
- <span>from:{thread.selection?.from}</span>
138
- <span>to:{thread.selection?.to}</span>
144
+ <span>from:{thread.range?.from}</span>
145
+ <span>to:{thread.range?.to}</span>
139
146
  <span>y:{thread.yPos}</span>
140
147
  <span className='grow' />
141
- {thread.deleted && <Trash />}
148
+ {!thread.cursor && <Trash />}
142
149
  </div>
143
150
 
144
151
  {thread.messages.map((message, i) => (
@@ -218,9 +225,10 @@ type StoryProps = {
218
225
  const Story = ({ text, autoCreate }: StoryProps) => {
219
226
  const [item] = useState({ text: new TextObject(text) });
220
227
  const [threads, setThreads] = useState<CommentThread[]>([]);
221
- const commentRanges = useMemo(() => threads.map((thread) => thread.range), [threads]);
222
228
  const [selected, setSelected] = useState<string>();
223
229
 
230
+ const comments = useMemo<Comment[]>(() => threads.map(({ id, cursor }) => ({ id, cursor })), [threads]);
231
+
224
232
  // Filter by visibility.
225
233
  const visibleThreads = useMemo(() => threads.filter((thread) => thread.yPos !== undefined), [threads]);
226
234
 
@@ -231,7 +239,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
231
239
  ...threads,
232
240
  {
233
241
  id,
234
- range: { id, cursor },
242
+ cursor,
235
243
  yPos: location ? Math.floor(location.top) : undefined,
236
244
  messages: autoCreate
237
245
  ? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 1, max: 3 } })
@@ -247,7 +255,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
247
255
  setThreads((threads) =>
248
256
  threads.map((thread) => {
249
257
  if (thread.id === id) {
250
- thread.deleted = true;
258
+ thread.cursor = undefined;
251
259
  }
252
260
 
253
261
  return thread;
@@ -262,8 +270,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
262
270
  setThreads((threads) =>
263
271
  threads.map((thread) => {
264
272
  if (thread.id === id) {
265
- thread.range.cursor = cursor;
266
- delete thread.deleted;
273
+ thread.cursor = cursor;
267
274
  }
268
275
 
269
276
  return thread;
@@ -271,14 +278,14 @@ const Story = ({ text, autoCreate }: StoryProps) => {
271
278
  );
272
279
  };
273
280
 
274
- const handleSelectComment: CommentsOptions['onSelect'] = ({ active, closest, ranges }) => {
281
+ const handleSelectComment: CommentsOptions['onSelect'] = ({ comments, selection: { active, closest } }) => {
275
282
  log.info('select', { active: active?.slice(0, 4), closest: closest?.slice(0, 4) });
276
283
  setThreads((threads) =>
277
284
  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 };
285
+ const comment = comments.find(({ comment }) => comment.id === thread.id);
286
+ if (comment) {
287
+ thread.yPos = comment.location ? Math.floor(comment.location.top) : undefined;
288
+ thread.range = { from: comment.range.from, to: comment.range.to };
282
289
  }
283
290
 
284
291
  return thread;
@@ -303,8 +310,8 @@ const Story = ({ text, autoCreate }: StoryProps) => {
303
310
  <div className='flex flex-col h-full w-[600px]'>
304
311
  <Editor
305
312
  item={item}
306
- commentSelected={selected}
307
- commentRanges={commentRanges}
313
+ selected={selected}
314
+ comments={comments}
308
315
  onCreateComment={handleCreateComment}
309
316
  onDeleteComment={handleDeleteComment}
310
317
  onUpdateComment={handleUpdateComment}
@@ -4,16 +4,15 @@
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 { StateField, type Extension } from '@codemirror/state';
9
8
 
10
- import { next as A } from '@dxos/automerge/automerge';
11
- import { type Prop } from '@dxos/automerge/automerge';
9
+ import { type Prop, next as A } from '@dxos/automerge/automerge';
12
10
 
13
11
  import { cursorConverter } from './cursor';
14
- import { effectType, type IDocHandle, isReconcileTx, semaphoreFacet, type Value } from './defs';
12
+ import { effectType, type IDocHandle, isReconcileTx, type Value } from './defs';
15
13
  import { PatchSemaphore } from './semaphore';
16
14
  import { Cursor } from '../cursor';
15
+ import { syncFacet } from '../sync';
17
16
 
18
17
  export type AutomergeOptions = {
19
18
  handle: IDocHandle;
@@ -21,18 +20,19 @@ export type AutomergeOptions = {
21
20
  };
22
21
 
23
22
  export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
24
- const stateField: StateField<Value> = StateField.define({
23
+ // TODO(burdon): Rename and comment.
24
+ const stateField = StateField.define<Value>({
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) => {
31
+ update: (value, tr) => {
32
32
  const result: Value = {
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;
@@ -57,36 +57,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
57
57
 
58
58
  const semaphore = new PatchSemaphore(handle, stateField);
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
- }
65
-
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
- }
72
- }
73
-
74
- destroy() {
75
- handle.addListener('change', this._handleChange);
76
- }
77
-
78
- private _handleChange = () => {
79
- this._view.state.facet(semaphoreFacet).reconcile(this._view);
80
- };
81
- },
82
- );
83
-
84
60
  return {
85
- extension: [
86
- Cursor.converter.of(cursorConverter(handle, path)),
87
- semaphoreFacet.of(semaphore),
88
- stateField,
89
- viewPlugin,
90
- ],
61
+ extension: [Cursor.converter.of(cursorConverter(handle, path)), syncFacet.of(semaphore), stateField],
91
62
  };
92
63
  };
@@ -4,15 +4,14 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
- import { Annotation, StateEffect, type StateField, type EditorState, type Transaction, Facet } from '@codemirror/state';
7
+ import { Annotation, StateEffect, type StateField, type EditorState, type Transaction } from '@codemirror/state';
8
8
 
9
9
  import { type ChangeFn, type ChangeOptions, type Doc, type Heads, type Prop } from '@dxos/automerge/automerge';
10
10
 
11
- import { type PatchSemaphore } from './semaphore';
12
-
11
+ // TODO(burdon): Rename and document.
13
12
  export type Value = {
14
- lastHeads: Heads;
15
13
  path: Prop[];
14
+ lastHeads: Heads;
16
15
  unreconciledTransactions: Transaction[];
17
16
  };
18
17
 
@@ -22,21 +21,17 @@ export type UpdateHeads = {
22
21
 
23
22
  export const effectType = StateEffect.define<UpdateHeads>({});
24
23
 
25
- export const updateHeads = (newHeads: Heads): StateEffect<UpdateHeads> => effectType.of({ newHeads });
26
-
27
- export const getLastHeads = (state: EditorState, field: StateField<Value>): Heads => state.field(field).lastHeads;
24
+ export const reconcileAnnotationType = Annotation.define<unknown>();
28
25
 
29
26
  export const getPath = (state: EditorState, field: StateField<Value>): Prop[] => state.field(field).path;
30
27
 
31
- export const reconcileAnnotationType = Annotation.define<unknown>();
28
+ export const getLastHeads = (state: EditorState, field: StateField<Value>): Heads => state.field(field).lastHeads;
32
29
 
33
- export const isReconcileTx = (tr: Transaction): boolean => !!tr.annotation(reconcileAnnotationType);
30
+ export const updateHeads = (newHeads: Heads): StateEffect<UpdateHeads> => effectType.of({ newHeads });
34
31
 
35
- // TODO(burdon): Rename sync/create generic def (like cursor) to decouple automerge deps.
36
- export const semaphoreFacet = Facet.define<PatchSemaphore, PatchSemaphore>({
37
- combine: (values) => values.at(-1)!, // Take last.
38
- });
32
+ export const isReconcileTx = (tr: Transaction): boolean => !!tr.annotation(reconcileAnnotationType);
39
33
 
34
+ // TODO(burdon): Remove?
40
35
  /*
41
36
  export const makeReconcile = (tr: TransactionSpec) => {
42
37
  if (tr.annotations != null) {
@@ -20,16 +20,23 @@ import {
20
20
  } from './defs';
21
21
  import { updateAutomerge } from './update-automerge';
22
22
  import { updateCodeMirror } from './update-codemirror';
23
+ import { type Syncer } from '../sync';
23
24
 
24
25
  /**
25
- * Implements three-way merge (on each mutation/key-stroke).
26
+ * Implements three-way merge (on each mutation/keystroke).
26
27
  */
27
- export class PatchSemaphore {
28
- _inReconcile = false;
28
+ export class PatchSemaphore implements Syncer {
29
+ private _inReconcile = false;
29
30
 
30
- constructor(private readonly _handle: IDocHandle, private readonly _field: StateField<Value>) {}
31
+ // prettier-ignore
32
+ constructor(
33
+ private readonly _handle: IDocHandle,
34
+ // TODO(burdon): Rename.
35
+ private readonly _field: StateField<Value>
36
+ ) {}
31
37
 
32
- reconcile = (view: EditorView) => {
38
+ // NOTE: Cannot destruct view.state.
39
+ reconcile(view: EditorView) {
33
40
  if (this._inReconcile) {
34
41
  return;
35
42
  }
@@ -58,7 +65,7 @@ export class PatchSemaphore {
58
65
 
59
66
  // NOTE: null and undefined each come from automerge and repo respectively.
60
67
  if (newHeads === null || newHeads === undefined) {
61
- // TODO: @alexjg this is the call that's resetting the editor state on click
68
+ // TODO(alexjg): this is the call that's resetting the editor state on click.
62
69
  newHeads = automerge.getHeads(this._handle.docSync()!);
63
70
  }
64
71
 
@@ -74,5 +81,5 @@ export class PatchSemaphore {
74
81
  });
75
82
 
76
83
  this._inReconcile = false;
77
- };
84
+ }
78
85
  }