@dxos/react-ui-editor 0.3.11-main.d3a2438 → 0.3.11-main.d46c1ca

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 (135) hide show
  1. package/dist/lib/browser/index.mjs +744 -896
  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 -14
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +16 -9
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +4 -4
  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/extensions/autocomplete.d.ts.map +1 -1
  13. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  14. package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
  15. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  16. package/dist/types/src/extensions/automerge/defs.d.ts +7 -9
  17. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  18. package/dist/types/src/extensions/automerge/index.d.ts +0 -1
  19. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
  20. package/dist/types/src/extensions/automerge/semaphore.d.ts +7 -12
  21. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  22. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  23. package/dist/types/src/extensions/awareness.d.ts +1 -1
  24. package/dist/types/src/extensions/awareness.d.ts.map +1 -1
  25. package/dist/types/src/extensions/basic.d.ts +4 -5
  26. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  27. package/dist/types/src/extensions/code.d.ts.map +1 -1
  28. package/dist/types/src/extensions/comments.d.ts +32 -19
  29. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  30. package/dist/types/src/{util → extensions}/cursor.d.ts +10 -2
  31. package/dist/types/src/extensions/cursor.d.ts.map +1 -0
  32. package/dist/types/src/extensions/hr.d.ts.map +1 -1
  33. package/dist/types/src/extensions/index.d.ts +2 -1
  34. package/dist/types/src/extensions/index.d.ts.map +1 -1
  35. package/dist/types/src/extensions/markdown/bundle.d.ts +2 -5
  36. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  37. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  38. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  39. package/dist/types/src/extensions/outliner.d.ts +4 -0
  40. package/dist/types/src/extensions/outliner.d.ts.map +1 -0
  41. package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
  42. package/dist/types/src/hooks/awareness-provider.d.ts +7 -4
  43. package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -1
  44. package/dist/types/src/hooks/defs.d.ts +25 -0
  45. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  46. package/dist/types/src/hooks/index.d.ts +2 -2
  47. package/dist/types/src/hooks/index.d.ts.map +1 -1
  48. package/dist/types/src/hooks/useTextEditor.d.ts +17 -0
  49. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
  50. package/dist/types/src/hooks/useTextModel.d.ts +2 -32
  51. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  52. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  53. package/dist/types/src/themes/default.d.ts.map +1 -1
  54. package/dist/types/src/util.d.ts +7 -0
  55. package/dist/types/src/util.d.ts.map +1 -0
  56. package/package.json +33 -35
  57. package/src/components/TextEditor/MarkdownEditor.stories.tsx +75 -80
  58. package/src/components/TextEditor/TextEditor.stories.tsx +10 -43
  59. package/src/components/TextEditor/TextEditor.tsx +106 -83
  60. package/src/components/TextEditor/comments.stories.tsx +357 -0
  61. package/src/extensions/autocomplete.ts +6 -3
  62. package/src/extensions/automerge/automerge.ts +40 -46
  63. package/src/extensions/automerge/cursor.ts +2 -1
  64. package/src/extensions/automerge/defs.ts +10 -31
  65. package/src/extensions/automerge/index.ts +0 -1
  66. package/src/extensions/automerge/semaphore.ts +23 -20
  67. package/src/extensions/automerge/update-automerge.ts +2 -2
  68. package/src/extensions/awareness.ts +40 -46
  69. package/src/extensions/basic.ts +22 -17
  70. package/src/extensions/code.ts +24 -18
  71. package/src/extensions/comments.ts +246 -238
  72. package/src/extensions/cursor.ts +50 -0
  73. package/src/extensions/hr.ts +49 -50
  74. package/src/extensions/image.ts +2 -2
  75. package/src/extensions/index.ts +2 -1
  76. package/src/extensions/link.ts +7 -7
  77. package/src/extensions/markdown/bundle.ts +18 -24
  78. package/src/extensions/markdown/highlight.ts +0 -8
  79. package/src/extensions/mention.ts +9 -2
  80. package/src/extensions/outliner.ts +12 -0
  81. package/src/extensions/table.ts +2 -2
  82. package/src/extensions/tasklist.ts +78 -86
  83. package/src/hooks/awareness-provider.ts +24 -20
  84. package/src/hooks/defs.ts +42 -0
  85. package/src/hooks/index.ts +3 -2
  86. package/src/hooks/useTextEditor.ts +29 -0
  87. package/src/hooks/useTextModel.ts +47 -59
  88. package/src/styles/markdown.ts +0 -2
  89. package/src/themes/default.ts +17 -50
  90. package/src/util.ts +41 -0
  91. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
  92. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
  93. package/dist/types/src/components/TextEditor/yjs.stories.d.ts +0 -12
  94. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  95. package/dist/types/src/extensions/yjs/cursor.d.ts +0 -4
  96. package/dist/types/src/extensions/yjs/cursor.d.ts.map +0 -1
  97. package/dist/types/src/extensions/yjs/index.d.ts +0 -2
  98. package/dist/types/src/extensions/yjs/index.d.ts.map +0 -1
  99. package/dist/types/src/extensions/yjs/yjs.d.ts +0 -4
  100. package/dist/types/src/extensions/yjs/yjs.d.ts.map +0 -1
  101. package/dist/types/src/extensions/yjs/yjs.test.d.ts +0 -2
  102. package/dist/types/src/extensions/yjs/yjs.test.d.ts.map +0 -1
  103. package/dist/types/src/hooks/automerge.d.ts +0 -3
  104. package/dist/types/src/hooks/automerge.d.ts.map +0 -1
  105. package/dist/types/src/hooks/yjs.d.ts +0 -27
  106. package/dist/types/src/hooks/yjs.d.ts.map +0 -1
  107. package/dist/types/src/testing/index.d.ts +0 -3
  108. package/dist/types/src/testing/index.d.ts.map +0 -1
  109. package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
  110. package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
  111. package/dist/types/src/testing/proto/index.d.ts +0 -2
  112. package/dist/types/src/testing/proto/index.d.ts.map +0 -1
  113. package/dist/types/src/testing/replicator.d.ts +0 -45
  114. package/dist/types/src/testing/replicator.d.ts.map +0 -1
  115. package/dist/types/src/util/cursor.d.ts.map +0 -1
  116. package/dist/types/src/util/index.d.ts +0 -3
  117. package/dist/types/src/util/index.d.ts.map +0 -1
  118. package/dist/types/src/util/util.d.ts +0 -5
  119. package/dist/types/src/util/util.d.ts.map +0 -1
  120. package/src/components/TextEditor/codemirror.stories.ts +0 -115
  121. package/src/components/TextEditor/yjs.stories.tsx +0 -56
  122. package/src/extensions/yjs/cursor.ts +0 -21
  123. package/src/extensions/yjs/index.ts +0 -5
  124. package/src/extensions/yjs/yjs.test.ts +0 -35
  125. package/src/extensions/yjs/yjs.ts +0 -16
  126. package/src/hooks/automerge.ts +0 -52
  127. package/src/hooks/yjs.ts +0 -178
  128. package/src/testing/index.ts +0 -6
  129. package/src/testing/proto/gen/schema.ts +0 -49
  130. package/src/testing/proto/index.ts +0 -5
  131. package/src/testing/proto/schema.proto +0 -15
  132. package/src/testing/replicator.ts +0 -184
  133. package/src/util/cursor.ts +0 -29
  134. package/src/util/index.ts +0 -6
  135. package/src/util/util.ts +0 -18
