@dxos/react-ui-editor 0.3.11-main.42059c4 → 0.3.11-main.463e5cd

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 (182) hide show
  1. package/dist/lib/browser/index.mjs +1610 -1059
  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 +13 -15
  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/automerge.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/TextEditor/{yjs.stories.d.ts → hooks.stories.d.ts} +13 -2
  11. package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +1 -0
  12. package/dist/types/src/components/Toolbar/Toolbar.d.ts +25 -0
  13. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -0
  14. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +23 -0
  15. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
  16. package/dist/types/src/components/Toolbar/index.d.ts +2 -0
  17. package/dist/types/src/components/Toolbar/index.d.ts.map +1 -0
  18. package/dist/types/src/components/index.d.ts +1 -0
  19. package/dist/types/src/components/index.d.ts.map +1 -1
  20. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  21. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  22. package/dist/types/src/extensions/automerge/automerge.spec.d.ts +2 -0
  23. package/dist/types/src/extensions/automerge/automerge.spec.d.ts.map +1 -0
  24. package/dist/types/src/extensions/automerge/automerge.test.d.ts +2 -0
  25. package/dist/types/src/extensions/automerge/automerge.test.d.ts.map +1 -0
  26. package/dist/types/src/extensions/automerge/defs.d.ts +8 -10
  27. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  28. package/dist/types/src/extensions/automerge/semaphore.d.ts +7 -6
  29. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  30. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  31. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  32. package/dist/types/src/extensions/awareness.d.ts +2 -2
  33. package/dist/types/src/extensions/awareness.d.ts.map +1 -1
  34. package/dist/types/src/extensions/basic.d.ts +4 -5
  35. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  36. package/dist/types/src/extensions/comments.d.ts +23 -15
  37. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  38. package/dist/types/src/extensions/cursor.d.ts +3 -3
  39. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  40. package/dist/types/src/extensions/index.d.ts +1 -7
  41. package/dist/types/src/extensions/index.d.ts.map +1 -1
  42. package/dist/types/src/extensions/markdown/bundle.d.ts +3 -6
  43. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  44. package/dist/types/src/extensions/markdown/code.d.ts.map +1 -0
  45. package/dist/types/src/extensions/markdown/formatting.d.ts +13 -0
  46. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
  47. package/dist/types/src/extensions/markdown/heading.d.ts +6 -0
  48. package/dist/types/src/extensions/markdown/heading.d.ts.map +1 -0
  49. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  50. package/dist/types/src/extensions/markdown/hr.d.ts.map +1 -0
  51. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -0
  52. package/dist/types/src/extensions/markdown/index.d.ts +8 -0
  53. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  54. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -0
  55. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -0
  56. package/dist/types/src/extensions/markdown/tasklist.d.ts +3 -0
  57. package/dist/types/src/extensions/markdown/tasklist.d.ts.map +1 -0
  58. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  59. package/dist/types/src/extensions/outliner.d.ts +4 -0
  60. package/dist/types/src/extensions/outliner.d.ts.map +1 -0
  61. package/dist/types/src/hooks/awareness-provider.d.ts +7 -4
  62. package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -1
  63. package/dist/types/src/hooks/defs.d.ts +25 -0
  64. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  65. package/dist/types/src/hooks/index.d.ts +4 -2
  66. package/dist/types/src/hooks/index.d.ts.map +1 -1
  67. package/dist/types/src/hooks/useActionHandler.d.ts +4 -0
  68. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -0
  69. package/dist/types/src/hooks/useEditorView.d.ts +17 -0
  70. package/dist/types/src/hooks/useEditorView.d.ts.map +1 -0
  71. package/dist/types/src/hooks/useTextEditor.d.ts +39 -0
  72. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
  73. package/dist/types/src/hooks/useTextModel.d.ts +14 -27
  74. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  75. package/dist/types/src/index.d.ts +1 -1
  76. package/dist/types/src/index.d.ts.map +1 -1
  77. package/dist/types/src/styles/markdown.d.ts +2 -2
  78. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  79. package/dist/types/src/themes/default.d.ts.map +1 -1
  80. package/dist/types/src/util.d.ts +2 -0
  81. package/dist/types/src/util.d.ts.map +1 -1
  82. package/package.json +46 -36
  83. package/src/components/TextEditor/MarkdownEditor.stories.tsx +73 -82
  84. package/src/components/TextEditor/TextEditor.tsx +107 -71
  85. package/src/components/TextEditor/automerge.stories.tsx +1 -2
  86. package/src/components/TextEditor/hooks.stories.tsx +106 -0
  87. package/src/components/Toolbar/Toolbar.stories.tsx +90 -0
  88. package/src/components/Toolbar/Toolbar.tsx +166 -0
  89. package/src/components/Toolbar/index.ts +5 -0
  90. package/src/components/index.ts +1 -0
  91. package/src/extensions/autocomplete.ts +6 -3
  92. package/src/extensions/automerge/automerge.spec.tsx +76 -0
  93. package/src/extensions/automerge/automerge.test.ts +13 -0
  94. package/src/extensions/automerge/automerge.ts +50 -41
  95. package/src/extensions/automerge/cursor.ts +3 -3
  96. package/src/extensions/automerge/defs.ts +11 -36
  97. package/src/extensions/automerge/semaphore.ts +31 -28
  98. package/src/extensions/automerge/update-automerge.ts +14 -8
  99. package/src/extensions/automerge/update-codemirror.ts +5 -5
  100. package/src/extensions/awareness.ts +22 -18
  101. package/src/extensions/basic.ts +17 -10
  102. package/src/extensions/blast.ts +4 -1
  103. package/src/extensions/comments.ts +253 -179
  104. package/src/extensions/cursor.ts +7 -3
  105. package/src/extensions/index.ts +1 -7
  106. package/src/extensions/markdown/bundle.ts +23 -28
  107. package/src/extensions/markdown/code.ts +150 -0
  108. package/src/extensions/markdown/formatting.ts +188 -0
  109. package/src/extensions/markdown/heading.ts +59 -0
  110. package/src/extensions/markdown/highlight.ts +9 -15
  111. package/src/extensions/markdown/hr.ts +71 -0
  112. package/src/extensions/markdown/index.ts +8 -0
  113. package/src/extensions/{link.ts → markdown/link.ts} +16 -7
  114. package/src/extensions/{tasklist.ts → markdown/tasklist.ts} +48 -33
  115. package/src/extensions/mention.ts +9 -2
  116. package/src/extensions/outliner.ts +12 -0
  117. package/src/hooks/awareness-provider.ts +24 -20
  118. package/src/hooks/defs.ts +42 -0
  119. package/src/hooks/index.ts +4 -2
  120. package/src/hooks/useActionHandler.ts +62 -0
  121. package/src/hooks/useEditorView.ts +29 -0
  122. package/src/hooks/useTextEditor.ts +153 -0
  123. package/src/hooks/useTextModel.ts +66 -56
  124. package/src/index.ts +1 -1
  125. package/src/styles/markdown.ts +9 -10
  126. package/src/themes/default.ts +9 -6
  127. package/src/util.ts +26 -3
  128. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
  129. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
  130. package/dist/types/src/components/TextEditor/comments.stories.d.ts +0 -26
  131. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +0 -1
  132. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  133. package/dist/types/src/extensions/code.d.ts.map +0 -1
  134. package/dist/types/src/extensions/hr.d.ts.map +0 -1
  135. package/dist/types/src/extensions/image.d.ts.map +0 -1
  136. package/dist/types/src/extensions/link.d.ts.map +0 -1
  137. package/dist/types/src/extensions/table.d.ts.map +0 -1
  138. package/dist/types/src/extensions/tasklist.d.ts +0 -7
  139. package/dist/types/src/extensions/tasklist.d.ts.map +0 -1
  140. package/dist/types/src/extensions/yjs/cursor.d.ts +0 -4
  141. package/dist/types/src/extensions/yjs/cursor.d.ts.map +0 -1
  142. package/dist/types/src/extensions/yjs/index.d.ts +0 -2
  143. package/dist/types/src/extensions/yjs/index.d.ts.map +0 -1
  144. package/dist/types/src/extensions/yjs/yjs.d.ts +0 -4
  145. package/dist/types/src/extensions/yjs/yjs.d.ts.map +0 -1
  146. package/dist/types/src/extensions/yjs/yjs.test.d.ts +0 -2
  147. package/dist/types/src/extensions/yjs/yjs.test.d.ts.map +0 -1
  148. package/dist/types/src/hooks/automerge.d.ts +0 -3
  149. package/dist/types/src/hooks/automerge.d.ts.map +0 -1
  150. package/dist/types/src/hooks/yjs.d.ts +0 -27
  151. package/dist/types/src/hooks/yjs.d.ts.map +0 -1
  152. package/dist/types/src/testing/index.d.ts +0 -3
  153. package/dist/types/src/testing/index.d.ts.map +0 -1
  154. package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
  155. package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
  156. package/dist/types/src/testing/proto/index.d.ts +0 -2
  157. package/dist/types/src/testing/proto/index.d.ts.map +0 -1
  158. package/dist/types/src/testing/replicator.d.ts +0 -45
  159. package/dist/types/src/testing/replicator.d.ts.map +0 -1
  160. package/src/components/TextEditor/codemirror.stories.ts +0 -115
  161. package/src/components/TextEditor/comments.stories.tsx +0 -355
  162. package/src/components/TextEditor/yjs.stories.tsx +0 -56
  163. package/src/extensions/code.ts +0 -106
  164. package/src/extensions/hr.ts +0 -73
  165. package/src/extensions/yjs/cursor.ts +0 -22
  166. package/src/extensions/yjs/index.ts +0 -5
  167. package/src/extensions/yjs/yjs.test.ts +0 -35
  168. package/src/extensions/yjs/yjs.ts +0 -16
  169. package/src/hooks/automerge.ts +0 -52
  170. package/src/hooks/yjs.ts +0 -156
  171. package/src/testing/index.ts +0 -6
  172. package/src/testing/proto/gen/schema.ts +0 -49
  173. package/src/testing/proto/index.ts +0 -5
  174. package/src/testing/proto/schema.proto +0 -15
  175. package/src/testing/replicator.ts +0 -184
  176. /package/dist/types/src/extensions/{code.d.ts → markdown/code.d.ts} +0 -0
  177. /package/dist/types/src/extensions/{hr.d.ts → markdown/hr.d.ts} +0 -0
  178. /package/dist/types/src/extensions/{image.d.ts → markdown/image.d.ts} +0 -0
  179. /package/dist/types/src/extensions/{link.d.ts → markdown/link.d.ts} +0 -0
  180. /package/dist/types/src/extensions/{table.d.ts → markdown/table.d.ts} +0 -0
  181. /package/src/extensions/{image.ts → markdown/image.ts} +0 -0
  182. /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 focusComment = (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) && effect.value.comments.length > 0) {
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,8 +184,8 @@ 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
  })),
