@dxos/react-ui-editor 0.3.11-main.42059c4 → 0.3.11-main.47875f3

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.
@@ -8,14 +8,17 @@ 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';
11
12
  import { nonNullable } from '@dxos/util';
12
13
 
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
+
19
22
  // TODO(burdon): Reconcile with theme.
20
23
  const styles = EditorView.baseTheme({
21
24
  '& .cm-comment': {
@@ -57,7 +60,7 @@ export const setFocus = (view: EditorView, thread: string) => {
57
60
  view.dispatch({
58
61
  effects: setSelection.of({ active: thread }),
59
62
  selection: { anchor: range.from },
60
- scrollIntoView: true, // TODO(burdon): Scroll to y-position (or center of screen?)
63
+ scrollIntoView: true,
61
64
  });
62
65
  };
63
66
 
@@ -69,7 +72,8 @@ const setCommentState = StateEffect.define<CommentsState>();
69
72
 
70
73
  /**
71
74
  * State field (reducer) that tracks comment ranges.
72
- * The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
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.
73
77
  */
74
78
  const commentsStateField = StateField.define<CommentsState>({
75
79
  create: () => ({ ranges: [] }),
@@ -83,12 +87,11 @@ const commentsStateField = StateField.define<CommentsState>({
83
87
  }
84
88
 
85
89
  // Update range from store.
86
- if (effect.is(setCommentRange) && effect.value.comments.length > 0) {
90
+ if (effect.is(setCommentRange)) {
87
91
  const { comments } = effect.value;
88
92
  const ranges: ExtendedCommentRange[] = comments
89
93
  .map((comment) => {
90
94
  const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
91
- // console.log('update', JSON.stringify({ range, cursor: comment.cursor }, undefined, 2));
92
95
  return range && { ...comment, ...range };
93
96
  })
94
97
  .filter(nonNullable);
@@ -132,22 +135,16 @@ const highlightDecorations = EditorView.decorations.compute([commentsStateField]
132
135
  });
133
136
 
134
137
  export type CommentsOptions = {
135
- /**
136
- * Key shortcut to create a new thread.
137
- */
138
138
  key?: string;
139
+ footnote?: boolean;
139
140
  /**
140
141
  * Called to create a new thread and return the thread id.
141
142
  */
142
143
  onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
143
- /**
144
- * Selection cut/deleted.
145
- */
146
- onDelete?: (thread: string) => void;
147
144
  /**
148
145
  * Called when a comment is moved.
149
146
  */
150
- onUpdate?: (thread: string, cursor: string) => void;
147
+ onMove?: (threadID: string, cursor: string) => void;
151
148
  /**
152
149
  * Called to notify which thread is currently closest to the cursor.
153
150
  */
@@ -158,78 +155,58 @@ export type CommentsOptions = {
158
155
  onHover?: (el: Element, shortcut: string) => void;
159
156
  };
160
157
 
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;
158
+ const trackPastedComments = (onMove: (threadID: string, cursor: string) => void) => {
159
+ let tracked: { text: Text; comments: { from: number; to: number; id: string }[] } | null = null;
166
160
 
167
- // Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
168
- const handleTrack = (event: Event, view: EditorView) => {
161
+ const registerCopy = (event: Event, view: EditorView) => {
169
162
  const comments = view.state.field(commentsStateField);
170
163
  const { main } = view.state.selection;
171
- const selectedRanges = comments.ranges.filter(
164
+ const inSel = comments.ranges.filter(
172
165
  (range) => range.from >= main.from && range.to <= main.to && range.from < range.to,
173
166
  );
174
-
175
- if (!selectedRanges.length) {
167
+ if (!inSel.length) {
176
168
  tracked = null;
177
169
  } else {
178
170
  tracked = {
179
171
  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
- })),
172
+ comments: inSel.map((range) => ({ from: range.from - main.from, to: range.to - main.from, id: range.id })),
185
173
  };
186
174
  }
187
175
  };
188
176
 
189
177
  return [
190
178
  EditorView.domEventHandlers({
191
- cut: handleTrack,
192
- copy: handleTrack,
179
+ cut: registerCopy,
180
+ copy: registerCopy,
193
181
  }),
194
182
 
195
- // Handle paste.
196
- EditorView.updateListener.of(({ view, state, transactions }) => {
183
+ EditorView.updateListener.of((update) => {
197
184
  if (tracked) {
198
- const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
185
+ const paste = update.transactions.find((tr) => tr.isUserEvent('input.paste'));
199
186
  if (paste) {
200
187
  let found = -1;
201
188
  paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
202
189
  if (text.eq(tracked!.text)) {
203
- for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
204
- fromB = transactions[i].changes.mapPos(fromB);
190
+ for (let i = update.transactions.indexOf(paste!) + 1; i < update.transactions.length; i++) {
191
+ fromB = update.transactions[i].changes.mapPos(fromB);
205
192
  }
206
-
207
193
  found = fromB;
208
194
  }
209
195
  });
210
-
211
196
  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) {
197
+ const active = update.view.state.field(commentsStateField).ranges;
198
+ for (const moved of tracked.comments) {
220
199
  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);
200
+ onMove(
201
+ moved.id,
202
+ Cursor.getCursorFromRange(update.view.state.facet(Cursor.converter), {
203
+ from: found + moved.from,
204
+ to: found + moved.to,
205
+ }),
206
+ );
229
207
  }
230
208
  }
231
209
  }
232
-
233
210
  tracked = null;
234
211
  }
235
212
  }
@@ -246,7 +223,8 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
246
223
  * 4). Tracks the current cursor position to:
247
224
  * a). Update the decoration to show if the cursor is within a current selection.
248
225
  * b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
249
- * 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
226
+ * 5). Optionally, inserts a markdown footnote when creating a comment thread.
227
+ * 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
250
228
  */
251
229
  export const comments = (options: CommentsOptions = {}): Extension => {
252
230
  const { key: shortcut = "meta-'" } = options;
@@ -260,7 +238,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
260
238
  const cursorConverter = view.state.facet(Cursor.converter);
261
239
 
262
240
  invariant(options.onCreate);
263
- const { from, to } = view.state.selection.main;
241
+ const { head, from, to } = view.state.selection.main;
264
242
  if (from === to) {
265
243
  return false;
266
244
  }
@@ -276,6 +254,15 @@ export const comments = (options: CommentsOptions = {}): Extension => {
276
254
  selection: { anchor: from },
277
255
  });
278
256
 
257
+ // Insert footnote.
258
+ if (options.footnote) {
259
+ const tag = `[^${id}]`;
260
+ view.dispatch({
261
+ changes: { from: head, insert: tag },
262
+ selection: { anchor: head + tag.length },
263
+ });
264
+ }
265
+
279
266
  return true;
280
267
  }
281
268
  }
@@ -314,6 +301,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
314
301
  end: selection.to,
315
302
  above: true,
316
303
  create: () => {
304
+ // TODO(burdon): Dispatch to react callback to render (or use SSR)?
317
305
  const el = document.createElement('div');
318
306
  options.onHover?.(el, shortcut);
319
307
  return { dom: el, offset: { x: 0, y: 8 } };
@@ -341,12 +329,12 @@ export const comments = (options: CommentsOptions = {}): Extension => {
341
329
  let mod = false;
342
330
  const { active, ranges } = state.field(commentsStateField);
343
331
  changes.iterChanges((from, to, from2, to2) => {
344
- // TODO(burdon): Skip deleted (tracked) ranges.
345
332
  ranges.forEach((range) => {
346
333
  if (from2 === to2) {
347
334
  const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
348
335
  if (!newRange || newRange.to - newRange.from === 0) {
349
- options.onDelete?.(range.id);
336
+ // TODO(burdon): Delete range if empty.
337
+ log.info('deleted comment', { thread: range.id });
350
338
  }
351
339
  }
352
340
 
@@ -372,8 +360,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
372
360
  let min = Infinity;
373
361
  const { active, closest, ranges } = state.field(commentsStateField);
374
362
  const selected: CommentSelected = { active: undefined, closest: undefined };
375
-
376
- // TODO(burdon): Skip deleted (tracked) ranges.
377
363
  ranges.forEach((comment) => {
378
364
  const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
379
365
  if (head >= comment.from && head <= comment.to) {
@@ -385,6 +371,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
385
371
  }
386
372
  });
387
373
 
374
+ // TODO(burdon): Fire debounced callback if range selected (to show hint).
375
+
388
376
  if (selected.active !== active || selected.closest !== closest) {
389
377
  view.dispatch({ effects: setSelection.of(selected) });
390
378
 
@@ -397,6 +385,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
397
385
  }
398
386
  }),
399
387
 
400
- options.onUpdate ? trackPastedComments(options.onUpdate) : [],
388
+ options.onMove ? trackPastedComments(options.onMove) : [],
401
389
  ];
402
390
  };
@@ -4,6 +4,5 @@
4
4
 
5
5
  export * from './automerge';
6
6
  export * from './awareness-provider';
7
- export * from './yjs';
8
-
9
7
  export * from './useTextModel';
8
+ export * from './yjs';