@dxos/react-ui-editor 0.3.11-main.00a28cb → 0.3.11-main.0252f93

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
@@ -2,17 +2,17 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type Extension, StateEffect, StateField, type Text } from '@codemirror/state';
5
+ import { invertedEffects } from '@codemirror/commands';
6
+ import { type Extension, Facet, StateEffect, StateField, type Text, type ChangeDesc } from '@codemirror/state';
6
7
  import { hoverTooltip, keymap, type Command, Decoration, EditorView, type Rect } from '@codemirror/view';
7
8
  import sortBy from 'lodash.sortby';
9
+ import { useEffect } from 'react';
8
10
 
9
11
  import { debounce } from '@dxos/async';
10
- import { invariant } from '@dxos/invariant';
11
12
  import { nonNullable } from '@dxos/util';
12
13
 
13
- import { semaphoreFacet } from './automerge/defs';
14
14
  import { Cursor } from './cursor';
15
- import { type CommentRange, type EditorModel, type Range } from '../hooks';
15
+ import { type Comment, type Range } from '../hooks';
16
16
  import { getToken } from '../styles';
17
17
  import { callbackWrapper } from '../util';
18
18
 
@@ -21,49 +21,52 @@ const styles = EditorView.baseTheme({
21
21
  '& .cm-comment': {
22
22
  backgroundColor: getToken('extend.colors.yellow.50'),
23
23
  },
24
- '& .cm-comment-active': {
24
+ '& .cm-comment-current': {
25
25
  backgroundColor: getToken('extend.colors.yellow.100'),
26
26
  },
27
27
  });
28
28
 
29
- const marks = {
30
- highlight: Decoration.mark({ class: 'cm-comment' }),
31
- highlightActive: Decoration.mark({ class: 'cm-comment-active' }),
29
+ const commentMark = Decoration.mark({ class: 'cm-comment' });
30
+ const commentCurrentMark = Decoration.mark({ class: 'cm-comment-current' });
31
+
32
+ type CommentState = {
33
+ comment: Comment;
34
+ range: Range;
35
+ location?: Rect | null;
32
36
  };
33
37
 
34
- // TODO(burdon): Should this be part of state?
35
- type CommentSelected = {
36
- active?: string;
38
+ type SelectionState = {
39
+ current?: string;
37
40
  closest?: string;
38
41
  };
39
42
 
40
- // TODO(burdon): Rename.
41
- type ExtendedCommentRange = Range &
42
- CommentRange & {
43
- // TODO(burdon): Not part of state; just required for callback.
44
- location?: Rect | null;
45
- };
46
-
47
- export type CommentsState = CommentSelected & {
48
- ranges: ExtendedCommentRange[];
43
+ export type CommentsState = {
44
+ comments: CommentState[];
45
+ selection: SelectionState;
49
46
  };
50
47
 
51
- export const setFocus = (view: EditorView, thread: string) => {
52
- const range = view.state.field(commentsStateField).ranges.find((range) => range.id === thread);
53
- if (!range) {
48
+ export const setFocus = (view: EditorView, id: string, center = true) => {
49
+ const comment = view.state.field(commentsState).comments.find((range) => range.comment.id === id);
50
+ if (!comment?.comment.cursor) {
54
51
  return;
55
52
  }
56
53
 
57
- view.dispatch({
58
- effects: setSelection.of({ active: thread }),
59
- selection: { anchor: range.from },
60
- scrollIntoView: true, // TODO(burdon): Scroll to y-position (or center of screen?)
61
- });
54
+ const range = Cursor.getRangeFromCursor(view.state, comment.comment.cursor);
55
+ if (range) {
56
+ view.dispatch({
57
+ selection: { anchor: range.from },
58
+ effects: [
59
+ //
60
+ EditorView.scrollIntoView(range.from, center ? { y: 'center' } : undefined),
61
+ setSelection.of({ current: id }),
62
+ ],
63
+ });
64
+ }
62
65
  };
63
66
 
64
- export const setCommentRange = StateEffect.define<{ model: EditorModel; comments: CommentRange[] }>();
67
+ export const setComments = StateEffect.define<Comment[]>();
65
68
 
66
- export const setSelection = StateEffect.define<CommentSelected>();
69
+ export const setSelection = StateEffect.define<SelectionState>();
67
70
 
68
71
  const setCommentState = StateEffect.define<CommentsState>();
69
72
 
@@ -71,29 +74,30 @@ const setCommentState = StateEffect.define<CommentsState>();
71
74
  * State field (reducer) that tracks comment ranges.
72
75
  * The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
73
76
  */
74
- const commentsStateField = StateField.define<CommentsState>({
75
- create: () => ({ ranges: [] }),
77
+ const commentsState = StateField.define<CommentsState>({
78
+ create: () => ({ comments: [], selection: {} }),
76
79
  update: (value, tr) => {
77
- const cursorConverter = tr.state.facet(Cursor.converter);
78
-
79
80
  for (const effect of tr.effects) {
80
81
  // Update selection.
81
82
  if (effect.is(setSelection)) {
82
- return { ...value, ...effect.value };
83
+ return { ...value, selection: effect.value };
83
84
  }
84
85
 
85
86
  // Update range from store.
86
- if (effect.is(setCommentRange)) {
87
- const { comments } = effect.value;
88
- const ranges: ExtendedCommentRange[] = comments
87
+ if (effect.is(setComments)) {
88
+ const comments: CommentState[] = effect.value
89
89
  .map((comment) => {
90
- const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
91
- // console.log('update', JSON.stringify({ range, cursor: comment.cursor }, undefined, 2));
92
- return range && { ...comment, ...range };
90
+ // Skip cut/deleted comments.
91
+ if (!comment.cursor) {
92
+ return undefined;
93
+ }
94
+
95
+ const range = Cursor.getRangeFromCursor(tr.state, comment.cursor);
96
+ return range && { comment, range };
93
97
  })
94
98
  .filter(nonNullable);
95
99
 
96
- return { ...value, ranges };
100
+ return { ...value, comments };
97
101
  }
98
102
 
99
103
  // Update entire state.
@@ -109,24 +113,27 @@ const commentsStateField = StateField.define<CommentsState>({
109
113
  /**
110
114
  * Decorate ranges.
111
115
  */
112
- const highlightDecorations = EditorView.decorations.compute([commentsStateField], (state) => {
113
- const { active, ranges } = state.field(commentsStateField);
114
- const decorations =
115
- sortBy(ranges ?? [], (range) => range.from)
116
- ?.flatMap((selection) => {
117
- // TODO(burdon): Invalid range (e.g., deleted).
118
- if (selection.from === selection.to) {
119
- return undefined;
120
- }
116
+ const commentsDecorations = EditorView.decorations.compute([commentsState], (state) => {
117
+ const {
118
+ selection: { current },
119
+ comments,
120
+ } = state.field(commentsState);
121
+
122
+ const decorations = sortBy(comments ?? [], (range) => range.range.from)
123
+ ?.flatMap((comment) => {
124
+ const range = comment.range;
125
+ if (!range || range.from === range.to) {
126
+ console.warn('Invalid range:', range);
127
+ return undefined;
128
+ }
121
129
 
122
- const range = { from: selection.from, to: selection.to };
123
- if (selection.id === active) {
124
- return marks.highlightActive.range(range.from, range.to);
125
- } else {
126
- return marks.highlight.range(range.from, range.to);
127
- }
128
- })
129
- .filter(nonNullable) ?? [];
130
+ if (comment.comment.id === current) {
131
+ return commentCurrentMark.range(range.from, range.to);
132
+ } else {
133
+ return commentMark.range(range.from, range.to);
134
+ }
135
+ })
136
+ .filter(nonNullable);
130
137
 
131
138
  return Decoration.set(decorations);
132
139
  });
@@ -158,18 +165,18 @@ export type CommentsOptions = {
158
165
  onHover?: (el: Element, shortcut: string) => void;
159
166
  };
160
167
 
161
- // TODO(burdon): Handle cut/restore via undo (need to integrate with history?)
168
+ type TrackedComment = { id: string; from: number; to: number };
169
+
162
170
  const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
163
- // Cut/deleted comments.
164
171
  // Tracks indexed selections within text.
165
- let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
172
+ let tracked: { text: Text; comments: TrackedComment[] } | null = null;
166
173
 
167
174
  // Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
168
175
  const handleTrack = (event: Event, view: EditorView) => {
169
- const comments = view.state.field(commentsStateField);
176
+ const comments = view.state.field(commentsState);
170
177
  const { main } = view.state.selection;
171
- const selectedRanges = comments.ranges.filter(
172
- (range) => range.from >= main.from && range.to <= main.to && range.from < range.to,
178
+ const selectedRanges = comments.comments.filter(
179
+ ({ range }) => range.from >= main.from && range.to <= main.to && range.from < range.to,
173
180
  );
174
181
 
175
182
  if (!selectedRanges.length) {
@@ -177,15 +184,13 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
177
184
  } else {
178
185
  tracked = {
179
186
  text: view.state.doc.slice(main.from, main.to),
180
- comments: selectedRanges.map((range) => ({
181
- id: range.id,
187
+ comments: selectedRanges.map(({ comment, range }) => ({
188
+ id: comment.id,
182
189
  from: range.from - main.from,
183
190
  to: range.to - main.from,
184
191
  })),
185
192
  };
186
193
  }
187
-
188
- // console.log('track', JSON.stringify({ tracked }, undefined, 2));
189
194
  };
190
195
 
191
196
  return [
@@ -194,16 +199,48 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
194
199
  copy: handleTrack,
195
200
  }),
196
201
 
197
- // Handle paste.
198
- EditorView.updateListener.of(({ view, state, transactions }) => {
202
+ // Track deleted comments.
203
+ invertedEffects.of((tr) => {
204
+ const { comments } = tr.startState.field(commentsState);
205
+ const effects: StateEffect<any>[] = [];
206
+ tr.changes.iterChangedRanges((fromA, toA) => {
207
+ for (const {
208
+ comment: { id },
209
+ range: { from, to },
210
+ } of comments) {
211
+ if (from < to && from >= fromA && to <= toA) {
212
+ effects.push(restoreCommentEffect.of({ id, from, to }));
213
+ }
214
+ }
215
+ });
216
+
217
+ return effects;
218
+ }),
219
+
220
+ // Handle paste or the undo of comment deletion.
221
+ EditorView.updateListener.of((update) => {
222
+ const restore: TrackedComment[] = [];
223
+
224
+ for (let i = 0; i < update.transactions.length; i++) {
225
+ const tr = update.transactions[i];
226
+ for (let j = 0; j < restore.length; j++) {
227
+ restore[j] = mapTrackedComment(restore[j], tr.changes);
228
+ }
229
+ for (const effect of tr.effects) {
230
+ if (effect.is(restoreCommentEffect)) {
231
+ restore.push(effect.value);
232
+ }
233
+ }
234
+ }
235
+
199
236
  if (tracked) {
200
- const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
237
+ const paste = update.transactions.find((tr) => tr.isUserEvent('input.paste'));
201
238
  if (paste) {
202
239
  let found = -1;
203
240
  paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
204
241
  if (text.eq(tracked!.text)) {
205
- for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
206
- fromB = transactions[i].changes.mapPos(fromB);
242
+ for (let i = update.transactions.indexOf(paste!) + 1; i < update.transactions.length; i++) {
243
+ fromB = update.transactions[i].changes.mapPos(fromB);
207
244
  }
208
245
 
209
246
  found = fromB;
@@ -211,34 +248,79 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
211
248
  });
212
249
 
213
250
  if (found > -1) {
214
- const comments = tracked.comments;
215
-
216
- // Sync before recomputing cursor/range.
217
- // TODO(burdon): Generalize sync facet to decouple from automerge?
218
- view.state.facet(semaphoreFacet).reconcile(view);
219
-
220
- const active = state.field(commentsStateField).ranges;
221
- for (const moved of comments) {
222
- if (active.some((range) => range.id === moved.id && range.from === range.to)) {
223
- const range = {
224
- from: found + moved.from,
225
- to: found + moved.to,
226
- };
227
-
228
- const cursor = Cursor.getCursorFromRange(state.facet(Cursor.converter), range);
229
- // console.log('paste', JSON.stringify({ moved, range, cursor }, undefined, 2));
230
- onUpdate(moved.id, cursor);
231
- }
251
+ for (const moved of tracked.comments) {
252
+ restore.push({ id: moved.id, from: found + moved.from, to: found + moved.to });
232
253
  }
233
254
  }
234
255
 
235
256
  tracked = null;
236
257
  }
237
258
  }
259
+
260
+ for (const comment of restore) {
261
+ const { comments } = update.startState.field(commentsState);
262
+ const exists = comments.some((c) => c.comment.id === comment.id && c.range.from < c.range.to);
263
+ if (!exists) {
264
+ const cursor = Cursor.getCursorFromRange(update.state, comment);
265
+ onUpdate(comment.id, cursor);
266
+ }
267
+ }
238
268
  }),
239
269
  ];
240
270
  };
241
271
 
272
+ const mapTrackedComment = (comment: TrackedComment, changes: ChangeDesc) => ({
273
+ id: comment.id,
274
+ from: changes.mapPos(comment.from, 1),
275
+ to: changes.mapPos(comment.to, 1),
276
+ });
277
+
278
+ // These are attached to undone/redone transactions in the editor for the purpose of restoring comments
279
+ // that were deleted by the original changes.
280
+ const restoreCommentEffect = StateEffect.define<TrackedComment>({ map: mapTrackedComment });
281
+
282
+ const optionsFacet = Facet.define<CommentsOptions, CommentsOptions>({
283
+ combine: (providers) => providers[0],
284
+ });
285
+
286
+ /**
287
+ * Create comment thread action.
288
+ */
289
+ export const createComment: Command = (view) => {
290
+ const options = view.state.facet(optionsFacet);
291
+ const { from, to } = view.state.selection.main;
292
+ if (from === to) {
293
+ return false;
294
+ }
295
+
296
+ // Don't allow selection at end of document.
297
+ if (to === view.state.doc.length) {
298
+ view.dispatch({
299
+ changes: {
300
+ from: to,
301
+ insert: '\n',
302
+ },
303
+ });
304
+ }
305
+
306
+ const cursor = Cursor.getCursorFromRange(view.state, { from, to });
307
+ if (cursor) {
308
+ // Create thread via callback.
309
+ const id = options.onCreate?.(cursor, view.coordsAtPos(from));
310
+ if (id) {
311
+ // Update range.
312
+ view.dispatch({
313
+ effects: setSelection.of({ current: id }),
314
+ selection: { anchor: to },
315
+ });
316
+
317
+ return true;
318
+ }
319
+ }
320
+
321
+ return false;
322
+ };
323
+
242
324
  /**
243
325
  * Comment threads.
244
326
  * 1). Updates the EditorModel to store relative selections for a set of comments threads.
@@ -255,39 +337,10 @@ export const comments = (options: CommentsOptions = {}): Extension => {
255
337
 
256
338
  const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
257
339
 
258
- /**
259
- * Create comment thread action.
260
- */
261
- const createCommentThread: Command = (view) => {
262
- const cursorConverter = view.state.facet(Cursor.converter);
263
-
264
- invariant(options.onCreate);
265
- const { from, to } = view.state.selection.main;
266
- if (from === to) {
267
- return false;
268
- }
269
-
270
- const cursor = Cursor.getCursorFromRange(cursorConverter, { from, to });
271
- if (cursor) {
272
- // Create thread via callback.
273
- const id = callbackWrapper(options.onCreate)(cursor, view.coordsAtPos(from));
274
- if (id) {
275
- // Update range.
276
- view.dispatch({
277
- effects: setSelection.of({ active: id }),
278
- selection: { anchor: from },
279
- });
280
-
281
- return true;
282
- }
283
- }
284
-
285
- return false;
286
- };
287
-
288
340
  return [
289
- commentsStateField,
290
- highlightDecorations,
341
+ optionsFacet.of(options),
342
+ commentsState,
343
+ commentsDecorations,
291
344
  styles,
292
345
 
293
346
  //
@@ -297,7 +350,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
297
350
  ? keymap.of([
298
351
  {
299
352
  key: shortcut,
300
- run: callbackWrapper(createCommentThread),
353
+ run: callbackWrapper(createComment),
301
354
  },
302
355
  ])
303
356
  : [],
@@ -334,75 +387,90 @@ export const comments = (options: CommentsOptions = {}): Extension => {
334
387
  : [],
335
388
 
336
389
  //
337
- // Monitor cursor movement and text updates.
338
- // TODO(burdon): Is there a better (finer grained) way to do this?
390
+ // Track deleted ranges and update ranges for decorations.
339
391
  //
340
392
  EditorView.updateListener.of(({ view, state, changes }) => {
341
- const cursorConverter = view.state.facet(Cursor.converter);
342
-
343
- //
344
- // Test if need to recompute indexed range if document changes before the end of the range.
345
- //
346
- {
347
- let mod = false;
348
- const { active, ranges } = state.field(commentsStateField);
349
- changes.iterChanges((from, to, from2, to2) => {
350
- // TODO(burdon): Skip deleted (tracked) ranges.
351
- ranges.forEach((range) => {
352
- if (from2 === to2) {
353
- const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
354
- if (!newRange || newRange.to - newRange.from === 0) {
355
- options.onDelete?.(range.id);
356
- }
393
+ let mod = false;
394
+ const { comments, ...value } = state.field(commentsState);
395
+ changes.iterChanges((from, to, from2, to2) => {
396
+ comments.forEach(({ comment, range }) => {
397
+ // Test if range deleted.
398
+ if (from2 === to2) {
399
+ const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
400
+ if (!newRange || newRange.to - newRange.from === 0) {
401
+ options.onDelete?.(comment.id);
357
402
  }
403
+ }
358
404
 
359
- if (from <= range.to) {
360
- const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
361
- Object.assign(range, newRange);
362
- mod = true;
363
- }
364
- });
405
+ // Update range.
406
+ if (from <= range.to) {
407
+ const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
408
+ Object.assign(range, newRange);
409
+ mod = true;
410
+ }
365
411
  });
412
+ });
366
413
 
367
- if (mod) {
368
- view.dispatch({ effects: setCommentState.of({ active, ranges }) });
369
- }
414
+ if (mod) {
415
+ view.dispatch({ effects: setCommentState.of({ comments, ...value }) });
370
416
  }
417
+ }),
371
418
 
372
- //
373
- // Track the current selection.
374
- //
375
- {
376
- const { head } = state.selection.main;
377
-
378
- let min = Infinity;
379
- const { active, closest, ranges } = state.field(commentsStateField);
380
- const selected: CommentSelected = { active: undefined, closest: undefined };
381
-
382
- // TODO(burdon): Skip deleted (tracked) ranges.
383
- ranges.forEach((comment) => {
384
- const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
385
- if (head >= comment.from && head <= comment.to) {
386
- selected.active = comment.id;
387
- }
419
+ //
420
+ // Track selection/proximity.
421
+ //
422
+ EditorView.updateListener.of(({ view, state }) => {
423
+ let min = Infinity;
424
+ const {
425
+ selection: { current, closest },
426
+ comments,
427
+ } = state.field(commentsState);
428
+
429
+ const { head } = state.selection.main;
430
+ const selection: SelectionState = {};
431
+ comments.forEach(({ comment, range }) => {
432
+ if (head >= range.from && head <= range.to) {
433
+ selection.current = comment.id;
434
+ selection.closest = undefined;
435
+ }
436
+
437
+ if (!selection.current) {
438
+ const d = Math.min(Math.abs(head - range.from), Math.abs(head - range.to));
388
439
  if (d < min) {
389
- selected.closest = comment.id;
440
+ selection.closest = comment.id;
390
441
  min = d;
391
442
  }
392
- });
393
-
394
- if (selected.active !== active || selected.closest !== closest) {
395
- view.dispatch({ effects: setSelection.of(selected) });
396
-
397
- // Update callback.
398
- handleSelect({
399
- ...selected,
400
- ranges: ranges.map((range) => ({ ...range, location: view.coordsAtPos(range.from) })),
401
- });
402
443
  }
444
+ });
445
+
446
+ if (selection.current !== current || selection.closest !== closest) {
447
+ view.dispatch({ effects: setSelection.of(selection) });
448
+
449
+ // Update callback.
450
+ handleSelect({
451
+ selection,
452
+ comments: comments.map(({ comment, range }) => ({
453
+ comment,
454
+ range,
455
+ location: view.coordsAtPos(range.from),
456
+ })),
457
+ });
403
458
  }
404
459
  }),
405
460
 
406
461
  options.onUpdate ? trackPastedComments(options.onUpdate) : [],
407
462
  ];
408
463
  };
464
+
465
+ /**
466
+ * Update comments state field.
467
+ */
468
+ export const useComments = (view: EditorView | null, comments: Comment[] = []) => {
469
+ useEffect(() => {
470
+ if (view) {
471
+ view.dispatch({
472
+ effects: setComments.of(comments),
473
+ });
474
+ }
475
+ }, [view, comments]);
476
+ };
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Facet } from '@codemirror/state';
5
+ import { type EditorState, Facet } from '@codemirror/state';
6
6
 
7
7
  import type { Range } from '../hooks';
8
8
 
@@ -31,13 +31,17 @@ export class Cursor {
31
31
  combine: (providers) => providers[0] ?? defaultCursorConverter,
32
32
  });
33
33
 
34
- static readonly getCursorFromRange = (cursorConverter: CursorConverter, range: Range) => {
34
+ static readonly getCursorFromRange = (state: EditorState, range: Range) => {
35
+ const cursorConverter = state.facet(Cursor.converter);
36
+
35
37
  const from = cursorConverter.toCursor(range.from);
36
38
  const to = cursorConverter.toCursor(range.to, -1);
37
39
  return [from, to].join(':');
38
40
  };
39
41
 
40
- static readonly getRangeFromCursor = (cursorConverter: CursorConverter, cursor: string) => {
42
+ static readonly getRangeFromCursor = (state: EditorState, cursor: string) => {
43
+ const cursorConverter = state.facet(Cursor.converter);
44
+
41
45
  const parts = cursor.split(':');
42
46
  const from = cursorConverter.fromCursor(parts[0]);
43
47
  const to = cursorConverter.fromCursor(parts[1]);
@@ -7,17 +7,10 @@ export * from './automerge';
7
7
  export * from './awareness';
8
8
  export * from './basic';
9
9
  export * from './blast';
10
- export * from './code';
11
10
  export * from './comments';
12
11
  export * from './cursor';
13
- export * from './hr';
14
- export * from './image';
15
- export * from './link';
16
12
  export * from './listener';
17
13
  export * from './markdown';
18
14
  export * from './mention';
19
15
  export * from './outliner';
20
- export * from './table';
21
- export * from './tasklist';
22
16
  export * from './typewriter';
23
- export * from './yjs';