@dxos/react-ui-editor 0.3.11-main.c86e105 → 0.3.11-main.cd64fe0

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 (41) hide show
  1. package/dist/lib/browser/index.mjs +84 -96
  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 +3 -0
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +2 -2
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/comments.stories.d.ts +1 -1
  9. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
  10. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  11. package/dist/types/src/extensions/automerge/defs.d.ts +4 -4
  12. package/dist/types/src/extensions/automerge/defs.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/code.d.ts.map +1 -1
  16. package/dist/types/src/extensions/comments.d.ts +9 -4
  17. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  18. package/dist/types/src/extensions/hr.d.ts.map +1 -1
  19. package/dist/types/src/extensions/index.d.ts +1 -0
  20. package/dist/types/src/extensions/index.d.ts.map +1 -1
  21. package/dist/types/src/extensions/outliner.d.ts +4 -0
  22. package/dist/types/src/extensions/outliner.d.ts.map +1 -0
  23. package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
  24. package/dist/types/src/hooks/index.d.ts +1 -1
  25. package/dist/types/src/hooks/index.d.ts.map +1 -1
  26. package/package.json +23 -23
  27. package/src/components/TextEditor/MarkdownEditor.stories.tsx +16 -14
  28. package/src/components/TextEditor/TextEditor.tsx +12 -12
  29. package/src/components/TextEditor/comments.stories.tsx +74 -48
  30. package/src/extensions/automerge/automerge.ts +10 -12
  31. package/src/extensions/automerge/defs.ts +13 -9
  32. package/src/extensions/automerge/semaphore.ts +10 -12
  33. package/src/extensions/awareness.ts +2 -1
  34. package/src/extensions/code.ts +15 -17
  35. package/src/extensions/comments.ts +68 -50
  36. package/src/extensions/hr.ts +6 -7
  37. package/src/extensions/index.ts +1 -0
  38. package/src/extensions/link.ts +3 -3
  39. package/src/extensions/outliner.ts +12 -0
  40. package/src/extensions/tasklist.ts +1 -0
  41. package/src/hooks/index.ts +2 -1
