@dxos/react-ui-editor 0.3.11-main.6fccc32 → 0.3.11-main.73ee990

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/lib/browser/index.mjs +1259 -1060
  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 -14
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +14 -6
  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 +1 -1
  10. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/Toolbar/Toolbar.d.ts +9 -0
  12. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -0
  13. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +14 -0
  14. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
  15. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  16. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  17. package/dist/types/src/extensions/automerge/defs.d.ts +7 -9
  18. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  19. package/dist/types/src/extensions/automerge/index.d.ts +0 -1
  20. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
  21. package/dist/types/src/extensions/automerge/semaphore.d.ts +7 -12
  22. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  23. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  24. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  25. package/dist/types/src/extensions/awareness.d.ts +1 -1
  26. package/dist/types/src/extensions/awareness.d.ts.map +1 -1
  27. package/dist/types/src/extensions/basic.d.ts +4 -5
  28. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  29. package/dist/types/src/extensions/comments.d.ts +35 -18
  30. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  31. package/dist/types/src/extensions/cursor.d.ts +3 -3
  32. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  33. package/dist/types/src/extensions/index.d.ts +1 -7
  34. package/dist/types/src/extensions/index.d.ts.map +1 -1
  35. package/dist/types/src/extensions/markdown/bundle.d.ts +3 -6
  36. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  37. package/dist/types/src/extensions/markdown/code.d.ts.map +1 -0
  38. package/dist/types/src/extensions/markdown/formatting.d.ts +11 -0
  39. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
  40. package/dist/types/src/extensions/markdown/heading.d.ts +6 -0
  41. package/dist/types/src/extensions/markdown/heading.d.ts.map +1 -0
  42. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  43. package/dist/types/src/extensions/markdown/hr.d.ts.map +1 -0
  44. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -0
  45. package/dist/types/src/extensions/markdown/index.d.ts +8 -0
  46. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  47. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -0
  48. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -0
  49. package/dist/types/src/extensions/markdown/tasklist.d.ts.map +1 -0
  50. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  51. package/dist/types/src/extensions/outliner.d.ts +4 -0
  52. package/dist/types/src/extensions/outliner.d.ts.map +1 -0
  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 +34 -37
  71. package/src/components/TextEditor/MarkdownEditor.stories.tsx +72 -80
  72. package/src/components/TextEditor/TextEditor.tsx +96 -70
  73. package/src/components/TextEditor/comments.stories.tsx +113 -71
  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.ts +39 -46
  78. package/src/extensions/automerge/defs.ts +10 -31
  79. package/src/extensions/automerge/index.ts +0 -1
  80. package/src/extensions/automerge/semaphore.ts +23 -20
  81. package/src/extensions/automerge/update-automerge.ts +14 -8
  82. package/src/extensions/awareness.ts +15 -14
  83. package/src/extensions/basic.ts +22 -17
  84. package/src/extensions/comments.ts +269 -162
  85. package/src/extensions/cursor.ts +7 -3
  86. package/src/extensions/index.ts +1 -7
  87. package/src/extensions/markdown/bundle.ts +20 -30
  88. package/src/extensions/markdown/code.ts +142 -0
  89. package/src/extensions/markdown/formatting.ts +159 -0
  90. package/src/extensions/markdown/heading.ts +59 -0
  91. package/src/extensions/markdown/highlight.ts +9 -15
  92. package/src/extensions/markdown/hr.ts +71 -0
  93. package/src/extensions/markdown/index.ts +8 -0
  94. package/src/extensions/{link.ts → markdown/link.ts} +8 -5
  95. package/src/extensions/markdown/tasklist.ts +110 -0
  96. package/src/extensions/mention.ts +9 -2
  97. package/src/extensions/outliner.ts +12 -0
  98. package/src/hooks/awareness-provider.ts +24 -20
  99. package/src/hooks/defs.ts +42 -0
  100. package/src/hooks/index.ts +4 -2
  101. package/src/hooks/useActionHandler.ts +45 -0
  102. package/src/hooks/useTextEditor.ts +29 -0
  103. package/src/hooks/useTextModel.ts +47 -56
  104. package/src/styles/markdown.ts +9 -10
  105. package/src/themes/default.ts +1 -11
  106. package/src/util.ts +26 -3
  107. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
  108. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
  109. package/dist/types/src/components/TextEditor/yjs.stories.d.ts +0 -12
  110. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  111. package/dist/types/src/extensions/code.d.ts.map +0 -1
  112. package/dist/types/src/extensions/hr.d.ts.map +0 -1
  113. package/dist/types/src/extensions/image.d.ts.map +0 -1
  114. package/dist/types/src/extensions/link.d.ts.map +0 -1
  115. package/dist/types/src/extensions/table.d.ts.map +0 -1
  116. package/dist/types/src/extensions/tasklist.d.ts.map +0 -1
  117. package/dist/types/src/extensions/yjs/cursor.d.ts +0 -4
  118. package/dist/types/src/extensions/yjs/cursor.d.ts.map +0 -1
  119. package/dist/types/src/extensions/yjs/index.d.ts +0 -2
  120. package/dist/types/src/extensions/yjs/index.d.ts.map +0 -1
  121. package/dist/types/src/extensions/yjs/yjs.d.ts +0 -4
  122. package/dist/types/src/extensions/yjs/yjs.d.ts.map +0 -1
  123. package/dist/types/src/extensions/yjs/yjs.test.d.ts +0 -2
  124. package/dist/types/src/extensions/yjs/yjs.test.d.ts.map +0 -1
  125. package/dist/types/src/hooks/automerge.d.ts +0 -3
  126. package/dist/types/src/hooks/automerge.d.ts.map +0 -1
  127. package/dist/types/src/hooks/yjs.d.ts +0 -27
  128. package/dist/types/src/hooks/yjs.d.ts.map +0 -1
  129. package/dist/types/src/testing/index.d.ts +0 -3
  130. package/dist/types/src/testing/index.d.ts.map +0 -1
  131. package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
  132. package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
  133. package/dist/types/src/testing/proto/index.d.ts +0 -2
  134. package/dist/types/src/testing/proto/index.d.ts.map +0 -1
  135. package/dist/types/src/testing/replicator.d.ts +0 -45
  136. package/dist/types/src/testing/replicator.d.ts.map +0 -1
  137. package/src/components/TextEditor/codemirror.stories.ts +0 -115
  138. package/src/components/TextEditor/yjs.stories.tsx +0 -56
  139. package/src/extensions/code.ts +0 -106
  140. package/src/extensions/hr.ts +0 -73
  141. package/src/extensions/tasklist.ts +0 -119
  142. package/src/extensions/yjs/cursor.ts +0 -22
  143. package/src/extensions/yjs/index.ts +0 -5
  144. package/src/extensions/yjs/yjs.test.ts +0 -35
  145. package/src/extensions/yjs/yjs.ts +0 -16
  146. package/src/hooks/automerge.ts +0 -52
  147. package/src/hooks/yjs.ts +0 -156
  148. package/src/testing/index.ts +0 -6
  149. package/src/testing/proto/gen/schema.ts +0 -49
  150. package/src/testing/proto/index.ts +0 -5
  151. package/src/testing/proto/schema.proto +0 -15
  152. package/src/testing/replicator.ts +0 -184
  153. /package/dist/types/src/extensions/{code.d.ts → markdown/code.d.ts} +0 -0
  154. /package/dist/types/src/extensions/{hr.d.ts → markdown/hr.d.ts} +0 -0
  155. /package/dist/types/src/extensions/{image.d.ts → markdown/image.d.ts} +0 -0
  156. /package/dist/types/src/extensions/{link.d.ts → markdown/link.d.ts} +0 -0
  157. /package/dist/types/src/extensions/{table.d.ts → markdown/table.d.ts} +0 -0
  158. /package/dist/types/src/extensions/{tasklist.d.ts → markdown/tasklist.d.ts} +0 -0
  159. /package/src/extensions/{image.ts → markdown/image.ts} +0 -0
  160. /package/src/extensions/{table.ts → markdown/table.ts} +0 -0
