@dxos/react-ui-editor 0.3.11-main.08776d7 → 0.3.11-main.0999ea0

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 (160) hide show
  1. package/dist/lib/browser/index.mjs +1211 -1005
  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 +12 -17
  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 -7
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +1 -1
  9. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/Toolbar/Toolbar.d.ts +9 -0
  11. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -0
  12. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +14 -0
  13. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
  14. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  15. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  16. package/dist/types/src/extensions/automerge/automerge.spec.d.ts +2 -0
  17. package/dist/types/src/extensions/automerge/automerge.spec.d.ts.map +1 -0
  18. package/dist/types/src/extensions/automerge/automerge.test.d.ts +2 -0
  19. package/dist/types/src/extensions/automerge/automerge.test.d.ts.map +1 -0
  20. package/dist/types/src/extensions/automerge/defs.d.ts +6 -6
  21. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  22. package/dist/types/src/extensions/automerge/semaphore.d.ts +6 -6
  23. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  24. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  25. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  26. package/dist/types/src/extensions/awareness.d.ts +2 -2
  27. package/dist/types/src/extensions/awareness.d.ts.map +1 -1
  28. package/dist/types/src/extensions/basic.d.ts +4 -5
  29. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  30. package/dist/types/src/extensions/comments.d.ts +11 -3
  31. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  32. package/dist/types/src/extensions/index.d.ts +0 -8
  33. package/dist/types/src/extensions/index.d.ts.map +1 -1
  34. package/dist/types/src/extensions/markdown/bundle.d.ts +3 -6
  35. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  36. package/dist/types/src/extensions/markdown/code.d.ts.map +1 -0
  37. package/dist/types/src/extensions/markdown/formatting.d.ts +11 -0
  38. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
  39. package/dist/types/src/extensions/markdown/heading.d.ts +6 -0
  40. package/dist/types/src/extensions/markdown/heading.d.ts.map +1 -0
  41. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  42. package/dist/types/src/extensions/markdown/hr.d.ts.map +1 -0
  43. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -0
  44. package/dist/types/src/extensions/markdown/index.d.ts +8 -0
  45. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  46. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -0
  47. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -0
  48. package/dist/types/src/extensions/markdown/tasklist.d.ts +3 -0
  49. package/dist/types/src/extensions/markdown/tasklist.d.ts.map +1 -0
  50. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  51. package/dist/types/src/hooks/awareness-provider.d.ts +7 -4
  52. package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -1
  53. package/dist/types/src/hooks/defs.d.ts +1 -4
  54. package/dist/types/src/hooks/defs.d.ts.map +1 -1
  55. package/dist/types/src/hooks/index.d.ts +2 -2
  56. package/dist/types/src/hooks/index.d.ts.map +1 -1
  57. package/dist/types/src/hooks/useActionHandler.d.ts +4 -0
  58. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -0
  59. package/dist/types/src/hooks/useTextEditor.d.ts +17 -0
  60. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
  61. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  62. package/dist/types/src/styles/markdown.d.ts +2 -2
  63. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  64. package/dist/types/src/themes/default.d.ts.map +1 -1
  65. package/dist/types/src/util.d.ts +2 -0
  66. package/dist/types/src/util.d.ts.map +1 -1
  67. package/package.json +45 -36
  68. package/src/components/TextEditor/MarkdownEditor.stories.tsx +61 -72
  69. package/src/components/TextEditor/TextEditor.tsx +101 -77
  70. package/src/components/TextEditor/comments.stories.tsx +16 -21
  71. package/src/components/Toolbar/Toolbar.stories.tsx +86 -0
  72. package/src/components/Toolbar/Toolbar.tsx +115 -0
  73. package/src/extensions/autocomplete.ts +6 -3
  74. package/src/extensions/automerge/automerge.spec.tsx +76 -0
  75. package/src/extensions/automerge/automerge.test.ts +13 -0
  76. package/src/extensions/automerge/automerge.ts +53 -13
  77. package/src/extensions/automerge/cursor.ts +3 -3
  78. package/src/extensions/automerge/defs.ts +9 -29
  79. package/src/extensions/automerge/semaphore.ts +24 -28
  80. package/src/extensions/automerge/update-automerge.ts +14 -8
  81. package/src/extensions/automerge/update-codemirror.ts +5 -5
  82. package/src/extensions/awareness.ts +17 -17
  83. package/src/extensions/basic.ts +22 -17
  84. package/src/extensions/comments.ts +149 -92
  85. package/src/extensions/index.ts +0 -8
  86. package/src/extensions/markdown/bundle.ts +20 -30
  87. package/src/extensions/markdown/code.ts +142 -0
  88. package/src/extensions/markdown/formatting.ts +159 -0
  89. package/src/extensions/markdown/heading.ts +59 -0
  90. package/src/extensions/markdown/highlight.ts +9 -15
  91. package/src/extensions/markdown/hr.ts +71 -0
  92. package/src/extensions/markdown/index.ts +8 -0
  93. package/src/extensions/{link.ts → markdown/link.ts} +5 -2
  94. package/src/extensions/{tasklist.ts → markdown/tasklist.ts} +48 -34
  95. package/src/extensions/mention.ts +9 -2
  96. package/src/hooks/awareness-provider.ts +24 -20
  97. package/src/hooks/defs.ts +1 -6
  98. package/src/hooks/index.ts +2 -2
  99. package/src/hooks/useActionHandler.ts +45 -0
  100. package/src/hooks/useTextEditor.ts +29 -0
  101. package/src/hooks/useTextModel.ts +48 -11
  102. package/src/styles/markdown.ts +9 -10
  103. package/src/themes/default.ts +1 -3
  104. package/src/util.ts +26 -3
  105. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
  106. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
  107. package/dist/types/src/components/TextEditor/yjs.stories.d.ts +0 -12
  108. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  109. package/dist/types/src/extensions/code.d.ts.map +0 -1
  110. package/dist/types/src/extensions/hr.d.ts.map +0 -1
  111. package/dist/types/src/extensions/image.d.ts.map +0 -1
  112. package/dist/types/src/extensions/link.d.ts.map +0 -1
  113. package/dist/types/src/extensions/sync.d.ts +0 -7
  114. package/dist/types/src/extensions/sync.d.ts.map +0 -1
  115. package/dist/types/src/extensions/table.d.ts.map +0 -1
  116. package/dist/types/src/extensions/tasklist.d.ts +0 -7
  117. package/dist/types/src/extensions/tasklist.d.ts.map +0 -1
  118. package/dist/types/src/extensions/yjs/cursor.d.ts +0 -4
  119. package/dist/types/src/extensions/yjs/cursor.d.ts.map +0 -1
  120. package/dist/types/src/extensions/yjs/index.d.ts +0 -2
  121. package/dist/types/src/extensions/yjs/index.d.ts.map +0 -1
  122. package/dist/types/src/extensions/yjs/yjs.d.ts +0 -4
  123. package/dist/types/src/extensions/yjs/yjs.d.ts.map +0 -1
  124. package/dist/types/src/extensions/yjs/yjs.test.d.ts +0 -2
  125. package/dist/types/src/extensions/yjs/yjs.test.d.ts.map +0 -1
  126. package/dist/types/src/hooks/automerge.d.ts +0 -4
  127. package/dist/types/src/hooks/automerge.d.ts.map +0 -1
  128. package/dist/types/src/hooks/yjs.d.ts +0 -28
  129. package/dist/types/src/hooks/yjs.d.ts.map +0 -1
  130. package/dist/types/src/testing/index.d.ts +0 -3
  131. package/dist/types/src/testing/index.d.ts.map +0 -1
  132. package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
  133. package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
  134. package/dist/types/src/testing/proto/index.d.ts +0 -2
  135. package/dist/types/src/testing/proto/index.d.ts.map +0 -1
  136. package/dist/types/src/testing/replicator.d.ts +0 -45
  137. package/dist/types/src/testing/replicator.d.ts.map +0 -1
  138. package/src/components/TextEditor/codemirror.stories.ts +0 -115
  139. package/src/components/TextEditor/yjs.stories.tsx +0 -56
  140. package/src/extensions/code.ts +0 -104
  141. package/src/extensions/hr.ts +0 -72
  142. package/src/extensions/sync.ts +0 -15
  143. package/src/extensions/yjs/cursor.ts +0 -22
  144. package/src/extensions/yjs/index.ts +0 -5
  145. package/src/extensions/yjs/yjs.test.ts +0 -35
  146. package/src/extensions/yjs/yjs.ts +0 -16
  147. package/src/hooks/automerge.ts +0 -53
  148. package/src/hooks/yjs.ts +0 -157
  149. package/src/testing/index.ts +0 -6
  150. package/src/testing/proto/gen/schema.ts +0 -49
  151. package/src/testing/proto/index.ts +0 -5
  152. package/src/testing/proto/schema.proto +0 -15
  153. package/src/testing/replicator.ts +0 -184
  154. /package/dist/types/src/extensions/{code.d.ts → markdown/code.d.ts} +0 -0
  155. /package/dist/types/src/extensions/{hr.d.ts → markdown/hr.d.ts} +0 -0
  156. /package/dist/types/src/extensions/{image.d.ts → markdown/image.d.ts} +0 -0
  157. /package/dist/types/src/extensions/{link.d.ts → markdown/link.d.ts} +0 -0
  158. /package/dist/types/src/extensions/{table.d.ts → markdown/table.d.ts} +0 -0
  159. /package/src/extensions/{image.ts → markdown/image.ts} +0 -0
  160. /package/src/extensions/{table.ts → markdown/table.ts} +0 -0
