@dxos/react-ui-editor 0.3.11-main.baa8c4d → 0.3.11-main.bc6b68b

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 (221) hide show
  1. package/dist/lib/browser/index.mjs +1882 -1531
  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 +14 -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 -3
  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/comments.stories.d.ts +26 -0
  13. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -0
  14. package/dist/types/src/components/TextEditor/index.d.ts +0 -2
  15. package/dist/types/src/components/TextEditor/index.d.ts.map +1 -1
  16. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -0
  17. package/dist/types/src/extensions/automerge/automerge.d.ts +9 -0
  18. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -0
  19. package/dist/types/src/extensions/automerge/cursor.d.ts +5 -0
  20. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -0
  21. package/dist/types/src/extensions/automerge/defs.d.ts +24 -0
  22. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -0
  23. package/dist/types/src/extensions/automerge/index.d.ts +3 -0
  24. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -0
  25. package/dist/types/src/extensions/automerge/semaphore.d.ts +14 -0
  26. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -0
  27. package/dist/types/src/extensions/automerge/update-automerge.d.ts +5 -0
  28. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -0
  29. package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -0
  30. package/dist/types/src/extensions/awareness.d.ts +46 -0
  31. package/dist/types/src/extensions/awareness.d.ts.map +1 -0
  32. package/dist/types/src/extensions/basic.d.ts +8 -0
  33. package/dist/types/src/extensions/basic.d.ts.map +1 -0
  34. package/dist/types/src/extensions/blast.d.ts.map +1 -0
  35. package/dist/types/src/extensions/code.d.ts.map +1 -0
  36. package/dist/types/src/{components/TextEditor/extensions → extensions}/comments.d.ts +33 -18
  37. package/dist/types/src/extensions/comments.d.ts.map +1 -0
  38. package/dist/types/src/extensions/cursor.d.ts +25 -0
  39. package/dist/types/src/extensions/cursor.d.ts.map +1 -0
  40. package/dist/types/src/extensions/hr.d.ts.map +1 -0
  41. package/dist/types/src/extensions/image.d.ts.map +1 -0
  42. package/dist/types/src/{components/TextEditor/extensions → extensions}/index.d.ts +4 -2
  43. package/dist/types/src/extensions/index.d.ts.map +1 -0
  44. package/dist/types/src/{components/TextEditor/extensions → extensions}/link.d.ts +1 -0
  45. package/dist/types/src/extensions/link.d.ts.map +1 -0
  46. package/dist/types/src/extensions/listener.d.ts +10 -0
  47. package/dist/types/src/extensions/listener.d.ts.map +1 -0
  48. package/dist/types/src/{components/TextEditor/extensions → extensions}/markdown/bundle.d.ts +2 -5
  49. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -0
  50. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -0
  51. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -0
  52. package/dist/types/src/extensions/mention.d.ts.map +1 -0
  53. package/dist/types/src/extensions/outliner.d.ts +4 -0
  54. package/dist/types/src/extensions/outliner.d.ts.map +1 -0
  55. package/dist/types/src/extensions/table.d.ts.map +1 -0
  56. package/dist/types/src/extensions/tasklist.d.ts.map +1 -0
  57. package/dist/types/src/extensions/typewriter.d.ts.map +1 -0
  58. package/dist/types/src/hooks/awareness-provider.d.ts +31 -0
  59. package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -0
  60. package/dist/types/src/hooks/defs.d.ts +25 -0
  61. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  62. package/dist/types/src/hooks/index.d.ts +3 -1
  63. package/dist/types/src/hooks/index.d.ts.map +1 -1
  64. package/dist/types/src/hooks/useTextEditor.d.ts +17 -0
  65. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
  66. package/dist/types/src/hooks/useTextModel.d.ts +2 -31
  67. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  68. package/dist/types/src/index.d.ts +5 -0
  69. package/dist/types/src/index.d.ts.map +1 -1
  70. package/dist/types/src/styles/cursors.d.ts.map +1 -0
  71. package/dist/types/src/styles/index.d.ts +1 -0
  72. package/dist/types/src/styles/index.d.ts.map +1 -1
  73. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  74. package/dist/types/src/styles/tokens.d.ts +1 -0
  75. package/dist/types/src/styles/tokens.d.ts.map +1 -1
  76. package/dist/types/src/{components/TextEditor/themes → themes}/default.d.ts +1 -1
  77. package/dist/types/src/themes/default.d.ts.map +1 -0
  78. package/dist/types/src/themes/index.d.ts.map +1 -0
  79. package/dist/types/src/util.d.ts +7 -0
  80. package/dist/types/src/util.d.ts.map +1 -0
  81. package/package.json +33 -34
  82. package/src/components/TextEditor/MarkdownEditor.stories.tsx +86 -91
  83. package/src/components/TextEditor/TextEditor.stories.tsx +13 -34
  84. package/src/components/TextEditor/TextEditor.tsx +124 -95
  85. package/src/{hooks/automerge → components/TextEditor}/automerge.stories.tsx +66 -6
  86. package/src/components/TextEditor/comments.stories.tsx +357 -0
  87. package/src/components/TextEditor/index.ts +0 -3
  88. package/src/{components/TextEditor/extensions → extensions}/autocomplete.ts +23 -19
  89. package/src/extensions/automerge/automerge.ts +88 -0
  90. package/src/extensions/automerge/cursor.ts +52 -0
  91. package/src/extensions/automerge/defs.ts +41 -0
  92. package/src/extensions/automerge/index.ts +6 -0
  93. package/src/{hooks → extensions}/automerge/semaphore.ts +34 -29
  94. package/src/{hooks → extensions}/automerge/update-automerge.ts +5 -6
  95. package/src/{hooks → extensions}/automerge/update-codemirror.ts +13 -5
  96. package/src/extensions/awareness.ts +310 -0
  97. package/src/extensions/basic.ts +37 -0
  98. package/src/{components/TextEditor/extensions → extensions}/code.ts +28 -23
  99. package/src/extensions/comments.ts +428 -0
  100. package/src/extensions/cursor.ts +50 -0
  101. package/src/extensions/hr.ts +72 -0
  102. package/src/extensions/image.ts +87 -0
  103. package/src/{components/TextEditor/extensions → extensions}/index.ts +4 -2
  104. package/src/{components/TextEditor/extensions → extensions}/link.ts +116 -62
  105. package/src/extensions/listener.ts +35 -0
  106. package/src/{components/TextEditor/extensions → extensions}/markdown/bundle.ts +18 -24
  107. package/src/{components/TextEditor/extensions → extensions}/markdown/highlight.ts +4 -13
  108. package/src/{components/TextEditor/extensions → extensions}/mention.ts +9 -2
  109. package/src/extensions/outliner.ts +12 -0
  110. package/src/{components/TextEditor/extensions → extensions}/table.ts +10 -4
  111. package/src/extensions/tasklist.ts +111 -0
  112. package/src/{components/TextEditor/extensions → extensions}/typewriter.ts +1 -1
  113. package/src/hooks/awareness-provider.ts +124 -0
  114. package/src/hooks/defs.ts +42 -0
  115. package/src/hooks/index.ts +4 -1
  116. package/src/hooks/useTextEditor.ts +29 -0
  117. package/src/hooks/useTextModel.ts +30 -115
  118. package/src/index.ts +5 -0
  119. package/src/styles/index.ts +1 -0
  120. package/src/styles/markdown.ts +0 -2
  121. package/src/styles/tokens.ts +3 -0
  122. package/src/{components/TextEditor/themes → themes}/default.ts +52 -79
  123. package/src/util.ts +41 -0
  124. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
  125. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
  126. package/dist/types/src/components/TextEditor/extensions/autocomplete.d.ts.map +0 -1
  127. package/dist/types/src/components/TextEditor/extensions/basic.d.ts +0 -9
  128. package/dist/types/src/components/TextEditor/extensions/basic.d.ts.map +0 -1
  129. package/dist/types/src/components/TextEditor/extensions/blast.d.ts.map +0 -1
  130. package/dist/types/src/components/TextEditor/extensions/code.d.ts.map +0 -1
  131. package/dist/types/src/components/TextEditor/extensions/comments.d.ts.map +0 -1
  132. package/dist/types/src/components/TextEditor/extensions/hr.d.ts.map +0 -1
  133. package/dist/types/src/components/TextEditor/extensions/image.d.ts.map +0 -1
  134. package/dist/types/src/components/TextEditor/extensions/index.d.ts.map +0 -1
  135. package/dist/types/src/components/TextEditor/extensions/link.d.ts.map +0 -1
  136. package/dist/types/src/components/TextEditor/extensions/listener.d.ts +0 -9
  137. package/dist/types/src/components/TextEditor/extensions/listener.d.ts.map +0 -1
  138. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts.map +0 -1
  139. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts.map +0 -1
  140. package/dist/types/src/components/TextEditor/extensions/markdown/index.d.ts.map +0 -1
  141. package/dist/types/src/components/TextEditor/extensions/mention.d.ts.map +0 -1
  142. package/dist/types/src/components/TextEditor/extensions/mermaid.d.ts +0 -3
  143. package/dist/types/src/components/TextEditor/extensions/mermaid.d.ts.map +0 -1
  144. package/dist/types/src/components/TextEditor/extensions/table.d.ts.map +0 -1
  145. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts.map +0 -1
  146. package/dist/types/src/components/TextEditor/extensions/tooltip.d.ts +0 -12
  147. package/dist/types/src/components/TextEditor/extensions/tooltip.d.ts.map +0 -1
  148. package/dist/types/src/components/TextEditor/extensions/typewriter.d.ts.map +0 -1
  149. package/dist/types/src/components/TextEditor/extensions/util.d.ts +0 -5
  150. package/dist/types/src/components/TextEditor/extensions/util.d.ts.map +0 -1
  151. package/dist/types/src/components/TextEditor/themes/default.d.ts.map +0 -1
  152. package/dist/types/src/components/TextEditor/themes/index.d.ts.map +0 -1
  153. package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +0 -1
  154. package/dist/types/src/hooks/automerge/handle.d.ts +0 -9
  155. package/dist/types/src/hooks/automerge/handle.d.ts.map +0 -1
  156. package/dist/types/src/hooks/automerge/index.d.ts +0 -3
  157. package/dist/types/src/hooks/automerge/index.d.ts.map +0 -1
  158. package/dist/types/src/hooks/automerge/plugin.d.ts +0 -26
  159. package/dist/types/src/hooks/automerge/plugin.d.ts.map +0 -1
  160. package/dist/types/src/hooks/automerge/semaphore.d.ts +0 -19
  161. package/dist/types/src/hooks/automerge/semaphore.d.ts.map +0 -1
  162. package/dist/types/src/hooks/automerge/update-automerge.d.ts +0 -6
  163. package/dist/types/src/hooks/automerge/update-automerge.d.ts.map +0 -1
  164. package/dist/types/src/hooks/automerge/update-codemirror.d.ts.map +0 -1
  165. package/dist/types/src/hooks/yjs/cursors.d.ts.map +0 -1
  166. package/dist/types/src/hooks/yjs/index.d.ts +0 -3
  167. package/dist/types/src/hooks/yjs/index.d.ts.map +0 -1
  168. package/dist/types/src/hooks/yjs/space-provider.d.ts +0 -27
  169. package/dist/types/src/hooks/yjs/space-provider.d.ts.map +0 -1
  170. package/dist/types/src/hooks/yjs/yjs.stories.d.ts +0 -12
  171. package/dist/types/src/hooks/yjs/yjs.stories.d.ts.map +0 -1
  172. package/dist/types/src/hooks/yjs/yjs.test.d.ts +0 -2
  173. package/dist/types/src/hooks/yjs/yjs.test.d.ts.map +0 -1
  174. package/dist/types/src/testing/index.d.ts +0 -3
  175. package/dist/types/src/testing/index.d.ts.map +0 -1
  176. package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
  177. package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
  178. package/dist/types/src/testing/proto/index.d.ts +0 -2
  179. package/dist/types/src/testing/proto/index.d.ts.map +0 -1
  180. package/dist/types/src/testing/replicator.d.ts +0 -45
  181. package/dist/types/src/testing/replicator.d.ts.map +0 -1
  182. package/src/components/TextEditor/codemirror.stories.ts +0 -114
  183. package/src/components/TextEditor/extensions/basic.ts +0 -32
  184. package/src/components/TextEditor/extensions/comments.ts +0 -388
  185. package/src/components/TextEditor/extensions/hr.ts +0 -74
  186. package/src/components/TextEditor/extensions/image.ts +0 -69
  187. package/src/components/TextEditor/extensions/listener.ts +0 -23
  188. package/src/components/TextEditor/extensions/mermaid.ts +0 -11
  189. package/src/components/TextEditor/extensions/tasklist.ts +0 -121
  190. package/src/components/TextEditor/extensions/tooltip.ts +0 -61
  191. package/src/components/TextEditor/extensions/util.ts +0 -18
  192. package/src/hooks/automerge/handle.ts +0 -14
  193. package/src/hooks/automerge/index.ts +0 -6
  194. package/src/hooks/automerge/plugin.ts +0 -134
  195. package/src/hooks/yjs/index.ts +0 -6
  196. package/src/hooks/yjs/space-provider.ts +0 -126
  197. package/src/hooks/yjs/yjs.stories.tsx +0 -56
  198. package/src/hooks/yjs/yjs.test.ts +0 -59
  199. package/src/testing/index.ts +0 -6
  200. package/src/testing/proto/gen/schema.ts +0 -49
  201. package/src/testing/proto/index.ts +0 -5
  202. package/src/testing/proto/schema.proto +0 -15
  203. package/src/testing/replicator.ts +0 -189
  204. /package/dist/types/src/{components/TextEditor/extensions → extensions}/autocomplete.d.ts +0 -0
  205. /package/dist/types/src/{hooks → extensions}/automerge/update-codemirror.d.ts +0 -0
  206. /package/dist/types/src/{components/TextEditor/extensions → extensions}/blast.d.ts +0 -0
  207. /package/dist/types/src/{components/TextEditor/extensions → extensions}/code.d.ts +0 -0
  208. /package/dist/types/src/{components/TextEditor/extensions → extensions}/hr.d.ts +0 -0
  209. /package/dist/types/src/{components/TextEditor/extensions → extensions}/image.d.ts +0 -0
  210. /package/dist/types/src/{components/TextEditor/extensions → extensions}/markdown/highlight.d.ts +0 -0
  211. /package/dist/types/src/{components/TextEditor/extensions → extensions}/markdown/index.d.ts +0 -0
  212. /package/dist/types/src/{components/TextEditor/extensions → extensions}/mention.d.ts +0 -0
  213. /package/dist/types/src/{components/TextEditor/extensions → extensions}/table.d.ts +0 -0
  214. /package/dist/types/src/{components/TextEditor/extensions → extensions}/tasklist.d.ts +0 -0
  215. /package/dist/types/src/{components/TextEditor/extensions → extensions}/typewriter.d.ts +0 -0
  216. /package/dist/types/src/{hooks/yjs → styles}/cursors.d.ts +0 -0
  217. /package/dist/types/src/{components/TextEditor/themes → themes}/index.d.ts +0 -0
  218. /package/src/{components/TextEditor/extensions → extensions}/blast.ts +0 -0
  219. /package/src/{components/TextEditor/extensions → extensions}/markdown/index.ts +0 -0
  220. /package/src/{hooks/yjs → styles}/cursors.ts +0 -0
  221. /package/src/{components/TextEditor/themes → themes}/index.ts +0 -0
