@dxos/react-ui-editor 0.3.11-main.ec7a2b3 → 0.3.11-main.ee2b64c

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 (185) hide show
  1. package/dist/lib/browser/index.mjs +1702 -1100
  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/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 +9 -11
  27. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  28. package/dist/types/src/extensions/automerge/index.d.ts +0 -1
  29. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
  30. package/dist/types/src/extensions/automerge/semaphore.d.ts +8 -12
  31. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  32. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  33. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  34. package/dist/types/src/extensions/awareness.d.ts +2 -2
  35. package/dist/types/src/extensions/awareness.d.ts.map +1 -1
  36. package/dist/types/src/extensions/basic.d.ts +4 -5
  37. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  38. package/dist/types/src/extensions/comments.d.ts +35 -18
  39. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  40. package/dist/types/src/extensions/cursor.d.ts +3 -3
  41. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  42. package/dist/types/src/extensions/index.d.ts +1 -7
  43. package/dist/types/src/extensions/index.d.ts.map +1 -1
  44. package/dist/types/src/extensions/markdown/bundle.d.ts +3 -6
  45. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  46. package/dist/types/src/extensions/markdown/code.d.ts.map +1 -0
  47. package/dist/types/src/extensions/markdown/formatting.d.ts +13 -0
  48. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
  49. package/dist/types/src/extensions/markdown/heading.d.ts +6 -0
  50. package/dist/types/src/extensions/markdown/heading.d.ts.map +1 -0
  51. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  52. package/dist/types/src/extensions/markdown/hr.d.ts.map +1 -0
  53. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -0
  54. package/dist/types/src/extensions/markdown/index.d.ts +8 -0
  55. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  56. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -0
  57. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -0
  58. package/dist/types/src/extensions/markdown/tasklist.d.ts.map +1 -0
  59. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  60. package/dist/types/src/extensions/outliner.d.ts +4 -0
  61. package/dist/types/src/extensions/outliner.d.ts.map +1 -0
  62. package/dist/types/src/hooks/awareness-provider.d.ts +7 -4
  63. package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -1
  64. package/dist/types/src/hooks/defs.d.ts +25 -0
  65. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  66. package/dist/types/src/hooks/index.d.ts +4 -2
  67. package/dist/types/src/hooks/index.d.ts.map +1 -1
  68. package/dist/types/src/hooks/useActionHandler.d.ts +4 -0
  69. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -0
  70. package/dist/types/src/hooks/useEditorView.d.ts +17 -0
  71. package/dist/types/src/hooks/useEditorView.d.ts.map +1 -0
  72. package/dist/types/src/hooks/useTextEditor.d.ts +39 -0
  73. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
  74. package/dist/types/src/hooks/useTextModel.d.ts +14 -27
  75. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  76. package/dist/types/src/index.d.ts +1 -1
  77. package/dist/types/src/index.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/themes/default.d.ts.map +1 -1
  81. package/dist/types/src/util.d.ts +2 -0
  82. package/dist/types/src/util.d.ts.map +1 -1
  83. package/package.json +48 -38
  84. package/src/components/TextEditor/MarkdownEditor.stories.tsx +73 -82
  85. package/src/components/TextEditor/TextEditor.tsx +111 -75
  86. package/src/components/TextEditor/automerge.stories.tsx +1 -2
  87. package/src/components/TextEditor/hooks.stories.tsx +106 -0
  88. package/src/components/Toolbar/Toolbar.stories.tsx +90 -0
  89. package/src/components/Toolbar/Toolbar.tsx +166 -0
  90. package/src/components/Toolbar/index.ts +5 -0
  91. package/src/components/index.ts +1 -0
  92. package/src/extensions/autocomplete.ts +6 -3
  93. package/src/extensions/automerge/automerge.spec.tsx +76 -0
  94. package/src/extensions/automerge/automerge.test.ts +13 -0
  95. package/src/extensions/automerge/automerge.ts +54 -45
  96. package/src/extensions/automerge/cursor.ts +3 -3
  97. package/src/extensions/automerge/defs.ts +13 -34
  98. package/src/extensions/automerge/index.ts +0 -1
  99. package/src/extensions/automerge/semaphore.ts +33 -32
  100. package/src/extensions/automerge/update-automerge.ts +14 -8
  101. package/src/extensions/automerge/update-codemirror.ts +5 -5
  102. package/src/extensions/awareness.ts +22 -18
  103. package/src/extensions/basic.ts +17 -10
  104. package/src/extensions/blast.ts +4 -1
  105. package/src/extensions/comments.ts +327 -166
  106. package/src/extensions/cursor.ts +7 -3
  107. package/src/extensions/index.ts +1 -7
  108. package/src/extensions/markdown/bundle.ts +23 -28
  109. package/src/extensions/markdown/code.ts +150 -0
  110. package/src/extensions/markdown/formatting.ts +188 -0
  111. package/src/extensions/markdown/heading.ts +59 -0
  112. package/src/extensions/markdown/highlight.ts +9 -15
  113. package/src/extensions/markdown/hr.ts +71 -0
  114. package/src/extensions/markdown/index.ts +8 -0
  115. package/src/extensions/{link.ts → markdown/link.ts} +16 -7
  116. package/src/extensions/markdown/tasklist.ts +110 -0
  117. package/src/extensions/mention.ts +9 -2
  118. package/src/extensions/outliner.ts +12 -0
  119. package/src/hooks/awareness-provider.ts +24 -20
  120. package/src/hooks/defs.ts +42 -0
  121. package/src/hooks/index.ts +5 -2
  122. package/src/hooks/useActionHandler.ts +62 -0
  123. package/src/hooks/useEditorView.ts +29 -0
  124. package/src/hooks/useTextEditor.ts +153 -0
  125. package/src/hooks/useTextModel.ts +66 -56
  126. package/src/index.ts +1 -1
  127. package/src/styles/markdown.ts +9 -10
  128. package/src/themes/default.ts +11 -8
  129. package/src/util.ts +26 -3
  130. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
  131. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
  132. package/dist/types/src/components/TextEditor/comments.stories.d.ts +0 -26
  133. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +0 -1
  134. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  135. package/dist/types/src/extensions/code.d.ts.map +0 -1
  136. package/dist/types/src/extensions/hr.d.ts.map +0 -1
  137. package/dist/types/src/extensions/image.d.ts.map +0 -1
  138. package/dist/types/src/extensions/link.d.ts.map +0 -1
  139. package/dist/types/src/extensions/table.d.ts.map +0 -1
  140. package/dist/types/src/extensions/tasklist.d.ts.map +0 -1
  141. package/dist/types/src/extensions/yjs/cursor.d.ts +0 -4
  142. package/dist/types/src/extensions/yjs/cursor.d.ts.map +0 -1
  143. package/dist/types/src/extensions/yjs/index.d.ts +0 -2
  144. package/dist/types/src/extensions/yjs/index.d.ts.map +0 -1
  145. package/dist/types/src/extensions/yjs/yjs.d.ts +0 -4
  146. package/dist/types/src/extensions/yjs/yjs.d.ts.map +0 -1
  147. package/dist/types/src/extensions/yjs/yjs.test.d.ts +0 -2
  148. package/dist/types/src/extensions/yjs/yjs.test.d.ts.map +0 -1
  149. package/dist/types/src/hooks/automerge.d.ts +0 -3
  150. package/dist/types/src/hooks/automerge.d.ts.map +0 -1
  151. package/dist/types/src/hooks/yjs.d.ts +0 -27
  152. package/dist/types/src/hooks/yjs.d.ts.map +0 -1
  153. package/dist/types/src/testing/index.d.ts +0 -3
  154. package/dist/types/src/testing/index.d.ts.map +0 -1
  155. package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
  156. package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
  157. package/dist/types/src/testing/proto/index.d.ts +0 -2
  158. package/dist/types/src/testing/proto/index.d.ts.map +0 -1
  159. package/dist/types/src/testing/replicator.d.ts +0 -45
  160. package/dist/types/src/testing/replicator.d.ts.map +0 -1
  161. package/src/components/TextEditor/codemirror.stories.ts +0 -115
  162. package/src/components/TextEditor/comments.stories.tsx +0 -315
  163. package/src/components/TextEditor/yjs.stories.tsx +0 -56
  164. package/src/extensions/code.ts +0 -106
  165. package/src/extensions/hr.ts +0 -73
  166. package/src/extensions/tasklist.ts +0 -119
  167. package/src/extensions/yjs/cursor.ts +0 -22
  168. package/src/extensions/yjs/index.ts +0 -5
  169. package/src/extensions/yjs/yjs.test.ts +0 -35
  170. package/src/extensions/yjs/yjs.ts +0 -16
  171. package/src/hooks/automerge.ts +0 -52
  172. package/src/hooks/yjs.ts +0 -156
  173. package/src/testing/index.ts +0 -6
  174. package/src/testing/proto/gen/schema.ts +0 -49
  175. package/src/testing/proto/index.ts +0 -5
  176. package/src/testing/proto/schema.proto +0 -15
  177. package/src/testing/replicator.ts +0 -184
  178. /package/dist/types/src/extensions/{code.d.ts → markdown/code.d.ts} +0 -0
  179. /package/dist/types/src/extensions/{hr.d.ts → markdown/hr.d.ts} +0 -0
  180. /package/dist/types/src/extensions/{image.d.ts → markdown/image.d.ts} +0 -0
  181. /package/dist/types/src/extensions/{link.d.ts → markdown/link.d.ts} +0 -0
  182. /package/dist/types/src/extensions/{table.d.ts → markdown/table.d.ts} +0 -0
  183. /package/dist/types/src/extensions/{tasklist.d.ts → markdown/tasklist.d.ts} +0 -0
  184. /package/src/extensions/{image.ts → markdown/image.ts} +0 -0
  185. /package/src/extensions/{table.ts → markdown/table.ts} +0 -0
