@dxos/react-ui-editor 0.3.11-main.f437bee → 0.3.11-main.f4c1077

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