@@ -8,17 +8,14 @@ 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: [] }),
@@ -92,6 +88,7 @@ const commentsStateField = StateField.define<CommentsState>({
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,16 +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;
144
147
  /**
145
148
  * Called when a comment is moved.
146
149
  */
147
- onMove?: (threadID: string, cursor: string) => void;
150
+ onUpdate?: (thread: string, cursor: string) => void;
148
151
  /**
149
152
  * Called to notify which thread is currently closest to the cursor.
150
153
  */
@@ -155,58 +158,80 @@ export type CommentsOptions = {
155
158
  onHover?: (el: Element, shortcut: string) => void;
156
159
  };
157
160
 
158
- const trackPastedComments = (onMove: (threadID: string, cursor: string) => void) => {
159
- let tracked: { text: Text; comments: { from: number; to: number; id: string }[] } | null = null;
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;
160
166
 
161
- const registerCopy = (event: Event, view: EditorView) => {
167
+ // Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
168
+ const handleTrack = (event: Event, view: EditorView) => {
162
169
  const comments = view.state.field(commentsStateField);
163
170
  const { main } = view.state.selection;
164
- const inSel = comments.ranges.filter(
171
+ const selectedRanges = comments.ranges.filter(
165
172
  (range) => range.from >= main.from && range.to <= main.to && range.from < range.to,
166
173
  );
167
- if (!inSel.length) {
174
+
175
+ if (!selectedRanges.length) {
168
176
  tracked = null;
169
177
  } else {
170
178
  tracked = {
171
179
  text: view.state.doc.slice(main.from, main.to),
172
- comments: inSel.map((range) => ({ from: range.from - main.from, to: range.to - main.from, id: range.id })),
180
+ comments: selectedRanges.map((range) => ({
181
+ id: range.id,
182
+ from: range.from - main.from,
183
+ to: range.to - main.from,
184
+ })),
173
185
  };
174
186
  }
187
+
188
+ // console.log('track', JSON.stringify({ tracked }, undefined, 2));
175
189
  };
176
190
 
177
191
  return [
178
192
  EditorView.domEventHandlers({
179
- cut: registerCopy,
180
- copy: registerCopy,
193
+ cut: handleTrack,
194
+ copy: handleTrack,
181
195
  }),
182
196
 
183
- EditorView.updateListener.of((update) => {
197
+ // Handle paste.
198
+ EditorView.updateListener.of(({ view, state, transactions }) => {
184
199
  if (tracked) {
185
- const paste = update.transactions.find((tr) => tr.isUserEvent('input.paste'));
200
+ const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
186
201
  if (paste) {
187
202
  let found = -1;
188
203
  paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
189
204
  if (text.eq(tracked!.text)) {
190
- for (let i = update.transactions.indexOf(paste!) + 1; i < update.transactions.length; i++) {
191
- fromB = update.transactions[i].changes.mapPos(fromB);
205
+ for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
206
+ fromB = transactions[i].changes.mapPos(fromB);
192
207
  }
208
+
193
209
  found = fromB;
194
210
  }
195
211
  });
212
+
196
213
  if (found > -1) {
197
- const active = update.view.state.field(commentsStateField).ranges;
198
- for (const moved of tracked.comments) {
214
+ const comments = tracked.comments;
215
+
216
+ // Sync before recomputing cursor/range.
217
+ // TODO(burdon): Generalize sync facet to decouple from automerge?
218
+ view.state.facet(semaphoreFacet).reconcile(view);
219
+
220
+ const active = state.field(commentsStateField).ranges;
221
+ for (const moved of comments) {
199
222
  if (active.some((range) => range.id === moved.id && range.from === range.to)) {
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
- );
223
+ const range = {
224
+ from: found + moved.from,
225
+ to: found + moved.to,
226
+ };
227
+
228
+ const cursor = Cursor.getCursorFromRange(state.facet(Cursor.converter), range);
229
+ // console.log('paste', JSON.stringify({ moved, range, cursor }, undefined, 2));
230
+ onUpdate(moved.id, cursor);
207
231
  }
208
232
  }
209
233
  }
234
+
210
235
  tracked = null;
211
236
  }
212
237
  }
@@ -223,8 +248,7 @@ const trackPastedComments = (onMove: (threadID: string, cursor: string) => void)
223
248
  * 4). Tracks the current cursor position to:
224
249
  * a). Update the decoration to show if the cursor is within a current selection.
225
250
  * b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
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.
251
+ * 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
228
252
  */
229
253
  export const comments = (options: CommentsOptions = {}): Extension => {
230
254
  const { key: shortcut = "meta-'" } = options;
@@ -238,7 +262,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
238
262
  const cursorConverter = view.state.facet(Cursor.converter);
239
263
 
240
264
  invariant(options.onCreate);
241
- const { head, from, to } = view.state.selection.main;
265
+ const { from, to } = view.state.selection.main;
242
266
  if (from === to) {
243
267
  return false;
244
268
  }
@@ -254,15 +278,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
254
278
  selection: { anchor: from },
255
279
  });
256
280
 
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
-
266
281
  return true;
267
282
  }
268
283
  }
@@ -301,9 +316,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
301
316
  end: selection.to,
302
317
  above: true,
303
318
  create: () => {
304
- // TODO(burdon): Dispatch to react callback to render (or use SSR)?
305
319
  const el = document.createElement('div');
306
- options.onHover?.(el, shortcut);
320
+ options.onHover!(el, shortcut);
307
321
  return { dom: el, offset: { x: 0, y: 8 } };
308
322
  },
309
323
  };
@@ -311,7 +325,11 @@ export const comments = (options: CommentsOptions = {}): Extension => {
311
325
 
312
326
  return null;
313
327
  },
314
- { hideOnChange: true, hoverTime: 1000 },
328
+ {
329
+ // TODO(burdon): Hide on change triggered immediately?
330
+ // hideOnChange: true,
331
+ hoverTime: 1_000,
332
+ },
315
333
  )
316
334
  : [],
317
335
 
@@ -329,12 +347,12 @@ export const comments = (options: CommentsOptions = {}): Extension => {
329
347
  let mod = false;
330
348
  const { active, ranges } = state.field(commentsStateField);
331
349
  changes.iterChanges((from, to, from2, to2) => {
350
+ // TODO(burdon): Skip deleted (tracked) ranges.
332
351
  ranges.forEach((range) => {
333
352
  if (from2 === to2) {
334
353
  const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
335
354
  if (!newRange || newRange.to - newRange.from === 0) {
336
- // TODO(burdon): Delete range if empty.
337
- log.info('deleted comment', { thread: range.id });
355
+ options.onDelete?.(range.id);
338
356
  }
339
357
  }
340
358
 
@@ -360,6 +378,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
360
378
  let min = Infinity;
361
379
  const { active, closest, ranges } = state.field(commentsStateField);
362
380
  const selected: CommentSelected = { active: undefined, closest: undefined };
381
+
382
+ // TODO(burdon): Skip deleted (tracked) ranges.
363
383
  ranges.forEach((comment) => {
364
384
  const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
365
385
  if (head >= comment.from && head <= comment.to) {
@@ -371,8 +391,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
371
391
  }
372
392
  });
373
393
 
374
- // TODO(burdon): Fire debounced callback if range selected (to show hint).
375
-
376
394
  if (selected.active !== active || selected.closest !== closest) {
377
395
  view.dispatch({ effects: setSelection.of(selected) });
378
396
 
@@ -385,6 +403,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
385
403
  }
386
404
  }),
387
405
 
388
- options.onMove ? trackPastedComments(options.onMove) : [],
406
+ options.onUpdate ? trackPastedComments(options.onUpdate) : [],
389
407
  ];
390
408
  };
@@ -33,15 +33,19 @@ class HorizontalRuleWidget extends WidgetType {
33
33
  }
34
34
 
35
35
  override toDOM(view: EditorView) {
36
- // TODO(burdon): Create <hr> element? Does this clash with markdown parser?
37
36
  const el = document.createElement('div');
38
37
  el.className = 'cm-hr';
39
38
  return el;
40
39
  }
41
40
  }
42
41
 
42
+ // TODO(burdon): Like Tasklist, allow cursor to move into range.
43
+
44
+ // NOTE: Without a blank line before this markup will treat the previous line as a heading.
45
+ // https://www.markdownguide.org/basic-syntax/#horizontal-rules
43
46
  const placeholderMatcher = new MatchDecorator({
44
- regexp: /^---$/g,
47
+ // regexp: /(?<=\n\n)---/gs,
48
+ regexp: /^---$/gs,
45
49
  decoration: (match, view, pos) =>
46
50
  Decoration.replace({
47
51
  widget: new HorizontalRuleWidget(pos),
@@ -63,11 +67,6 @@ export const hr = () => [
63
67
  },
64
68
  {
65
69
  decorations: (instance) => instance.rules,
66
- // TODO(burdon): Is this really atomic (cursor can move into ---).
67
- provide: (plugin) =>
68
- EditorView.atomicRanges.of((view) => {
69
- return view.plugin(plugin)?.rules || Decoration.none;
70
- }),
71
70
  },
72
71
  ),
73
72
  ];
@@ -16,6 +16,7 @@ export * from './link';
16
16
  export * from './listener';
17
17
  export * from './markdown';
18
18
  export * from './mention';
19
+ export * from './outliner';
19
20
  export * from './table';
20
21
  export * from './tasklist';
21
22
  export * from './typewriter';
@@ -6,13 +6,13 @@
6
6
  import { syntaxTree } from '@codemirror/language';
7
7
  import { type Extension, RangeSetBuilder } from '@codemirror/state';
8
8
  import {
9
+ hoverTooltip,
9
10
  Decoration,
11
+ type DecorationSet,
10
12
  type EditorView,
11
- WidgetType,
12
- hoverTooltip,
13
13
  ViewPlugin,
14
14
  type ViewUpdate,
15
- type DecorationSet,
15
+ WidgetType,
16
16
  } from '@codemirror/view';
17
17
  import { type SyntaxNode } from '@lezer/common';
18
18
 
@@ -0,0 +1,12 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+
7
+ export type OutlinerOptions = {};
8
+
9
+ // TODO(burdon): Experimental component: multiple hierarchical draggable editor sections.
10
+ export const outliner = (options: OutlinerOptions = {}): Extension => {
11
+ return [];
12
+ };
@@ -64,6 +64,7 @@ export const tasklist = (options: TasklistOptions = {}) => {
64
64
  },
65
65
  {
66
66
  decorations: (v) => v.decorations,
67
+ // TODO(burdon): Is this still required?
67
68
  provide: (plugin) =>
68
69
  EditorView.atomicRanges.of((view) => {
69
70
  return view.plugin(plugin)?.decorations || Decoration.none;
@@ -4,5 +4,6 @@
4
4
 
5
5
  export * from './automerge';
6
6
  export * from './awareness-provider';
7
- export * from './useTextModel';
8
7
  export * from './yjs';
8
+
9
+ export * from './useTextModel';