@dxos/react-ui-editor 0.3.11-main.6cabbc5 → 0.3.11-main.6cd6cc9

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