@dxos/react-ui-editor 0.3.11-main.6cabbc5 → 0.3.11-main.6cd6cc9

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 (178) hide show
  1. package/dist/lib/browser/index.mjs +1597 -1076
  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 +13 -15
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +14 -6
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +1 -1
  9. package/dist/types/src/components/TextEditor/comments.stories.d.ts +1 -1
  10. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/TextEditor/{yjs.stories.d.ts → hooks.stories.d.ts} +13 -2
  12. package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +1 -0
  13. package/dist/types/src/components/Toolbar/Toolbar.d.ts +25 -0
  14. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -0
  15. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +23 -0
  16. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
  17. package/dist/types/src/components/Toolbar/index.d.ts +2 -0
  18. package/dist/types/src/components/Toolbar/index.d.ts.map +1 -0
  19. package/dist/types/src/components/index.d.ts +1 -0
  20. package/dist/types/src/components/index.d.ts.map +1 -1
  21. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  22. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  23. package/dist/types/src/extensions/automerge/automerge.spec.d.ts +2 -0
  24. package/dist/types/src/extensions/automerge/automerge.spec.d.ts.map +1 -0
  25. package/dist/types/src/extensions/automerge/automerge.test.d.ts +2 -0
  26. package/dist/types/src/extensions/automerge/automerge.test.d.ts.map +1 -0
  27. package/dist/types/src/extensions/automerge/defs.d.ts +9 -11
  28. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  29. package/dist/types/src/extensions/automerge/index.d.ts +0 -1
  30. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
  31. package/dist/types/src/extensions/automerge/semaphore.d.ts +8 -12
  32. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  33. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  34. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  35. package/dist/types/src/extensions/awareness.d.ts +2 -2
  36. package/dist/types/src/extensions/awareness.d.ts.map +1 -1
  37. package/dist/types/src/extensions/basic.d.ts +4 -5
  38. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  39. package/dist/types/src/extensions/comments.d.ts +32 -19
  40. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  41. package/dist/types/src/extensions/cursor.d.ts +3 -3
  42. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  43. package/dist/types/src/extensions/index.d.ts +1 -7
  44. package/dist/types/src/extensions/index.d.ts.map +1 -1
  45. package/dist/types/src/extensions/markdown/bundle.d.ts +3 -6
  46. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  47. package/dist/types/src/extensions/markdown/code.d.ts.map +1 -0
  48. package/dist/types/src/extensions/markdown/formatting.d.ts +13 -0
  49. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
  50. package/dist/types/src/extensions/markdown/heading.d.ts +6 -0
  51. package/dist/types/src/extensions/markdown/heading.d.ts.map +1 -0
  52. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  53. package/dist/types/src/extensions/markdown/hr.d.ts.map +1 -0
  54. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -0
  55. package/dist/types/src/extensions/markdown/index.d.ts +8 -0
  56. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  57. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -0
  58. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -0
  59. package/dist/types/src/extensions/markdown/tasklist.d.ts +3 -0
  60. package/dist/types/src/extensions/markdown/tasklist.d.ts.map +1 -0
  61. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  62. package/dist/types/src/extensions/outliner.d.ts +4 -0
  63. package/dist/types/src/extensions/outliner.d.ts.map +1 -0
  64. package/dist/types/src/hooks/awareness-provider.d.ts +7 -4
  65. package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -1
  66. package/dist/types/src/hooks/defs.d.ts +25 -0
  67. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  68. package/dist/types/src/hooks/index.d.ts +4 -2
  69. package/dist/types/src/hooks/index.d.ts.map +1 -1
  70. package/dist/types/src/hooks/useActionHandler.d.ts +4 -0
  71. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -0
  72. package/dist/types/src/hooks/useEditorView.d.ts +17 -0
  73. package/dist/types/src/hooks/useEditorView.d.ts.map +1 -0
  74. package/dist/types/src/hooks/useTextEditor.d.ts +39 -0
  75. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
  76. package/dist/types/src/hooks/useTextModel.d.ts +2 -27
  77. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  78. package/dist/types/src/styles/markdown.d.ts +2 -2
  79. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  80. package/dist/types/src/util.d.ts +2 -0
  81. package/dist/types/src/util.d.ts.map +1 -1
  82. package/package.json +47 -37
  83. package/src/components/TextEditor/MarkdownEditor.stories.tsx +73 -82
  84. package/src/components/TextEditor/TextEditor.tsx +112 -75
  85. package/src/components/TextEditor/comments.stories.tsx +104 -76
  86. package/src/components/TextEditor/hooks.stories.tsx +106 -0
  87. package/src/components/Toolbar/Toolbar.stories.tsx +90 -0
  88. package/src/components/Toolbar/Toolbar.tsx +166 -0
  89. package/src/components/Toolbar/index.ts +5 -0
  90. package/src/components/index.ts +1 -0
  91. package/src/extensions/autocomplete.ts +6 -3
  92. package/src/extensions/automerge/automerge.spec.tsx +76 -0
  93. package/src/extensions/automerge/automerge.test.ts +13 -0
  94. package/src/extensions/automerge/automerge.ts +54 -45
  95. package/src/extensions/automerge/cursor.ts +3 -3
  96. package/src/extensions/automerge/defs.ts +13 -34
  97. package/src/extensions/automerge/index.ts +0 -1
  98. package/src/extensions/automerge/semaphore.ts +33 -32
  99. package/src/extensions/automerge/update-automerge.ts +14 -8
  100. package/src/extensions/automerge/update-codemirror.ts +5 -5
  101. package/src/extensions/awareness.ts +18 -17
  102. package/src/extensions/basic.ts +17 -10
  103. package/src/extensions/comments.ts +273 -187
  104. package/src/extensions/cursor.ts +7 -3
  105. package/src/extensions/index.ts +1 -7
  106. package/src/extensions/markdown/bundle.ts +23 -28
  107. package/src/extensions/markdown/code.ts +150 -0
  108. package/src/extensions/markdown/formatting.ts +188 -0
  109. package/src/extensions/markdown/heading.ts +59 -0
  110. package/src/extensions/markdown/highlight.ts +9 -15
  111. package/src/extensions/markdown/hr.ts +71 -0
  112. package/src/extensions/markdown/index.ts +8 -0
  113. package/src/extensions/{link.ts → markdown/link.ts} +8 -5
  114. package/src/extensions/{tasklist.ts → markdown/tasklist.ts} +48 -33
  115. package/src/extensions/mention.ts +9 -2
  116. package/src/extensions/outliner.ts +12 -0
  117. package/src/hooks/awareness-provider.ts +24 -20
  118. package/src/hooks/defs.ts +42 -0
  119. package/src/hooks/index.ts +5 -2
  120. package/src/hooks/useActionHandler.ts +62 -0
  121. package/src/hooks/useEditorView.ts +29 -0
  122. package/src/hooks/useTextEditor.ts +153 -0
  123. package/src/hooks/useTextModel.ts +49 -55
  124. package/src/styles/markdown.ts +9 -10
  125. package/src/themes/default.ts +4 -4
  126. package/src/util.ts +26 -3
  127. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
  128. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
  129. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  130. package/dist/types/src/extensions/code.d.ts.map +0 -1
  131. package/dist/types/src/extensions/hr.d.ts.map +0 -1
  132. package/dist/types/src/extensions/image.d.ts.map +0 -1
  133. package/dist/types/src/extensions/link.d.ts.map +0 -1
  134. package/dist/types/src/extensions/table.d.ts.map +0 -1
  135. package/dist/types/src/extensions/tasklist.d.ts +0 -7
  136. package/dist/types/src/extensions/tasklist.d.ts.map +0 -1
  137. package/dist/types/src/extensions/yjs/cursor.d.ts +0 -4
  138. package/dist/types/src/extensions/yjs/cursor.d.ts.map +0 -1
  139. package/dist/types/src/extensions/yjs/index.d.ts +0 -2
  140. package/dist/types/src/extensions/yjs/index.d.ts.map +0 -1
  141. package/dist/types/src/extensions/yjs/yjs.d.ts +0 -4
  142. package/dist/types/src/extensions/yjs/yjs.d.ts.map +0 -1
  143. package/dist/types/src/extensions/yjs/yjs.test.d.ts +0 -2
  144. package/dist/types/src/extensions/yjs/yjs.test.d.ts.map +0 -1
  145. package/dist/types/src/hooks/automerge.d.ts +0 -3
  146. package/dist/types/src/hooks/automerge.d.ts.map +0 -1
  147. package/dist/types/src/hooks/yjs.d.ts +0 -27
  148. package/dist/types/src/hooks/yjs.d.ts.map +0 -1
  149. package/dist/types/src/testing/index.d.ts +0 -3
  150. package/dist/types/src/testing/index.d.ts.map +0 -1
  151. package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
  152. package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
  153. package/dist/types/src/testing/proto/index.d.ts +0 -2
  154. package/dist/types/src/testing/proto/index.d.ts.map +0 -1
  155. package/dist/types/src/testing/replicator.d.ts +0 -45
  156. package/dist/types/src/testing/replicator.d.ts.map +0 -1
  157. package/src/components/TextEditor/codemirror.stories.ts +0 -115
  158. package/src/components/TextEditor/yjs.stories.tsx +0 -56
  159. package/src/extensions/code.ts +0 -106
  160. package/src/extensions/hr.ts +0 -73
  161. package/src/extensions/yjs/cursor.ts +0 -22
  162. package/src/extensions/yjs/index.ts +0 -5
  163. package/src/extensions/yjs/yjs.test.ts +0 -35
  164. package/src/extensions/yjs/yjs.ts +0 -16
  165. package/src/hooks/automerge.ts +0 -52
  166. package/src/hooks/yjs.ts +0 -156
  167. package/src/testing/index.ts +0 -6
  168. package/src/testing/proto/gen/schema.ts +0 -49
  169. package/src/testing/proto/index.ts +0 -5
  170. package/src/testing/proto/schema.proto +0 -15
  171. package/src/testing/replicator.ts +0 -184
  172. /package/dist/types/src/extensions/{code.d.ts → markdown/code.d.ts} +0 -0
  173. /package/dist/types/src/extensions/{hr.d.ts → markdown/hr.d.ts} +0 -0
  174. /package/dist/types/src/extensions/{image.d.ts → markdown/image.d.ts} +0 -0
  175. /package/dist/types/src/extensions/{link.d.ts → markdown/link.d.ts} +0 -0
  176. /package/dist/types/src/extensions/{table.d.ts → markdown/table.d.ts} +0 -0
  177. /package/src/extensions/{image.ts → markdown/image.ts} +0 -0
  178. /package/src/extensions/{table.ts → markdown/table.ts} +0 -0