@@ -0,0 +1,428 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Extension, StateEffect, StateField, type Text } from '@codemirror/state';
6
+ import { hoverTooltip, keymap, type Command, Decoration, EditorView, type Rect } from '@codemirror/view';
7
+ import sortBy from 'lodash.sortby';
8
+ import { useEffect } from 'react';
9
+
10
+ import { debounce } from '@dxos/async';
11
+ import { invariant } from '@dxos/invariant';
12
+ import { nonNullable } from '@dxos/util';
13
+
14
+ import { Cursor } from './cursor';
15
+ import { type Comment, type Range } from '../hooks';
16
+ import { getToken } from '../styles';
17
+ import { callbackWrapper } from '../util';
18
+
19
+ // TODO(burdon): Reconcile with theme.
20
+ const styles = EditorView.baseTheme({
21
+ '& .cm-comment': {
22
+ backgroundColor: getToken('extend.colors.yellow.50'),
23
+ },
24
+ '& .cm-comment-current': {
25
+ backgroundColor: getToken('extend.colors.yellow.100'),
26
+ },
27
+ });
28
+
29
+ const marks = {
30
+ highlight: Decoration.mark({ class: 'cm-comment' }),
31
+ highlightActive: Decoration.mark({ class: 'cm-comment-current' }),
32
+ };
33
+
34
+ type CommentState = {
35
+ comment: Comment;
36
+ range: Range;
37
+ location?: Rect | null;
38
+ };
39
+
40
+ type SelectionState = {
41
+ current?: string;
42
+ closest?: string;
43
+ };
44
+
45
+ export type CommentsState = {
46
+ comments: CommentState[];
47
+ selection: SelectionState;
48
+ };
49
+
50
+ export const setFocus = (view: EditorView, id: string, center = true) => {
51
+ const comment = view.state.field(commentsState).comments.find((range) => range.comment.id === id);
52
+ if (!comment?.comment.cursor) {
53
+ return;
54
+ }
55
+
56
+ const range = Cursor.getRangeFromCursor(view.state, comment.comment.cursor);
57
+ if (range) {
58
+ view.dispatch({
59
+ selection: { anchor: range.from },
60
+ effects: [
61
+ //
62
+ EditorView.scrollIntoView(range.from, center ? { y: 'center' } : undefined),
63
+ setSelection.of({ current: id }),
64
+ ],
65
+ });
66
+ }
67
+ };
68
+
69
+ export const setComments = StateEffect.define<Comment[]>();
70
+
71
+ export const setSelection = StateEffect.define<SelectionState>();
72
+
73
+ const setCommentState = StateEffect.define<CommentsState>();
74
+
75
+ /**
76
+ * State field (reducer) that tracks comment ranges.
77
+ * The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
78
+ */
79
+ const commentsState = StateField.define<CommentsState>({
80
+ create: () => ({ comments: [], selection: {} }),
81
+ update: (value, tr) => {
82
+ for (const effect of tr.effects) {
83
+ // Update selection.
84
+ if (effect.is(setSelection)) {
85
+ return { ...value, selection: effect.value };
86
+ }
87
+
88
+ // Update range from store.
89
+ if (effect.is(setComments)) {
90
+ const comments: CommentState[] = effect.value
91
+ .map((comment) => {
92
+ // Skip cut/deleted comments.
93
+ if (!comment.cursor) {
94
+ return undefined;
95
+ }
96
+
97
+ const range = Cursor.getRangeFromCursor(tr.state, comment.cursor);
98
+ return range && { comment, range };
99
+ })
100
+ .filter(nonNullable);
101
+
102
+ return { ...value, comments };
103
+ }
104
+
105
+ // Update entire state.
106
+ if (effect.is(setCommentState)) {
107
+ return effect.value;
108
+ }
109
+ }
110
+
111
+ return value;
112
+ },
113
+ });
114
+
115
+ /**
116
+ * Decorate ranges.
117
+ */
118
+ const highlightDecorations = EditorView.decorations.compute([commentsState], (state) => {
119
+ const {
120
+ selection: { current },
121
+ comments,
122
+ } = state.field(commentsState);
123
+
124
+ const decorations = sortBy(comments ?? [], (range) => range.range.from)
125
+ ?.flatMap((comment) => {
126
+ const range = comment.range;
127
+ if (!range || range.from === range.to) {
128
+ console.warn('Invalid range:', range);
129
+ return undefined;
130
+ }
131
+
132
+ if (comment.comment.id === current) {
133
+ return marks.highlightActive.range(range.from, range.to);
134
+ } else {
135
+ return marks.highlight.range(range.from, range.to);
136
+ }
137
+ })
138
+ .filter(nonNullable);
139
+
140
+ return Decoration.set(decorations);
141
+ });
142
+
143
+ export type CommentsOptions = {
144
+ /**
145
+ * Key shortcut to create a new thread.
146
+ */
147
+ key?: string;
148
+ /**
149
+ * Called to create a new thread and return the thread id.
150
+ */
151
+ onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
152
+ /**
153
+ * Selection cut/deleted.
154
+ */
155
+ onDelete?: (thread: string) => void;
156
+ /**
157
+ * Called when a comment is moved.
158
+ */
159
+ onUpdate?: (thread: string, cursor: string) => void;
160
+ /**
161
+ * Called to notify which thread is currently closest to the cursor.
162
+ */
163
+ onSelect?: (state: CommentsState) => void;
164
+ /**
165
+ * Called to render tooltip.
166
+ */
167
+ onHover?: (el: Element, shortcut: string) => void;
168
+ };
169
+
170
+ // TODO(burdon): Handle cut/restore via undo (need to integrate with history?)
171
+ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
172
+ // Tracks indexed selections within text.
173
+ // TODO(burdon): Move to main state field?
174
+ let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
175
+
176
+ // Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
177
+ const handleTrack = (event: Event, view: EditorView) => {
178
+ const comments = view.state.field(commentsState);
179
+ const { main } = view.state.selection;
180
+ const selectedRanges = comments.comments.filter(
181
+ ({ range }) => range.from >= main.from && range.to <= main.to && range.from < range.to,
182
+ );
183
+
184
+ if (!selectedRanges.length) {
185
+ tracked = null;
186
+ } else {
187
+ tracked = {
188
+ text: view.state.doc.slice(main.from, main.to),
189
+ comments: selectedRanges.map(({ comment, range }) => ({
190
+ id: comment.id,
191
+ from: range.from - main.from,
192
+ to: range.to - main.from,
193
+ })),
194
+ };
195
+ }
196
+ };
197
+
198
+ return [
199
+ EditorView.domEventHandlers({
200
+ cut: handleTrack,
201
+ copy: handleTrack,
202
+ }),
203
+
204
+ // Handle paste.
205
+ EditorView.updateListener.of(({ view, state, transactions }) => {
206
+ if (tracked) {
207
+ const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
208
+ if (paste) {
209
+ let found = -1;
210
+ paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
211
+ if (text.eq(tracked!.text)) {
212
+ for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
213
+ fromB = transactions[i].changes.mapPos(fromB);
214
+ }
215
+
216
+ found = fromB;
217
+ }
218
+ });
219
+
220
+ if (found > -1) {
221
+ for (const moved of tracked.comments) {
222
+ const range = {
223
+ from: found + moved.from,
224
+ to: found + moved.to,
225
+ };
226
+
227
+ const cursor = Cursor.getCursorFromRange(state, range);
228
+ onUpdate(moved.id, cursor);
229
+ }
230
+ }
231
+
232
+ tracked = null;
233
+ }
234
+ }
235
+ }),
236
+ ];
237
+ };
238
+
239
+ /**
240
+ * Comment threads.
241
+ * 1). Updates the EditorModel to store relative selections for a set of comments threads.
242
+ * Since the selections are relative, they do not need to be updated when the document is edited.
243
+ * 2). Implements a StateField to track absolute selections corresponding to the comments (i.e., when the document is edited).
244
+ * 3). Creates decoration marks to apply classes to each selection.
245
+ * 4). Tracks the current cursor position to:
246
+ * a). Update the decoration to show if the cursor is within a current selection.
247
+ * b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
248
+ * 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
249
+ */
250
+ export const comments = (options: CommentsOptions = {}): Extension => {
251
+ const { key: shortcut = "meta-'" } = options;
252
+
253
+ const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
254
+
255
+ /**
256
+ * Create comment thread action.
257
+ */
258
+ const createCommentThread: Command = (view) => {
259
+ invariant(options.onCreate);
260
+ const { from, to } = view.state.selection.main;
261
+ if (from === to) {
262
+ return false;
263
+ }
264
+
265
+ // Don't allow selection at end of document.
266
+ if (to === view.state.doc.length) {
267
+ view.dispatch({
268
+ changes: {
269
+ from: to,
270
+ insert: '\n',
271
+ },
272
+ });
273
+ }
274
+
275
+ const cursor = Cursor.getCursorFromRange(view.state, { from, to });
276
+ if (cursor) {
277
+ // Create thread via callback.
278
+ const id = options.onCreate?.(cursor, view.coordsAtPos(from));
279
+ if (id) {
280
+ // Update range.
281
+ view.dispatch({
282
+ effects: setSelection.of({ current: id }),
283
+ selection: { anchor: from },
284
+ });
285
+
286
+ return true;
287
+ }
288
+ }
289
+
290
+ return false;
291
+ };
292
+
293
+ return [
294
+ commentsState,
295
+ highlightDecorations,
296
+ styles,
297
+
298
+ //
299
+ // Keymap.
300
+ //
301
+ options.onCreate
302
+ ? keymap.of([
303
+ {
304
+ key: shortcut,
305
+ run: callbackWrapper(createCommentThread),
306
+ },
307
+ ])
308
+ : [],
309
+
310
+ //
311
+ // Hover tooltip (for key shortcut hints, etc.)
312
+ // TODO(burdon): Factor out to generic hints extension for current selection/line.
313
+ //
314
+ options.onHover
315
+ ? hoverTooltip(
316
+ (view, pos) => {
317
+ const selection = view.state.selection.main;
318
+ if (selection && pos >= selection.from && pos <= selection.to) {
319
+ return {
320
+ pos: selection.from,
321
+ end: selection.to,
322
+ above: true,
323
+ create: () => {
324
+ const el = document.createElement('div');
325
+ options.onHover!(el, shortcut);
326
+ return { dom: el, offset: { x: 0, y: 8 } };
327
+ },
328
+ };
329
+ }
330
+
331
+ return null;
332
+ },
333
+ {
334
+ // TODO(burdon): Hide on change triggered immediately?
335
+ // hideOnChange: true,
336
+ hoverTime: 1_000,
337
+ },
338
+ )
339
+ : [],
340
+
341
+ //
342
+ // Track deleted ranges and update ranges for decorations.
343
+ //
344
+ EditorView.updateListener.of(({ view, state, changes }) => {
345
+ let mod = false;
346
+ const { comments, ...value } = state.field(commentsState);
347
+ changes.iterChanges((from, to, from2, to2) => {
348
+ comments.forEach(({ comment, range }) => {
349
+ // Test if range deleted.
350
+ if (from2 === to2) {
351
+ const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
352
+ if (!newRange || newRange.to - newRange.from === 0) {
353
+ options.onDelete?.(comment.id);
354
+ }
355
+ }
356
+
357
+ // Update range.
358
+ if (from <= range.to) {
359
+ const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
360
+ Object.assign(range, newRange);
361
+ mod = true;
362
+ }
363
+ });
364
+ });
365
+
366
+ if (mod) {
367
+ view.dispatch({ effects: setCommentState.of({ comments, ...value }) });
368
+ }
369
+ }),
370
+
371
+ //
372
+ // Track selection/proximity.
373
+ //
374
+ EditorView.updateListener.of(({ view, state }) => {
375
+ let min = Infinity;
376
+ const {
377
+ selection: { current, closest },
378
+ comments,
379
+ } = state.field(commentsState);
380
+
381
+ const { head } = state.selection.main;
382
+ const selection: SelectionState = {};
383
+ comments.forEach(({ comment, range }) => {
384
+ if (head >= range.from && head <= range.to) {
385
+ selection.current = comment.id;
386
+ selection.closest = undefined;
387
+ }
388
+
389
+ if (!selection.current) {
390
+ const d = Math.min(Math.abs(head - range.from), Math.abs(head - range.to));
391
+ if (d < min) {
392
+ selection.closest = comment.id;
393
+ min = d;
394
+ }
395
+ }
396
+ });
397
+
398
+ if (selection.current !== current || selection.closest !== closest) {
399
+ view.dispatch({ effects: setSelection.of(selection) });
400
+
401
+ // Update callback.
402
+ handleSelect({
403
+ selection,
404
+ comments: comments.map(({ comment, range }) => ({
405
+ comment,
406
+ range,
407
+ location: view.coordsAtPos(range.from),
408
+ })),
409
+ });
410
+ }
411
+ }),
412
+
413
+ options.onUpdate ? trackPastedComments(options.onUpdate) : [],
414
+ ];
415
+ };
416
+
417
+ /**
418
+ * Update comments state field.
419
+ */
420
+ export const useComments = (view: EditorView | null, comments: Comment[] = []) => {
421
+ useEffect(() => {
422
+ if (view) {
423
+ view.dispatch({
424
+ effects: setComments.of(comments),
425
+ });
426
+ }
427
+ }, [view, comments]);
428
+ };
@@ -0,0 +1,50 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type EditorState, Facet } from '@codemirror/state';
6
+
7
+ import type { Range } from '../hooks';
8
+
9
+ /**
10
+ * Converts indexes into the text document into stable peer-independent cursors.
11
+ *
12
+ * See:
13
+ * - https://automerge.org/automerge/api-docs/js/functions/next.getCursor.html
14
+ * - https://github.com/yjs/yjs?tab=readme-ov-file#relative-positions
15
+ *
16
+ * @param {assoc} number Negative values will associate the cursor with the previous character
17
+ * while positive - with the next one.
18
+ */
19
+ export interface CursorConverter {
20
+ toCursor(position: number, assoc?: -1 | 1 | undefined): string;
21
+ fromCursor(cursor: string): number;
22
+ }
23
+
24
+ const defaultCursorConverter: CursorConverter = {
25
+ toCursor: (position) => position.toString(),
26
+ fromCursor: (cursor) => parseInt(cursor),
27
+ };
28
+
29
+ export class Cursor {
30
+ static readonly converter = Facet.define<CursorConverter, CursorConverter>({
31
+ combine: (providers) => providers[0] ?? defaultCursorConverter,
32
+ });
33
+
34
+ static readonly getCursorFromRange = (state: EditorState, range: Range) => {
35
+ const cursorConverter = state.facet(Cursor.converter);
36
+
37
+ const from = cursorConverter.toCursor(range.from);
38
+ const to = cursorConverter.toCursor(range.to, -1);
39
+ return [from, to].join(':');
40
+ };
41
+
42
+ static readonly getRangeFromCursor = (state: EditorState, cursor: string) => {
43
+ const cursorConverter = state.facet(Cursor.converter);
44
+
45
+ const parts = cursor.split(':');
46
+ const from = cursorConverter.fromCursor(parts[0]);
47
+ const to = cursorConverter.fromCursor(parts[1]);
48
+ return from !== undefined && to !== undefined ? { from, to } : undefined;
49
+ };
50
+ }
@@ -0,0 +1,72 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { syntaxTree } from '@codemirror/language';
6
+ import { RangeSetBuilder } from '@codemirror/state';
7
+ import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate, WidgetType } from '@codemirror/view';
8
+
9
+ import { getToken } from '../styles';
10
+
11
+ // TODO(burdon): Reconcile with theme.
12
+ const styles = EditorView.baseTheme({
13
+ '& .cm-hr': {
14
+ // Note that block-level decorations should not have vertical margins,
15
+ borderTop: `1px solid ${getToken('extend.colors.neutral.200')}`,
16
+ },
17
+ });
18
+
19
+ class HorizontalRuleWidget extends WidgetType {
20
+ override toDOM() {
21
+ const el = document.createElement('div');
22
+ el.className = 'cm-hr';
23
+ return el;
24
+ }
25
+ }
26
+
27
+ const decoration = Decoration.replace({ widget: new HorizontalRuleWidget() });
28
+
29
+ const buildDecorations = (view: EditorView): DecorationSet => {
30
+ const builder = new RangeSetBuilder<Decoration>();
31
+ const { state } = view;
32
+ const cursor = state.selection.main.head;
33
+
34
+ for (const { from, to } of view.visibleRanges) {
35
+ syntaxTree(state).iterate({
36
+ enter: (node) => {
37
+ if (node.name === 'HorizontalRule') {
38
+ // Check if cursor is inside text.
39
+ if (cursor <= node.from || cursor >= node.to) {
40
+ builder.add(node.from, node.to, decoration);
41
+ }
42
+ }
43
+ },
44
+ from,
45
+ to,
46
+ });
47
+ }
48
+
49
+ return builder.finish();
50
+ };
51
+
52
+ export const hr = () => {
53
+ return [
54
+ styles,
55
+ ViewPlugin.fromClass(
56
+ class {
57
+ decorations: DecorationSet;
58
+
59
+ constructor(view: EditorView) {
60
+ this.decorations = buildDecorations(view);
61
+ }
62
+
63
+ update(update: ViewUpdate) {
64
+ this.decorations = buildDecorations(update.view);
65
+ }
66
+ },
67
+ {
68
+ decorations: (v) => v.decorations,
69
+ },
70
+ ),
71
+ ];
72
+ };
@@ -0,0 +1,87 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { syntaxTree } from '@codemirror/language';
6
+ import { type EditorState, type Extension, StateField, type Transaction, type Range } from '@codemirror/state';
7
+ import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
8
+
9
+ export type ImageOptions = {};
10
+
11
+ export const image = (options: ImageOptions = {}): Extension => {
12
+ return StateField.define<DecorationSet>({
13
+ create: (state) => {
14
+ return Decoration.set(buildDecorations(0, state.doc.length, state));
15
+ },
16
+ update: (value: DecorationSet, tr: Transaction) => {
17
+ if (!tr.docChanged && !tr.selection) {
18
+ return value;
19
+ }
20
+ // Find range of changes and cursor changes.
21
+ const cursor = tr.state.selection.main.head;
22
+ const oldCursor = tr.changes.mapPos(tr.startState.selection.main.head);
23
+ let from = Math.min(cursor, oldCursor);
24
+ let to = Math.max(cursor, oldCursor);
25
+ tr.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
26
+ from = Math.min(from, fromB);
27
+ to = Math.max(to, toB);
28
+ });
29
+ // Expand to cover lines
30
+ from = tr.state.doc.lineAt(from).from;
31
+ to = tr.state.doc.lineAt(to).to;
32
+ return value.map(tr.changes).update({
33
+ filterFrom: from,
34
+ filterTo: to,
35
+ filter: () => false,
36
+ add: buildDecorations(from, to, tr.state),
37
+ });
38
+ },
39
+ provide: (field) => EditorView.decorations.from(field),
40
+ });
41
+ };
42
+
43
+ const buildDecorations = (from: number, to: number, state: EditorState) => {
44
+ const decorations: Range<Decoration>[] = [];
45
+ const cursor = state.selection.main.head;
46
+
47
+ syntaxTree(state).iterate({
48
+ enter: (node) => {
49
+ if (node.name === 'Image') {
50
+ const urlNode = node.node.getChild('URL');
51
+ if (urlNode) {
52
+ const hide = state.readOnly || cursor < node.from || cursor > node.to;
53
+ const url = state.sliceDoc(urlNode.from, urlNode.to);
54
+ decorations.push(
55
+ Decoration.replace({
56
+ block: true, // Prevent cursor from entering.
57
+ widget: new ImageWidget(url),
58
+ }).range(hide ? node.from : node.to, node.to),
59
+ );
60
+ }
61
+ }
62
+ },
63
+ from,
64
+ to,
65
+ });
66
+
67
+ return decorations;
68
+ };
69
+
70
+ class ImageWidget extends WidgetType {
71
+ constructor(readonly _url: string) {
72
+ super();
73
+ }
74
+
75
+ override eq(other: this) {
76
+ return this._url === (other as any as ImageWidget)._url;
77
+ }
78
+
79
+ override toDOM(view: EditorView) {
80
+ const img = document.createElement('img');
81
+ img.setAttribute('src', this._url);
82
+ img.setAttribute('class', 'cm-image');
83
+ // Images are hidden until successfully loaded to avoid flickering effects.
84
+ img.onload = () => img.classList.add('cm-loaded-image');
85
+ return img;
86
+ }
87
+ }
@@ -3,18 +3,20 @@
3
3
  //
4
4
 
5
5
  export * from './autocomplete';
6
+ export * from './automerge';
7
+ export * from './awareness';
6
8
  export * from './basic';
7
9
  export * from './blast';
8
10
  export * from './code';
9
11
  export * from './comments';
12
+ export * from './cursor';
10
13
  export * from './hr';
11
14
  export * from './image';
12
15
  export * from './link';
13
16
  export * from './listener';
14
17
  export * from './markdown';
15
18
  export * from './mention';
16
- export * from './mermaid';
19
+ export * from './outliner';
17
20
  export * from './table';
18
21
  export * from './tasklist';
19
- export * from './tooltip';
20
22
  export * from './typewriter';