@@ -2,16 +2,16 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type Extension, StateEffect, StateField, type Text } from '@codemirror/state';
5
+ import { invertedEffects } from '@codemirror/commands';
6
+ import { type Extension, Facet, StateEffect, StateField, type Text, type ChangeDesc } from '@codemirror/state';
6
7
  import { hoverTooltip, keymap, type Command, Decoration, EditorView, type Rect } from '@codemirror/view';
7
8
  import sortBy from 'lodash.sortby';
9
+ import { useEffect } from 'react';
8
10
 
9
11
  import { debounce } from '@dxos/async';
10
- import { invariant } from '@dxos/invariant';
11
12
  import { nonNullable } from '@dxos/util';
12
13
 
13
14
  import { Cursor } from './cursor';
14
- import { syncFacet } from './sync';
15
15
  import { type Comment, type Range } from '../hooks';
16
16
  import { getToken } from '../styles';
17
17
  import { callbackWrapper } from '../util';
@@ -21,15 +21,13 @@ const styles = EditorView.baseTheme({
21
21
  '& .cm-comment': {
22
22
  backgroundColor: getToken('extend.colors.yellow.50'),
23
23
  },
24
- '& .cm-comment-active': {
24
+ '& .cm-comment-current': {
25
25
  backgroundColor: getToken('extend.colors.yellow.100'),
26
26
  },
27
27
  });
28
28
 
29
- const marks = {
30
- highlight: Decoration.mark({ class: 'cm-comment' }),
31
- highlightActive: Decoration.mark({ class: 'cm-comment-active' }),
32
- };
29
+ const commentMark = Decoration.mark({ class: 'cm-comment' });
30
+ const commentCurrentMark = Decoration.mark({ class: 'cm-comment-current' });
33
31
 
34
32
  type CommentState = {
35
33
  comment: Comment;
@@ -38,7 +36,7 @@ type CommentState = {
38
36
  };
39
37
 
40
38
  type SelectionState = {
41
- active?: string;
39
+ current?: string;
42
40
  closest?: string;
43
41
  };
44
42
 
@@ -47,17 +45,23 @@ export type CommentsState = {
47
45
  selection: SelectionState;
48
46
  };
49
47
 
50
- export const setFocus = (view: EditorView, thread: string) => {
51
- const comment = view.state.field(commentsState).comments.find((range) => range.comment.id === thread);
52
- if (!comment) {
48
+ export const setFocus = (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) {
53
51
  return;
54
52
  }
55
53
 
56
- view.dispatch({
57
- effects: setSelection.of({ active: thread }),
58
- selection: { anchor: comment.range.from },
59
- scrollIntoView: true, // TODO(burdon): Scroll to y-position (or center of screen?)
60
- });
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
+ }
61
65
  };
62
66
 
63
67
  export const setComments = StateEffect.define<Comment[]>();
@@ -89,7 +93,6 @@ const commentsState = StateField.define<CommentsState>({
89
93
  }
90
94
 
91
95
  const range = Cursor.getRangeFromCursor(tr.state, comment.cursor);
92
- // console.log('update', JSON.stringify({ range, cursor: comment.cursor }, undefined, 2));
93
96
  return range && { comment, range };
94
97
  })
95
98
  .filter(nonNullable);
@@ -110,9 +113,9 @@ const commentsState = StateField.define<CommentsState>({
110
113
  /**
111
114
  * Decorate ranges.
112
115
  */
113
- const highlightDecorations = EditorView.decorations.compute([commentsState], (state) => {
116
+ const commentsDecorations = EditorView.decorations.compute([commentsState], (state) => {
114
117
  const {
115
- selection: { active },
118
+ selection: { current },
116
119
  comments,
117
120
  } = state.field(commentsState);
118
121
 
@@ -124,10 +127,10 @@ const highlightDecorations = EditorView.decorations.compute([commentsState], (st
124
127
  return undefined;
125
128
  }
126
129
 
127
- if (comment.comment.id === active) {
128
- return marks.highlightActive.range(range.from, range.to);
130
+ if (comment.comment.id === current) {
131
+ return commentCurrentMark.range(range.from, range.to);
129
132
  } else {
130
- return marks.highlight.range(range.from, range.to);
133
+ return commentMark.range(range.from, range.to);
131
134
  }
132
135
  })
133
136
  .filter(nonNullable);
@@ -162,12 +165,11 @@ export type CommentsOptions = {
162
165
  onHover?: (el: Element, shortcut: string) => void;
163
166
  };
164
167
 
165
- // TODO(burdon): Handle cut/restore via undo (need to integrate with history?)
168
+ type TrackedComment = { id: string; from: number; to: number };
169
+
166
170
  const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
167
- // Cut/deleted comments.
168
171
  // Tracks indexed selections within text.
169
- // TODO(burdon): Move to main state field?
170
- let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
172
+ let tracked: { text: Text; comments: TrackedComment[] } | null = null;
171
173
 
172
174
  // Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
173
175
  const handleTrack = (event: Event, view: EditorView) => {
@@ -189,8 +191,6 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
189
191
  })),
190
192
  };
191
193
  }
192
-
193
- // console.log('track', JSON.stringify({ tracked }, undefined, 2));
194
194
  };
195
195
 
196
196
  return [
@@ -199,16 +199,48 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
199
199
  copy: handleTrack,
200
200
  }),
201
201
 
202
- // Handle paste.
203
- EditorView.updateListener.of(({ view, state, transactions }) => {
202
+ // Track deleted comments.
203
+ invertedEffects.of((tr) => {
204
+ const { comments } = tr.startState.field(commentsState);
205
+ const effects: StateEffect<any>[] = [];
206
+ tr.changes.iterChangedRanges((fromA, toA) => {
207
+ for (const {
208
+ comment: { id },
209
+ range: { from, to },
210
+ } of comments) {
211
+ if (from < to && from >= fromA && to <= toA) {
212
+ effects.push(restoreCommentEffect.of({ id, from, to }));
213
+ }
214
+ }
215
+ });
216
+
217
+ return effects;
218
+ }),
219
+
220
+ // Handle paste or the undo of comment deletion.
221
+ EditorView.updateListener.of((update) => {
222
+ const restore: TrackedComment[] = [];
223
+
224
+ for (let i = 0; i < update.transactions.length; i++) {
225
+ const tr = update.transactions[i];
226
+ for (let j = 0; j < restore.length; j++) {
227
+ restore[j] = mapTrackedComment(restore[j], tr.changes);
228
+ }
229
+ for (const effect of tr.effects) {
230
+ if (effect.is(restoreCommentEffect)) {
231
+ restore.push(effect.value);
232
+ }
233
+ }
234
+ }
235
+
204
236
  if (tracked) {
205
- const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
237
+ const paste = update.transactions.find((tr) => tr.isUserEvent('input.paste'));
206
238
  if (paste) {
207
239
  let found = -1;
208
240
  paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
209
241
  if (text.eq(tracked!.text)) {
210
- for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
211
- fromB = transactions[i].changes.mapPos(fromB);
242
+ for (let i = update.transactions.indexOf(paste!) + 1; i < update.transactions.length; i++) {
243
+ fromB = update.transactions[i].changes.mapPos(fromB);
212
244
  }
213
245
 
214
246
  found = fromB;
@@ -216,29 +248,79 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
216
248
  });
217
249
 
218
250
  if (found > -1) {
219
- // Sync before recomputing cursor/range.
220
- // TODO(burdon): Try to avoid this? See TextEditor dispatch.
221
- state.facet(syncFacet)?.reconcile(view);
222
-
223
251
  for (const moved of tracked.comments) {
224
- const range = {
225
- from: found + moved.from,
226
- to: found + moved.to,
227
- };
228
-
229
- const cursor = Cursor.getCursorFromRange(state, range);
230
- // console.log('paste', JSON.stringify({ moved, range, cursor }, undefined, 2));
231
- onUpdate(moved.id, cursor);
252
+ restore.push({ id: moved.id, from: found + moved.from, to: found + moved.to });
232
253
  }
233
254
  }
234
255
 
235
256
  tracked = null;
236
257
  }
237
258
  }
259
+
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
+ }
267
+ }
238
268
  }),