@@ -2,9 +2,9 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { EditorState, type Extension } from '@codemirror/state';
5
+ import { EditorState, type Extension, type StateEffect } from '@codemirror/state';
6
6
  import { EditorView } from '@codemirror/view';
7
- import { useFocusableGroup } from '@fluentui/react-tabster';
7
+ // import { useFocusableGroup } from '@fluentui/react-tabster';
8
8
  import { vim } from '@replit/codemirror-vim';
9
9
  import defaultsDeep from 'lodash.defaultsdeep';
10
10
  import React, {
@@ -14,19 +14,22 @@ import React, {
14
14
  useCallback,
15
15
  useEffect,
16
16
  useImperativeHandle,
17
- useState,
18
17
  useRef,
18
+ useState,
19
19
  } from 'react';
20
20
 
21
- import { generateName } from '@dxos/display-name';
21
+ import { log } from '@dxos/log';
22
22
  import { useThemeContext } from '@dxos/react-ui';
23
- import { getColorForValue, inputSurface, mx } from '@dxos/react-ui-theme';
23
+ import { attentionSurface, mx } from '@dxos/react-ui-theme';
24
+ import { isNotFalsy } from '@dxos/util';
24
25
 
25
- import { basicBundle, markdownBundle, setCommentRange } from '../../extensions';
26
- import { type CommentRange, type EditorModel } from '../../hooks';
26
+ import { createBasicBundle, createMarkdownExtensions } from '../../extensions';
27
+ import { type EditorModel } from '../../hooks';
27
28
  import { type ThemeStyles } from '../../styles';
28
29
  import { defaultTheme, markdownTheme, textTheme } from '../../themes';
30
+ import { logChanges } from '../../util';
29
31
 
32
+ // TODO(burdon): Change to enum?
30
33
  export const EditorModes = ['default', 'vim'] as const;
31
34
  export type EditorMode = (typeof EditorModes)[number];
32
35
 
@@ -41,23 +44,28 @@ export type CursorInfo = {
41
44
 
42
45
  export type TextEditorSlots = {
43
46
  root?: Omit<ComponentProps<'div'>, 'ref'>;
44
- // editor?: {
45
- // className?: string;
46
- // };
47
+ editor?: {
48
+ className?: string;
49
+ };
50
+ content?: {
51
+ className?: string;
52
+ };
47
53
  };
48
54
 
49
55
  // TODO(burdon): Spellcheck?
50
56
  export type TextEditorProps = {
51
- model: EditorModel;
52
- focus?: boolean;
53
- selection?: { anchor: number; head?: number };
57
+ model: EditorModel; // TODO(burdon): Optional (e.g., just provide content if readonly).
54
58
  readonly?: boolean; // TODO(burdon): Move into model.
55
- comments?: CommentRange[]; // TODO(burdon): Move into extension.
56
- extensions?: Extension[];
57
- editorMode?: EditorMode;
59
+ autoFocus?: boolean;
60
+ lineWrapping?: boolean;
61
+ scrollTo?: StateEffect<any>; // TODO(burdon): Restore scroll position: scrollTo EditorView.scrollSnapshot().
62
+ selection?: { anchor: number; head?: number };
63
+ editorMode?: EditorMode; // TODO(burdon): Factor out.
58
64
  placeholder?: string;
59
65
  theme?: ThemeStyles;
60
66
  slots?: TextEditorSlots;
67
+ extensions?: Extension[];
68
+ debug?: boolean;
61
69
  };
62
70
 
63
71
  /**
@@ -65,108 +73,134 @@ export type TextEditorProps = {
65
73
  */
66
74
  export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
67
75
  (
68
- { model, focus, selection, readonly, comments, extensions = [], editorMode, theme, slots = defaultSlots },
76
+ {
77
+ model,
78
+ readonly,
79
+ autoFocus,
80
+ scrollTo,
81
+ selection,
82
+ editorMode,
83
+ theme,
84
+ slots = defaultSlots,
85
+ extensions = [],
86
+ debug,
87
+ },
69
88
  forwardedRef,
70
89
  ) => {
71
- const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
90
+ // TODO(burdon): Hook causes error even if properties are not spread into div.
91
+ // Uncaught TypeError: Cannot read properties of undefined (reading 'relatedTarget')
92
+ // Uses event.detail, which is deprecated (not event.details). At runtime the event has a property `details`.
93
+ // https://github.com/microsoft/tabster/blob/master/src/State/FocusedElement.ts#L348 (e.detail.relatedTarget)
94
+ // https://github.com/microsoft/keyborg/blob/49e49b2c3ba0a5f6cc518ac46825d7551def8109/src/FocusEvent.ts#L58
95
+ // const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
72
96
  const { themeMode } = useThemeContext();
73
97
 
74
- // The editor view ref should only be used as an escape hatch.
75
98
  const rootRef = useRef<HTMLDivElement>(null);
76
99
  const [view, setView] = useState<EditorView | null>(null);
77
- useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
78
-
79
- // Focus.
80
- useEffect(() => {
81
- if (view && focus) {
82
- view.focus();
83
- }
84
- }, [view, focus]);
85
100
 
86
- // TODO(burdon): Factor out as extension.
87
- const { awareness, peer } = model;
88
- useEffect(() => {
89
- if (awareness && peer) {
90
- awareness.setLocalStateField('user', {
91
- name: peer.name ?? generateName(peer.id),
92
- color: getColorForValue({ value: peer.id, type: 'color' }),
93
- colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
94
- });
95
- }
96
- }, [awareness, peer, themeMode]);
101
+ // The view ref can be used to focus the editor.
102
+ // NOTE: This does not cause the parent to re-render, so the ref is not available immediately.
103
+ useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
97
104
 
98
- // TODO(burdon): Factor out as extension.
105
+ // Set focus.
99
106
  useEffect(() => {
100
- if (view && comments !== undefined) {
101
- view.dispatch({
102
- effects: setCommentRange.of({ model, comments }),
103
- });
107
+ if (autoFocus && !view?.hasFocus) {
108
+ if (view?.state.selection.main?.from === 0) {
109
+ // Start at end of line.
110
+ const { to } = view.state.doc.lineAt(0);
111
+ view?.dispatch({ selection: { anchor: to } });
112
+ }
113
+ view?.focus();
104
114
  }
105
- }, [view, comments]);
115
+ }, [view, autoFocus]);
106
116
 
117
+ // Create editor state and view.
118
+ // The view is recreated if the model or extensions are changed.
107
119
  useEffect(() => {
108
120
  if (!model || !rootRef.current) {
109
121
  return;
110
122
  }
111
123
 
124
+ //
125
+ // EditorState
112
126
  // https://codemirror.net/docs/ref/#state.EditorStateConfig
127
+ //
113
128
  const state = EditorState.create({
114
129
  doc: model.text(),
115
- // TODO(burdon): Composer should store and set selection when switching documents.
116
130
  selection,
117
131
  extensions: [
118
- readonly && EditorState.readOnly.of(readonly),
119
-
120
- // TODO(burdon): Factor out VIM mode? (manage via MarkdownPlugin).
121
- editorMode === 'vim' && vim(),
132
+ // TODO(burdon): Doesn't catch errors in keymap functions.
133
+ EditorView.exceptionSink.of((err) => {
134
+ log.catch(err);
135
+ }),
122
136
 
123
137
  // Theme.
124
- // TODO(burdon): Make theme configurable.
138
+ // TODO(burdon): Make configurable.
125
139
  EditorView.baseTheme(defaultTheme),
126
140
  EditorView.theme(theme ?? {}),
127
141
  EditorView.darkTheme.of(themeMode === 'dark'),
142
+ EditorView.editorAttributes.of({ class: slots.editor?.className ?? '' }),
143
+ EditorView.contentAttributes.of({ class: slots.content?.className ?? '' }),
144
+
145
+ // State.
146
+ EditorView.editable.of(!readonly),
147
+ EditorState.readOnly.of(!!readonly),
128
148
 
129
149
  // Storage and replication.
150
+ // NOTE: This must come before user extensions.
130
151
  model.extension,
131
152
 
153
+ // TODO(burdon): Factor out? (Requires special handling for Escape/Enter below).
154
+ editorMode === 'vim' && vim(),
155
+
132
156
  // Custom.
133
157
  ...extensions,
134
- ].filter(Boolean) as Extension[],
158
+ ].filter(isNotFalsy),
135
159
  });
136
160
 
137
- // NOTE: This repaints the editor.
138
- // If the new state is derived from the old state, it will likely not be visible other than the cursor resetting.
139
- // Ideally this should not happen except when changing between text objects.
140
- view?.destroy();
161
+ //
162
+ // EditorView
163
+ // https://codemirror.net/docs/ref/#view.EditorViewConfig
164
+ //
141
165
  const newView = new EditorView({
142
- parent: rootRef.current,
143
166
  state,
144
- // NOTE: Uncomment to spy on all transactions.
167
+ parent: rootRef.current,
168
+ scrollTo,
169
+ // NOTE: Uncomment to debug/monitor all transactions.
145
170
  // https://codemirror.net/docs/ref/#view.EditorView.dispatch
146
- // dispatch: (transaction, view) => {
147
- // view.update([transaction]);
148
- // },
171
+ dispatchTransactions: (trs, view) => {
172
+ if (debug) {
173
+ logChanges(trs);
174
+ }
175
+ view.update(trs);
176
+ },
149
177
  });
150
178
 
179
+ view?.destroy();
151
180
  setView(newView);
181
+
152
182
  return () => {
153
183
  newView?.destroy();
154
184
  setView(null);
155
185
  };
156
186
  }, [rootRef, model, readonly, editorMode, themeMode]);
157
187
 
188
+ // Handles tab/focus.
189
+ // Pressing Escape focuses the outer div (to support tab navigation); pressing Enter refocuses the editor.
190
+ // TODO(burdon): Convert to keymap?
158
191
  const handleKeyUp = useCallback(
159
192
  (event: KeyboardEvent) => {
160
193
  const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
161
194
  switch (key) {
162
- // TODO(burdon): Is this required (for vim mode?)
163
- // case 'Enter': {
164
- // view?.contentDOM.focus();
165
- // break;
166
- // }
195
+ case 'Enter': {
196
+ view?.focus();
197
+ break;
198
+ }
167
199
 
168
200
  case 'Escape': {
169
- editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey) && rootRef.current?.focus();
201
+ if (editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)) {
202
+ view?.focus();
203
+ }
170
204
  break;
171
205
  }
172
206
  }
@@ -178,25 +212,25 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
178
212
  <div
179
213
  key={model.id}
180
214
  role='none'
181
- ref={rootRef}
182
215
  tabIndex={0}
183
- onKeyUp={handleKeyUp}
184
216
  {...slots.root}
185
- {...(editorMode !== 'vim' && tabsterDOMAttribute)}
217
+ // {...(editorMode !== 'vim' && tabsterDOMAttribute)}
218
+ onKeyUp={handleKeyUp}
219
+ ref={rootRef}
186
220
  />
187
221
  );