@@ -2,7 +2,7 @@
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
7
  import { useFocusableGroup } from '@fluentui/react-tabster';
8
8
  import { vim } from '@replit/codemirror-vim';
@@ -14,19 +14,21 @@ 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
24
 
25
- import { basicBundle, markdownBundle, setCommentRange } from '../../extensions';
26
- import { type CommentRange, type EditorModel } from '../../hooks';
25
+ import { basicBundle, markdownBundle } from '../../extensions';
26
+ import { type EditorModel } from '../../hooks';
27
27
  import { type ThemeStyles } from '../../styles';
28
28
  import { defaultTheme, markdownTheme, textTheme } from '../../themes';
29
+ import { logChanges } from '../../util';
29
30
 
31
+ // TODO(burdon): Change to enum?
30
32
  export const EditorModes = ['default', 'vim'] as const;
31
33
  export type EditorMode = (typeof EditorModes)[number];
32
34
 
@@ -43,123 +45,149 @@ export type TextEditorSlots = {
43
45
  root?: Omit<ComponentProps<'div'>, 'ref'>;
44
46
  editor?: {
45
47
  className?: string;
46
- placeholder?: string;
47
- spellCheck?: boolean;
48
- tabIndex?: number;
49
- theme?: ThemeStyles;
48
+ };
49
+ content?: {
50
+ className?: string;
50
51
  };
51
52
  };