239
269
  ];
240
270
  };
241
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
+
242
324
  /**
243
325
  * Comment threads.
244
326
  * 1). Updates the EditorModel to store relative selections for a set of comments threads.
@@ -255,47 +337,10 @@ export const comments = (options: CommentsOptions = {}): Extension => {
255
337
 
256
338
  const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
257
339
 
258
- /**
259
- * Create comment thread action.
260
- */
261
- const createCommentThread: Command = (view) => {
262
- invariant(options.onCreate);
263
- const { from, to } = view.state.selection.main;
264
- if (from === to) {
265
- return false;
266
- }
267
-
268
- // Don't allow selection at end of document.
269
- if (to === view.state.doc.length) {
270
- view.dispatch({
271
- changes: {
272
- from: to,
273
- insert: '\n',
274
- },
275
- });
276
- }
277
-
278
- const cursor = Cursor.getCursorFromRange(view.state, { from, to });
279
- if (cursor) {
280
- // Create thread via callback.
281
- const id = callbackWrapper(options.onCreate)(cursor, view.coordsAtPos(from));
282
- if (id) {
283
- // Update range.
284
- view.dispatch({
285
- effects: setSelection.of({ active: id }),
286
- selection: { anchor: from },
287
- });
288
-
289
- return true;
290
- }
291
- }
292
-
293
- return false;
294
- };
295
-
296
340
  return [
341
+ optionsFacet.of(options),
297
342
  commentsState,
298
- highlightDecorations,
343
+ commentsDecorations,
299
344
  styles,
300
345
 
301
346
  //
@@ -305,8 +350,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
305
350
  ? keymap.of([
306
351
  {
307
352
  key: shortcut,
308
- // TODO(burdon): Check if there's a better way?
309
- run: callbackWrapper(createCommentThread),
353
+ run: callbackWrapper(createComment),
310
354
  },
311
355
  ])
312
356
  : [],
@@ -375,10 +419,10 @@ export const comments = (options: CommentsOptions = {}): Extension => {
375
419
  //
376
420
  // Track selection/proximity.
377
421
  //
378
- EditorView.updateListener.of(({ view, state, changes }) => {
422
+ EditorView.updateListener.of(({ view, state }) => {
379
423
  let min = Infinity;
380
424
  const {
381
- selection: { active, closest },
425
+ selection: { current, closest },
382
426
  comments,
383
427
  } = state.field(commentsState);
384
428
 
@@ -386,11 +430,11 @@ export const comments = (options: CommentsOptions = {}): Extension => {
386
430
  const selection: SelectionState = {};
387
431
  comments.forEach(({ comment, range }) => {
388
432
  if (head >= range.from && head <= range.to) {
389
- selection.active = comment.id;
433
+ selection.current = comment.id;
390
434
  selection.closest = undefined;
391
435
  }
392
436
 
393
- if (!selection.active) {
437
+ if (!selection.current) {
394
438
  const d = Math.min(Math.abs(head - range.from), Math.abs(head - range.to));
395
439
  if (d < min) {
396
440
  selection.closest = comment.id;
@@ -399,7 +443,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
399
443
  }
400
444
  });
401
445
 
402
- if (selection.active !== active || selection.closest !== closest) {
446
+ if (selection.current !== current || selection.closest !== closest) {
403
447
  view.dispatch({ effects: setSelection.of(selection) });
404
448
 
405
449
  // Update callback.
@@ -417,3 +461,16 @@ export const comments = (options: CommentsOptions = {}): Extension => {
417
461
  options.onUpdate ? trackPastedComments(options.onUpdate) : [],
418
462
  ];
419
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
+ };
@@ -7,18 +7,10 @@ export * from './automerge';
7
7
  export * from './awareness';
8
8
  export * from './basic';
9
9
  export * from './blast';
10
- export * from './code';
11
10
  export * from './comments';
12
11
  export * from './cursor';
13
- export * from './hr';
14
- export * from './image';
15
- export * from './link';
16
12
  export * from './listener';
17
13
  export * from './markdown';
18
14
  export * from './mention';
19
15
  export * from './outliner';
20
- export * from './sync';
21
- export * from './table';
22
- export * from './tasklist';
23
16
  export * from './typewriter';
24
- export * from './yjs';
@@ -23,34 +23,26 @@ import {
23
23
  import type { ThemeMode } from '@dxos/react-ui';
24
24
 
25
25
  import { markdownHighlightStyle, markdownTagsExtensions } from './highlight';
26
+ import { type TextEditorProps } from '../../components';
26
27
 
27
28
  export type MarkdownBundleOptions = {
28
- readonly?: boolean;
29
29
  themeMode?: ThemeMode;
30
- placeholder?: string;
31
- };
30
+ } & Pick<TextEditorProps, 'placeholder'>;
32
31
 
33
32
  /**
34
33
  * Markdown bundle.
35
34
  * Refs:
36
- * https://github.com/codemirror/basic-setup
37
- * https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts#L50
38
35
  * https://codemirror.net/docs/community
39
36
  * https://codemirror.net/docs/ref/#codemirror.basicSetup
40
37
  */
41
38
  // TODO(burdon): Add Composer here: https://codemirror.net/docs/community
42
- export const markdownBundle = ({
43
- readonly,
44
- themeMode,
45
- placeholder: _placeholder,
46
- }: MarkdownBundleOptions): Extension[] => {
39
+ export const markdownBundle = ({ themeMode, placeholder: _placeholder }: MarkdownBundleOptions): Extension[] => {
47
40
  return [
48
41
  EditorState.allowMultipleSelections.of(true),
49
42
  EditorState.tabSize.of(2),
50
43
  EditorView.lineWrapping,
51
44
 
52
- customKeymap(),
53
-
45
+ // https://github.com/codemirror/basic-setup
54
46
  bracketMatching(),
55
47
  closeBrackets(),
56
48
  crosshairCursor(),
@@ -59,7 +51,7 @@ export const markdownBundle = ({
59
51
  highlightActiveLine(),
60
52
  history(),
61
53
  indentOnInput(),
62
- _placeholder && placeholder(_placeholder),
54
+ _placeholder ? placeholder(_placeholder) : [],
63
55
 
64
56
  // Main extension.
65
57
  // https://github.com/codemirror/lang-markdown
@@ -86,21 +78,19 @@ export const markdownBundle = ({
86
78
  themeMode === 'dark' ? syntaxHighlighting(oneDarkHighlightStyle) : syntaxHighlighting(defaultHighlightStyle),
87
79
 
88
80
  // Custom styles.
89
- syntaxHighlighting(markdownHighlightStyle(readonly)),
90
- ].filter(Boolean) as Extension[];
91
- };
81
+ syntaxHighlighting(markdownHighlightStyle()),
92
82
 
93
- // https://codemirror.net/docs/ref/#view.keymap
94
- const customKeymap = () =>
95
- keymap.of([
96
- // https://codemirror.net/docs/ref/#commands.indentWithTab
97
- indentWithTab,
98
- // https://codemirror.net/docs/ref/#commands.standardKeymap
99
- ...standardKeymap,
100
- // https://codemirror.net/docs/ref/#commands.historyKeymap
101
- ...historyKeymap,
102
- // https://codemirror.net/docs/ref/#autocomplete.closeBracketsKeymap
103
- ...closeBracketsKeymap,
104
- // https://codemirror.net/docs/ref/#search.searchKeymap
105
- ...searchKeymap,
106
- ]);
83
+ keymap.of([
84
+ // https://codemirror.net/docs/ref/#commands.indentWithTab
85
+ indentWithTab,
86
+ // https://codemirror.net/docs/ref/#autocomplete.closeBracketsKeymap
87
+ ...closeBracketsKeymap,
88
+ // https://codemirror.net/docs/ref/#commands.historyKeymap
89
+ ...historyKeymap,
90
+ // https://codemirror.net/docs/ref/#search.searchKeymap
91
+ ...searchKeymap,
92
+ // https://codemirror.net/docs/ref/#commands.standardKeymap
93
+ ...standardKeymap,
94
+ ]),
95
+ ];
96
+ };
@@ -0,0 +1,142 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { syntaxTree } from '@codemirror/language';
6
+ import { RangeSetBuilder } from '@codemirror/state';
7
+ import {
8
+ ViewPlugin,
9
+ type DecorationSet,
10
+ EditorView,
11
+ type ViewUpdate,
12
+ Decoration,
13
+ type BlockInfo,
14
+ WidgetType,
15
+ } from '@codemirror/view';
16
+
17
+ import { mx } from '@dxos/react-ui-theme';
18
+
19
+ import { getToken } from '../../styles';
20
+
21
+ // TODO(burdon): Reconcile with theme.
22
+ const styles = EditorView.baseTheme({
23
+ '& .cm-code': {
24
+ paddingInline: '1rem !important',
25
+ fontFamily: getToken('fontFamily.mono', []).join(','),
26
+ },
27
+ '& .cm-codeblock': {
28
+ display: 'inline-block',
29
+ width: '100%',
30
+ position: 'relative',
31
+ '&::after': {
32
+ content: '""',
33
+ position: 'absolute',
34
+ inset: 0,
35
+ },
36
+ },
37
+ '&light .cm-codeblock': {
38
+ '&::after': {
39
+ background: getToken('extend.semanticColors.input.light'),
40
+ mixBlendMode: 'darken',
41
+ },
42
+ },
43
+ '&dark .cm-codeblock': {
44
+ '&::after': {
45
+ background: getToken('extend.semanticColors.input.dark'),
46
+ mixBlendMode: 'lighten',
47
+ },
48
+ },
49
+ '& .cm-codeblock-first': {
50
+ '&::after': {
51
+ borderTopLeftRadius: '.5rem',
52
+ borderTopRightRadius: '.5rem',
53
+ },
54
+ },
55
+ '& .cm-codeblock-last': {
56
+ '&::after': {
57
+ borderBottomLeftRadius: '.5rem',
58
+ borderBottomRightRadius: '.5rem',
59
+ },
60
+ },
61
+ });
62
+
63
+ // TODO(burdon): Start from EditorView.lineBlockAt(pos).
64
+ const getLineRange = (lines: BlockInfo[], from: number, to: number) => {
65
+ const start = lines.findIndex((line) => line.from >= from);
66
+ const end = lines.findIndex((line) => line.to >= to);
67
+ return [start, end];
68
+ };
69
+
70
+ class LineWidget extends WidgetType {
71
+ constructor(private readonly _className: string) {
72
+ super();
73
+ }
74
+
75
+ override toDOM() {
76
+ const el = document.createElement('span');
77
+ el.innerHTML = '&nbsp;';
78
+ el.className = mx('cm-code cm-codeblock', this._className);
79
+ return el;
80
+ }
81
+ }
82
+
83
+ const top = new LineWidget('cm-codeblock-first');
84
+ const bottom = new LineWidget('cm-codeblock-last');
85
+
86
+ const buildDecorations = (view: EditorView): DecorationSet => {
87
+ const builder = new RangeSetBuilder<Decoration>();
88
+ const { state } = view;
89
+ const blocks = view.viewportLineBlocks;
90
+ const cursor = state.selection.main.head;
91
+
92
+ // TODO(burdon): Add copy to clipboard widget.
93
+ for (const { from, to } of view.visibleRanges) {
94
+ syntaxTree(state).iterate({
95
+ enter: (node) => {
96
+ if (node.name === 'FencedCode') {
97
+ const edit = !view.state.readOnly && cursor >= node.from && cursor <= node.to;
98
+ const range = getLineRange(blocks, node.from, node.to);
99
+ for (let i = range[0]; i <= range[1]; i++) {
100
+ const block = blocks[i];
101
+ if (!edit && (i === range[0] || i === range[1])) {
102
+ builder.add(block.from, block.to, Decoration.replace({ widget: i === range[0] ? top : bottom }));
103
+ } else {
104
+ builder.add(
105
+ block.from,
106
+ block.from,
107
+ Decoration.line({
108
+ class: mx('cm-code cm-codeblock'),
109
+ }),
110
+ );
111
+ }
112
+ }
113
+ }
114
+ },
115
+ from,
116
+ to,
117
+ });
118
+ }
119
+
120
+ return builder.finish();
121
+ };
122
+
123
+ export const code = () => {
124
+ return [
125
+ ViewPlugin.fromClass(
126
+ class {
127
+ decorations: DecorationSet;
128
+ constructor(view: EditorView) {
129
+ this.decorations = buildDecorations(view);
130
+ }
131
+
132
+ update(update: ViewUpdate) {
133
+ this.decorations = buildDecorations(update.view);
134
+ }
135
+ },
136
+ {
137
+ decorations: (value) => value.decorations,
138
+ },
139
+ ),
140
+ styles,
141
+ ];
142
+ };