@@ -192,16 +199,48 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
192
199
  copy: handleTrack,
193
200
  }),
194
201
 
195
- // Handle paste.
196
- 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
+
197
236
  if (tracked) {
198
- const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
237
+ const paste = update.transactions.find((tr) => tr.isUserEvent('input.paste'));
199
238
  if (paste) {
200
239
  let found = -1;
201
240
  paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
202
241
  if (text.eq(tracked!.text)) {
203
- for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
204
- 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);
205
244
  }
206
245
 
207
246
  found = fromB;
@@ -209,34 +248,79 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
209
248
  });
210
249
 
211
250
  if (found > -1) {
212
- const comments = tracked.comments;
213
-
214
- // Sync before recomputing cursor/range.
215
- // TODO(burdon): Decouple from automerge?
216
- view.state.facet(semaphoreFacet).reconcile(view);
217
-
218
- const active = state.field(commentsStateField).ranges;
219
- for (const moved of comments) {
220
- if (active.some((range) => range.id === moved.id && range.from === range.to)) {
221
- const range = {
222
- from: found + moved.from,
223
- to: found + moved.to,
224
- };
225
-
226
- const cursor = Cursor.getCursorFromRange(state.facet(Cursor.converter), range);
227
- // console.log('paste', JSON.stringify({ moved, range, cursor }, undefined, 2));
228
- onUpdate(moved.id, cursor);
229
- }
251
+ for (const moved of tracked.comments) {
252
+ restore.push({ id: moved.id, from: found + moved.from, to: found + moved.to });
230
253
  }
231
254
  }
232
255
 
233
256
  tracked = null;
234
257
  }
