@dxos/react-ui-editor 0.3.11-main.5a4849d → 0.3.11-main.5c52a13

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