@dxos/react-ui-editor 0.3.11-main.e0dc42b → 0.3.11-main.e19f39d

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