@@ -2,101 +2,112 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type Extension, StateEffect, StateField } 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
- '& .cm-comment': {
21
+ '&light .cm-comment, &light .cm-comment-current': { mixBlendMode: 'darken' },
22
+ '&dark .cm-comment, &dark .cm-comment-current': { mixBlendMode: 'plus-lighter' },
23
+ '&light .cm-comment': {
25
24
  backgroundColor: getToken('extend.colors.yellow.50'),
26
25
  },
27
- '& .cm-comment-active': {
26
+ '&light .cm-comment-current': {
28
27
  backgroundColor: getToken('extend.colors.yellow.100'),
29
28
  },
29
+ '&dark .cm-comment': {
30
+ color: getToken('extend.colors.yellow.50'),
31
+ backgroundColor: getToken('extend.colors.yellow.900'),
32
+ },
33
+ '&dark .cm-comment-current': {
34
+ color: getToken('extend.colors.yellow.100'),
35
+ backgroundColor: getToken('extend.colors.yellow.950'),
36
+ },
30
37
  });
31
38
 
32
- const marks = {
33
- highlight: Decoration.mark({ class: 'cm-comment' }),
34
- highlightActive: Decoration.mark({ class: 'cm-comment-active' }),
39
+ const commentMark = Decoration.mark({ class: 'cm-comment' });
40
+ const commentCurrentMark = Decoration.mark({ class: 'cm-comment-current' });
41
+
42
+ type CommentState = {
43
+ comment: Comment;
44
+ range: Range;
45
+ location?: Rect | null;
35
46
  };
36
47
 
37
- // TODO(burdon): Should this be part of state?
38
- type CommentSelected = {
39
- active?: string;
48
+ type SelectionState = {
49
+ current?: string;
40
50
  closest?: string;
41
51
  };
42
52
 
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[];
53
+ export type CommentsState = {
54
+ comments: CommentState[];
55
+ selection: SelectionState;
52
56
  };
53
57
 
54
- export const setFocus = (view: EditorView, thread: string) => {
55
- const range = view.state.field(commentsStateField).ranges.find((range) => range.id === thread);
56
- if (!range) {
58
+ export const focusComment = (view: EditorView, id: string, center = true) => {
59
+ const comment = view.state.field(commentsState).comments.find((range) => range.comment.id === id);
60
+ if (!comment?.comment.cursor) {
57
61
  return;
58
62
  }
59
63
 
60
- view.dispatch({
61
- effects: setSelection.of({ active: thread }),
62
- selection: { anchor: range.from },
63
- scrollIntoView: true,
64
- });
64
+ const range = Cursor.getRangeFromCursor(view.state, comment.comment.cursor);
65
+ if (range) {
66
+ view.dispatch({
67
+ selection: { anchor: range.from },
68
+ effects: [
69
+ //
70
+ EditorView.scrollIntoView(range.from, center ? { y: 'center' } : undefined),
71
+ setSelection.of({ current: id }),
72
+ ],
73
+ });
74
+ }
65
75
  };
66
76
 
67
- export const setCommentRange = StateEffect.define<{ model: EditorModel; comments: CommentRange[] }>();
77
+ export const setComments = StateEffect.define<Comment[]>();
68
78
 
69
- export const setSelection = StateEffect.define<CommentSelected>();
79
+ export const setSelection = StateEffect.define<SelectionState>();
70
80
 
71
81
  const setCommentState = StateEffect.define<CommentsState>();
72
82
 
73
83
  /**
74
84
  * 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.
85
+ * The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
77
86
  */
78
- const commentsStateField = StateField.define<CommentsState>({
79
- create: () => ({ ranges: [] }),
87
+ const commentsState = StateField.define<CommentsState>({
88
+ create: () => ({ comments: [], selection: {} }),
80
89
  update: (value, tr) => {
81
- const cursorConverter = tr.state.facet(Cursor.converter);
82
-
83
90
  for (const effect of tr.effects) {
84
91
  // Update selection.
85
92
  if (effect.is(setSelection)) {
86
- return { ...value, ...effect.value };
93
+ return { ...value, selection: effect.value };
87
94
  }
88
95
 
89
96
  // Update range from store.
90
- if (effect.is(setCommentRange)) {
91
- const { comments } = effect.value;
92
- const ranges: ExtendedCommentRange[] = comments
97
+ if (effect.is(setComments)) {
98
+ const comments: CommentState[] = effect.value
93
99
  .map((comment) => {
94
- const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
95
- return range && { ...comment, ...range };
100
+ // Skip cut/deleted comments.
101
+ if (!comment.cursor) {
102
+ return undefined;
103
+ }
104
+
105
+ const range = Cursor.getRangeFromCursor(tr.state, comment.cursor);
106
+ return range && { comment, range };
96
107
  })
97
108
  .filter(nonNullable);
98
109
 
99
- return { ...value, ranges };
110
+ return { ...value, comments };
100
111
  }
101
112
 
102
113
  // Update entire state.
@@ -112,35 +123,48 @@ const commentsStateField = StateField.define<CommentsState>({
112
123
  /**
113
124
  * Decorate ranges.
114
125
  */
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
- }
126
+ const commentsDecorations = EditorView.decorations.compute([commentsState], (state) => {
127
+ const {
128
+ selection: { current },
129
+ comments,
130
+ } = state.field(commentsState);
131
+
132
+ const decorations = sortBy(comments ?? [], (range) => range.range.from)
133
+ ?.flatMap((comment) => {
134
+ const range = comment.range;
135
+ if (!range || range.from === range.to) {
136
+ console.warn('Invalid range:', range);
137
+ return undefined;
138
+ }
124
139
 
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) ?? [];
140
+ if (comment.comment.id === current) {
141
+ return commentCurrentMark.range(range.from, range.to);
142
+ } else {
143
+ return commentMark.range(range.from, range.to);
144
+ }
145
+ })
146
+ .filter(nonNullable);
133
147
 
134
148
  return Decoration.set(decorations);
135
149
  });
136
150
 
137
151
  export type CommentsOptions = {
152
+ /**
153
+ * Key shortcut to create a new thread.
154
+ */
138
155
  key?: string;
139
- footnote?: boolean;
140
156
  /**
141
157
  * Called to create a new thread and return the thread id.
142
158
  */
143
159
  onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
160
+ /**
161
+ * Selection cut/deleted.
162
+ */
163
+ onDelete?: (thread: string) => void;
164
+ /**
165
+ * Called when a comment is moved.
166
+ */
167
+ onUpdate?: (thread: string, cursor: string) => void;
144
168
  /**
145
169
  * Called to notify which thread is currently closest to the cursor.
146
170
  */
@@ -151,6 +175,162 @@ export type CommentsOptions = {
151
175
  onHover?: (el: Element, shortcut: string) => void;
152
176
  };
153
177
 
178
+ type TrackedComment = { id: string; from: number; to: number };
179
+
180
+ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
181
+ // Tracks indexed selections within text.
182
+ let tracked: { text: Text; comments: TrackedComment[] } | null = null;
183
+
184
+ // Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
185
+ const handleTrack = (event: Event, view: EditorView) => {
186
+ const comments = view.state.field(commentsState);
187
+ const { main } = view.state.selection;
188
+ const selectedRanges = comments.comments.filter(
189
+ ({ range }) => range.from >= main.from && range.to <= main.to && range.from < range.to,
190
+ );
191
+
192
+ if (!selectedRanges.length) {
193
+ tracked = null;
194
+ } else {
195
+ tracked = {
196
+ text: view.state.doc.slice(main.from, main.to),
197
+ comments: selectedRanges.map(({ comment, range }) => ({
198
+ id: comment.id,
199
+ from: range.from - main.from,
200
+ to: range.to - main.from,
201
+ })),
202
+ };
203
+ }
204
+ };
205
+
206
+ return [
207
+ EditorView.domEventHandlers({
208
+ cut: handleTrack,
209
+ copy: handleTrack,
210
+ }),
211
+
212
+ // Track deleted comments.
213
+ invertedEffects.of((tr) => {
214
+ const { comments } = tr.startState.field(commentsState);
215
+ const effects: StateEffect<any>[] = [];
216
+ tr.changes.iterChangedRanges((fromA, toA) => {
217
+ for (const {
218
+ comment: { id },
219
+ range: { from, to },
220
+ } of comments) {
221
+ if (from < to && from >= fromA && to <= toA) {
222
+ effects.push(restoreCommentEffect.of({ id, from, to }));
223
+ }
224
+ }
225
+ });
226
+
227
+ return effects;
228
+ }),
229
+
230
+ // Handle paste or the undo of comment deletion.
231
+ EditorView.updateListener.of((update) => {
232
+ const restore: TrackedComment[] = [];
233
+
234
+ for (let i = 0; i < update.transactions.length; i++) {
235
+ const tr = update.transactions[i];
236
+ for (let j = 0; j < restore.length; j++) {
237
+ restore[j] = mapTrackedComment(restore[j], tr.changes);
238
+ }
239
+ for (const effect of tr.effects) {
240
+ if (effect.is(restoreCommentEffect)) {
241
+ restore.push(effect.value);
242
+ }
243
+ }
244
+ }
245
+
246
+ if (tracked) {
247
+ const paste = update.transactions.find((tr) => tr.isUserEvent('input.paste'));
248
+ if (paste) {
249
+ let found = -1;
250
+ paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
251
+ if (text.eq(tracked!.text)) {
252
+ for (let i = update.transactions.indexOf(paste!) + 1; i < update.transactions.length; i++) {
253
+ fromB = update.transactions[i].changes.mapPos(fromB);
254
+ }
255
+
256
+ found = fromB;
257
+ }
258
+ });
259
+
260
+ if (found > -1) {
261
+ for (const moved of tracked.comments) {
262
+ restore.push({ id: moved.id, from: found + moved.from, to: found + moved.to });
263
+ }
264
+ }
265
+
266
+ tracked = null;
267
+ }
268
+ }
269
+
270
+ for (const comment of restore) {
271
+ const { comments } = update.startState.field(commentsState);
272
+ const exists = comments.some((c) => c.comment.id === comment.id && c.range.from < c.range.to);
273
+ if (!exists) {
274
+ const cursor = Cursor.getCursorFromRange(update.state, comment);
275
+ onUpdate(comment.id, cursor);
276
+ }
277
+ }
278
+ }),
279
+ ];
280
+ };
281
+
282
+ const mapTrackedComment = (comment: TrackedComment, changes: ChangeDesc) => ({
283
+ id: comment.id,
284
+ from: changes.mapPos(comment.from, 1),
285
+ to: changes.mapPos(comment.to, 1),
286
+ });
287
+
288
+ // These are attached to undone/redone transactions in the editor for the purpose of restoring comments
289
+ // that were deleted by the original changes.
290
+ const restoreCommentEffect = StateEffect.define<TrackedComment>({ map: mapTrackedComment });
291
+
292
+ const optionsFacet = Facet.define<CommentsOptions, CommentsOptions>({
293
+ combine: (providers) => providers[0],
294
+ });
295
+
296
+ /**
297
+ * Create comment thread action.
298
+ */
299
+ export const createComment: Command = (view) => {
300
+ const options = view.state.facet(optionsFacet);
301
+ const { from, to } = view.state.selection.main;
302
+ if (from === to) {
303
+ return false;
304
+ }
305
+
306
+ // Don't allow selection at end of document.
307
+ if (to === view.state.doc.length) {
308
+ view.dispatch({
309
+ changes: {
310
+ from: to,
311
+ insert: '\n',
312
+ },
313
+ });
314
+ }
315
+
316
+ const cursor = Cursor.getCursorFromRange(view.state, { from, to });
317
+ if (cursor) {
318
+ // Create thread via callback.
319
+ const id = options.onCreate?.(cursor, view.coordsAtPos(from));
320
+ if (id) {
321
+ // Update range.
322
+ view.dispatch({
323
+ effects: setSelection.of({ current: id }),
324
+ selection: { anchor: to },
325
+ });
326
+
327
+ return true;
328
+ }
329
+ }
330
+
331
+ return false;
332
+ };
333
+
154
334
  /**
155
335
  * Comment threads.
156
336
  * 1). Updates the EditorModel to store relative selections for a set of comments threads.
@@ -160,56 +340,17 @@ export type CommentsOptions = {
160
340
  * 4). Tracks the current cursor position to:
161
341
  * a). Update the decoration to show if the cursor is within a current selection.
162
342
  * 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.
343
+ * 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
165
344
  */
166
345
  export const comments = (options: CommentsOptions = {}): Extension => {
167
346
  const { key: shortcut = "meta-'" } = options;
168
347
 
169
348
  const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
170
349
 
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
350
  return [
211
- commentsStateField,
212
- highlightDecorations,
351
+ optionsFacet.of(options),
352
+ commentsState,
353
+ commentsDecorations,
213
354
  styles,
214
355
 
215
356
  //
@@ -219,7 +360,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
219
360
  ? keymap.of([
220
361
  {
221
362
  key: shortcut,
222
- run: callbackWrapper(createCommentThread),
363
+ run: callbackWrapper(createComment),
223
364
  },
224
365
  ])
225
366
  : [],
@@ -238,9 +379,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
238
379
  end: selection.to,
239
380
  above: true,
240
381
  create: () => {
241
- // TODO(burdon): Dispatch to react callback to render (or use SSR)?
242
382
  const el = document.createElement('div');
243
- options.onHover?.(el, shortcut);
383
+ options.onHover!(el, shortcut);
244
384
  return { dom: el, offset: { x: 0, y: 8 } };
245
385
  },
246
386
  };
@@ -248,78 +388,99 @@ export const comments = (options: CommentsOptions = {}): Extension => {
248
388
 
249
389
  return null;
250
390
  },
251
- { hideOnChange: true, hoverTime: 1000 },
391
+ {
392
+ // TODO(burdon): Hide on change triggered immediately?
393
+ // hideOnChange: true,
394
+ hoverTime: 1_000,
395
+ },
252
396
  )
253
397
  : [],
254
398
 
255
399
  //
256
- // Monitor cursor movement and text updates.
257
- // TODO(burdon): Is there a better (finer grained) way to do this?
400
+ // Track deleted ranges and update ranges for decorations.
258
401
  //
259
402
  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
- }
403
+ let mod = false;
404
+ const { comments, ...value } = state.field(commentsState);
405
+ changes.iterChanges((from, to, from2, to2) => {
406
+ comments.forEach(({ comment, range }) => {
407
+ // Test if range deleted.
408
+ if (from2 === to2) {
409
+ const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
410
+ if (!newRange || newRange.to - newRange.from === 0) {
411
+ options.onDelete?.(comment.id);
276
412
  }
413
+ }
277
414
 
278
- if (from <= range.to) {
279
- const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
280
- Object.assign(range, newRange);
281
- mod = true;
282
- }
283
- });
415
+ // Update range.
416
+ if (from <= range.to) {
417
+ const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
418
+ Object.assign(range, newRange);
419
+ mod = true;
420
+ }
284
421
  });
422
+ });
285
423
 
286
- if (mod) {
287
- view.dispatch({ effects: setCommentState.of({ active, ranges }) });
288
- }
424
+ if (mod) {
425
+ view.dispatch({ effects: setCommentState.of({ comments, ...value }) });
289
426
  }
427
+ }),
290
428
 
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
- }
429
+ //
430
+ // Track selection/proximity.
431
+ //
432
+ EditorView.updateListener.of(({ view, state }) => {
433
+ let min = Infinity;
434
+ const {
435
+ selection: { current, closest },
436
+ comments,
437
+ } = state.field(commentsState);
438
+
439
+ const { head } = state.selection.main;
440
+ const selection: SelectionState = {};
441
+ comments.forEach(({ comment, range }) => {
442
+ if (head >= range.from && head <= range.to) {
443
+ selection.current = comment.id;
444
+ selection.closest = undefined;
445
+ }
446
+
447
+ if (!selection.current) {
448
+ const d = Math.min(Math.abs(head - range.from), Math.abs(head - range.to));
305
449
  if (d < min) {
306
- selected.closest = comment.id;
450
+ selection.closest = comment.id;
307
451
  min = d;
308
452
  }
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
453
  }
454
+ });
455
+
456
+ if (selection.current !== current || selection.closest !== closest) {
457
+ view.dispatch({ effects: setSelection.of(selection) });
458
+
459
+ // Update callback.
460
+ handleSelect({
461
+ selection,
462
+ comments: comments.map(({ comment, range }) => ({
463
+ comment,
464
+ range,
465
+ location: view.coordsAtPos(range.from),
466
+ })),
467
+ });
322
468
  }
323
469
  }),
470
+
471
+ options.onUpdate ? trackPastedComments(options.onUpdate) : [],
324
472
  ];
325
473
  };
474
+
475
+ /**
476
+ * Update comments state field.
477
+ */
478
+ export const useComments = (view: EditorView | null, comments: Comment[] = []) => {
479
+ useEffect(() => {
480
+ if (view) {
481
+ view.dispatch({
482
+ effects: setComments.of(comments),
483
+ });
484
+ }
485
+ }, [view, comments]);
486
+ };
@@ -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';