@dxos/react-ui-editor 0.3.11-main.24b414e → 0.3.11-main.264ec26

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 (78) hide show
  1. package/dist/lib/browser/index.mjs +607 -471
  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 -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 +13 -6
  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 +1 -1
  10. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
  11. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  12. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  13. package/dist/types/src/extensions/automerge/defs.d.ts +7 -9
  14. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  15. package/dist/types/src/extensions/automerge/index.d.ts +0 -1
  16. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
  17. package/dist/types/src/extensions/automerge/semaphore.d.ts +7 -12
  18. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  19. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  20. package/dist/types/src/extensions/awareness.d.ts +2 -0
  21. package/dist/types/src/extensions/awareness.d.ts.map +1 -1
  22. package/dist/types/src/extensions/basic.d.ts +4 -5
  23. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  24. package/dist/types/src/extensions/code.d.ts.map +1 -1
  25. package/dist/types/src/extensions/comments.d.ts +29 -16
  26. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  27. package/dist/types/src/extensions/cursor.d.ts +3 -3
  28. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  29. package/dist/types/src/extensions/hr.d.ts.map +1 -1
  30. package/dist/types/src/extensions/index.d.ts +1 -0
  31. package/dist/types/src/extensions/index.d.ts.map +1 -1
  32. package/dist/types/src/extensions/markdown/bundle.d.ts +2 -5
  33. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  34. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  35. package/dist/types/src/extensions/outliner.d.ts +4 -0
  36. package/dist/types/src/extensions/outliner.d.ts.map +1 -0
  37. package/dist/types/src/extensions/tasklist.d.ts +1 -5
  38. package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
  39. package/dist/types/src/hooks/automerge.d.ts +2 -1
  40. package/dist/types/src/hooks/automerge.d.ts.map +1 -1
  41. package/dist/types/src/hooks/defs.d.ts +28 -0
  42. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  43. package/dist/types/src/hooks/index.d.ts +2 -1
  44. package/dist/types/src/hooks/index.d.ts.map +1 -1
  45. package/dist/types/src/hooks/useTextModel.d.ts +2 -27
  46. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  47. package/dist/types/src/hooks/yjs.d.ts +2 -1
  48. package/dist/types/src/hooks/yjs.d.ts.map +1 -1
  49. package/dist/types/src/util.d.ts +2 -0
  50. package/dist/types/src/util.d.ts.map +1 -1
  51. package/package.json +31 -31
  52. package/src/components/TextEditor/MarkdownEditor.stories.tsx +64 -69
  53. package/src/components/TextEditor/TextEditor.tsx +85 -68
  54. package/src/components/TextEditor/comments.stories.tsx +111 -69
  55. package/src/extensions/autocomplete.ts +6 -3
  56. package/src/extensions/automerge/automerge.ts +23 -48
  57. package/src/extensions/automerge/defs.ts +10 -31
  58. package/src/extensions/automerge/index.ts +0 -1
  59. package/src/extensions/automerge/semaphore.ts +23 -20
  60. package/src/extensions/automerge/update-automerge.ts +2 -2
  61. package/src/extensions/awareness.ts +20 -2
  62. package/src/extensions/basic.ts +22 -17
  63. package/src/extensions/code.ts +15 -17
  64. package/src/extensions/comments.ts +235 -132
  65. package/src/extensions/cursor.ts +7 -3
  66. package/src/extensions/hr.ts +49 -50
  67. package/src/extensions/index.ts +1 -0
  68. package/src/extensions/link.ts +3 -3
  69. package/src/extensions/markdown/bundle.ts +18 -24
  70. package/src/extensions/mention.ts +9 -2
  71. package/src/extensions/outliner.ts +12 -0
  72. package/src/extensions/tasklist.ts +49 -33
  73. package/src/hooks/automerge.ts +2 -1
  74. package/src/hooks/defs.ts +47 -0
  75. package/src/hooks/index.ts +3 -1
  76. package/src/hooks/useTextModel.ts +2 -45
  77. package/src/hooks/yjs.ts +2 -1
  78. package/src/util.ts +26 -3
