@dxos/react-ui-editor 0.3.11-next.0fb359e → 0.3.11-next.e28df4f

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