188
222
  },
189
223
  );
190
224
 
191
225
  export const TextEditor = forwardRef<EditorView, TextEditorProps>(
192
- ({ readonly, placeholder, extensions = [], theme = textTheme, slots, ...props }, forwardedRef) => {
226
+ ({ readonly, placeholder, lineWrapping, theme = textTheme, slots, extensions = [], ...props }, forwardedRef) => {
193
227
  const { themeMode } = useThemeContext();
194
228
  const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
195
229
  return (
196
230
  <BaseTextEditor
197
231
  ref={forwardedRef}
198
232
  readonly={readonly}
199
- extensions={[basicBundle({ readonly, themeMode, placeholder }), ...extensions]}
233
+ extensions={[createBasicBundle({ themeMode, placeholder, lineWrapping }), ...extensions]}
200
234
  theme={theme}
201
235
  slots={updatedSlots}
202
236
  {...props}
@@ -205,16 +239,15 @@ export const TextEditor = forwardRef<EditorView, TextEditorProps>(
205
239
  },
206
240
  );
207
241
 
208
- // TODO(burdon): Remove (Just provide bundle, slots).
209
242
  export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
210
- ({ readonly, placeholder, extensions = [], theme = markdownTheme, slots, ...props }, forwardedRef) => {
243
+ ({ readonly, placeholder, theme = markdownTheme, slots, extensions = [], ...props }, forwardedRef) => {
211
244
  const { themeMode } = useThemeContext();
212
245
  const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
213
246
  return (
214
247
  <BaseTextEditor
215
248
  ref={forwardedRef}
216
249
  readonly={readonly}
217
- extensions={[markdownBundle({ readonly, themeMode, placeholder }), ...extensions]}
250
+ extensions={[createMarkdownExtensions({ themeMode, placeholder }), ...extensions]}
218
251
  theme={theme}
219
252
  slots={updatedSlots}
220
253
  {...props}
@@ -225,7 +258,11 @@ export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
225
258
 
226
259
  export const defaultSlots: TextEditorSlots = {
227
260
  root: {
228
- className: mx('p-2 overflow-y-auto', inputSurface),
261
+ // TODO(burdon): Add focusRing by default/as property?
262
+ className: mx('flex flex-col grow overflow-y-auto', attentionSurface),
263
+ },
264
+ editor: {
265
+ className: 'h-full p-2',
229
266
  },
230
267
  };
231
268
 
@@ -6,18 +6,19 @@ 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';
17
18
 
18
19
  import { MarkdownEditor, TextEditor } from './TextEditor';
19
- import { comments, type CommentsOptions, Cursor } from '../../extensions';
20
- import { type CommentRange, type Range, useTextModel } from '../../hooks';
20
+ import { comments, type CommentsOptions, focusComment, useComments } from '../../extensions';
21
+ import { type Comment, type Range, useTextModel } from '../../hooks';
21
22
 
22
23
  faker.seed(101);
23
24
 
@@ -27,36 +28,41 @@ faker.seed(101);
27
28
 
28
29
  const Editor: FC<{
29
30
  item: { text: TextObject };
30
- commentSelected?: string;
31
- commentRanges: CommentRange[];
31
+ comments: Comment[];
32
+ selected?: string;
32
33
  onCreateComment: CommentsOptions['onCreate'];
33
- onMoveComment: CommentsOptions['onMove'];
34
+ onDeleteComment: CommentsOptions['onDelete'];
35
+ onUpdateComment: CommentsOptions['onUpdate'];
34
36
  onSelectComment: CommentsOptions['onSelect'];
35
- }> = ({ item, commentSelected, commentRanges, onCreateComment, onMoveComment, onSelectComment }) => {
37
+ }> = ({
38
+ item,
39
+ selected: selectedValue,
40
+ comments: commentRanges,
41
+ onCreateComment,
42
+ onDeleteComment,
43
+ onUpdateComment,
44
+ onSelectComment,
45
+ }) => {
36
46
  const model = useTextModel({ text: item.text });
37
- const editorRef = useRef<EditorView>(null);
47
+ const view = useRef<EditorView>(null);
38
48
  const [selected, setSelected] = useState<string>();
39
49
  useEffect(() => {
40
- if (!editorRef.current?.hasFocus && commentSelected !== selected) {
41
- const thread = commentRanges.find((range) => range.id === commentSelected);
42
- if (thread) {
43
- const { cursor } = thread;
44
- const range = Cursor.getRangeFromCursor(editorRef.current!.state.facet(Cursor.converter), cursor);
45
- if (range) {
46
- // TODO(burdon): Scroll selection to center of screen?
47
- editorRef.current?.dispatch({ selection: { anchor: range.from }, scrollIntoView: true });
48
- }
50
+ if (!view.current?.hasFocus && selectedValue !== selected) {
51
+ setSelected(selectedValue);
52
+ if (selectedValue) {
53
+ focusComment(view.current!, selectedValue);
49
54
  }
50
-
51
- setSelected(commentSelected);
52
55
  }
53
- }, [selected, commentRanges, commentSelected]);
56
+ }, [selected, commentRanges, selectedValue]);
57
+
58
+ useComments(view.current, commentRanges);
54
59
 
55
60
  const extensions = useMemo(() => {
56
61
  return [
57
62
  comments({
58
63
  onCreate: onCreateComment,
59
- onMove: onMoveComment,
64
+ onDelete: onDeleteComment,
65
+ onUpdate: onUpdateComment,
60
66
  onSelect: onSelectComment,
61
67
  }),
62
68
  ];
@@ -69,7 +75,7 @@ const Editor: FC<{
69
75
  // TODO(burdon): Highlight currently selected comment.
70
76
  return (
71
77
  <div className='flex grow overflow-y-scroll'>
72
- <MarkdownEditor ref={editorRef} model={model} comments={commentRanges} extensions={extensions} />
78
+ <MarkdownEditor ref={view} model={model} extensions={extensions} />
73
79
  </div>
74
80
  );
75
81
  };
@@ -80,9 +86,9 @@ const Editor: FC<{
80
86
 
81
87
  type CommentThread = {
82
88
  id: string;
83
- range: CommentRange;
89
+ cursor?: string;
90
+ range?: Range;
84
91
  yPos?: number;
85
- selection?: Range;
86
92
  messages: TextObject[];
87
93
  };
88
94
 
@@ -92,23 +98,17 @@ const Thread: FC<{
92
98
  onSelect: () => void;
93
99
  onResolve: () => void;
94
100
  }> = ({ thread, selected, onSelect, onResolve }) => {
101
+ const [focus, setFocus] = useState(false);
95
102
  const [item, setItem] = useState({ text: new TextObject() });
96
103
  const model = useTextModel({ text: item.text });
97
104
  const editorRef = useRef<EditorView>(null);
98
105
 
99
- // TODO(burdon): Hack to focus (view is recreated when text object changes).
100
- const focus = () => {
101
- setTimeout(() => {
102
- editorRef.current?.focus();
103
- }, 100);
104
- };
105
-
106
106
  const containerRef = useRef<HTMLDivElement>(null);
107
107
  useEffect(() => {
108
108
  if (selected) {
109
109
  containerRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
110
110
  if (thread.messages.length === 0) {
111
- focus();
111
+ setFocus(true);
112
112
  }
113
113
  }
114
114
  }, [selected]);
@@ -118,7 +118,7 @@ const Thread: FC<{
118
118
  if (text?.length) {
119
119
  thread.messages.push(item.text);
120
120
  setItem({ text: new TextObject() });
121
- focus();
121
+ setFocus(true);
122
122
  }
123
123
  };
124
124
 
@@ -127,41 +127,49 @@ const Thread: FC<{
127
127
  }
128
128
 
129
129
  return (
130
- <div className={mx('flex flex-col m-1 rounded shadow divide-y bg-white', selected && 'ring')}>
131
- <div className='flex p-2 text-xs font-mono gap-2 text-neutral-500 font-thin'>
130
+ <div
131
+ className={mx(
132
+ 'flex flex-col m-1 rounded shadow divide-y bg-white',
133
+ selected && 'ring',
134
+ !thread.cursor && 'opacity-50',
135
+ )}
136
+ >
137
+ <div className='flex p-2 gap-2 items-center text-xs font-mono text-neutral-500 font-thin'>
132
138
  <span>id:{thread.id.slice(0, 4)}</span>
133
- <span>from:{thread.selection?.from}</span>
134
- <span>to:{thread.selection?.to}</span>
139
+ <span>from:{thread.range?.from}</span>
140
+ <span>to:{thread.range?.to}</span>
135
141
  <span>y:{thread.yPos}</span>
142
+ <span className='grow' />
143
+ {!thread.cursor && <Trash />}
136
144
  </div>
137
145
 
138
146
  {thread.messages.map((message, i) => (
139
147
  // TODO(burdon): Fix default editor padding so content doesn't jump on creating message.
140
148
  <div key={i} className='p-2' onClick={() => onSelect()}>
141
- {message.text}
149
+ {getTextContent(message)}
142
150
  </div>
143
151
  ))}
144
152
 
145
- <div ref={containerRef} onClick={() => onSelect()} className='flex py-1'>
146
- <div className='grow'>
147
- <TextEditor
148
- ref={editorRef}
149
- model={model}
150
- placeholder={'Enter comment...'}
151
- extensions={[
152
- keymap.of([
153
- {
154
- key: 'Enter',
155
- run: () => {
156
- handleCreateMessage();
157
- return true;
158
- },
153
+ <div ref={containerRef} className='flex' onClick={() => onSelect()}>
154
+ <TextEditor
155
+ ref={editorRef}
156
+ autoFocus={focus}
157
+ model={model}
158
+ placeholder={'Enter comment...'}
159
+ slots={{ root: { className: 'grow rounded-b' } }}
160
+ extensions={[
161
+ keymap.of([
162
+ {
163
+ key: 'Enter',
164
+ run: () => {
165
+ handleCreateMessage();
166
+ return true;
159
167
  },
160
- ]),
161
- ]}
162
- />
163
- </div>
164
- <Button variant='ghost' classNames='px-1 mr-1' title='Resolve' onClick={onResolve}>
168
+ },
169
+ ]),
170
+ ]}
171
+ />
172
+ <Button variant='ghost' classNames='px-1' title='Resolve' onClick={onResolve}>
165
173
  <Check />
166
174
  </Button>
167
175
  </div>
@@ -185,7 +193,7 @@ const Sidebar: FC<{
185
193
 
186
194
  return (
187
195
  <DensityProvider density='fine'>
188
- <div className='flex flex-col grow overflow-y-scroll py-4 gap-4 pr-4'>
196
+ <div className='flex flex-col grow overflow-y-scroll py-4 gap-4'>
189
197
  {sortedThreads.map((thread) => (
190
198
  <Thread
191
199
  key={thread.id}
@@ -212,55 +220,74 @@ type StoryProps = {
212
220
  const Story = ({ text, autoCreate }: StoryProps) => {
213
221
  const [item] = useState({ text: new TextObject(text) });
214
222
  const [threads, setThreads] = useState<CommentThread[]>([]);
215
- const commentRanges = useMemo(() => threads.map((thread) => thread.range), [threads]);
216
223
  const [selected, setSelected] = useState<string>();
217
224
 
225
+ const comments = useMemo<Comment[]>(() => threads.map(({ id, cursor }) => ({ id, cursor })), [threads]);
226
+
218
227
  // Filter by visibility.
219
228
  const visibleThreads = useMemo(() => threads.filter((thread) => thread.yPos !== undefined), [threads]);
220
229
 
221
230
  const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
222
231
  const id = PublicKey.random().toHex();
232
+ log.info('create', { id: id.slice(0, 4), cursor });
223
233
  setThreads((threads) => [
224
234
  ...threads,
225
235
  {
226
236
  id,
227
- range: { id, cursor },
237
+ cursor,
228
238
  yPos: location ? Math.floor(location.top) : undefined,
229
239
  messages: autoCreate
230
- ? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 2, max: 5 } })
240
+ ? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 1, max: 3 } })
231
241
  : [],
232
242
  },
233
243
  ]);
234
- setSelected(id);
244
+ setSelected(id); // TODO(burdon): Not required.
235
245
  return id;
236
246
  };
237
247
 
238
- // TODO(burdon): Scroll sidebar on select.
239
- const handleSelectComment: CommentsOptions['onSelect'] = ({ active, closest, ranges }) => {
248
+ const handleDeleteComment: CommentsOptions['onDelete'] = (id) => {
249
+ log.info('delete', { id: id.slice(0, 4) });
240
250
  setThreads((threads) =>
241
251
  threads.map((thread) => {
242
- const range = ranges.find((range) => range.id === thread.range.id);
243
- if (range) {
244
- thread.yPos = range.location ? Math.floor(range.location.top) : undefined;
245
- thread.selection = { from: range.from, to: range.to };
252
+ if (thread.id === id) {
253
+ thread.cursor = undefined;
246
254
  }
247
255
 
248
256
  return thread;
249
257
  }),
250
258
  );
251
259
 
252
- setSelected(active ?? closest);
260
+ setSelected((selected) => (selected === id ? undefined : selected));
253
261
  };
254
262
 
255
- const handleMoveComment: CommentsOptions['onMove'] = (id, cursor) => {
263
+ const handleUpdateComment: CommentsOptions['onUpdate'] = (id, cursor) => {
264
+ log.info('update', { id: id.slice(0, 4), cursor });
256
265
  setThreads((threads) =>
257
266
  threads.map((thread) => {
258
267
  if (thread.id === id) {
259
- thread.range.cursor = cursor;
268
+ thread.cursor = cursor;
260
269
  }
270
+
271
+ return thread;
272
+ }),
273
+ );
274
+ };
275
+
276
+ const handleSelectComment: CommentsOptions['onSelect'] = ({ comments, selection: { current, closest } }) => {
277
+ log.info('select', { active: current?.slice(0, 4), closest: closest?.slice(0, 4) });
278
+ setThreads((threads) =>
279
+ threads.map((thread) => {
280
+ const comment = comments.find(({ comment }) => comment.id === thread.id);
281
+ if (comment) {
282
+ thread.yPos = comment.location ? Math.floor(comment.location.top) : undefined;
283
+ thread.range = { from: comment.range.from, to: comment.range.to };
284
+ }
285
+
261
286
  return thread;
262
287
  }),
263
288
  );
289
+
290
+ setSelected(current ?? closest);
264
291
  };
265
292
 
266
293
  const handleSelectThread = (id: string) => {
@@ -278,10 +305,11 @@ const Story = ({ text, autoCreate }: StoryProps) => {
278
305
  <div className='flex flex-col h-full w-[600px]'>
279
306
  <Editor
280
307
  item={item}
281
- commentSelected={selected}
282
- commentRanges={commentRanges}
308
+ selected={selected}
309
+ comments={comments}
283
310
  onCreateComment={handleCreateComment}
284
- onMoveComment={handleMoveComment}
311
+ onDeleteComment={handleDeleteComment}
312
+ onUpdateComment={handleUpdateComment}
285
313
  onSelectComment={handleSelectComment}
286
314
  />
287
315
  </div>
@@ -321,7 +349,7 @@ export const Default = {
321
349
  },
322
350
  };
323
351
 
324
- export const Demo = {
352
+ export const Testing = {
325
353
  args: {
326
354
  text: document,
327
355
  autoCreate: true,