@dxos/react-ui-editor 0.3.11-main.2c83f41 → 0.3.11-main.2e1a8e1

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 (107) hide show
  1. package/dist/lib/browser/index.mjs +748 -753
  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 +16 -13
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +19 -17
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +6 -6
  9. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/TextEditor/comments.stories.d.ts +26 -0
  11. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -0
  12. package/dist/types/src/components/TextEditor/yjs.stories.d.ts +1 -1
  13. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  14. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  15. package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
  16. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  17. package/dist/types/src/extensions/automerge/defs.d.ts +7 -9
  18. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  19. package/dist/types/src/extensions/automerge/index.d.ts +0 -1
  20. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
  21. package/dist/types/src/extensions/automerge/semaphore.d.ts +7 -12
  22. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  23. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  24. package/dist/types/src/extensions/awareness.d.ts +2 -0
  25. package/dist/types/src/extensions/awareness.d.ts.map +1 -1
  26. package/dist/types/src/extensions/basic.d.ts +4 -5
  27. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  28. package/dist/types/src/extensions/code.d.ts.map +1 -1
  29. package/dist/types/src/extensions/comments.d.ts +31 -18
  30. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  31. package/dist/types/src/{util → extensions}/cursor.d.ts +10 -2
  32. package/dist/types/src/extensions/cursor.d.ts.map +1 -0
  33. package/dist/types/src/extensions/hr.d.ts.map +1 -1
  34. package/dist/types/src/extensions/index.d.ts +2 -0
  35. package/dist/types/src/extensions/index.d.ts.map +1 -1
  36. package/dist/types/src/extensions/markdown/bundle.d.ts +2 -5
  37. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  38. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  39. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  40. package/dist/types/src/extensions/outliner.d.ts +4 -0
  41. package/dist/types/src/extensions/outliner.d.ts.map +1 -0
  42. package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
  43. package/dist/types/src/extensions/yjs/cursor.d.ts +1 -1
  44. package/dist/types/src/extensions/yjs/cursor.d.ts.map +1 -1
  45. package/dist/types/src/hooks/automerge.d.ts +2 -1
  46. package/dist/types/src/hooks/automerge.d.ts.map +1 -1
  47. package/dist/types/src/hooks/defs.d.ts +28 -0
  48. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  49. package/dist/types/src/hooks/index.d.ts +2 -1
  50. package/dist/types/src/hooks/index.d.ts.map +1 -1
  51. package/dist/types/src/hooks/useTextModel.d.ts +2 -32
  52. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  53. package/dist/types/src/hooks/yjs.d.ts +2 -1
  54. package/dist/types/src/hooks/yjs.d.ts.map +1 -1
  55. package/dist/types/src/index.d.ts +1 -0
  56. package/dist/types/src/index.d.ts.map +1 -1
  57. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  58. package/dist/types/src/themes/default.d.ts.map +1 -1
  59. package/dist/types/src/util.d.ts +7 -0
  60. package/dist/types/src/util.d.ts.map +1 -0
  61. package/package.json +32 -31
  62. package/src/components/TextEditor/MarkdownEditor.stories.tsx +77 -79
  63. package/src/components/TextEditor/TextEditor.stories.tsx +12 -46
  64. package/src/components/TextEditor/TextEditor.tsx +113 -92
  65. package/src/components/TextEditor/comments.stories.tsx +357 -0
  66. package/src/extensions/autocomplete.ts +6 -3
  67. package/src/extensions/automerge/automerge.ts +24 -49
  68. package/src/extensions/automerge/cursor.ts +2 -1
  69. package/src/extensions/automerge/defs.ts +10 -31
  70. package/src/extensions/automerge/index.ts +0 -1
  71. package/src/extensions/automerge/semaphore.ts +23 -20
  72. package/src/extensions/automerge/update-automerge.ts +2 -2
  73. package/src/extensions/awareness.ts +45 -34
  74. package/src/extensions/basic.ts +22 -17
  75. package/src/extensions/code.ts +24 -18
  76. package/src/extensions/comments.ts +242 -234
  77. package/src/extensions/cursor.ts +50 -0
  78. package/src/extensions/hr.ts +49 -50
  79. package/src/extensions/image.ts +2 -2
  80. package/src/extensions/index.ts +2 -0
  81. package/src/extensions/link.ts +7 -7
  82. package/src/extensions/markdown/bundle.ts +18 -24
  83. package/src/extensions/markdown/highlight.ts +0 -8
  84. package/src/extensions/mention.ts +9 -2
  85. package/src/extensions/outliner.ts +12 -0
  86. package/src/extensions/table.ts +2 -2
  87. package/src/extensions/tasklist.ts +78 -86
  88. package/src/extensions/yjs/cursor.ts +2 -1
  89. package/src/extensions/yjs/yjs.test.ts +1 -1
  90. package/src/extensions/yjs/yjs.ts +2 -2
  91. package/src/hooks/automerge.ts +2 -1
  92. package/src/hooks/defs.ts +47 -0
  93. package/src/hooks/index.ts +3 -1
  94. package/src/hooks/useTextModel.ts +3 -49
  95. package/src/hooks/yjs.ts +2 -23
  96. package/src/index.ts +1 -0
  97. package/src/styles/markdown.ts +0 -2
  98. package/src/themes/default.ts +17 -50
  99. package/src/util.ts +41 -0
  100. package/dist/types/src/util/cursor.d.ts.map +0 -1
  101. package/dist/types/src/util/index.d.ts +0 -3
  102. package/dist/types/src/util/index.d.ts.map +0 -1
  103. package/dist/types/src/util/util.d.ts +0 -5
  104. package/dist/types/src/util/util.d.ts.map +0 -1
  105. package/src/util/cursor.ts +0 -29
  106. package/src/util/index.ts +0 -6
  107. package/src/util/util.ts +0 -18