@@ -12,11 +12,16 @@ import {
12
12
  type ViewUpdate,
13
13
  WidgetType,
14
14
  } from '@codemirror/view';
15
+ import { useEffect } from 'react';
15
16
 
16
17
  import { Event } from '@dxos/async';
17
18
  import { Context } from '@dxos/context';
19
+ import { generateName } from '@dxos/display-name';
20
+ import { useThemeContext } from '@dxos/react-ui';
21
+ import { getColorForValue } from '@dxos/react-ui-theme';
18
22
 
19
23
  import { Cursor, type CursorConverter } from './cursor';
24
+ import { type EditorModel } from '../hooks';
20
25
 
21
26
  export interface AwarenessProvider {
22
27
  remoteStateChange: Event<void>;
@@ -66,7 +71,6 @@ export type AwarenessState = {
66
71
  /**
67
72
  * Extension provides presence information about other peers.
68
73
  */
69
- // TODO(burdon): Why provide default?
70
74
  export const awareness = (provider = dummyProvider): Extension => {
71
75
  return [
72
76
  AwarenessProvider.of(provider),
@@ -77,6 +81,19 @@ export const awareness = (provider = dummyProvider): Extension => {
77
81
  ];
78
82
  };
79
83
 
84
+ export const useAwareness = ({ awareness, peer }: EditorModel) => {
85
+ const { themeMode } = useThemeContext();
86
+ useEffect(() => {
87
+ if (awareness && peer) {
88
+ awareness.setLocalStateField('user', {
89
+ name: peer.name ?? generateName(peer.id),
90
+ color: getColorForValue({ value: peer.id, type: 'color' }),
91
+ colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
92
+ });
93
+ }
94
+ }, [awareness, peer, themeMode]);
95
+ };
96
+
80
97
  /**
81
98
  * Generates selection decorations from remote peers.
82
99
  */
@@ -251,6 +268,7 @@ class RemoteCaretWidget extends WidgetType {
251
268
  }
252
269
  }
253
270
 
271
+ // TODO(burdon): Mixed case?
254
272
  const styles = EditorView.baseTheme({
255
273
  '.cm-collab-selection': {},
256
274
  '.cm-collab-selectionLine': {
@@ -297,7 +315,7 @@ const styles = EditorView.baseTheme({
297
315
  zIndex: 101,
298
316
  transition: 'opacity .3s ease-in-out',
299
317
  backgroundColor: 'inherit',
300
- // these should be separate
318
+ // These should be separate.
301
319
  opacity: 0,
302
320
  transitionDelay: '0s',
303
321
  whiteSpace: 'nowrap',
@@ -3,30 +3,35 @@
3
3
  //
4
4
 
5
5
  import { closeBrackets } from '@codemirror/autocomplete';
6
+ import { history } from '@codemirror/commands';
6
7
  import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
7
8
  import { type Extension } from '@codemirror/state';
8
9
  import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
9
- import { drawSelection, EditorView, placeholder } from '@codemirror/view';
10
+ import { drawSelection, EditorView, highlightActiveLine, placeholder } from '@codemirror/view';
10
11
 
11
- import type { ThemeMode } from '@dxos/react-ui';
12
+ import { type ThemeMode } from '@dxos/react-ui';
13
+
14
+ import { type TextEditorProps } from '../components';
12
15
 
13
16
  export type BasicBundleOptions = {
14
- readonly?: boolean;
15
17
  themeMode?: ThemeMode;
16
- placeholder?: string;
17
- };
18
+ } & Pick<TextEditorProps, 'placeholder' | 'multiline'>;
18
19
 
19
- export const basicBundle = ({ readonly, themeMode, placeholder: _placeholder }: BasicBundleOptions): Extension[] =>
20
- [
21
- EditorView.lineWrapping,
20
+ export const basicBundle = ({
21
+ themeMode,
22
+ placeholder: _placeholder,
23
+ multiline = true,
24
+ }: BasicBundleOptions): Extension[] => [
25
+ multiline ? EditorView.lineWrapping : [],
22
26
 
23
- // TODO(burdon): Compare with minimalSetup.
24
- // https://codemirror.net/docs/ref/#codemirror.minimalSetup
25
- bracketMatching(),
26
- closeBrackets(),
27
- drawSelection(),
28
- _placeholder && placeholder(_placeholder),
27
+ // https://codemirror.net/docs/ref/#codemirror.minimalSetup
28
+ bracketMatching(),
29
+ closeBrackets(),
30
+ drawSelection(),
31
+ highlightActiveLine(),
32
+ history(),
33
+ _placeholder ? placeholder(_placeholder) : [],
29
34
 
30
- // TODO(burdon): Is this required?
31
- themeMode === 'dark' ? syntaxHighlighting(oneDarkHighlightStyle) : syntaxHighlighting(defaultHighlightStyle),
32
- ].filter(Boolean) as Extension[];
35
+ // https://github.com/codemirror/theme-one-dark
36
+ themeMode === 'dark' ? syntaxHighlighting(oneDarkHighlightStyle) : syntaxHighlighting(defaultHighlightStyle),
37
+ ];
@@ -53,22 +53,20 @@ const getLineRange = (lines: BlockInfo[], from: number, to: number) => {
53
53
  };
54
54
 
55
55
  export const code = () => {
56
- const getDecorations = (view: EditorView) => {
56
+ const buildDecorations = (view: EditorView) => {
57
57
  const decorations: Range<Decoration>[] = [];
58
- if (!view.hasFocus || view.state.readOnly) {
59
- const text = view.state.doc.sliceString(0);
60
- const matches = text.matchAll(CODE_REGEX);
61
- const blocks = view.viewportLineBlocks;
62
- for (const match of matches) {
63
- const range = getLineRange(blocks, match.index!, match.index! + match[0].length);
64
- for (let i = range[0]; i <= range[1]; i++) {
65
- const block = blocks[i];
66
- decorations.push(
67
- Decoration.line({
68
- class: mx('cm-codeblock', i === range[0] && 'cm-codeblock-first', i === range[1] && 'cm-codeblock-last'),
69
- }).range(block.from),
70
- );
71
- }
58
+ const text = view.state.doc.sliceString(0);
59
+ const matches = text.matchAll(CODE_REGEX);
60
+ const blocks = view.viewportLineBlocks;
61
+ for (const match of matches) {
62
+ const range = getLineRange(blocks, match.index!, match.index! + match[0].length);
63
+ for (let i = range[0]; i <= range[1]; i++) {
64
+ const block = blocks[i];
65
+ decorations.push(
66
+ Decoration.line({
67
+ class: mx('cm-codeblock', i === range[0] && 'cm-codeblock-first', i === range[1] && 'cm-codeblock-last'),
68
+ }).range(block.from),
69
+ );
72
70
  }
73
71
  }
74
72
 
@@ -81,7 +79,7 @@ export const code = () => {
81
79
  hasFocus = false;
82
80
  decorations: DecorationSet;
83
81
  constructor(view: EditorView) {
84
- this.decorations = getDecorations(view);
82
+ this.decorations = buildDecorations(view);
85
83
  }
86
84
 
87
85
  update(update: ViewUpdate) {
@@ -93,7 +91,7 @@ export const code = () => {
93
91
  update.viewportChanged
94
92
  ) {
95
93
  this.hasFocus = update.view.hasFocus;
96
- this.decorations = getDecorations(update.view);
94
+ this.decorations = buildDecorations(update.view);
97
95
  }
98
96
  }
99
97
  },
@@ -2,23 +2,20 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type Extension, StateEffect, StateField } from '@codemirror/state';
5
+ import { type Extension, StateEffect, StateField, type Text } from '@codemirror/state';
6
6
  import { hoverTooltip, keymap, type Command, Decoration, EditorView, type Rect } from '@codemirror/view';
7
7
  import sortBy from 'lodash.sortby';
8
+ import { useEffect } from 'react';
8
9
 
9
10
  import { debounce } from '@dxos/async';
10
11
  import { invariant } from '@dxos/invariant';
11
- import { log } from '@dxos/log';
12
12
  import { nonNullable } from '@dxos/util';
13
13
 
14
14
  import { Cursor } from './cursor';
15
- import { type CommentRange, type EditorModel, type Range } from '../hooks';
15
+ import { type Comment, type Range } from '../hooks';
16
16
  import { getToken } from '../styles';
17
17
  import { callbackWrapper } from '../util';
18
18
 
19
- // TODO(burdon): Handle delete, cut, copy, and paste (separately) text that includes comment range.
20
- // TODO(burdon): Consider breaking into separate plugin (since not standalone)? Like mermaid?
21
-
22
19
  // TODO(burdon): Reconcile with theme.
23
20
  const styles = EditorView.baseTheme({
24
21
  '& .cm-comment': {
@@ -34,69 +31,75 @@ const marks = {
34
31
  highlightActive: Decoration.mark({ class: 'cm-comment-active' }),
35
32
  };
36
33
 
37
- // TODO(burdon): Should this be part of state?
38
- type CommentSelected = {
34
+ type CommentState = {
35
+ comment: Comment;
36
+ range: Range;
37
+ location?: Rect | null;
38
+ };
39
+
40
+ type SelectionState = {
39
41
  active?: string;
40
42
  closest?: string;
41
43
  };
42
44
 
43
- // TODO(burdon): Rename.
44
- type ExtendedCommentRange = Range &
45
- CommentRange & {
46
- // TODO(burdon): Not part of state; just required for callback.
47
- location?: Rect | null;
48
- };
49
-
50
- export type CommentsState = CommentSelected & {
51
- ranges: ExtendedCommentRange[];
45
+ export type CommentsState = {
46
+ comments: CommentState[];
47
+ selection: SelectionState;
52
48
  };
53
49
 
54
- export const setFocus = (view: EditorView, thread: string) => {
55
- const range = view.state.field(commentsStateField).ranges.find((range) => range.id === thread);
56
- if (!range) {
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) {
57
53
  return;
58
54
  }
59
55
 
60
- view.dispatch({
61
- effects: setSelection.of({ active: thread }),
62
- selection: { anchor: range.from },
63
- scrollIntoView: true,
64
- });
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({ active: id }),
64
+ ],
65
+ });
66
+ }
65
67
  };
66
68
 
67
- export const setCommentRange = StateEffect.define<{ model: EditorModel; comments: CommentRange[] }>();
69
+ export const setComments = StateEffect.define<Comment[]>();
68
70
 
69
- export const setSelection = StateEffect.define<CommentSelected>();
71
+ export const setSelection = StateEffect.define<SelectionState>();
70
72
 
71
73
  const setCommentState = StateEffect.define<CommentsState>();
72
74
 
73
75
  /**
74
76
  * State field (reducer) that tracks comment ranges.
75
- * The ranges are tracked as codemirror ranges (i.e., not relative YJS/Automerge positions), and
76
- * therefore must be updated when the document changes.
77
+ * The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
77
78
  */
78
- const commentsStateField = StateField.define<CommentsState>({
79
- create: () => ({ ranges: [] }),
79
+ const commentsState = StateField.define<CommentsState>({
80
+ create: () => ({ comments: [], selection: {} }),
80
81
  update: (value, tr) => {
81
- const cursorConverter = tr.state.facet(Cursor.converter);
82
-
83
82
  for (const effect of tr.effects) {
84
83
  // Update selection.
85
84
  if (effect.is(setSelection)) {
86
- return { ...value, ...effect.value };
85
+ return { ...value, selection: effect.value };
87
86
  }
88
87
 
89
88
  // Update range from store.
90
- if (effect.is(setCommentRange)) {
91
- const { comments } = effect.value;
92
- const ranges: ExtendedCommentRange[] = comments
89
+ if (effect.is(setComments)) {
90
+ const comments: CommentState[] = effect.value
93
91
  .map((comment) => {
94
- const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
95
- return range && { ...comment, ...range };
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 };
96
99
  })
97
100
  .filter(nonNullable);
98
101
 
99
- return { ...value, ranges };
102
+ return { ...value, comments };
100
103
  }
101
104
 
102
105
  // Update entire state.
@@ -112,35 +115,48 @@ const commentsStateField = StateField.define<CommentsState>({
112
115
  /**
113
116
  * Decorate ranges.
114
117
  */
115
- const highlightDecorations = EditorView.decorations.compute([commentsStateField], (state) => {
116
- const { active, ranges } = state.field(commentsStateField);
117
- const decorations =
118
- sortBy(ranges ?? [], (range) => range.from)
119
- ?.flatMap((selection) => {
120
- // TODO(burdon): Invalid range (e.g., deleted).
121
- if (selection.from === selection.to) {
122
- return undefined;
123
- }
118
+ const highlightDecorations = EditorView.decorations.compute([commentsState], (state) => {
119
+ const {
120
+ selection: { active },
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
+ }
124
131
 
125
- const range = { from: selection.from, to: selection.to };
126
- if (selection.id === active) {
127
- return marks.highlightActive.range(range.from, range.to);
128
- } else {
129
- return marks.highlight.range(range.from, range.to);
130
- }
131
- })
132
- .filter(nonNullable) ?? [];
132
+ if (comment.comment.id === active) {
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);
133
139
 
134
140
  return Decoration.set(decorations);
135
141
  });
136
142
 
137
143
  export type CommentsOptions = {
144
+ /**
145
+ * Key shortcut to create a new thread.
146
+ */
138
147
  key?: string;
139
- footnote?: boolean;
140
148
  /**
141
149
  * Called to create a new thread and return the thread id.
142
150
  */
143
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;
144
160
  /**
145
161
  * Called to notify which thread is currently closest to the cursor.
146
162
  */
@@ -151,6 +167,75 @@ export type CommentsOptions = {
151
167
  onHover?: (el: Element, shortcut: string) => void;
152
168
  };
153
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
+
154
239
  /**
155
240
  * Comment threads.
156
241
  * 1). Updates the EditorModel to store relative selections for a set of comments threads.
@@ -160,8 +245,7 @@ export type CommentsOptions = {
160
245
  * 4). Tracks the current cursor position to:
161
246
  * a). Update the decoration to show if the cursor is within a current selection.
162
247
  * b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
163
- * 5). Optionally, inserts a markdown footnote when creating a comment thread.
164
- * 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
248
+ * 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
165
249
  */
166
250
  export const comments = (options: CommentsOptions = {}): Extension => {
167
251
  const { key: shortcut = "meta-'" } = options;
@@ -172,18 +256,26 @@ export const comments = (options: CommentsOptions = {}): Extension => {
172
256
  * Create comment thread action.
173
257
  */
174
258
  const createCommentThread: Command = (view) => {
175
- const cursorConverter = view.state.facet(Cursor.converter);
176
-
177
259
  invariant(options.onCreate);
178
- const { head, from, to } = view.state.selection.main;
260
+ const { from, to } = view.state.selection.main;
179
261
  if (from === to) {
180
262
  return false;
181
263
  }
182
264
 
183
- const cursor = Cursor.getCursorFromRange(cursorConverter, { from, to });
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 });
184
276
  if (cursor) {
185
277
  // Create thread via callback.
186
- const id = callbackWrapper(options.onCreate)(cursor, view.coordsAtPos(from));
278
+ const id = options.onCreate?.(cursor, view.coordsAtPos(from));
187
279
  if (id) {
188
280
  // Update range.
189
281
  view.dispatch({
@@ -191,15 +283,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
191
283
  selection: { anchor: from },
192
284
  });
193
285
 
194
- // Insert footnote.
195
- if (options.footnote) {
196
- const tag = `[^${id}]`;
197
- view.dispatch({
198
- changes: { from: head, insert: tag },
199
- selection: { anchor: head + tag.length },
200
- });
201
- }
202
-
203
286
  return true;
204
287
  }
205
288
  }
@@ -208,7 +291,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
208
291
  };
209
292
 
210
293
  return [
211
- commentsStateField,
294
+ commentsState,
212
295
  highlightDecorations,
213
296
  styles,
214
297
 
@@ -238,9 +321,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
238
321
  end: selection.to,
239
322
  above: true,
240
323
  create: () => {
241
- // TODO(burdon): Dispatch to react callback to render (or use SSR)?
242
324
  const el = document.createElement('div');
243
- options.onHover?.(el, shortcut);
325
+ options.onHover!(el, shortcut);
244
326
  return { dom: el, offset: { x: 0, y: 8 } };
245
327
  },
246
328
  };
@@ -248,78 +330,99 @@ export const comments = (options: CommentsOptions = {}): Extension => {
248
330
 
249
331
  return null;
250
332
  },
251
- { hideOnChange: true, hoverTime: 1000 },
333
+ {
334
+ // TODO(burdon): Hide on change triggered immediately?
335
+ // hideOnChange: true,
336
+ hoverTime: 1_000,
337
+ },
252
338
  )
253
339
  : [],
254
340
 
255
341
  //
256
- // Monitor cursor movement and text updates.
257
- // TODO(burdon): Is there a better (finer grained) way to do this?
342
+ // Track deleted ranges and update ranges for decorations.
258
343
  //
259
344
  EditorView.updateListener.of(({ view, state, changes }) => {
260
- const cursorConverter = view.state.facet(Cursor.converter);
261
-
262
- //
263
- // Test if need to recompute indexed range if document changes before the end of the range.
264
- //
265
- {
266
- let mod = false;
267
- const { active, ranges } = state.field(commentsStateField);
268
- changes.iterChanges((from, to, from2, to2) => {
269
- ranges.forEach((range) => {
270
- if (from2 === to2) {
271
- const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
272
- if (!newRange || newRange.to - newRange.from === 0) {
273
- // TODO(burdon): Delete range if empty.
274
- log.info('deleted comment', { thread: range.id });
275
- }
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);
276
354
  }
355
+ }
277
356
 
278
- if (from <= range.to) {
279
- const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
280
- Object.assign(range, newRange);
281
- mod = true;
282
- }
283
- });
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
+ }
284
363
  });
364
+ });
285
365
 
286
- if (mod) {
287
- view.dispatch({ effects: setCommentState.of({ active, ranges }) });
288
- }
366
+ if (mod) {
367
+ view.dispatch({ effects: setCommentState.of({ comments, ...value }) });
289
368
  }
369
+ }),
290
370
 
291
- //
292
- // Track the current selection.
293
- //
294
- {
295
- const { head } = state.selection.main;
296
-
297
- let min = Infinity;
298
- const { active, closest, ranges } = state.field(commentsStateField);
299
- const selected: CommentSelected = { active: undefined, closest: undefined };
300
- ranges.forEach((comment) => {
301
- const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
302
- if (head >= comment.from && head <= comment.to) {
303
- selected.active = comment.id;
304
- }
371
+ //
372
+ // Track selection/proximity.
373
+ //
374
+ EditorView.updateListener.of(({ view, state, changes }) => {
375
+ let min = Infinity;
376
+ const {
377
+ selection: { active, 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.active = comment.id;
386
+ selection.closest = undefined;
387
+ }
388
+
389
+ if (!selection.active) {
390
+ const d = Math.min(Math.abs(head - range.from), Math.abs(head - range.to));
305
391
  if (d < min) {
306
- selected.closest = comment.id;
392
+ selection.closest = comment.id;
307
393
  min = d;
308
394
  }
309
- });
310
-
311
- // TODO(burdon): Fire debounced callback if range selected (to show hint).
312
-
313
- if (selected.active !== active || selected.closest !== closest) {
314
- view.dispatch({ effects: setSelection.of(selected) });
315
-
316
- // Update callback.
317
- handleSelect({
318
- ...selected,
319
- ranges: ranges.map((range) => ({ ...range, location: view.coordsAtPos(range.from) })),
320
- });
321
395
  }
396
+ });
397
+
398
+ if (selection.active !== active || 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
+ });
322
410
  }
323
411
  }),
412
+
413
+ options.onUpdate ? trackPastedComments(options.onUpdate) : [],
324
414
  ];
325
415
  };
416
+
417
+ /**
418
+ * Update state field.
419
+ */
420
+ export const useComments = (view?: EditorView | null, comments?: Comment[]) => {
421
+ useEffect(() => {
422
+ if (view && comments) {
423
+ view.dispatch({
424
+ effects: setComments.of(comments),
425
+ });
426
+ }
427
+ }, [view, comments]);
428
+ };