52
53
 
54
+ // TODO(burdon): Spellcheck?
53
55
  export type TextEditorProps = {
54
- model: EditorModel;
55
- focus?: boolean;
56
+ model: EditorModel; // TODO(burdon): Optional (e.g., just provide content if readonly).
56
57
  readonly?: boolean; // TODO(burdon): Move into model.
57
- comments?: CommentRange[]; // TODO(burdon): Move into extension.
58
- extensions?: Extension[];
59
- editorMode?: EditorMode;
58
+ autoFocus?: boolean;
59
+ lineWrapping?: boolean;
60
+ scrollTo?: StateEffect<any>; // TODO(burdon): Restore scroll position: scrollTo EditorView.scrollSnapshot().
61
+ selection?: { anchor: number; head?: number };
62
+ editorMode?: EditorMode; // TODO(burdon): Factor out.
63
+ placeholder?: string;
64
+ theme?: ThemeStyles;
60
65
  slots?: TextEditorSlots;
66
+ extensions?: Extension[];
67
+ debug?: boolean;
61
68
  };
62
69
 
63
70
  /**
64
71
  * Base text editor.
65
72
  */
66
73
  export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
67
- ({ model, focus, readonly, comments, extensions = [], editorMode, slots = defaultSlots }, forwardedRef) => {
74
+ (
75
+ {
76
+ model,
77
+ readonly,
78
+ autoFocus,
79
+ scrollTo,
80
+ selection,
81
+ editorMode,
82
+ theme,
83
+ slots = defaultSlots,
84
+ extensions = [],
85
+ debug,
86
+ },
87
+ forwardedRef,
88
+ ) => {
68
89
  const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
69
90
  const { themeMode } = useThemeContext();
91
+
70
92
  const rootRef = useRef<HTMLDivElement>(null);
71
93
  const [view, setView] = useState<EditorView | null>(null);
72
- // NOTE: This doesn't update useRef.
73
- useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
74
- useEffect(() => {
75
- if (focus && view) {
76
- view.focus();
77
- }
78
- }, [focus, view]);
79
94
 
80
- // TODO(burdon): Factor out as extension.
81
- const { awareness, peer } = model;
82
- useEffect(() => {
83
- if (awareness && peer) {
84
- awareness.setLocalStateField('user', {
85
- name: peer.name ?? generateName(peer.id),
86
- color: getColorForValue({ value: peer.id, type: 'color' }),
87
- colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
88
- });
89
- }
90
- }, [awareness, peer, themeMode]);
95
+ // The view ref can be used to focus the editor.
96
+ // NOTE: This does not cause the parent to re-render, so the ref is not available immediately.
97
+ useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
91
98
 
92
- // TODO(burdon): Factor out as extension.
99
+ // Set focus.
93
100
  useEffect(() => {
94
- if (view && comments?.length) {
95
- view.dispatch({
96
- effects: setCommentRange.of({ model, comments }),
97
- });
101
+ if (autoFocus) {
102
+ view?.focus();
98
103
  }
99
- }, [view, comments]);
104
+ }, [view, autoFocus]);
100
105
 
106
+ // Create editor state and view.
107
+ // The view is recreated if the model or extensions are changed.
101
108
  useEffect(() => {
102
109
  if (!model || !rootRef.current) {
103
110
  return;
104
111
  }
105
112
 
106
- // TODO(burdon): Remember cursor position.
113
+ //
114
+ // EditorState
107
115
  // https://codemirror.net/docs/ref/#state.EditorStateConfig
116
+ //
108
117
  const state = EditorState.create({
109
118
  doc: model.text(),
119
+ selection,
110
120
  extensions: [
111
- readonly && EditorState.readOnly.of(readonly),
112
-
113
- // TODO(burdon): Factor out VIM mode? (manage via MarkdownPlugin).
114
- editorMode === 'vim' && vim(),
121
+ // TODO(burdon): Doesn't catch errors in keymap functions.
122
+ EditorView.exceptionSink.of((err) => {
123
+ log.catch(err);
124
+ }),
115
125
 
116
126
  // Theme.
117
- // TODO(burdon): Make theme configurable.
127
+ // TODO(burdon): Make configurable.
118
128
  EditorView.baseTheme(defaultTheme),
119
- EditorView.theme(slots?.editor?.theme ?? {}),
129
+ EditorView.theme(theme ?? {}),
120
130
  EditorView.darkTheme.of(themeMode === 'dark'),
131
+ EditorView.editorAttributes.of({ class: slots.editor?.className ?? '' }),
132
+ EditorView.contentAttributes.of({ class: slots.content?.className ?? '' }),
133
+
134
+ // State.
135
+ EditorState.readOnly.of(!!readonly),
121
136
 
122
137
  // Storage and replication.
138
+ // NOTE: This must come before user extensions.
123
139
  model.extension,
124
140
 
141
+ // TODO(burdon): Factor out (requires special handling for Escape/focus).
142
+ editorMode === 'vim' && vim(),
143
+
125
144
  // Custom.
126
145
  ...extensions,
127
146
  ].filter(Boolean) as Extension[],
128
147
  });
129
148
 
130
- // NOTE: This repaints the editor.
131
- // If the new state is derived from the old state, it will likely not be visible other than the cursor resetting.
132
- // Ideally this should not happen except when changing between text objects.
133
- view?.destroy();
149
+ //
150
+ // EditorView
151
+ // https://codemirror.net/docs/ref/#view.EditorViewConfig
152
+ //
134
153
  const newView = new EditorView({
135
- parent: rootRef.current,
136
154
  state,
137
- // NOTE: Uncomment to spy on all transactions.
155
+ parent: rootRef.current,
156
+ scrollTo,
157
+ // NOTE: Uncomment to debug/monitor all transactions.
138
158
  // https://codemirror.net/docs/ref/#view.EditorView.dispatch
139
- // dispatch: (transaction, view) => {
140
- // view.update([transaction]);
141
- // },
159
+ dispatchTransactions: (trs, view) => {
160
+ if (debug) {
161
+ logChanges(trs);
162
+ }
163
+ view.update(trs);
164
+ },
142
165
  });
143
166
 
167
+ view?.destroy();
144
168
  setView(newView);
169
+
145
170
  return () => {
146
171
  newView?.destroy();
147
172
  setView(null);
148
173
  };
149
174
  }, [rootRef, model, readonly, editorMode, themeMode]);
150
175
 
176
+ // Handles tab/focus.
177
+ // Pressing Escape focuses the outer div (to support tab navigation); pressing Enter refocuses the editor.
151
178
  const handleKeyUp = useCallback(
152
179
  (event: KeyboardEvent) => {
153
180
  const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
154
181
  switch (key) {
155
- // TODO(burdon): ???
156
- // case 'Enter': {
157
- // view?.contentDOM.focus();
158
- // break;
159
- // }
182
+ case 'Enter': {
183
+ view?.focus();
184
+ break;
185
+ }
160
186
 
161
187
  case 'Escape': {
162
- editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey) && rootRef.current?.focus();
188
+ if (editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)) {
189
+ rootRef.current?.focus();
190
+ }
163
191
  break;
164
192
  }
165
193
  }
@@ -170,28 +198,28 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
170
198
  return (
171
199
  <div
172
200
  key={model.id}
173
- ref={rootRef}
201
+ role='none'
174
202
  tabIndex={0}
175
- {...slots?.root}
176
- {...(editorMode !== 'vim' && tabsterDOMAttribute)}
177
203
  onKeyUp={handleKeyUp}
204
+ {...slots.root}
205
+ {...(editorMode !== 'vim' && tabsterDOMAttribute)}
206
+ ref={rootRef}
178
207
  />
179
208
  );
180
209
  },