@@ -35,10 +35,13 @@ export const autocomplete = ({ onSearch }: AutocompleteOptions) => {
35
35
  // https://codemirror.net/examples/autocompletion
36
36
  // https://codemirror.net/docs/ref/#autocomplete.autocompletion
37
37
  autocompletion({
38
- defaultKeymap: false,
38
+ activateOnTyping: true,
39
39
 
40
- // Don't start unless key press.
41
- activateOnTyping: false,
40
+ // closeOnBlur: false,
41
+ // defaultKeymap: false,
42
+
43
+ // TODO(burdon): Styles/fragments.
44
+ tooltipClass: () => 'shadow rounded',
42
45
  }),
43
46
 
44
47
  // TODO(burdon): Option to create new page?
@@ -4,20 +4,15 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
- import { Facet, StateField, type Extension, type Transaction } from '@codemirror/state';
8
- import { ViewPlugin, type EditorView, type PluginValue, type ViewUpdate } from '@codemirror/view';
7
+ import { StateField, type Extension } from '@codemirror/state';
8
+ import { EditorView } from '@codemirror/view';
9
9
 
10
- import { next as A } from '@dxos/automerge/automerge';
11
- import { type Prop } from '@dxos/automerge/automerge';
10
+ import { type Prop, next as A } from '@dxos/automerge/automerge';
12
11
 
13
12
  import { cursorConverter } from './cursor';
14
- import { effectType, type IDocHandle, isReconcileTx, type Value } from './defs';
13
+ import { effectType, type IDocHandle, isReconcileTx, type State } from './defs';
15
14
  import { PatchSemaphore } from './semaphore';
16
- import { CursorConverter } from '../../util';
17
-
18
- const semaphoreFacet = Facet.define<PatchSemaphore, PatchSemaphore>({
19
- combine: (values) => values.at(-1)!, // Take last.
20
- });
15
+ import { Cursor } from '../cursor';
21
16
 
22
17
  export type AutomergeOptions = {
23
18
  handle: IDocHandle;
@@ -25,19 +20,20 @@ export type AutomergeOptions = {
25
20
  };
26
21
 
27
22
  export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
28
- // TODO(burdon): Rename.
29
- const stateField: StateField<Value> = StateField.define({
23
+ const state = StateField.define<State>({
30
24
  create: () => ({
25
+ path: path.slice(),
31
26
  lastHeads: A.getHeads(handle.docSync()!),
32
27
  unreconciledTransactions: [],
33
- path: path.slice(),
34
28
  }),
35
- update: (value: Value, tr: Transaction) => {
36
- const result: Value = {
29
+
30
+ update: (value, tr) => {
31
+ const result: State = {
32
+ path: path.slice(),
37
33
  lastHeads: value.lastHeads,
38
34
  unreconciledTransactions: value.unreconciledTransactions.slice(),
39
- path: path.slice(),
40
35
  };
36
+
41
37
  let clearUnreconciled = false;
42
38
  for (const effect of tr.effects) {
43
39
  if (effect.is(effectType)) {
@@ -45,6 +41,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
45
41
  clearUnreconciled = true;
46
42
  }
47
43
  }
44
+
48
45
  if (clearUnreconciled) {
49
46
  result.unreconciledTransactions = [];
50
47
  } else {
@@ -52,43 +49,21 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
52
49
  result.unreconciledTransactions.push(tr);
53
50
  }
54
51
  }
52
+
55
53
  return result;
56
54
  },
57
55
  });
58
56
 
59
- const semaphore = new PatchSemaphore(stateField);
57
+ const semaphore = new PatchSemaphore(handle, state);
60
58
 
61
- const viewPlugin = ViewPlugin.fromClass(
62
- class AutomergeCodemirrorViewPlugin implements PluginValue {
63
- constructor(private readonly _view: EditorView) {
64
- handle.addListener('change', this._handleChange);
59
+ return [
60
+ Cursor.converter.of(cursorConverter(handle, path)),
61
+ EditorView.updateListener.of(({ view, changes }) => {
62
+ if (!changes.empty) {
63
+ // TODO(burdon): Loses cursor position if auto closing brackets. Call explicitly?
64
+ semaphore.reconcile(view);
65
65
  }
66
-
67
- update(update: ViewUpdate) {
68
- if (update.transactions.length > 0 && update.transactions.some((tr) => !isReconcileTx(tr))) {
69
- // TODO(burdon): This is a hack to ensure that the view is updated after the transaction is applied.
70
- queueMicrotask(() => {
71
- this._view.state.facet(semaphoreFacet).reconcile(handle, this._view);
72
- });
73
- }
74
- }
75
-
76
- destroy() {
77
- handle.addListener('change', this._handleChange);
78
- }
79
-
80
- private _handleChange = () => {
81
- this._view.state.facet(semaphoreFacet).reconcile(handle, this._view);
82
- };
83
- },
84
- );
85
-
86
- return {
87
- extension: [
88
- CursorConverter.of(cursorConverter(handle, path)),
89
- semaphoreFacet.of(semaphore),
90
- stateField,
91
- viewPlugin,
92
- ],
93
- };
66
+ }),
67
+ state,
68
+ ];
94
69
  };
@@ -8,7 +8,7 @@ import type { Prop } from '@dxos/automerge/automerge';
8
8
  import { next as automerge } from '@dxos/automerge/automerge';
9
9
 
10
10
  import { type IDocHandle } from './defs';
11
- import { type CursorConverter } from '../../util';
11
+ import { type CursorConverter } from '../cursor';
12
12
 
13
13
  export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConverter => ({
14
14
  // TODO(burdon): Handle assoc to associate with a previous character.
@@ -26,6 +26,7 @@ export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConvert
26
26
  // NOTE: Slice is needed because getCursor mutates the array.
27
27
  return automerge.getCursor(doc, path.slice(), pos);
28
28
  },
29
+
29
30
  fromCursor: (cursor) => {
30
31
  if (cursor === '') {
31
32
  return 0;
@@ -1,57 +1,36 @@
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 } from '@codemirror/state';
14
8
 
15
9
  import { type ChangeFn, type ChangeOptions, type Doc, type Heads, type Prop } from '@dxos/automerge/automerge';
16
10
 
17
- export type Value = {
18
- lastHeads: Heads;
11
+ // TODO(burdon): Rename.
12
+ export type State = {
19
13
  path: Prop[];
14
+ lastHeads: Heads;
20
15
  unreconciledTransactions: Transaction[];
21
16
  };
22
17
 
23
- type UpdateHeads = {
18
+ export type UpdateHeads = {
24
19
  newHeads: Heads;
25
20
  };
26
21
 
27
22
  export const effectType = StateEffect.define<UpdateHeads>({});
28
23
 
29
- export const updateHeads = (newHeads: Heads): StateEffect<UpdateHeads> => effectType.of({ newHeads });
24
+ export const reconcileAnnotationType = Annotation.define<unknown>();
30
25
 
31
- export const getLastHeads = (state: EditorState, field: StateField<Value>): Heads => state.field(field).lastHeads;
26
+ export const getPath = (state: EditorState, field: StateField<State>): Prop[] => state.field(field).path;
32
27
 
33
- export const getPath = (state: EditorState, field: StateField<Value>): Prop[] => state.field(field).path;
28
+ export const getLastHeads = (state: EditorState, field: StateField<State>): Heads => state.field(field).lastHeads;
34
29
 
35
- export const reconcileAnnotationType = Annotation.define<unknown>();
30
+ export const updateHeads = (newHeads: Heads): StateEffect<UpdateHeads> => effectType.of({ newHeads });
36
31
 
37
32
  export const isReconcileTx = (tr: Transaction): boolean => !!tr.annotation(reconcileAnnotationType);
38
33
 
39
- export const makeReconcile = (tr: TransactionSpec) => {
40
- if (tr.annotations != null) {
41
- if (tr.annotations instanceof Array) {
42
- tr.annotations = [...tr.annotations, reconcileAnnotationType.of({})];
43
- } else {
44
- tr.annotations = [tr.annotations, reconcileAnnotationType.of({})];
45
- }
46
- } else {
47
- tr.annotations = [reconcileAnnotationType.of({})];
48
- }
49
- // return {
50
- // ...tr,
51
- // annotations: reconcileAnnotationType.of({})
52
- // }
53
- };
54
-
55
34
  export type IDocHandle<T = any> = {
56
35
  docSync(): Doc<T> | undefined;
57
36
  change(callback: ChangeFn<T>, options?: ChangeOptions<T>): void;
@@ -4,4 +4,3 @@
4
4
 
5
5
  export { automerge } from './automerge';
6
6
  export { type IDocHandle } from './defs';
7
- export { cursorConverter } from './cursor';
@@ -16,37 +16,38 @@ import {
16
16
  reconcileAnnotationType,
17
17
  updateHeads,
18
18
  type IDocHandle,
19
- type Value,
19
+ type State,
20
20
  } from './defs';
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/keystroke).
31
26
  */
32
27
  export class PatchSemaphore {
33
- _inReconcile = false;
34
- _queue: Array<ChangeFn> = [];
28
+ private _inReconcile = false;
35
29
 
36
- constructor(private readonly _field: StateField<Value>) {}
30
+ // prettier-ignore
31
+ constructor(
32
+ private readonly _handle: IDocHandle,
33
+ private readonly _state: StateField<State>
34
+ ) {}
37
35
 
38
- reconcile = (handle: IDocHandle, view: EditorView) => {
36
+ // NOTE: Cannot destruct view.state.
37
+ reconcile(view: EditorView) {
39
38
  if (this._inReconcile) {
40
39
  return;
41
40
  }
42
41
  this._inReconcile = true;
43
42
 
44
- const path = getPath(view.state, this._field);
45
- const oldHeads = getLastHeads(view.state, this._field);
43
+ const path = getPath(view.state, this._state);
44
+
45
+ // Get the heads before the unreconciled transactions are applied.
46
+ const oldHeads = getLastHeads(view.state, this._state);
46
47
  let selection = view.state.selection;
47
48
 
48
49
  // First undo all the unreconciled transactions.
49
- const transactions = view.state.field(this._field).unreconciledTransactions.filter((tx) => !isReconcileTx(tx));
50
+ const transactions = view.state.field(this._state).unreconciledTransactions.filter((tx) => !isReconcileTx(tx));
50
51
  const toInvert = transactions.slice().reverse();
51
52
  for (const tx of toInvert) {
52
53
  const inverted = tx.changes.invert(tx.startState.doc);
@@ -58,23 +59,25 @@ export class PatchSemaphore {
58
59
  }
59
60
 
60
61
  // Apply the unreconciled transactions to the document.
61
- let newHeads = updateAutomerge(this._field, handle, transactions, view.state);
62
-
63
62
  // NOTE: null and undefined each come from automerge and repo respectively.
63
+ let newHeads = updateAutomerge(this._state, this._handle, transactions, view.state);
64
64
  if (newHeads === null || newHeads === undefined) {
65
- // TODO: @alexjg this is the call that's resetting the editor state on click
66
- newHeads = automerge.getHeads(handle.docSync()!);
65
+ // TODO(alexjg): this is the call that's resetting the editor state on click.
66
+ newHeads = automerge.getHeads(this._handle.docSync()!);
67
67
  }
68
68
 
69
69
  // 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);
70
+ const diff = automerge.equals(oldHeads, newHeads)
71
+ ? []
72
+ : automerge.diff(this._handle.docSync()!, oldHeads, newHeads);
71
73
  updateCodeMirror(view, selection, path, diff);
72
74
 
75
+ // Update automerge state.
73
76
  view.dispatch({
74
77
  effects: updateHeads(newHeads),
75
78
  annotations: reconcileAnnotationType.of({}),
76
79
  });
77
80
 
78
81
  this._inReconcile = false;
79
- };
82
+ }
80
83
  }
@@ -8,10 +8,10 @@ import { type EditorState, type StateField, type Text, type Transaction } from '
8
8
 
9
9
  import { next as am, type Heads } from '@dxos/automerge/automerge';
10
10
 
11
- import { type IDocHandle, type Value } from './defs';
11
+ import { type IDocHandle, type State } from './defs';
12
12
 
13
13
  export const updateAutomerge = (
14
- field: StateField<Value>,
14
+ field: StateField<State>,
15
15
  handle: IDocHandle,
16
16
  transactions: Transaction[],
17
17
  state: EditorState,
@@ -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
- import { CursorConverter } from '../util';
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
  */
@@ -91,7 +108,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
91
108
  private _lastHead?: number = undefined;
92
109
 
93
110
  constructor(view: EditorView) {
94
- this._cursorConverter = view.state.facet(CursorConverter);
111
+ this._cursorConverter = view.state.facet(Cursor.converter);
95
112
  this._provider = view.state.facet(AwarenessProvider);
96
113
  this._provider.open();
97
114
  this._provider.remoteStateChange.on(this._ctx, () => {
@@ -156,7 +173,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
156
173
  to: end,
157
174
  value: Decoration.mark({
158
175
  attributes: { style: `background-color: ${lightColor}` },
159
- class: 'cm-ySelection',
176
+ class: 'cm-collab-selection',
160
177
  }),
161
178
  });
162
179
  } else {
@@ -166,7 +183,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
166
183
  to: startLine.from + startLine.length,
167
184
  value: Decoration.mark({
168
185
  attributes: { style: `background-color: ${color}` },
169
- class: 'cm-ySelection',
186
+ class: 'cm-collab-selection',
170
187
  }),
171
188
  });
172
189
 
@@ -176,7 +193,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
176
193
  to: end,
177
194
  value: Decoration.mark({
178
195
  attributes: { style: `background-color: ${color}` },
179
- class: 'cm-ySelection',
196
+ class: 'cm-collab-selection',
180
197
  }),
181
198
  });
182
199
 
@@ -186,7 +203,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
186
203
  from: linePos,
187
204
  to: linePos,
188
205
  value: Decoration.line({
189
- attributes: { style: `background-color: ${color}`, class: 'cm-yLineSelection' },
206
+ attributes: { style: `background-color: ${color}`, class: 'cm-collab-selectionLine' },
190
207
  }),
191
208
  });
192
209
  }
@@ -208,24 +225,22 @@ export class RemoteSelectionsDecorator implements PluginValue {
208
225
  }
209
226
 
210
227
  class RemoteCaretWidget extends WidgetType {
211
- constructor(public name: string, public color: string) {
228
+ constructor(private readonly _name: string, private readonly _color: string) {
212
229
  super();
213
- this.name = name;
214
- this.color = color;
215
230
  }
216
231
 
217
- toDOM(): HTMLElement {
232
+ override toDOM(): HTMLElement {
218
233
  const span = document.createElement('span');
219
- span.className = 'cm-ySelectionCaret';
220
- span.style.backgroundColor = this.color;
221
- span.style.borderColor = this.color;
234
+ span.className = 'cm-collab-selectionCaret';
235
+ span.style.backgroundColor = this._color;
236
+ span.style.borderColor = this._color;
222
237
 
223
238
  const dot = document.createElement('div');
224
- dot.className = 'cm-ySelectionCaretDot';
239
+ dot.className = 'cm-collab-selectionCaretDot';
225
240
 
226
241
  const info = document.createElement('div');
227
- info.className = 'cm-ySelectionInfo';
228
- info.innerText = this.name;
242
+ info.className = 'cm-collab-selectionInfo';
243
+ info.innerText = this._name;
229
244
 
230
245
  span.appendChild(document.createTextNode('\u2060'));
231
246
  span.appendChild(dot);
@@ -236,18 +251,14 @@ class RemoteCaretWidget extends WidgetType {
236
251
  return span;
237
252
  }
238
253
 
239
- override eq(widget: this) {
240
- return widget.color === this.color;
241
- }
242
-
243
- compare(widget: this) {
244
- return widget.color === this.color;
245
- }
246
-
247
254
  override updateDOM() {
248
255
  return false;
249
256
  }
250
257
 
258
+ override eq(widget: this) {
259
+ return widget._color === this._color;
260
+ }
261
+
251
262
  override get estimatedHeight() {
252
263
  return -1;
253
264
  }
@@ -257,14 +268,14 @@ class RemoteCaretWidget extends WidgetType {
257
268
  }
258
269
  }
259
270
 
260
- // TODO(burdon): Rename prefix (y for yjs?)
271
+ // TODO(burdon): Mixed case?
261
272
  const styles = EditorView.baseTheme({
262
- '.cm-ySelection': {},
263
- '.cm-yLineSelection': {
273
+ '.cm-collab-selection': {},
274
+ '.cm-collab-selectionLine': {
264
275
  padding: 0,
265
276
  margin: '0px 2px 0px 4px',
266
277
  },
267
- '.cm-ySelectionCaret': {
278
+ '.cm-collab-selectionCaret': {
268
279
  position: 'relative',
269
280
  borderLeft: '1px solid black',
270
281
  borderRight: '1px solid black',
@@ -273,7 +284,7 @@ const styles = EditorView.baseTheme({
273
284
  boxSizing: 'border-box',
274
285
  display: 'inline',
275
286
  },
276
- '.cm-ySelectionCaretDot': {
287
+ '.cm-collab-selectionCaretDot': {
277
288
  borderRadius: '50%',
278
289
  position: 'absolute',
279
290
  width: '.4em',
@@ -284,11 +295,11 @@ const styles = EditorView.baseTheme({
284
295
  transition: 'transform .3s ease-in-out',
285
296
  boxSizing: 'border-box',
286
297
  },
287
- '.cm-ySelectionCaret:hover > .cm-ySelectionCaretDot': {
298
+ '.cm-collab-selectionCaret:hover > .cm-collab-selectionCaretDot': {
288
299
  transformOrigin: 'bottom center',
289
300
  transform: 'scale(0)',
290
301
  },
291
- '.cm-ySelectionInfo': {
302
+ '.cm-collab-selectionInfo': {
292
303
  position: 'absolute',
293
304
  top: '-1.05em',
294
305
  left: '-1px',
@@ -304,12 +315,12 @@ const styles = EditorView.baseTheme({
304
315
  zIndex: 101,
305
316
  transition: 'opacity .3s ease-in-out',
306
317
  backgroundColor: 'inherit',
307
- // these should be separate
318
+ // These should be separate.
308
319
  opacity: 0,
309
320
  transitionDelay: '0s',
310
321
  whiteSpace: 'nowrap',
311
322
  },
312
- '.cm-ySelectionCaret:hover > .cm-ySelectionInfo': {
323
+ '.cm-collab-selectionCaret:hover > .cm-collab-selectionInfo': {
313
324
  opacity: 1,
314
325
  transitionDelay: '0s',
315
326
  },
@@ -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.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
 
@@ -78,14 +76,22 @@ export const code = () => {
78
76
  return [
79
77
  ViewPlugin.fromClass(
80
78
  class {
79
+ hasFocus = false;
81
80
  decorations: DecorationSet;
82
81
  constructor(view: EditorView) {
83
- this.decorations = getDecorations(view);
82
+ this.decorations = buildDecorations(view);
84
83
  }
85
84
 
86
85
  update(update: ViewUpdate) {
87
- if (update.docChanged || update.viewportChanged) {
88
- this.decorations = getDecorations(update.view);
86
+ if (
87
+ // TODO(burdon): Generalize for other extensions.
88
+ this.hasFocus !== update.view.hasFocus ||
89
+ update.startState.readOnly !== update.view.state.readOnly ||
90
+ update.docChanged ||
91
+ update.viewportChanged
92
+ ) {
93
+ this.hasFocus = update.view.hasFocus;
94
+ this.decorations = buildDecorations(update.view);
89
95
  }
90
96
  }
91
97
  },