@@ -2,101 +2,103 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type Extension, StateEffect, StateField } from '@codemirror/state';
5
+ import { type Extension, Facet, StateEffect, StateField, type Text } from '@codemirror/state';
6
6
  import { hoverTooltip, keymap, type Command, Decoration, EditorView, type Rect } from '@codemirror/view';
7
7
  import sortBy from 'lodash.sortby';
8
+ import { useEffect } from 'react';
8
9
 
9
10
  import { debounce } from '@dxos/async';
10
- import { invariant } from '@dxos/invariant';
11
- import { log } from '@dxos/log';
12
11
  import { nonNullable } from '@dxos/util';
13
12
 
14
13
  import { Cursor } from './cursor';
15
- import { type CommentRange, type EditorModel, type Range } from '../hooks';
14
+ import { type Comment, type Range } from '../hooks';
16
15
  import { getToken } from '../styles';
17
16
  import { callbackWrapper } from '../util';
18
17
 
19
- // TODO(burdon): Handle delete, cut, copy, and paste (separately) text that includes comment range.
20
- // TODO(burdon): Consider breaking into separate plugin (since not standalone)? Like mermaid?
21
-
22
18
  // TODO(burdon): Reconcile with theme.
23
19
  const styles = EditorView.baseTheme({
24
20
  '& .cm-comment': {
25
21
  backgroundColor: getToken('extend.colors.yellow.50'),
26
22
  },
27
- '& .cm-comment-active': {
23
+ '& .cm-comment-current': {
28
24
  backgroundColor: getToken('extend.colors.yellow.100'),
29
25
  },
30
26
  });
31
27
 
32
28
  const marks = {
33
29
  highlight: Decoration.mark({ class: 'cm-comment' }),
34
- highlightActive: Decoration.mark({ class: 'cm-comment-active' }),
30
+ highlightActive: Decoration.mark({ class: 'cm-comment-current' }),
35
31
  };
36
32
 
37
- // TODO(burdon): Should this be part of state?
38
- type CommentSelected = {
39
- active?: string;
40
- closest?: string;
33
+ type CommentState = {
34
+ comment: Comment;
35
+ range: Range;
36
+ location?: Rect | null;
41
37
  };
42
38
 
43
- // TODO(burdon): Rename.
44
- type ExtendedCommentRange = Range &
45
- CommentRange & {
46
- // TODO(burdon): Not part of state; just required for callback.
47
- location?: Rect | null;
48
- };
39
+ type SelectionState = {
40
+ current?: string;
41
+ closest?: string;
42
+ };
49
43
 
50
- export type CommentsState = CommentSelected & {
51
- ranges: ExtendedCommentRange[];
44
+ export type CommentsState = {
45
+ comments: CommentState[];
46
+ selection: SelectionState;
52
47
  };
53
48
 
54
- export const setFocus = (view: EditorView, thread: string) => {
55
- const range = view.state.field(commentsStateField).ranges.find((range) => range.id === thread);
56
- if (!range) {
49
+ export const setFocus = (view: EditorView, id: string, center = true) => {
50
+ const comment = view.state.field(commentsState).comments.find((range) => range.comment.id === id);
51
+ if (!comment?.comment.cursor) {
57
52
  return;
58
53
  }
59
54
 
60
- view.dispatch({
61
- effects: setSelection.of({ active: thread }),
62
- selection: { anchor: range.from },
63
- scrollIntoView: true,
64
- });
55
+ const range = Cursor.getRangeFromCursor(view.state, comment.comment.cursor);
56
+ if (range) {
57
+ view.dispatch({
58
+ selection: { anchor: range.from },
59
+ effects: [
60
+ //
61
+ EditorView.scrollIntoView(range.from, center ? { y: 'center' } : undefined),
62
+ setSelection.of({ current: id }),
63
+ ],
64
+ });
65
+ }
65
66
  };
66
67
 
67
- export const setCommentRange = StateEffect.define<{ model: EditorModel; comments: CommentRange[] }>();
68
+ export const setComments = StateEffect.define<Comment[]>();
68
69
 
69
- export const setSelection = StateEffect.define<CommentSelected>();
70
+ export const setSelection = StateEffect.define<SelectionState>();
70
71
 
71
72
  const setCommentState = StateEffect.define<CommentsState>();
72
73
 
73
74
  /**
74
75
  * State field (reducer) that tracks comment ranges.
75
- * The ranges are tracked as codemirror ranges (i.e., not relative YJS/Automerge positions), and
76
- * therefore must be updated when the document changes.
76
+ * The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
77
77
  */
78
- const commentsStateField = StateField.define<CommentsState>({
79
- create: () => ({ ranges: [] }),
78
+ const commentsState = StateField.define<CommentsState>({
79
+ create: () => ({ comments: [], selection: {} }),
80
80
  update: (value, tr) => {
81
- const cursorConverter = tr.state.facet(Cursor.converter);
82
-
83
81
  for (const effect of tr.effects) {
84
82
  // Update selection.
85
83
  if (effect.is(setSelection)) {
86
- return { ...value, ...effect.value };
84
+ return { ...value, selection: effect.value };
87
85
  }
88
86
 
89
87
  // Update range from store.
90
- if (effect.is(setCommentRange)) {
91
- const { comments } = effect.value;
92
- const ranges: ExtendedCommentRange[] = comments
88
+ if (effect.is(setComments)) {
89
+ const comments: CommentState[] = effect.value
93
90
  .map((comment) => {
94
- const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
95
- return range && { ...comment, ...range };
91
+ // Skip cut/deleted comments.
92
+ if (!comment.cursor) {
93
+ return undefined;
94
+ }
95
+
96
+ const range = Cursor.getRangeFromCursor(tr.state, comment.cursor);
97
+ return range && { comment, range };
96
98
  })
97
99
  .filter(nonNullable);
98
100
 
99
- return { ...value, ranges };
101
+ return { ...value, comments };
100
102
  }
101
103
 
102
104
  // Update entire state.
@@ -112,35 +114,48 @@ const commentsStateField = StateField.define<CommentsState>({
112
114
  /**
113
115
  * Decorate ranges.
114
116
  */
115
- const highlightDecorations = EditorView.decorations.compute([commentsStateField], (state) => {
116
- const { active, ranges } = state.field(commentsStateField);
117
- const decorations =
118
- sortBy(ranges ?? [], (range) => range.from)
119
- ?.flatMap((selection) => {
120
- // TODO(burdon): Invalid range (e.g., deleted).
121
- if (selection.from === selection.to) {
122
- return undefined;
123
- }
117
+ const highlightDecorations = EditorView.decorations.compute([commentsState], (state) => {
118
+ const {
119
+ selection: { current },
120
+ comments,
121
+ } = state.field(commentsState);
122
+
123
+ const decorations = sortBy(comments ?? [], (range) => range.range.from)
124
+ ?.flatMap((comment) => {
125
+ const range = comment.range;
126
+ if (!range || range.from === range.to) {
127
+ console.warn('Invalid range:', range);
128
+ return undefined;
129
+ }
124
130
 
125
- const range = { from: selection.from, to: selection.to };
126
- if (selection.id === active) {
127
- return marks.highlightActive.range(range.from, range.to);
128
- } else {
129
- return marks.highlight.range(range.from, range.to);
130
- }
131
- })
132
- .filter(nonNullable) ?? [];
131
+ if (comment.comment.id === current) {
132
+ return marks.highlightActive.range(range.from, range.to);
133
+ } else {
134
+ return marks.highlight.range(range.from, range.to);
135
+ }
136
+ })
137
+ .filter(nonNullable);
133
138
 
134
139
  return Decoration.set(decorations);
135
140
  });
136
141
 
137
142
  export type CommentsOptions = {
143
+ /**
144
+ * Key shortcut to create a new thread.
145
+ */
138
146
  key?: string;
139
- footnote?: boolean;
140
147
  /**
141
148
  * Called to create a new thread and return the thread id.
142
149
  */
143
150
  onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
151
+ /**
152
+ * Selection cut/deleted.
153
+ */
154
+ onDelete?: (thread: string) => void;
155
+ /**
156
+ * Called when a comment is moved.
157
+ */
158
+ onUpdate?: (thread: string, cursor: string) => void;
144
159
  /**
145
160
  * Called to notify which thread is currently closest to the cursor.
146
161
  */
@@ -151,6 +166,117 @@ export type CommentsOptions = {
151
166
  onHover?: (el: Element, shortcut: string) => void;
152
167
  };
153
168
 
169
+ // TODO(burdon): Handle cut/restore via undo (need to integrate with history?)
170
+ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
171
+ // Tracks indexed selections within text.
172
+ // TODO(burdon): Move to main state field?
173
+ let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
174
+
175
+ // Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
176
+ const handleTrack = (event: Event, view: EditorView) => {
177
+ const comments = view.state.field(commentsState);
178
+ const { main } = view.state.selection;
179
+ const selectedRanges = comments.comments.filter(
180
+ ({ range }) => range.from >= main.from && range.to <= main.to && range.from < range.to,
181
+ );
182
+
183
+ if (!selectedRanges.length) {
184
+ tracked = null;
185
+ } else {
186
+ tracked = {
187
+ text: view.state.doc.slice(main.from, main.to),
188
+ comments: selectedRanges.map(({ comment, range }) => ({
189
+ id: comment.id,
190
+ from: range.from - main.from,
191
+ to: range.to - main.from,
192
+ })),
193
+ };
194
+ }
195
+ };
196
+
197
+ return [
198
+ EditorView.domEventHandlers({
199
+ cut: handleTrack,
200
+ copy: handleTrack,
201
+ }),
202
+
203
+ // Handle paste.
204
+ EditorView.updateListener.of(({ view, state, transactions }) => {
205
+ if (tracked) {
206
+ const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
207
+ if (paste) {
208
+ let found = -1;
209
+ paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
210
+ if (text.eq(tracked!.text)) {
211
+ for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
212
+ fromB = transactions[i].changes.mapPos(fromB);
213
+ }
214
+
215
+ found = fromB;
216
+ }
217
+ });
218
+
219
+ if (found > -1) {
220
+ for (const moved of tracked.comments) {
221
+ const range = {
222
+ from: found + moved.from,
223
+ to: found + moved.to,
224
+ };
225
+
226
+ const cursor = Cursor.getCursorFromRange(state, range);
227
+ onUpdate(moved.id, cursor);
228
+ }
229
+ }
230
+
231
+ tracked = null;
232
+ }
233
+ }
234
+ }),
235
+ ];
236
+ };
237
+
238
+ const optionsFacet = Facet.define<CommentsOptions, CommentsOptions>({
239
+ combine: (providers) => providers[0],
240
+ });
241
+
242
+ /**
243
+ * Create comment thread action.
244
+ */
245
+ export const createComment: Command = (view) => {
246
+ const options = view.state.facet(optionsFacet);
247
+ const { from, to } = view.state.selection.main;
248
+ if (from === to) {
249
+ return false;
250
+ }
251
+
252
+ // Don't allow selection at end of document.
253
+ if (to === view.state.doc.length) {
254
+ view.dispatch({
255
+ changes: {
256
+ from: to,
257
+ insert: '\n',
258
+ },
259
+ });
260
+ }
261
+
262
+ const cursor = Cursor.getCursorFromRange(view.state, { from, to });
263
+ if (cursor) {
264
+ // Create thread via callback.
265
+ const id = options.onCreate?.(cursor, view.coordsAtPos(from));
266
+ if (id) {
267
+ // Update range.
268
+ view.dispatch({
269
+ effects: setSelection.of({ current: id }),
270
+ selection: { anchor: from },
271
+ });
272
+
273
+ return true;
274
+ }
275
+ }
276
+
277
+ return false;
278
+ };
279
+
154
280
  /**
155
281
  * Comment threads.
156
282
  * 1). Updates the EditorModel to store relative selections for a set of comments threads.
@@ -160,55 +286,16 @@ export type CommentsOptions = {
160
286
  * 4). Tracks the current cursor position to:
161
287
  * a). Update the decoration to show if the cursor is within a current selection.
162
288
  * b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
163
- * 5). Optionally, inserts a markdown footnote when creating a comment thread.
164
- * 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
289
+ * 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
165
290
  */
166
291
  export const comments = (options: CommentsOptions = {}): Extension => {
167
292
  const { key: shortcut = "meta-'" } = options;
168
293
 
169
294
  const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
170
295
 
171
- /**
172
- * Create comment thread action.
173
- */
174
- const createCommentThread: Command = (view) => {
175
- const cursorConverter = view.state.facet(Cursor.converter);
176
-
177
- invariant(options.onCreate);
178
- const { head, from, to } = view.state.selection.main;
179
- if (from === to) {
180
- return false;
181
- }
182
-
183
- const cursor = Cursor.getCursorFromRange(cursorConverter, { from, to });
184
- if (cursor) {
185
- // Create thread via callback.
186
- const id = callbackWrapper(options.onCreate)(cursor, view.coordsAtPos(from));
187
- if (id) {
188
- // Update range.
189
- view.dispatch({
190
- effects: setSelection.of({ active: id }),
191
- selection: { anchor: from },
192
- });
193
-
194
- // Insert footnote.
195
- if (options.footnote) {
196
- const tag = `[^${id}]`;
197
- view.dispatch({
198
- changes: { from: head, insert: tag },
199
- selection: { anchor: head + tag.length },
200
- });
201
- }
202
-
203
- return true;
204
- }
205
- }
206
-
207
- return false;
208
- };
209
-
210
296
  return [
211
- commentsStateField,
297
+ optionsFacet.of(options),
298
+ commentsState,
212
299
  highlightDecorations,
213
300
  styles,
214
301
 
@@ -219,7 +306,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
219
306
  ? keymap.of([
220
307
  {
221
308
  key: shortcut,
222
- run: callbackWrapper(createCommentThread),
309
+ run: callbackWrapper(createComment),
223
310
  },
224
311
  ])
225
312
  : [],
@@ -238,9 +325,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
238
325
  end: selection.to,
239
326
  above: true,
240
327
  create: () => {
241
- // TODO(burdon): Dispatch to react callback to render (or use SSR)?
242
328
  const el = document.createElement('div');
243
- options.onHover?.(el, shortcut);
329
+ options.onHover!(el, shortcut);
244
330
  return { dom: el, offset: { x: 0, y: 8 } };
245
331
  },
246
332
  };
@@ -248,78 +334,99 @@ export const comments = (options: CommentsOptions = {}): Extension => {
248
334
 
249
335
  return null;
250
336
  },
251
- { hideOnChange: true, hoverTime: 1000 },
337
+ {
338
+ // TODO(burdon): Hide on change triggered immediately?
339
+ // hideOnChange: true,
340
+ hoverTime: 1_000,
341
+ },
252
342
  )
253
343
  : [],
254
344
 
255
345
  //
256
- // Monitor cursor movement and text updates.
257
- // TODO(burdon): Is there a better (finer grained) way to do this?
346
+ // Track deleted ranges and update ranges for decorations.
258
347
  //
259
348
  EditorView.updateListener.of(({ view, state, changes }) => {
260
- const cursorConverter = view.state.facet(Cursor.converter);
261
-
262
- //
263
- // Test if need to recompute indexed range if document changes before the end of the range.
264
- //
265
- {
266
- let mod = false;
267
- const { active, ranges } = state.field(commentsStateField);
268
- changes.iterChanges((from, to, from2, to2) => {
269
- ranges.forEach((range) => {
270
- if (from2 === to2) {
271
- const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
272
- if (!newRange || newRange.to - newRange.from === 0) {
273
- // TODO(burdon): Delete range if empty.
274
- log.info('deleted comment', { thread: range.id });
275
- }
349
+ let mod = false;
350
+ const { comments, ...value } = state.field(commentsState);
351
+ changes.iterChanges((from, to, from2, to2) => {
352
+ comments.forEach(({ comment, range }) => {
353
+ // Test if range deleted.
354
+ if (from2 === to2) {
355
+ const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
356
+ if (!newRange || newRange.to - newRange.from === 0) {
357
+ options.onDelete?.(comment.id);
276
358
  }
359
+ }
277
360
 
278
- if (from <= range.to) {
279
- const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
280
- Object.assign(range, newRange);
281
- mod = true;
282
- }
283
- });
361
+ // Update range.
362
+ if (from <= range.to) {
363
+ const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
364
+ Object.assign(range, newRange);
365
+ mod = true;
366
+ }
284
367
  });
368
+ });
285
369
 
286
- if (mod) {
287
- view.dispatch({ effects: setCommentState.of({ active, ranges }) });
288
- }
370
+ if (mod) {
371
+ view.dispatch({ effects: setCommentState.of({ comments, ...value }) });
289
372
  }
373
+ }),
290
374
 
291
- //
292
- // Track the current selection.
293
- //
294
- {
295
- const { head } = state.selection.main;
296
-
297
- let min = Infinity;
298
- const { active, closest, ranges } = state.field(commentsStateField);
299
- const selected: CommentSelected = { active: undefined, closest: undefined };
300
- ranges.forEach((comment) => {
301
- const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
302
- if (head >= comment.from && head <= comment.to) {
303
- selected.active = comment.id;
304
- }
375
+ //
376
+ // Track selection/proximity.
377
+ //
378
+ EditorView.updateListener.of(({ view, state }) => {
379
+ let min = Infinity;
380
+ const {
381
+ selection: { current, closest },
382
+ comments,
383
+ } = state.field(commentsState);
384
+
385
+ const { head } = state.selection.main;
386
+ const selection: SelectionState = {};
387
+ comments.forEach(({ comment, range }) => {
388
+ if (head >= range.from && head <= range.to) {
389
+ selection.current = comment.id;
390
+ selection.closest = undefined;
391
+ }
392
+
393
+ if (!selection.current) {
394
+ const d = Math.min(Math.abs(head - range.from), Math.abs(head - range.to));
305
395
  if (d < min) {
306
- selected.closest = comment.id;
396
+ selection.closest = comment.id;
307
397
  min = d;
308
398
  }
309
- });
310
-
311
- // TODO(burdon): Fire debounced callback if range selected (to show hint).
312
-
313
- if (selected.active !== active || selected.closest !== closest) {
314
- view.dispatch({ effects: setSelection.of(selected) });
315
-
316
- // Update callback.
317
- handleSelect({
318
- ...selected,
319
- ranges: ranges.map((range) => ({ ...range, location: view.coordsAtPos(range.from) })),
320
- });
321
399
  }
400
+ });
401
+
402
+ if (selection.current !== current || selection.closest !== closest) {
403
+ view.dispatch({ effects: setSelection.of(selection) });
404
+
405
+ // Update callback.
406
+ handleSelect({
407
+ selection,
408
+ comments: comments.map(({ comment, range }) => ({
409
+ comment,
410
+ range,
411
+ location: view.coordsAtPos(range.from),
412
+ })),
413
+ });
322
414
  }
323
415
  }),
416
+
417
+ options.onUpdate ? trackPastedComments(options.onUpdate) : [],
324
418
  ];
325
419
  };
420
+
421
+ /**
422
+ * Update comments state field.
423
+ */
424
+ export const useComments = (view: EditorView | null, comments: Comment[] = []) => {
425
+ useEffect(() => {
426
+ if (view) {
427
+ view.dispatch({
428
+ effects: setComments.of(comments),
429
+ });
430
+ }
431
+ }, [view, comments]);
432
+ };
@@ -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';