235
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
+ }
236
268
  }),
237
269
  ];
238
270
  };
239
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
+
240
324
  /**
241
325
  * Comment threads.
242
326
  * 1). Updates the EditorModel to store relative selections for a set of comments threads.
@@ -253,39 +337,10 @@ export const comments = (options: CommentsOptions = {}): Extension => {
253
337
 
254
338
  const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
255
339
 
256
- /**
257
- * Create comment thread action.
258
- */
259
- const createCommentThread: Command = (view) => {
260
- const cursorConverter = view.state.facet(Cursor.converter);
261
-
262
- invariant(options.onCreate);
263
- const { from, to } = view.state.selection.main;
264
- if (from === to) {
265
- return false;
266
- }
267
-
268
- const cursor = Cursor.getCursorFromRange(cursorConverter, { from, to });
269
- if (cursor) {
270
- // Create thread via callback.
271
- const id = callbackWrapper(options.onCreate)(cursor, view.coordsAtPos(from));
272
- if (id) {
273
- // Update range.
274
- view.dispatch({
275
- effects: setSelection.of({ active: id }),
276
- selection: { anchor: from },
277
- });
278
-
279
- return true;
280
- }
281
- }
282
-
283
- return false;
284
- };
285
-
286
340
  return [
287
- commentsStateField,
288
- highlightDecorations,
341
+ optionsFacet.of(options),
342
+ commentsState,
343
+ commentsDecorations,
289
344
  styles,
290
345
 
291
346
  //
@@ -295,7 +350,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
295
350
  ? keymap.of([
296
351
  {
297
352
  key: shortcut,
298
- run: callbackWrapper(createCommentThread),
353
+ run: callbackWrapper(createComment),
299
354
  },
300
355
  ])
301
356
  : [],
@@ -315,7 +370,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
315
370
  above: true,
316
371
  create: () => {
317
372
  const el = document.createElement('div');
318
- options.onHover?.(el, shortcut);
373
+ options.onHover!(el, shortcut);
319
374
  return { dom: el, offset: { x: 0, y: 8 } };
320
375
  },
321
376
  };