181
210
  );
182
211
 
212
+ // TODO(burdon): Single-line/scroll.
183
213
  export const TextEditor = forwardRef<EditorView, TextEditorProps>(
184
- ({ readonly, extensions = [], slots, ...props }, forwardedRef) => {
214
+ ({ readonly, placeholder, lineWrapping, theme = textTheme, slots, extensions = [], ...props }, forwardedRef) => {
185
215
  const { themeMode } = useThemeContext();
186
216
  const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
187
217
  return (
188
218
  <BaseTextEditor
189
219
  ref={forwardedRef}
190
220
  readonly={readonly}
191
- extensions={[
192
- basicBundle({ readonly, themeMode, placeholder: updatedSlots?.editor?.placeholder }),
193
- ...extensions,
194
- ]}
221
+ extensions={[basicBundle({ themeMode, placeholder, lineWrapping }), ...extensions]}
222
+ theme={theme}
195
223
  slots={updatedSlots}
196
224
  {...props}
197
225
  />
@@ -199,19 +227,16 @@ export const TextEditor = forwardRef<EditorView, TextEditorProps>(
199
227
  },
200
228
  );
201
229
 
202
- // TODO(burdon): Remove (Just provide bundle, slots).
203
230
  export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
204
- ({ readonly, extensions = [], slots, ...props }, forwardedRef) => {
231
+ ({ readonly, placeholder, theme = markdownTheme, slots, extensions = [], ...props }, forwardedRef) => {
205
232
  const { themeMode } = useThemeContext();
206
233
  const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
207
234
  return (
208
235
  <BaseTextEditor
209
236
  ref={forwardedRef}
210
237
  readonly={readonly}
211
- extensions={[
212
- markdownBundle({ readonly, themeMode, placeholder: updatedSlots?.editor?.placeholder }),
213
- ...extensions,
214
- ]}
238
+ extensions={[markdownBundle({ themeMode, readonly, placeholder }), ...extensions]}
239
+ theme={theme}
215
240
  slots={updatedSlots}
216
241
  {...props}
217
242
  />
@@ -221,20 +246,18 @@ export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
221
246
 
222
247
  export const defaultSlots: TextEditorSlots = {
223
248
  root: {
224
- className: mx('p-2', inputSurface),
249
+ // TODO(burdon): Add focusRing by default/as property?
250
+ className: mx('flex flex-col grow overflow-y-auto', attentionSurface),
251
+ },
252
+ editor: {
253
+ className: 'h-full p-2',
225
254
  },
226
255
  };
227
256
 
228
257
  export const defaultTextSlots: TextEditorSlots = {
229
258
  ...defaultSlots,
230
- editor: {
231
- theme: textTheme,
232
- },
233
259
  };
234
260
 
235
261
  export const defaultMarkdownSlots: TextEditorSlots = {
236
262
  ...defaultSlots,
237
- editor: {
238
- theme: markdownTheme,
239
- },
240
263
  };
@@ -0,0 +1,357 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import '@dxosTheme';
6
+
7
+ import { type EditorView, keymap } from '@codemirror/view';
8
+ import { faker } from '@faker-js/faker';
9
+ import { Check, Trash } from '@phosphor-icons/react';
10
+ import React, { type FC, useEffect, useMemo, useRef, useState } from 'react';
11
+
12
+ import { getTextContent, TextObject } from '@dxos/echo-schema';
13
+ import { PublicKey } from '@dxos/keys';
14
+ import { log } from '@dxos/log';
15
+ import { Button, DensityProvider } from '@dxos/react-ui';
16
+ import { fixedInsetFlexLayout, mx } from '@dxos/react-ui-theme';
17
+ import { withTheme } from '@dxos/storybook-utils';
18
+
19
+ import { MarkdownEditor, TextEditor } from './TextEditor';
20
+ import { comments, type CommentsOptions, setFocus, useComments } from '../../extensions';
21
+ import { type Comment, type Range, useTextModel } from '../../hooks';
22
+
23
+ faker.seed(101);
24
+
25
+ //
26
+ // Editor
27
+ //
28
+
29
+ const Editor: FC<{
30
+ item: { text: TextObject };
31
+ comments: Comment[];
32
+ selected?: string;
33
+ onCreateComment: CommentsOptions['onCreate'];
34
+ onDeleteComment: CommentsOptions['onDelete'];
35
+ onUpdateComment: CommentsOptions['onUpdate'];
36
+ onSelectComment: CommentsOptions['onSelect'];
37
+ }> = ({
38
+ item,
39
+ selected: selectedValue,
40
+ comments: commentRanges,
41
+ onCreateComment,
42
+ onDeleteComment,
43
+ onUpdateComment,
44
+ onSelectComment,
45
+ }) => {
46
+ const model = useTextModel({ text: item.text });
47
+ const view = useRef<EditorView>(null);
48
+ const [selected, setSelected] = useState<string>();
49
+ useEffect(() => {
50
+ if (!view.current?.hasFocus && selectedValue !== selected) {
51
+ setSelected(selectedValue);
52
+ if (selectedValue) {
53
+ setFocus(view.current!, selectedValue);
54
+ }
55
+ }
56
+ }, [selected, commentRanges, selectedValue]);
57
+
58
+ useComments(view.current, commentRanges);
59
+
60
+ const extensions = useMemo(() => {
61
+ return [
62
+ comments({
63
+ onCreate: onCreateComment,
64
+ onDelete: onDeleteComment,
65
+ onUpdate: onUpdateComment,
66
+ onSelect: onSelectComment,
67
+ }),
68
+ ];
69
+ }, []);
70
+
71
+ if (!model) {
72
+ return null;
73
+ }
74
+
75
+ // TODO(burdon): Highlight currently selected comment.
76
+ return (
77
+ <div className='flex grow overflow-y-scroll'>
78
+ <MarkdownEditor ref={view} model={model} extensions={extensions} />
79
+ </div>
80
+ );
81
+ };
82
+
83
+ //
84
+ // Comments thread
85
+ //
86
+
87
+ type CommentThread = {
88
+ id: string;
89
+ cursor?: string;
90
+ range?: Range;
91
+ yPos?: number;
92
+ messages: TextObject[];
93
+ };
94
+
95
+ const Thread: FC<{
96
+ thread: CommentThread;
97
+ selected: boolean;
98
+ onSelect: () => void;
99
+ onResolve: () => void;
100
+ }> = ({ thread, selected, onSelect, onResolve }) => {
101
+ const [focus, setFocus] = useState(false);
102
+ const [item, setItem] = useState({ text: new TextObject() });
103
+ const model = useTextModel({ text: item.text });
104
+ const editorRef = useRef<EditorView>(null);
105
+
106
+ const containerRef = useRef<HTMLDivElement>(null);
107
+ useEffect(() => {
108
+ if (selected) {
109
+ containerRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
110
+ if (thread.messages.length === 0) {
111
+ setFocus(true);
112
+ }
113
+ }
114
+ }, [selected]);
115
+
116
+ const handleCreateMessage = () => {
117
+ const text = model?.text().trim();
118
+ if (text?.length) {
119
+ thread.messages.push(item.text);
120
+ setItem({ text: new TextObject() });
121
+ setFocus(true);
122
+ }
123
+ };
124
+
125
+ if (!model) {
126
+ return null;
127
+ }
128
+
129
+ return (
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'>
138
+ <span>id:{thread.id.slice(0, 4)}</span>
139
+ <span>from:{thread.range?.from}</span>
140
+ <span>to:{thread.range?.to}</span>
141
+ <span>y:{thread.yPos}</span>
142
+ <span className='grow' />
143
+ {!thread.cursor && <Trash />}
144
+ </div>
145
+
146
+ {thread.messages.map((message, i) => (
147
+ // TODO(burdon): Fix default editor padding so content doesn't jump on creating message.
148
+ <div key={i} className='p-2' onClick={() => onSelect()}>
149
+ {getTextContent(message)}
150
+ </div>
151
+ ))}
152
+
153
+ <div ref={containerRef} onClick={() => onSelect()} className='flex'>
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;
167
+ },
168
+ },
169
+ ]),
170
+ ]}
171
+ />
172
+ <Button variant='ghost' classNames='px-1' title='Resolve' onClick={onResolve}>
173
+ <Check />
174
+ </Button>
175
+ </div>
176
+ </div>
177
+ );
178
+ };
179
+
180
+ const Sidebar: FC<{
181
+ threads: CommentThread[];
182
+ selected?: string;
183
+ onSelect: (thread: string) => void;
184
+ onResolve: (thread: string) => void;
185
+ }> = ({ threads, selected, onSelect, onResolve }) => {
186
+ // Sort by y-position.
187
+ const sortedThreads = useMemo(() => {
188
+ const sorted = [...threads];
189
+ return sorted.sort(({ yPos: a = Infinity }, { yPos: b = Infinity }) => {
190
+ return a < b ? -1 : a > b ? 1 : 0;
191
+ });
192
+ }, [threads]);
193
+
194
+ return (
195
+ <DensityProvider density='fine'>
196
+ <div className='flex flex-col grow overflow-y-scroll py-4 gap-4'>
197
+ {sortedThreads.map((thread) => (
198
+ <Thread
199
+ key={thread.id}
200
+ thread={thread}
201
+ selected={thread.id === selected}
202
+ onSelect={() => onSelect(thread.id)}
203
+ onResolve={() => onResolve(thread.id)}
204
+ />
205
+ ))}
206
+ </div>
207
+ </DensityProvider>
208
+ );
209
+ };
210
+
211
+ //
212
+ // Story container.
213
+ //
214
+
215
+ type StoryProps = {
216
+ text?: string;
217
+ autoCreate?: boolean;
218
+ };
219
+
220
+ const Story = ({ text, autoCreate }: StoryProps) => {
221
+ const [item] = useState({ text: new TextObject(text) });
222
+ const [threads, setThreads] = useState<CommentThread[]>([]);
223
+ const [selected, setSelected] = useState<string>();
224
+
225
+ const comments = useMemo<Comment[]>(() => threads.map(({ id, cursor }) => ({ id, cursor })), [threads]);
226
+
227
+ // Filter by visibility.
228
+ const visibleThreads = useMemo(() => threads.filter((thread) => thread.yPos !== undefined), [threads]);
229
+
230
+ const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
231
+ const id = PublicKey.random().toHex();
232
+ log.info('create', { id: id.slice(0, 4), cursor });
233
+ setThreads((threads) => [
234
+ ...threads,
235
+ {
236
+ id,
237
+ cursor,
238
+ yPos: location ? Math.floor(location.top) : undefined,
239
+ messages: autoCreate
240
+ ? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 1, max: 3 } })
241
+ : [],
242
+ },
243
+ ]);
244
+ setSelected(id); // TODO(burdon): Not required.
245
+ return id;
246
+ };
247
+
248
+ const handleDeleteComment: CommentsOptions['onDelete'] = (id) => {
249
+ log.info('delete', { id: id.slice(0, 4) });
250
+ setThreads((threads) =>
251
+ threads.map((thread) => {
252
+ if (thread.id === id) {
253
+ thread.cursor = undefined;
254
+ }
255
+
256
+ return thread;
257
+ }),
258
+ );
259
+
260
+ setSelected((selected) => (selected === id ? undefined : selected));
261
+ };
262
+
263
+ const handleUpdateComment: CommentsOptions['onUpdate'] = (id, cursor) => {
264
+ log.info('update', { id: id.slice(0, 4), cursor });
265
+ setThreads((threads) =>
266
+ threads.map((thread) => {
267
+ if (thread.id === id) {
268
+ thread.cursor = cursor;
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
+
286
+ return thread;
287
+ }),
288
+ );
289
+
290
+ setSelected(current ?? closest);
291
+ };
292
+
293
+ const handleSelectThread = (id: string) => {
294
+ setSelected(id);
295
+ };
296
+
297
+ const handleResolveThread = (id: string) => {
298
+ setThreads((threads) => [...threads.filter((thread) => thread.id !== id)]);
299
+ setSelected(undefined);
300
+ };
301
+
302
+ return (
303
+ <div className={mx(fixedInsetFlexLayout, 'bg-neutral-100')}>
304
+ <div className='flex justify-center h-full gap-8'>
305
+ <div className='flex flex-col h-full w-[600px]'>
306
+ <Editor
307
+ item={item}
308
+ selected={selected}
309
+ comments={comments}
310
+ onCreateComment={handleCreateComment}
311
+ onDeleteComment={handleDeleteComment}
312
+ onUpdateComment={handleUpdateComment}
313
+ onSelectComment={handleSelectComment}
314
+ />
315
+ </div>
316
+
317
+ <div className='flex flex-col h-full w-[300px]'>
318
+ <Sidebar
319
+ threads={visibleThreads}
320
+ selected={selected}
321
+ onSelect={handleSelectThread}
322
+ onResolve={handleResolveThread}
323
+ />
324
+ </div>
325
+ </div>
326
+ </div>
327
+ );
328
+ };
329
+
330
+ export default {
331
+ title: 'react-ui-editor/comments',
332
+ component: MarkdownEditor,
333
+ decorators: [withTheme],
334
+ render: (args: StoryProps) => <Story {...args} />,
335
+ };
336
+
337
+ const str = (...lines: string[]) => lines.join('\n');
338
+
339
+ const document = str(
340
+ '# Comments',
341
+ '',
342
+ str(...faker.helpers.multiple(() => [faker.lorem.paragraph({ min: 5, max: 10 }), ''], { count: 20 }).flat()),
343
+ '',
344
+ );
345
+
346
+ export const Default = {
347
+ args: {
348
+ text: document,
349
+ },
350
+ };
351
+
352
+ export const Testing = {
353
+ args: {
354
+ text: document,
355
+ autoCreate: true,
356
+ },
357
+ };
@@ -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?