@dxos/react-ui-editor 0.3.11-main.42059c4 → 0.3.11-main.474ad53

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