@dxos/react-ui-editor 0.3.11-main.73ee990 → 0.3.11-main.764a9c1

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 (88) hide show
  1. package/dist/lib/browser/index.mjs +1649 -234
  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 +1 -2
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  7. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +1 -1
  8. package/dist/types/src/components/TextEditor/automerge.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/TextEditor/{comments.stories.d.ts → hooks.stories.d.ts} +9 -12
  10. package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +1 -0
  11. package/dist/types/src/components/Toolbar/Toolbar.d.ts +27 -7
  12. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  13. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +12 -1
  14. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Toolbar/index.d.ts +2 -0
  16. package/dist/types/src/components/Toolbar/index.d.ts.map +1 -0
  17. package/dist/types/src/components/index.d.ts +1 -0
  18. package/dist/types/src/components/index.d.ts.map +1 -1
  19. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  20. package/dist/types/src/extensions/automerge/automerge.spec.d.ts +2 -0
  21. package/dist/types/src/extensions/automerge/automerge.spec.d.ts.map +1 -0
  22. package/dist/types/src/extensions/automerge/automerge.test.d.ts +2 -0
  23. package/dist/types/src/extensions/automerge/automerge.test.d.ts.map +1 -0
  24. package/dist/types/src/extensions/automerge/defs.d.ts +5 -5
  25. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  26. package/dist/types/src/extensions/automerge/semaphore.d.ts +2 -1
  27. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  28. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  29. package/dist/types/src/extensions/awareness.d.ts +1 -1
  30. package/dist/types/src/extensions/basic.d.ts +1 -1
  31. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  32. package/dist/types/src/extensions/comments.d.ts +6 -2
  33. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  34. package/dist/types/src/extensions/markdown/bundle.d.ts +2 -2
  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 -1
  37. package/dist/types/src/extensions/markdown/formatting.d.ts +55 -8
  38. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  39. package/dist/types/src/extensions/markdown/formatting.test.d.ts +3 -0
  40. package/dist/types/src/extensions/markdown/formatting.test.d.ts.map +1 -0
  41. package/dist/types/src/hooks/index.d.ts +1 -0
  42. package/dist/types/src/hooks/index.d.ts.map +1 -1
  43. package/dist/types/src/hooks/useActionHandler.d.ts +2 -2
  44. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -1
  45. package/dist/types/src/hooks/useEditorView.d.ts +17 -0
  46. package/dist/types/src/hooks/useEditorView.d.ts.map +1 -0
  47. package/dist/types/src/hooks/useTextEditor.d.ts +36 -14
  48. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  49. package/dist/types/src/hooks/useTextModel.d.ts +12 -0
  50. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  51. package/dist/types/src/index.d.ts +1 -1
  52. package/dist/types/src/index.d.ts.map +1 -1
  53. package/dist/types/src/themes/default.d.ts.map +1 -1
  54. package/package.json +39 -26
  55. package/src/components/TextEditor/MarkdownEditor.stories.tsx +5 -6
  56. package/src/components/TextEditor/TextEditor.tsx +26 -16
  57. package/src/components/TextEditor/automerge.stories.tsx +1 -2
  58. package/src/components/TextEditor/hooks.stories.tsx +111 -0
  59. package/src/components/Toolbar/Toolbar.stories.tsx +22 -6
  60. package/src/components/Toolbar/Toolbar.tsx +224 -75
  61. package/src/components/Toolbar/index.ts +5 -0
  62. package/src/components/index.ts +1 -0
  63. package/src/extensions/automerge/automerge.spec.tsx +76 -0
  64. package/src/extensions/automerge/automerge.test.ts +13 -0
  65. package/src/extensions/automerge/automerge.ts +27 -11
  66. package/src/extensions/automerge/cursor.ts +3 -3
  67. package/src/extensions/automerge/defs.ts +9 -9
  68. package/src/extensions/automerge/semaphore.ts +17 -19
  69. package/src/extensions/automerge/update-automerge.ts +9 -9
  70. package/src/extensions/automerge/update-codemirror.ts +5 -5
  71. package/src/extensions/awareness.ts +7 -4
  72. package/src/extensions/basic.ts +15 -13
  73. package/src/extensions/blast.ts +4 -1
  74. package/src/extensions/comments.ts +84 -30
  75. package/src/extensions/markdown/bundle.ts +10 -5
  76. package/src/extensions/markdown/code.ts +12 -4
  77. package/src/extensions/markdown/formatting.test.ts +482 -0
  78. package/src/extensions/markdown/formatting.ts +1133 -55
  79. package/src/extensions/markdown/link.ts +8 -2
  80. package/src/hooks/index.ts +1 -0
  81. package/src/hooks/useActionHandler.ts +61 -13
  82. package/src/hooks/useEditorView.ts +29 -0
  83. package/src/hooks/useTextEditor.ts +143 -19
  84. package/src/hooks/useTextModel.ts +28 -9
  85. package/src/index.ts +1 -1
  86. package/src/themes/default.ts +10 -5
  87. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +0 -1
  88. package/src/components/TextEditor/comments.stories.tsx +0 -357
