@dxos/react-ui-editor 0.3.11-main.3d4adea → 0.3.11-main.42059c4

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 (31) hide show
  1. package/dist/lib/browser/index.mjs +148 -111
  2. package/dist/lib/browser/index.mjs.map +3 -3
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/components/TextEditor/TextEditor.d.ts +2 -2
  5. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/comments.stories.d.ts +1 -1
  7. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
  8. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  9. package/dist/types/src/extensions/automerge/defs.d.ts +4 -4
  10. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  11. package/dist/types/src/extensions/automerge/index.d.ts +0 -1
  12. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
  13. package/dist/types/src/extensions/automerge/semaphore.d.ts +4 -9
  14. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  15. package/dist/types/src/extensions/comments.d.ts +12 -3
  16. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  17. package/dist/types/src/extensions/tasklist.d.ts +5 -1
  18. package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
  19. package/dist/types/src/hooks/index.d.ts +1 -1
  20. package/dist/types/src/hooks/index.d.ts.map +1 -1
  21. package/package.json +25 -25
  22. package/src/components/TextEditor/TextEditor.tsx +10 -10
  23. package/src/components/TextEditor/comments.stories.tsx +78 -38
  24. package/src/extensions/automerge/automerge.ts +11 -11
  25. package/src/extensions/automerge/defs.ts +13 -9
  26. package/src/extensions/automerge/index.ts +0 -1
  27. package/src/extensions/automerge/semaphore.ts +10 -12
  28. package/src/extensions/comments.ts +104 -27
  29. package/src/extensions/tasklist.ts +51 -75
  30. package/src/extensions/yjs/yjs.test.ts +1 -1
  31. package/src/hooks/index.ts +2 -1
@@ -6,11 +6,12 @@ import '@dxosTheme';
6
6
 
7
7
  import { type EditorView, keymap } from '@codemirror/view';
8
8
  import { faker } from '@faker-js/faker';
9
- import { Check } from '@phosphor-icons/react';
9
+ import { Check, Trash } from '@phosphor-icons/react';
10
10
  import React, { type FC, useEffect, useMemo, useRef, useState } from 'react';
11
11
 
12
- import { TextObject } from '@dxos/echo-schema';
12
+ import { getTextContent, TextObject } from '@dxos/echo-schema';
13
13
  import { PublicKey } from '@dxos/keys';
14
+ import { log } from '@dxos/log';
14
15
  import { Button, DensityProvider } from '@dxos/react-ui';
15
16
  import { fixedInsetFlexLayout, mx } from '@dxos/react-ui-theme';
16
17
  import { withTheme } from '@dxos/storybook-utils';