@@ -323,80 +378,99 @@ export const comments = (options: CommentsOptions = {}): Extension => {
323
378
 
324
379
  return null;
325
380
  },
326
- { hideOnChange: true, hoverTime: 1000 },
381
+ {
382
+ // TODO(burdon): Hide on change triggered immediately?
383
+ // hideOnChange: true,
384
+ hoverTime: 1_000,
385
+ },
327
386
  )
328
387
  : [],
329
388
 
330
389
  //
331
- // Monitor cursor movement and text updates.
332
- // TODO(burdon): Is there a better (finer grained) way to do this?
390
+ // Track deleted ranges and update ranges for decorations.
333
391
  //
334
392
  EditorView.updateListener.of(({ view, state, changes }) => {
335
- const cursorConverter = view.state.facet(Cursor.converter);
336
-
337
- //
338
- // Test if need to recompute indexed range if document changes before the end of the range.
339
- //
340
- {
341
- let mod = false;
342
- const { active, ranges } = state.field(commentsStateField);
343
- changes.iterChanges((from, to, from2, to2) => {
344
- // TODO(burdon): Skip deleted (tracked) ranges.
345
- ranges.forEach((range) => {
346
- if (from2 === to2) {
347
- const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
348
- if (!newRange || newRange.to - newRange.from === 0) {
349
- options.onDelete?.(range.id);
350
- }
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);
351
402
  }
403
+ }
352
404
 
353
- if (from <= range.to) {
354
- const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
355
- Object.assign(range, newRange);
356
- mod = true;
357
- }
358
- });
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
+ }
359
411
  });
412
+ });
360
413
 
361
- if (mod) {
362
- view.dispatch({ effects: setCommentState.of({ active, ranges }) });
363
- }
414
+ if (mod) {
415
+ view.dispatch({ effects: setCommentState.of({ comments, ...value }) });
364
416
  }
417
+ }),
365
418
 
366
- //
367
- // Track the current selection.
368
- //
369
- {
370
- const { head } = state.selection.main;
371
-
372
- let min = Infinity;
373
- const { active, closest, ranges } = state.field(commentsStateField);
374
- const selected: CommentSelected = { active: undefined, closest: undefined };
375
-
376
- // TODO(burdon): Skip deleted (tracked) ranges.
377
- ranges.forEach((comment) => {
378
- const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
379
- if (head >= comment.from && head <= comment.to) {
380
- selected.active = comment.id;
381
- }
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));
382
439
  if (d < min) {
383
- selected.closest = comment.id;
440
+ selection.closest = comment.id;
384
441
  min = d;
385
442
  }
386
- });
387
-
388
- if (selected.active !== active || selected.closest !== closest) {
389
- view.dispatch({ effects: setSelection.of(selected) });
390
-
391
- // Update callback.
392
- handleSelect({
393
- ...selected,
394
- ranges: ranges.map((range) => ({ ...range, location: view.coordsAtPos(range.from) })),
395
- });
396
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
+ });
397
458
  }
398
459
  }),
399
460
 
400
461
  options.onUpdate ? trackPastedComments(options.onUpdate) : [],
401
462
  ];
402
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,16 +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
- export * from './table';
20
- export * from './tasklist';
15
+ export * from './outliner';
21
16
  export * from './typewriter';
22
- export * from './yjs';