@@ -4,14 +4,14 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
- import { StateField, type Extension } from '@codemirror/state';
7
+ import { invertedEffects } from '@codemirror/commands';
8
+ import { StateField, type Extension, type StateEffect } from '@codemirror/state';
8
9
  import { EditorView, ViewPlugin } from '@codemirror/view';
9
10
 
10
- import { type Prop, next as _automerge } from '@dxos/automerge/automerge';
11
- import { invariant } from '@dxos/invariant';
11
+ import { next as A, type Prop } from '@dxos/automerge/automerge';
12
12
 
13
13
  import { cursorConverter } from './cursor';
14
- import { effectType, type IDocHandle, isReconcileTx, type State } from './defs';
14
+ import { updateHeadsEffect, type IDocHandle, isReconcile, type State } from './defs';
15
15
  import { PatchSemaphore } from './semaphore';
16
16
  import { Cursor } from '../cursor';
17
17
 
@@ -21,10 +21,10 @@ export type AutomergeOptions = {
21
21
  };
22
22
 
23
23
  export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
24
- const state = StateField.define<State>({
24
+ const syncState = StateField.define<State>({
25
25
  create: () => ({
26
26
  path: path.slice(),
27
- lastHeads: _automerge.getHeads(handle.docSync()!),
27
+ lastHeads: A.getHeads(handle.docSync()!),
28
28
  unreconciledTransactions: [],
29
29
  }),
30
30
 
@@ -37,7 +37,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
37
37
 
38
38
  let clearUnreconciled = false;
39
39
  for (const effect of tr.effects) {
40
- if (effect.is(effectType)) {
40
+ if (effect.is(updateHeadsEffect)) {
41
41
  result.lastHeads = effect.value.newHeads;
42
42
  clearUnreconciled = true;
43
43
  }
@@ -46,7 +46,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
46
46
  if (clearUnreconciled) {
47
47
  result.unreconciledTransactions = [];
48
48
  } else {
49
- if (!isReconcileTx(tr)) {
49
+ if (!isReconcile(tr)) {
50
50
  result.unreconciledTransactions.push(tr);
51
51
  }
52
52
  }
@@ -55,15 +55,16 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
55
55
  },
56
56
  });
57
57
 
58
- const semaphore = new PatchSemaphore(handle, state);
58
+ const semaphore = new PatchSemaphore(handle, syncState);
59
59
 
60
60
  return [
61
61
  Cursor.converter.of(cursorConverter(handle, path)),
62
+ syncState,
63
+
62
64
  // Reconcile external updates.
63
65
  ViewPlugin.fromClass(
64
66
  class {
65
67
  constructor(private readonly _view: EditorView) {
66
- invariant(this._view);
67
68
  handle.addListener('change', this._handleChange.bind(this));
68
69
  }
69
70
 
@@ -76,12 +77,27 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
76
77
  }
77
78
  },
78
79
  ),
80
+
79
81
  // Reconcile local updates.
80
82
  EditorView.updateListener.of(({ view, changes }) => {
81
83
  if (!changes.empty) {
82
84
  semaphore.reconcile(view);
83
85
  }
84
86
  }),
85
- state,
87
+
88
+ // TODO(burdon): Record undo transactions.
89
+ // See https://github.com/yjs/y-codemirror.next/tree/main
90
+ // https://codemirror.net/examples/inverted-effect
91
+ invertedEffects.of((tr) => {
92
+ // Each change results it three transactions: insert, delete, insert.
93
+ const effects: StateEffect<any>[] = [];
94
+ if (!tr.changes.empty) {
95
+ tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
96
+ // effects.push(effectType.of({});
97
+ });
98
+ }
99
+
100
+ return effects;
101
+ }),
86
102
  ];
87
103
  };