@@ -30,8 +31,10 @@ const Editor: FC<{
30
31
  commentSelected?: string;
31
32
  commentRanges: CommentRange[];
32
33
  onCreateComment: CommentsOptions['onCreate'];
34
+ onDeleteComment: CommentsOptions['onDelete'];
35
+ onUpdateComment: CommentsOptions['onUpdate'];
33
36
  onSelectComment: CommentsOptions['onSelect'];
34
- }> = ({ item, commentSelected, commentRanges, onCreateComment, onSelectComment }) => {
37
+ }> = ({ item, commentSelected, commentRanges, onCreateComment, onDeleteComment, onUpdateComment, onSelectComment }) => {
35
38
  const model = useTextModel({ text: item.text });
36
39
  const editorRef = useRef<EditorView>(null);
37
40
  const [selected, setSelected] = useState<string>();
@@ -55,6 +58,8 @@ const Editor: FC<{
55
58
  return [
56
59
  comments({
57
60
  onCreate: onCreateComment,
61
+ onDelete: onDeleteComment,
62
+ onUpdate: onUpdateComment,
58
63
  onSelect: onSelectComment,
59
64
  }),
60
65
  ];
@@ -82,6 +87,7 @@ type CommentThread = {
82
87
  yPos?: number;
83
88
  selection?: Range;
84
89
  messages: TextObject[];
90
+ deleted?: boolean;
85
91
  };
86
92
 
87
93
  const Thread: FC<{
@@ -90,23 +96,17 @@ const Thread: FC<{
90
96
  onSelect: () => void;
91
97
  onResolve: () => void;
92
98
  }> = ({ thread, selected, onSelect, onResolve }) => {
99
+ const [focus, setFocus] = useState(false);
93
100
  const [item, setItem] = useState({ text: new TextObject() });
94
101
  const model = useTextModel({ text: item.text });
95
102
  const editorRef = useRef<EditorView>(null);
96
103
 
97
- // TODO(burdon): Hack to focus (view is recreated when text object changes).
98
- const focus = () => {
99
- setTimeout(() => {
100
- editorRef.current?.focus();
101
- }, 100);
102
- };
103
-
104
104
  const containerRef = useRef<HTMLDivElement>(null);
105
105
  useEffect(() => {
106
106
  if (selected) {
107
107
  containerRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
108
108
  if (thread.messages.length === 0) {
109
- focus();
109
+ setFocus(true);
110
110
  }
111
111
  }
112
112
  }, [selected]);
@@ -116,7 +116,7 @@ const Thread: FC<{
116
116
  if (text?.length) {
117
117
  thread.messages.push(item.text);
118
118
  setItem({ text: new TextObject() });
119
- focus();
119
+ setFocus(true);
120
120
  }
121
121
  };
122
122
 
@@ -125,41 +125,49 @@ const Thread: FC<{
125
125
  }
126
126
 
127
127
  return (
128
- <div className={mx('flex flex-col m-1 rounded shadow divide-y bg-white', selected && 'ring')}>
129
- <div className='flex p-2 text-xs font-mono gap-2 text-neutral-500 font-thin'>
128
+ <div
129
+ className={mx(
130
+ 'flex flex-col m-1 rounded shadow divide-y bg-white',
131
+ selected && 'ring',
132
+ thread.deleted && 'opacity-50',
133
+ )}
134
+ >
135
+ <div className='flex p-2 gap-2 items-center text-xs font-mono text-neutral-500 font-thin'>
130
136
  <span>id:{thread.id.slice(0, 4)}</span>
131
137
  <span>from:{thread.selection?.from}</span>
132
138
  <span>to:{thread.selection?.to}</span>
133
139
  <span>y:{thread.yPos}</span>
140
+ <span className='grow' />
141
+ {thread.deleted && <Trash />}
134
142
  </div>
135
143
 
136
144
  {thread.messages.map((message, i) => (
137
145
  // TODO(burdon): Fix default editor padding so content doesn't jump on creating message.
138
146
  <div key={i} className='p-2' onClick={() => onSelect()}>
139
- {message.text}
147
+ {getTextContent(message)}
140
148
  </div>
141
149
  ))}
142
150
 
143
- <div ref={containerRef} onClick={() => onSelect()} className='flex py-1'>
144
- <div className='grow'>
145
- <TextEditor
146
- ref={editorRef}
147
- model={model}
148
- placeholder={'Enter comment...'}
149
- extensions={[
150
- keymap.of([
151
- {
152
- key: 'Enter',
153
- run: () => {
154
- handleCreateMessage();
155
- return true;
156
- },
151
+ <div ref={containerRef} onClick={() => onSelect()} className='flex'>
152
+ <TextEditor
153
+ ref={editorRef}
154
+ autofocus={focus}
155
+ model={model}
156
+ placeholder={'Enter comment...'}
157
+ slots={{ root: { className: 'grow p-2 rounded-b' } }}
158
+ extensions={[
159
+ keymap.of([
160
+ {
161
+ key: 'Enter',
162
+ run: () => {
163
+ handleCreateMessage();
164
+ return true;
157
165
  },
158
- ]),
159
- ]}
160
- />
161
- </div>
162
- <Button variant='ghost' classNames='px-1 mr-1' title='Resolve' onClick={onResolve}>
166
+ },
167
+ ]),
168
+ ]}
169
+ />
170
+ <Button variant='ghost' classNames='px-1' title='Resolve' onClick={onResolve}>
163
171
  <Check />
164
172
  </Button>
165
173
  </div>
@@ -183,7 +191,7 @@ const Sidebar: FC<{
183
191
 
184
192
  return (
185
193
  <DensityProvider density='fine'>
186
- <div className='flex flex-col grow overflow-y-scroll py-4 gap-4 pr-4'>
194
+ <div className='flex flex-col grow overflow-y-scroll py-4 gap-4'>
187
195
  {sortedThreads.map((thread) => (
188
196
  <Thread
189
197
  key={thread.id}
@@ -218,6 +226,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
218
226
 
219
227
  const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
220
228
  const id = PublicKey.random().toHex();
229
+ log.info('create', { id: id.slice(0, 4), cursor });
221
230
  setThreads((threads) => [
222
231
  ...threads,
223
232
  {
@@ -225,7 +234,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
225
234
  range: { id, cursor },
226
235
  yPos: location ? Math.floor(location.top) : undefined,
227
236
  messages: autoCreate
228
- ? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 2, max: 5 } })
237
+ ? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 1, max: 3 } })
229
238
  : [],
230
239
  },
231
240
  ]);
@@ -233,8 +242,37 @@ const Story = ({ text, autoCreate }: StoryProps) => {
233
242
  return id;
234
243
  };
235
244
 
236
- // TODO(burdon): Scroll sidebar on select.
245
+ const handleDeleteComment: CommentsOptions['onDelete'] = (id) => {
246
+ log.info('delete', { id: id.slice(0, 4) });
247
+ setThreads((threads) =>
248
+ threads.map((thread) => {
249
+ if (thread.id === id) {
250
+ thread.deleted = true;
251
+ }
252
+
253
+ return thread;
254
+ }),
255
+ );
256
+
257
+ setSelected((selected) => (selected === id ? undefined : selected));
258
+ };
259
+
260
+ const handleUpdateComment: CommentsOptions['onUpdate'] = (id, cursor) => {
261
+ log.info('update', { id: id.slice(0, 4), cursor });
262
+ setThreads((threads) =>
263
+ threads.map((thread) => {
264
+ if (thread.id === id) {
265
+ thread.range.cursor = cursor;
266
+ delete thread.deleted;
267
+ }
268
+
269
+ return thread;
270
+ }),
271
+ );
272
+ };
273
+
237
274
  const handleSelectComment: CommentsOptions['onSelect'] = ({ active, closest, ranges }) => {
275
+ log.info('select', { active: active?.slice(0, 4), closest: closest?.slice(0, 4) });
238
276
  setThreads((threads) =>
239
277
  threads.map((thread) => {
240
278
  const range = ranges.find((range) => range.id === thread.range.id);
@@ -268,6 +306,8 @@ const Story = ({ text, autoCreate }: StoryProps) => {
268
306
  commentSelected={selected}
269
307
  commentRanges={commentRanges}
270
308
  onCreateComment={handleCreateComment}
309
+ onDeleteComment={handleDeleteComment}
310
+ onUpdateComment={handleUpdateComment}
271
311
  onSelectComment={handleSelectComment}
272
312
  />
273
313
  </div>
@@ -307,7 +347,7 @@ export const Default = {
307
347
  },
308
348
  };
309
349
 
310
- export const Demo = {
350
+ export const Testing = {
311
351
  args: {
312
352
  text: document,
313
353
  autoCreate: true,
@@ -4,40 +4,37 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
- import { Facet, StateField, type Extension, type Transaction } from '@codemirror/state';
7
+ import { StateField, type Extension, type Transaction } from '@codemirror/state';
8
8
  import { ViewPlugin, type EditorView, type PluginValue, type ViewUpdate } from '@codemirror/view';
9
9
 
10
10
  import { next as A } from '@dxos/automerge/automerge';
11
11
  import { type Prop } from '@dxos/automerge/automerge';
12
12
 
13
13
  import { cursorConverter } from './cursor';
14
- import { effectType, type IDocHandle, isReconcileTx, type Value } from './defs';
14
+ import { effectType, type IDocHandle, isReconcileTx, semaphoreFacet, type Value } from './defs';
15
15
  import { PatchSemaphore } from './semaphore';
16
16
  import { Cursor } from '../cursor';
17
17
 
18
- const semaphoreFacet = Facet.define<PatchSemaphore, PatchSemaphore>({
19
- combine: (values) => values.at(-1)!, // Take last.
20
- });
21
-
22
18
  export type AutomergeOptions = {
23
19
  handle: IDocHandle;
24
20
  path: Prop[];
25
21
  };
26
22
 
27
23
  export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
28
- // TODO(burdon): Rename.
29
24
  const stateField: StateField<Value> = StateField.define({
30
25
  create: () => ({
31
26
  lastHeads: A.getHeads(handle.docSync()!),
32
27
  unreconciledTransactions: [],
33
28
  path: path.slice(),
34
29
  }),
30
+
35
31
  update: (value: Value, tr: Transaction) => {
36
32
  const result: Value = {
37
33
  lastHeads: value.lastHeads,
38
34
  unreconciledTransactions: value.unreconciledTransactions.slice(),
39
35
  path: path.slice(),
40
36
  };
37
+
41
38
  let clearUnreconciled = false;
42
39
  for (const effect of tr.effects) {
43
40
  if (effect.is(effectType)) {
@@ -45,6 +42,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
45
42
  clearUnreconciled = true;
46
43
  }
47
44
  }
45
+
48
46
  if (clearUnreconciled) {
49
47
  result.unreconciledTransactions = [];
50
48
  } else {
@@ -52,14 +50,15 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
52
50
  result.unreconciledTransactions.push(tr);
53
51
  }
54
52
  }
53
+
55
54
  return result;
56
55
  },
57
56
  });
58
57
 
59
- const semaphore = new PatchSemaphore(stateField);
58
+ const semaphore = new PatchSemaphore(handle, stateField);
60
59
 
61
60
  const viewPlugin = ViewPlugin.fromClass(
62
- class AutomergeCodemirrorViewPlugin implements PluginValue {
61
+ class AutomergeViewPlugin implements PluginValue {
63
62
  constructor(private readonly _view: EditorView) {
64
63
  handle.addListener('change', this._handleChange);
65
64
  }
@@ -67,8 +66,9 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
67
66
  update(update: ViewUpdate) {
68
67
  if (update.transactions.length > 0 && update.transactions.some((tr) => !isReconcileTx(tr))) {
69
68
  // TODO(burdon): This is a hack to ensure that the view is updated after the transaction is applied.
69
+ // Cannot trigger update while update is in progress.
70
70
  queueMicrotask(() => {
71
- this._view.state.facet(semaphoreFacet).reconcile(handle, this._view);
71
+ this._view.state.facet(semaphoreFacet).reconcile(this._view);
72
72
  });
73
73
  }
74
74
  }
@@ -78,7 +78,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
78
78
  }
79
79
 
80
80
  private _handleChange = () => {
81
- this._view.state.facet(semaphoreFacet).reconcile(handle, this._view);
81
+ this._view.state.facet(semaphoreFacet).reconcile(this._view);
82
82
  };
83
83
  },
84
84
  );
@@ -1,26 +1,22 @@
1
1
  //
2
2
  // Copyright 2023 DXOS.org
3
+ // Copyright 2024 Automerge
3
4
  // Ref: https://github.com/automerge/automerge-codemirror
4
5
  //
5
6
 
6
- import {
7
- Annotation,
8
- StateEffect,
9
- type StateField,
10
- type EditorState,
11
- type Transaction,
12
- type TransactionSpec,
13
- } from '@codemirror/state';
7
+ import { Annotation, StateEffect, type StateField, type EditorState, type Transaction, Facet } from '@codemirror/state';
14
8
 
15
9
  import { type ChangeFn, type ChangeOptions, type Doc, type Heads, type Prop } from '@dxos/automerge/automerge';
16
10
 
11
+ import { type PatchSemaphore } from './semaphore';
12
+
17
13
  export type Value = {
18
14
  lastHeads: Heads;
19
15
  path: Prop[];
20
16
  unreconciledTransactions: Transaction[];
21
17
  };
22
18
 
23
- type UpdateHeads = {
19
+ export type UpdateHeads = {
24
20
  newHeads: Heads;
25
21
  };
26
22
 
@@ -36,6 +32,12 @@ export const reconcileAnnotationType = Annotation.define<unknown>();
36
32
 
37
33
  export const isReconcileTx = (tr: Transaction): boolean => !!tr.annotation(reconcileAnnotationType);
38
34
 
35
+ // TODO(burdon): Rename sync/create generic def (like cursor) to decouple automerge deps.
36
+ export const semaphoreFacet = Facet.define<PatchSemaphore, PatchSemaphore>({
37
+ combine: (values) => values.at(-1)!, // Take last.
38
+ });
39
+
40
+ /*
39
41
  export const makeReconcile = (tr: TransactionSpec) => {
40
42
  if (tr.annotations != null) {
41
43
  if (tr.annotations instanceof Array) {
@@ -46,11 +48,13 @@ export const makeReconcile = (tr: TransactionSpec) => {
46
48
  } else {
47
49
  tr.annotations = [reconcileAnnotationType.of({})];
48
50
  }
51
+
49
52
  // return {
50
53
  // ...tr,
51
54
  // annotations: reconcileAnnotationType.of({})
52
55
  // }
53
56
  };
57
+ */
54
58
 
55
59
  export type IDocHandle<T = any> = {
56
60
  docSync(): Doc<T> | undefined;
@@ -4,4 +4,3 @@
4
4
 
5
5
  export { automerge } from './automerge';
6
6
  export { type IDocHandle } from './defs';
7
- export { cursorConverter } from './cursor';
@@ -21,27 +21,23 @@ import {
21
21
  import { updateAutomerge } from './update-automerge';
22
22
  import { updateCodeMirror } from './update-codemirror';
23
23
 
24
- type Doc<T> = automerge.Doc<T>;
25
- type Heads = automerge.Heads;
26
-
27
- type ChangeFn = (atHeads: Heads, change: (doc: Doc<unknown>) => void) => Heads | undefined;
28
-
29
24
  /**
30
- * TODO(burdon): Comment.
25
+ * Implements three-way merge (on each mutation/key-stroke).
31
26
  */
32
27
  export class PatchSemaphore {
33
28
  _inReconcile = false;
34
- _queue: Array<ChangeFn> = [];
35
29
 
36
- constructor(private readonly _field: StateField<Value>) {}
30
+ constructor(private readonly _handle: IDocHandle, private readonly _field: StateField<Value>) {}
37
31
 
38
- reconcile = (handle: IDocHandle, view: EditorView) => {
32
+ reconcile = (view: EditorView) => {
39
33
  if (this._inReconcile) {
40
34
  return;
41
35
  }
42
36
  this._inReconcile = true;
43
37
 
44
38
  const path = getPath(view.state, this._field);
39
+
40
+ // Get the heads before the unreconciled transactions are applied.
45
41
  const oldHeads = getLastHeads(view.state, this._field);
46
42
  let selection = view.state.selection;
47
43
 
@@ -58,16 +54,18 @@ export class PatchSemaphore {
58
54
  }
59
55
 
60
56
  // Apply the unreconciled transactions to the document.
61
- let newHeads = updateAutomerge(this._field, handle, transactions, view.state);
57
+ let newHeads = updateAutomerge(this._field, this._handle, transactions, view.state);
62
58
 
63
59
  // NOTE: null and undefined each come from automerge and repo respectively.
64
60
  if (newHeads === null || newHeads === undefined) {
65
61
  // TODO: @alexjg this is the call that's resetting the editor state on click
66
- newHeads = automerge.getHeads(handle.docSync()!);
62
+ newHeads = automerge.getHeads(this._handle.docSync()!);
67
63
  }
68
64
 
69
65
  // Now get the diff between the updated state of the document and the heads and apply that to the codemirror doc.
70
- const diff = automerge.equals(oldHeads, newHeads) ? [] : automerge.diff(handle.docSync()!, oldHeads, newHeads);
66
+ const diff = automerge.equals(oldHeads, newHeads)
67
+ ? []
68
+ : automerge.diff(this._handle.docSync()!, oldHeads, newHeads);
71
69
  updateCodeMirror(view, selection, path, diff);
72
70
 
73
71
  view.dispatch({
@@ -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
8
 
9
9
  import { debounce } from '@dxos/async';
10
10
  import { invariant } from '@dxos/invariant';
11
- import { log } from '@dxos/log';
12
11
  import { nonNullable } from '@dxos/util';
13
12
 
13
+ import { semaphoreFacet } from './automerge/defs';
14
14
  import { Cursor } from './cursor';
15
15
  import { type CommentRange, type EditorModel, 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': {
@@ -60,7 +57,7 @@ export const setFocus = (view: EditorView, thread: string) => {
60
57
  view.dispatch({
61
58
  effects: setSelection.of({ active: thread }),
62
59
  selection: { anchor: range.from },
63
- scrollIntoView: true,
60
+ scrollIntoView: true, // TODO(burdon): Scroll to y-position (or center of screen?)
64
61
  });
65
62
  };
66
63
 
@@ -72,8 +69,7 @@ const setCommentState = StateEffect.define<CommentsState>();
72
69
 
73
70
  /**
74
71
  * 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.
72
+ * The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
77
73
  */
78
74
  const commentsStateField = StateField.define<CommentsState>({
79
75
  create: () => ({ ranges: [] }),
@@ -87,11 +83,12 @@ const commentsStateField = StateField.define<CommentsState>({
87
83
  }
88
84
 
89
85
  // Update range from store.
90
- if (effect.is(setCommentRange)) {
86
+ if (effect.is(setCommentRange) && effect.value.comments.length > 0) {
91
87
  const { comments } = effect.value;
92
88
  const ranges: ExtendedCommentRange[] = comments
93
89
  .map((comment) => {
94
90
  const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
91
+ // console.log('update', JSON.stringify({ range, cursor: comment.cursor }, undefined, 2));
95
92
  return range && { ...comment, ...range };
96
93
  })
97
94
  .filter(nonNullable);
@@ -135,12 +132,22 @@ const highlightDecorations = EditorView.decorations.compute([commentsStateField]
135
132
  });
136
133
 
137
134
  export type CommentsOptions = {
135
+ /**
136
+ * Key shortcut to create a new thread.
137
+ */
138
138
  key?: string;
139
- footnote?: boolean;
140
139
  /**
141
140
  * Called to create a new thread and return the thread id.
142
141
  */
143
142
  onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
143
+ /**
144
+ * Selection cut/deleted.
145
+ */
146
+ onDelete?: (thread: string) => void;
147
+ /**
148
+ * Called when a comment is moved.
149
+ */
150
+ onUpdate?: (thread: string, cursor: string) => void;
144
151
  /**
145
152
  * Called to notify which thread is currently closest to the cursor.
146
153
  */
@@ -151,6 +158,85 @@ export type CommentsOptions = {
151
158
  onHover?: (el: Element, shortcut: string) => void;
152
159
  };
153
160
 
161
+ // TODO(burdon): Handle cut/restore via undo (need to integrate with history?)
162
+ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
163
+ // Cut/deleted comments.
164
+ // Tracks indexed selections within text.
165
+ let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
166
+
167
+ // Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
168
+ const handleTrack = (event: Event, view: EditorView) => {
169
+ const comments = view.state.field(commentsStateField);
170
+ const { main } = view.state.selection;
171
+ const selectedRanges = comments.ranges.filter(
172
+ (range) => range.from >= main.from && range.to <= main.to && range.from < range.to,
173
+ );
174
+
175
+ if (!selectedRanges.length) {
176
+ tracked = null;
177
+ } else {
178
+ tracked = {
179
+ text: view.state.doc.slice(main.from, main.to),
180
+ comments: selectedRanges.map((range) => ({
181
+ id: range.id,
182
+ from: range.from - main.from,
183
+ to: range.to - main.from,
184
+ })),
185
+ };
186
+ }
187
+ };
188
+
189
+ return [
190
+ EditorView.domEventHandlers({
191
+ cut: handleTrack,
192
+ copy: handleTrack,
193
+ }),
194
+
195
+ // Handle paste.
196
+ EditorView.updateListener.of(({ view, state, transactions }) => {
197
+ if (tracked) {
198
+ const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
199
+ if (paste) {
200
+ let found = -1;
201
+ paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
202
+ if (text.eq(tracked!.text)) {
203
+ for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
204
+ fromB = transactions[i].changes.mapPos(fromB);
205
+ }
206
+
207
+ found = fromB;
208
+ }
209
+ });
210
+
211
+ if (found > -1) {
212
+ const comments = tracked.comments;
213
+
214
+ // Sync before recomputing cursor/range.
215
+ // TODO(burdon): Decouple from automerge?
216
+ view.state.facet(semaphoreFacet).reconcile(view);
217
+
218
+ const active = state.field(commentsStateField).ranges;
219
+ for (const moved of comments) {
220
+ if (active.some((range) => range.id === moved.id && range.from === range.to)) {
221
+ const range = {
222
+ from: found + moved.from,
223
+ to: found + moved.to,
224
+ };
225
+
226
+ const cursor = Cursor.getCursorFromRange(state.facet(Cursor.converter), range);
227
+ // console.log('paste', JSON.stringify({ moved, range, cursor }, undefined, 2));
228
+ onUpdate(moved.id, cursor);
229
+ }
230
+ }
231
+ }
232
+
233
+ tracked = null;
234
+ }
235
+ }
236
+ }),
237
+ ];
238
+ };
239
+
154
240
  /**
155
241
  * Comment threads.
156
242
  * 1). Updates the EditorModel to store relative selections for a set of comments threads.
@@ -160,8 +246,7 @@ export type CommentsOptions = {
160
246
  * 4). Tracks the current cursor position to:
161
247
  * a). Update the decoration to show if the cursor is within a current selection.
162
248
  * 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.
249
+ * 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
165
250
  */
166
251
  export const comments = (options: CommentsOptions = {}): Extension => {
167
252
  const { key: shortcut = "meta-'" } = options;
@@ -175,7 +260,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
175
260
  const cursorConverter = view.state.facet(Cursor.converter);
176
261
 
177
262
  invariant(options.onCreate);
178
- const { head, from, to } = view.state.selection.main;
263
+ const { from, to } = view.state.selection.main;
179
264
  if (from === to) {
180
265
  return false;
181
266
  }
@@ -191,15 +276,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
191
276
  selection: { anchor: from },
192
277
  });
193
278
 
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
279
  return true;
204
280
  }
205
281
  }
@@ -238,7 +314,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
238
314
  end: selection.to,
239
315
  above: true,
240
316
  create: () => {
241
- // TODO(burdon): Dispatch to react callback to render (or use SSR)?
242
317
  const el = document.createElement('div');
243
318
  options.onHover?.(el, shortcut);
244
319
  return { dom: el, offset: { x: 0, y: 8 } };
@@ -266,12 +341,12 @@ export const comments = (options: CommentsOptions = {}): Extension => {
266
341
  let mod = false;
267
342
  const { active, ranges } = state.field(commentsStateField);
268
343
  changes.iterChanges((from, to, from2, to2) => {
344
+ // TODO(burdon): Skip deleted (tracked) ranges.
269
345
  ranges.forEach((range) => {
270
346
  if (from2 === to2) {
271
347
  const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
272
348
  if (!newRange || newRange.to - newRange.from === 0) {
273
- // TODO(burdon): Delete range if empty.
274
- log.info('deleted comment', { thread: range.id });
349
+ options.onDelete?.(range.id);
275
350
  }
276
351
  }
277
352
 
@@ -297,6 +372,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
297
372
  let min = Infinity;
298
373
  const { active, closest, ranges } = state.field(commentsStateField);
299
374
  const selected: CommentSelected = { active: undefined, closest: undefined };
375
+
376
+ // TODO(burdon): Skip deleted (tracked) ranges.
300
377
  ranges.forEach((comment) => {
301
378
  const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
302
379
  if (head >= comment.from && head <= comment.to) {
@@ -308,8 +385,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
308
385
  }
309
386
  });
310
387
 
311
- // TODO(burdon): Fire debounced callback if range selected (to show hint).
312
-
313
388
  if (selected.active !== active || selected.closest !== closest) {
314
389
  view.dispatch({ effects: setSelection.of(selected) });
315
390
 
@@ -321,5 +396,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
321
396
  }
322
397
  }
323
398
  }),
399
+
400
+ options.onUpdate ? trackPastedComments(options.onUpdate) : [],
324
401
  ];
325
402
  };