@@ -5,7 +5,7 @@
5
5
  import get from 'lodash.get';
6
6
 
7
7
  import type { Prop } from '@dxos/automerge/automerge';
8
- import { next as automerge } from '@dxos/automerge/automerge';
8
+ import { next as A } from '@dxos/automerge/automerge';
9
9
 
10
10
  import { type IDocHandle } from './defs';
11
11
  import { type CursorConverter } from '../cursor';
@@ -24,7 +24,7 @@ export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConvert
24
24
  }
25
25
 
26
26
  // NOTE: Slice is needed because getCursor mutates the array.
27
- return automerge.getCursor(doc, path.slice(), pos);
27
+ return A.getCursor(doc, path.slice(), pos);
28
28
  },
29
29
 
30
30
  fromCursor: (cursor) => {
@@ -47,6 +47,6 @@ export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConvert
47
47
  }
48
48
 
49
49
  // NOTE: Slice is needed because getCursor mutates the array.
50
- return automerge.getCursorPosition(doc, path.slice(), cursor);
50
+ return A.getCursorPosition(doc, path.slice(), cursor);
51
51
  },
52
52
  });
@@ -8,28 +8,28 @@ import { Annotation, StateEffect, type StateField, type EditorState, type Transa
8
8
 
9
9
  import { type ChangeFn, type ChangeOptions, type Doc, type Heads, type Prop } from '@dxos/automerge/automerge';
10
10
 
11
- // TODO(burdon): Rename.
12
11
  export type State = {
13
12
  path: Prop[];
14
13
  lastHeads: Heads;
15
14
  unreconciledTransactions: Transaction[];
16
15
  };
17
16
 
17
+ export const getPath = (state: EditorState, field: StateField<State>): Prop[] => state.field(field).path;
18
+ export const getLastHeads = (state: EditorState, field: StateField<State>): Heads => state.field(field).lastHeads;
19
+
18
20
  export type UpdateHeads = {
19
21
  newHeads: Heads;
20
22
  };
21
23
 
22
- export const effectType = StateEffect.define<UpdateHeads>({});
23
-
24
- export const reconcileAnnotationType = Annotation.define<unknown>();
24
+ export const updateHeadsEffect = StateEffect.define<UpdateHeads>({});
25
25
 
26
- export const getPath = (state: EditorState, field: StateField<State>): Prop[] => state.field(field).path;
27
-
28
- export const getLastHeads = (state: EditorState, field: StateField<State>): Heads => state.field(field).lastHeads;
26
+ export const updateHeads = (newHeads: Heads): StateEffect<UpdateHeads> => updateHeadsEffect.of({ newHeads });
29
27
 
30
- export const updateHeads = (newHeads: Heads): StateEffect<UpdateHeads> => effectType.of({ newHeads });
28
+ export const reconcileAnnotation = Annotation.define<boolean>();
31
29
 
32
- export const isReconcileTx = (tr: Transaction): boolean => !!tr.annotation(reconcileAnnotationType);
30
+ export const isReconcile = (tr: Transaction): boolean => {
31
+ return !!tr.annotation(reconcileAnnotation);
32
+ };
33
33
 
34
34
  export type IDocHandle<T = any> = {
35
35
  docSync(): Doc<T> | undefined;
@@ -7,13 +7,13 @@
7
7
  import { type StateField } from '@codemirror/state';
8
8
  import { type EditorView } from '@codemirror/view';
9
9
 
10
- import { next as automerge } from '@dxos/automerge/automerge';
10
+ import { next as A } from '@dxos/automerge/automerge';
11
11
 
12
12
  import {
13
13
  getLastHeads,
14
14
  getPath,
15
- isReconcileTx,
16
- reconcileAnnotationType,
15
+ isReconcile,
16
+ reconcileAnnotation,
17
17
  updateHeads,
18
18
  type IDocHandle,
19
19
  type State,
@@ -22,7 +22,7 @@ import { updateAutomerge } from './update-automerge';
22
22
  import { updateCodeMirror } from './update-codemirror';
23
23
 
24
24
  /**
25
- * Implements three-way merge (on each mutation/keystroke).
25
+ * Implements three-way merge (on each mutation).
26
26
  */
27
27
  export class PatchSemaphore {
28
28
  private _inReconcile = false;
@@ -33,13 +33,15 @@ export class PatchSemaphore {
33
33
  private readonly _state: StateField<State>
34
34
  ) {}
35
35
 
36
- // NOTE: Cannot destruct view.state.
37
36
  reconcile(view: EditorView) {
38
- if (this._inReconcile) {
39
- return;
37
+ if (!this._inReconcile) {
38
+ this._inReconcile = true;
39
+ this.doReconcile(view);
40
+ this._inReconcile = false;
40
41
  }
41
- this._inReconcile = true;
42
+ }
42
43
 
44
+ private doReconcile(view: EditorView) {
43
45
  const path = getPath(view.state, this._state);
44
46
 
45
47
  // Get the heads before the unreconciled transactions are applied.
@@ -47,14 +49,14 @@ export class PatchSemaphore {
47
49
  let selection = view.state.selection;
48
50
 
49
51
  // First undo all the unreconciled transactions.
50
- const transactions = view.state.field(this._state).unreconciledTransactions.filter((tx) => !isReconcileTx(tx));
52
+ const transactions = view.state.field(this._state).unreconciledTransactions.filter((tx) => !isReconcile(tx));
51
53
  const toInvert = transactions.slice().reverse();
52
- for (const tx of toInvert) {
53
- const inverted = tx.changes.invert(tx.startState.doc);
54
+ for (const tr of toInvert) {
55
+ const inverted = tr.changes.invert(tr.startState.doc);
54
56
  selection = selection.map(inverted);
55
57
  view.dispatch({
56
58
  changes: inverted,
57
- annotations: reconcileAnnotationType.of(true),
59
+ annotations: reconcileAnnotation.of(true),
58
60
  });
59
61
  }
60
62
 
@@ -63,21 +65,17 @@ export class PatchSemaphore {
63
65
  let newHeads = updateAutomerge(this._state, this._handle, transactions, view.state);
64
66
  if (newHeads === null || newHeads === undefined) {
65
67
  // TODO(alexjg): this is the call that's resetting the editor state on click.
66
- newHeads = automerge.getHeads(this._handle.docSync()!);
68
+ newHeads = A.getHeads(this._handle.docSync()!);
67
69
  }
68
70
 
69
71
  // 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)
71
- ? []
72
- : automerge.diff(this._handle.docSync()!, oldHeads, newHeads);
72
+ const diff = A.equals(oldHeads, newHeads) ? [] : A.diff(this._handle.docSync()!, oldHeads, newHeads);
73
73
  updateCodeMirror(view, selection, path, diff);
74
74
 
75
75
  // Update automerge state.
76
76
  view.dispatch({
77
77
  effects: updateHeads(newHeads),
78
- annotations: reconcileAnnotationType.of({}),
78
+ annotations: reconcileAnnotation.of(false),
79
79
  });
80
-
81
- this._inReconcile = false;
82
80
  }
83
81
  }
@@ -4,9 +4,9 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
- import { type EditorState, type StateField, type Transaction } from '@codemirror/state';
7
+ import { type EditorState, type StateField, type Transaction, type Text } from '@codemirror/state';
8
8
 
9
- import { next as automerge, type Heads } from '@dxos/automerge/automerge';
9
+ import { next as A, type Heads } from '@dxos/automerge/automerge';
10
10
 
11
11
  import { type IDocHandle, type State } from './defs';
12
12
 
@@ -14,7 +14,7 @@ export const updateAutomerge = (
14
14
  field: StateField<State>,
15
15
  handle: IDocHandle,
16
16
  transactions: Transaction[],
17
- state: EditorState, // TODO(burdon): Just pass in the state field value.
17
+ state: EditorState, // TODO(burdon): Just pass in the state field value?
18
18
  ): Heads | undefined => {
19
19
  const { lastHeads, path } = state.field(field);
20
20
 
@@ -31,17 +31,17 @@ export const updateAutomerge = (
31
31
  return undefined;
32
32
  }
33
33
 
34
- const newHeads = handle.changeAt(lastHeads, (doc: automerge.Doc<unknown>) => {
35
- const t: any[] = [];
34
+ const newHeads = handle.changeAt(lastHeads, (doc: A.Doc<unknown>) => {
35
+ const invertedTransactions: { from: number; del: number; insert: Text }[] = [];
36
36
  for (const tr of transactions) {
37
37
  tr.changes.iterChanges((fromA, toA, _fromB, _toB, insert) => {
38
- t.push({ fromA, toA, insert });
38
+ invertedTransactions.push({ from: fromA, del: toA - fromA, insert });
39
39
  });
40
40
  }
41
41
 
42
- // Apply in reverse order to properly apply range.
43
- t.reverse().forEach(({ fromA, toA, insert }) => {
44
- automerge.splice(doc, path.slice(), fromA, toA - fromA, insert.toString());
42
+ // TODO(burdon): Hack to apply in reverse order to properly apply range.
43
+ invertedTransactions.reverse().forEach(({ from, del, insert }) => {
44
+ A.splice(doc, path.slice(), from, del, insert.toString());
45
45
  });
46
46
  });
47
47
 
@@ -16,7 +16,7 @@ import {
16
16
  type SpliceTextPatch,
17
17
  } from '@dxos/automerge/automerge';
18
18
 
19
- import { reconcileAnnotationType } from './defs';
19
+ import { reconcileAnnotation } from './defs';
20
20
 
21
21
  export const updateCodeMirror = (view: EditorView, selection: EditorSelection, target: Prop[], patches: Patch[]) => {
22
22
  for (const patch of patches) {
@@ -26,14 +26,14 @@ export const updateCodeMirror = (view: EditorView, selection: EditorSelection, t
26
26
  selection = selection.map(changeSet, 1);
27
27
  view.dispatch({
28
28
  changes: changeSet,
29
- annotations: reconcileAnnotationType.of({}),
29
+ annotations: reconcileAnnotation.of(false),
30
30
  });
31
31
  }
32
32
  }
33
33
 
34
34
  view.dispatch({
35
35
  selection,
36
- annotations: reconcileAnnotationType.of({}),
36
+ annotations: reconcileAnnotation.of(false),
37
37
  });
38
38
  };
39
39
 
@@ -57,7 +57,7 @@ const handleInsert = (target: Prop[], patch: InsertPatch): Array<ChangeSpec> =>
57
57
  return [];
58
58
  }
59
59
 
60
- const text = patch.values.map((v) => (v ? v.toString() : '')).join('');
60
+ const text = patch.values.map((value) => (value ? value.toString() : '')).join('');
61
61
  return [{ from: index, to: index, insert: text }];
62
62
  };
63
63
 
@@ -88,7 +88,7 @@ const handlePut = (target: Prop[], patch: PutPatch, state: EditorState): Array<C
88
88
 
89
89
  const length = state.doc.length;
90
90
  if (typeof patch.value !== 'string') {
91
- return []; // TODO(dmaretskyi): How to handle non string values?
91
+ return []; // TODO(dmaretskyi): How to handle non-string values?
92
92
  }
93
93
 
94
94
  return [{ from: 0, to: length, insert: patch.value as any }];
@@ -38,7 +38,7 @@ const dummyProvider: AwarenessProvider = {
38
38
  update: () => {},
39
39
  };
40
40
 
41
- export const AwarenessProvider = Facet.define<AwarenessProvider, AwarenessProvider>({
41
+ export const awarenessProvider = Facet.define<AwarenessProvider, AwarenessProvider>({
42
42
  combine: (providers) => providers[0] ?? dummyProvider,
43
43
  });
44
44
 
@@ -68,7 +68,7 @@ export type AwarenessState = {
68
68
  */
69
69
  export const awareness = (provider = dummyProvider): Extension => {
70
70
  return [
71
- AwarenessProvider.of(provider),
71
+ awarenessProvider.of(provider),
72
72
  ViewPlugin.fromClass(RemoteSelectionsDecorator, {
73
73
  decorations: (value) => value.decorations,
74
74
  }),
@@ -91,7 +91,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
91
91
 
92
92
  constructor(view: EditorView) {
93
93
  this._cursorConverter = view.state.facet(Cursor.converter);
94
- this._provider = view.state.facet(AwarenessProvider);
94
+ this._provider = view.state.facet(awarenessProvider);
95
95
  this._provider.open();
96
96
  this._provider.remoteStateChange.on(this._ctx, () => {
97
97
  view.dispatch({ annotations: [RemoteSelectionChangedAnnotation.of([])] });
@@ -207,7 +207,10 @@ export class RemoteSelectionsDecorator implements PluginValue {
207
207
  }
208
208
 
209
209
  class RemoteCaretWidget extends WidgetType {
210
- constructor(private readonly _name: string, private readonly _color: string) {
210
+ constructor(
211
+ private readonly _name: string,
212
+ private readonly _color: string,
213
+ ) {
211
214
  super();
212
215
  }
213
216
 
@@ -10,6 +10,7 @@ import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
10
10
  import { drawSelection, EditorView, highlightActiveLine, placeholder } from '@codemirror/view';
11
11
 
12
12
  import { type ThemeMode } from '@dxos/react-ui';
13
+ import { isNotFalsy } from '@dxos/util';
13
14
 
14
15
  import { type TextEditorProps } from '../components';
15
16
 
@@ -17,21 +18,22 @@ export type BasicBundleOptions = {
17
18
  themeMode?: ThemeMode;
18
19
  } & Pick<TextEditorProps, 'placeholder' | 'lineWrapping'>;
19
20
 
20
- export const basicBundle = ({
21
+ export const createBasicBundle = ({
21
22
  themeMode,
22
23
  placeholder: _placeholder,
23
24
  lineWrapping = true,
24
- }: BasicBundleOptions): Extension[] => [
25
- lineWrapping ? EditorView.lineWrapping : [],
25
+ }: BasicBundleOptions = {}): Extension[] =>
26
+ [
27
+ lineWrapping && EditorView.lineWrapping,
26
28
 
27
- // https://codemirror.net/docs/ref/#codemirror.minimalSetup
28
- bracketMatching(),
29
- closeBrackets(),
30
- drawSelection(),
31
- highlightActiveLine(),
32
- history(),
33
- _placeholder ? placeholder(_placeholder) : [],
29
+ // https://codemirror.net/docs/ref/#codemirror.minimalSetup
30
+ bracketMatching(),
31
+ closeBrackets(),
32
+ drawSelection(),
33
+ highlightActiveLine(),
34
+ history(),
35
+ _placeholder && placeholder(_placeholder),
34
36
 
35
- // https://github.com/codemirror/theme-one-dark
36
- themeMode === 'dark' ? syntaxHighlighting(oneDarkHighlightStyle) : syntaxHighlighting(defaultHighlightStyle),
37
- ];
37
+ // https://github.com/codemirror/theme-one-dark
38
+ themeMode === 'dark' ? syntaxHighlighting(oneDarkHighlightStyle) : syntaxHighlighting(defaultHighlightStyle),
39
+ ].filter(isNotFalsy);
@@ -125,7 +125,10 @@ class Blaster {
125
125
 
126
126
  _lastPoint = { x: 0, y: 0 };
127
127
 
128
- constructor(private readonly _node: HTMLElement, private readonly _options: BlastOptions) {
128
+ constructor(
129
+ private readonly _node: HTMLElement,
130
+ private readonly _options: BlastOptions,
131
+ ) {
129
132
  this._effect = this._options.effect === 1 ? new Effect1(_options) : new Effect2(_options);
130
133
  }
131
134
 
@@ -2,7 +2,8 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type Extension, Facet, 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';
8
9
  import { useEffect } from 'react';
@@ -17,18 +18,26 @@ import { callbackWrapper } from '../util';
17
18
 
18
19
  // TODO(burdon): Reconcile with theme.
19
20
  const styles = EditorView.baseTheme({
20
- '& .cm-comment': {
21
+ '&light .cm-comment, &light .cm-comment-current': { mixBlendMode: 'darken' },
22
+ '&dark .cm-comment, &dark .cm-comment-current': { mixBlendMode: 'plus-lighter' },
23
+ '&light .cm-comment': {
21
24
  backgroundColor: getToken('extend.colors.yellow.50'),
22
25
  },
23
- '& .cm-comment-current': {
26
+ '&light .cm-comment-current': {
24
27
  backgroundColor: getToken('extend.colors.yellow.100'),
25
28
  },
29
+ '&dark .cm-comment': {
30
+ color: getToken('extend.colors.yellow.50'),
31
+ backgroundColor: getToken('extend.colors.yellow.900'),
32
+ },
33
+ '&dark .cm-comment-current': {
34
+ color: getToken('extend.colors.yellow.100'),
35
+ backgroundColor: getToken('extend.colors.yellow.950'),
36
+ },
26
37
  });
27
38
 
28
- const marks = {
29
- highlight: Decoration.mark({ class: 'cm-comment' }),
30
- highlightActive: Decoration.mark({ class: 'cm-comment-current' }),
31
- };
39
+ const commentMark = Decoration.mark({ class: 'cm-comment' });
40
+ const commentCurrentMark = Decoration.mark({ class: 'cm-comment-current' });
32
41
 
33
42
  type CommentState = {
34
43
  comment: Comment;
@@ -46,7 +55,7 @@ export type CommentsState = {
46
55
  selection: SelectionState;
47
56
  };
48
57
 
49
- export const setFocus = (view: EditorView, id: string, center = true) => {
58
+ export const focusComment = (view: EditorView, id: string, center = true) => {
50
59
  const comment = view.state.field(commentsState).comments.find((range) => range.comment.id === id);
51
60
  if (!comment?.comment.cursor) {
52
61
  return;
@@ -114,7 +123,7 @@ const commentsState = StateField.define<CommentsState>({
114
123
  /**
115
124
  * Decorate ranges.
116
125
  */
117
- const highlightDecorations = EditorView.decorations.compute([commentsState], (state) => {
126
+ const commentsDecorations = EditorView.decorations.compute([commentsState], (state) => {
118
127
  const {
119
128
  selection: { current },
120
129
  comments,
@@ -129,9 +138,9 @@ const highlightDecorations = EditorView.decorations.compute([commentsState], (st
129
138
  }
130
139
 
131
140
  if (comment.comment.id === current) {
132
- return marks.highlightActive.range(range.from, range.to);
141
+ return commentCurrentMark.range(range.from, range.to);
133
142
  } else {
134
- return marks.highlight.range(range.from, range.to);
143
+ return commentMark.range(range.from, range.to);
135
144
  }
136
145
  })
137
146
  .filter(nonNullable);
@@ -147,7 +156,7 @@ export type CommentsOptions = {
147
156
  /**
148
157
  * Called to create a new thread and return the thread id.
149
158
  */
150
- onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
159
+ onCreate?: (params: { cursor: string; from: number; location?: Rect | null }) => string | undefined;
151
160
  /**
152
161
  * Selection cut/deleted.
153
162
  */
@@ -166,11 +175,11 @@ export type CommentsOptions = {
166
175
  onHover?: (el: Element, shortcut: string) => void;
167
176
  };
168
177
 
169
- // TODO(burdon): Handle cut/restore via undo (need to integrate with history?)
178
+ type TrackedComment = { id: string; from: number; to: number };
179
+
170
180
  const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
171
181
  // Tracks indexed selections within text.
172
- // TODO(burdon): Move to main state field?
173
- let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
182
+ let tracked: { text: Text; comments: TrackedComment[] } | null = null;
174
183
 
175
184
  // Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
176
185
  const handleTrack = (event: Event, view: EditorView) => {
@@ -200,16 +209,48 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
200
209
  copy: handleTrack,
201
210
  }),
202
211
 
203
- // Handle paste.
204
- EditorView.updateListener.of(({ view, state, transactions }) => {
212
+ // Track deleted comments.
213
+ invertedEffects.of((tr) => {
214
+ const { comments } = tr.startState.field(commentsState);
215
+ const effects: StateEffect<any>[] = [];
216
+ tr.changes.iterChangedRanges((fromA, toA) => {
217
+ for (const {
218
+ comment: { id },
219
+ range: { from, to },
220
+ } of comments) {
221
+ if (from < to && from >= fromA && to <= toA) {
222
+ effects.push(restoreCommentEffect.of({ id, from, to }));
223
+ }
224
+ }
225
+ });
226
+
227
+ return effects;
228
+ }),
229
+
230
+ // Handle paste or the undo of comment deletion.
231
+ EditorView.updateListener.of((update) => {
232
+ const restore: TrackedComment[] = [];
233
+
234
+ for (let i = 0; i < update.transactions.length; i++) {
235
+ const tr = update.transactions[i];
236
+ for (let j = 0; j < restore.length; j++) {
237
+ restore[j] = mapTrackedComment(restore[j], tr.changes);
238
+ }
239
+ for (const effect of tr.effects) {
240
+ if (effect.is(restoreCommentEffect)) {
241
+ restore.push(effect.value);
242
+ }
243
+ }
244
+ }
245
+
205
246
  if (tracked) {
206
- const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
247
+ const paste = update.transactions.find((tr) => tr.isUserEvent('input.paste'));
207
248
  if (paste) {
208
249
  let found = -1;
209
250
  paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
210
251
  if (text.eq(tracked!.text)) {
211
- for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
212
- fromB = transactions[i].changes.mapPos(fromB);
252
+ for (let i = update.transactions.indexOf(paste!) + 1; i < update.transactions.length; i++) {
253
+ fromB = update.transactions[i].changes.mapPos(fromB);
213
254
  }
214
255
 
215
256
  found = fromB;
@@ -218,23 +259,36 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
218
259
 
219
260
  if (found > -1) {
220
261
  for (const moved of tracked.comments) {
221
- const range = {
222
- from: found + moved.from,
223
- to: found + moved.to,
224
- };
225
-
226
- const cursor = Cursor.getCursorFromRange(state, range);
227
- onUpdate(moved.id, cursor);
262
+ restore.push({ id: moved.id, from: found + moved.from, to: found + moved.to });
228
263
  }
229
264
  }
230
265
 
231
266
  tracked = null;
232
267
  }
233
268
  }
269
+
270
+ for (const comment of restore) {
271
+ const { comments } = update.startState.field(commentsState);
272
+ const exists = comments.some((c) => c.comment.id === comment.id && c.range.from < c.range.to);
273
+ if (!exists) {
274
+ const cursor = Cursor.getCursorFromRange(update.state, comment);
275
+ onUpdate(comment.id, cursor);
276
+ }
277
+ }
234
278
  }),
235
279
  ];
236
280
  };
237
281
 
282
+ const mapTrackedComment = (comment: TrackedComment, changes: ChangeDesc) => ({
283
+ id: comment.id,
284
+ from: changes.mapPos(comment.from, 1),
285
+ to: changes.mapPos(comment.to, 1),
286
+ });
287
+
288
+ // These are attached to undone/redone transactions in the editor for the purpose of restoring comments
289
+ // that were deleted by the original changes.
290
+ const restoreCommentEffect = StateEffect.define<TrackedComment>({ map: mapTrackedComment });
291
+
238
292
  const optionsFacet = Facet.define<CommentsOptions, CommentsOptions>({
239
293
  combine: (providers) => providers[0],
240
294
  });
@@ -262,12 +316,12 @@ export const createComment: Command = (view) => {
262
316
  const cursor = Cursor.getCursorFromRange(view.state, { from, to });
263
317
  if (cursor) {
264
318
  // Create thread via callback.
265
- const id = options.onCreate?.(cursor, view.coordsAtPos(from));
319
+ const id = options.onCreate?.({ cursor, from, location: view.coordsAtPos(from) });
266
320
  if (id) {
267
321
  // Update range.
268
322
  view.dispatch({
269
323
  effects: setSelection.of({ current: id }),
270
- selection: { anchor: from },
324
+ selection: { anchor: to },
271
325
  });
272
326
 
273
327
  return true;
@@ -296,7 +350,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
296
350
  return [
297
351
  optionsFacet.of(options),
298
352
  commentsState,
299
- highlightDecorations,
353
+ commentsDecorations,
300
354
  styles,
301
355
 
302
356
  //