@dxos/react-ui-editor 0.3.11-main.9d68491 → 0.3.11-main.9de2c34

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 +743 -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 +13 -10
  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 +101 -86
  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,22 +45,26 @@ 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
- selection?: { anchor: number; head?: number };
56
+ model: EditorModel; // TODO(burdon): Optional (e.g., just provide content if readonly).
57
57
  readonly?: boolean; // TODO(burdon): Move into model.
58
- comments?: CommentRange[]; // TODO(burdon): Move into extension.
59
- extensions?: Extension[];
60
- 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;
61
65
  slots?: TextEditorSlots;
66
+ extensions?: Extension[];
67
+ debug?: boolean;
62
68
  };
63
69
 
64
70
  /**
@@ -66,108 +72,122 @@ export type TextEditorProps = {
66
72
  */
67
73
  export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
68
74
  (
69
- { model, focus, selection, readonly, comments, extensions = [], editorMode, slots = defaultSlots },
75
+ {
76
+ model,
77
+ readonly,
78
+ autoFocus,
79
+ scrollTo,
80
+ selection,
81
+ editorMode,
82
+ theme,
83
+ slots = defaultSlots,
84
+ extensions = [],
85
+ debug,
86
+ },
70
87
  forwardedRef,
71
88
  ) => {
72
89
  const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
73
90
  const { themeMode } = useThemeContext();
74
91
 
75
- // The editor view ref should only be used as an escape hatch.
76
92
  const rootRef = useRef<HTMLDivElement>(null);
77
93
  const [view, setView] = useState<EditorView | null>(null);
78
- useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
79
-
80
- // Focus.
81
- useEffect(() => {
82
- if (view && focus) {
83
- view.focus();
84
- }
85
- }, [view, focus]);
86
94
 
87
- // TODO(burdon): Factor out as extension.
88
- const { awareness, peer } = model;
89
- useEffect(() => {
90
- if (awareness && peer) {
91
- awareness.setLocalStateField('user', {
92
- name: peer.name ?? generateName(peer.id),
93
- color: getColorForValue({ value: peer.id, type: 'color' }),
94
- colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
95
- });
96
- }
97
- }, [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]);
98
98
 
99
- // TODO(burdon): Factor out as extension.
99
+ // Set focus.
100
100
  useEffect(() => {
101
- if (view && comments?.length) {
102
- view.dispatch({
103
- effects: setCommentRange.of({ model, comments }),
104
- });
101
+ if (autoFocus) {
102
+ view?.focus();
105
103
  }
106
- }, [view, comments]);
104
+ }, [view, autoFocus]);
107
105
 
106
+ // Create editor state and view.
107
+ // The view is recreated if the model or extensions are changed.
108
108
  useEffect(() => {
109
109
  if (!model || !rootRef.current) {
110
110
  return;
111
111
  }
112
112
 
113
- // TODO(burdon): Remember cursor position.
113
+ //
114
+ // EditorState
114
115
  // https://codemirror.net/docs/ref/#state.EditorStateConfig
116
+ //
115
117
  const state = EditorState.create({
116
118
  doc: model.text(),
117
119
  selection,
118
120
  extensions: [
119
- readonly && EditorState.readOnly.of(readonly),
120
-
121
- // TODO(burdon): Factor out VIM mode? (manage via MarkdownPlugin).
122
- editorMode === 'vim' && vim(),
121
+ // TODO(burdon): Doesn't catch errors in keymap functions.
122
+ EditorView.exceptionSink.of((err) => {
123
+ log.catch(err);
124
+ }),
123
125
 
124
126
  // Theme.
125
- // TODO(burdon): Make theme configurable.
127
+ // TODO(burdon): Make configurable.
126
128
  EditorView.baseTheme(defaultTheme),
127
- EditorView.theme(slots?.editor?.theme ?? {}),
129
+ EditorView.theme(theme ?? {}),
128
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),
129
136
 
130
137
  // Storage and replication.
138
+ // NOTE: This must come before user extensions.
131
139
  model.extension,
132
140
 
141
+ // TODO(burdon): Factor out (requires special handling for Escape/focus).
142
+ editorMode === 'vim' && vim(),
143
+
133
144
  // Custom.
134
145
  ...extensions,
135
146
  ].filter(Boolean) as Extension[],
136
147
  });
137
148
 
138
- // NOTE: This repaints the editor.
139
- // If the new state is derived from the old state, it will likely not be visible other than the cursor resetting.
140
- // Ideally this should not happen except when changing between text objects.
141
- view?.destroy();
149
+ //
150
+ // EditorView
151
+ // https://codemirror.net/docs/ref/#view.EditorViewConfig
152
+ //
142
153
  const newView = new EditorView({
143
- parent: rootRef.current,
144
154
  state,
145
- // NOTE: Uncomment to spy on all transactions.
155
+ parent: rootRef.current,
156
+ scrollTo,
157
+ // NOTE: Uncomment to debug/monitor all transactions.
146
158
  // https://codemirror.net/docs/ref/#view.EditorView.dispatch
147
- // dispatch: (transaction, view) => {
148
- // view.update([transaction]);
149
- // },
159
+ dispatchTransactions: (trs, view) => {
160
+ if (debug) {
161
+ logChanges(trs);
162
+ }
163
+ view.update(trs);
164
+ },
150
165
  });
151
166
 
167
+ view?.destroy();
152
168
  setView(newView);
169
+
153
170
  return () => {
154
171
  newView?.destroy();
155
172
  setView(null);
156
173
  };
157
174
  }, [rootRef, model, readonly, editorMode, themeMode]);
158
175
 
176
+ // Handles tab/focus.
177
+ // Pressing Escape focuses the outer div (to support tab navigation); pressing Enter refocuses the editor.
159
178
  const handleKeyUp = useCallback(
160
179
  (event: KeyboardEvent) => {
161
180
  const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
162
181
  switch (key) {
163
- // TODO(burdon): ???
164
- // case 'Enter': {
165
- // view?.contentDOM.focus();
166
- // break;
167
- // }
182
+ case 'Enter': {
183
+ view?.focus();
184
+ break;
185
+ }
168
186
 
169
187
  case 'Escape': {
170
- editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey) && rootRef.current?.focus();
188
+ if (editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)) {
189
+ rootRef.current?.focus();
190
+ }
171
191
  break;
172
192
  }
173
193
  }
@@ -178,28 +198,28 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
178
198
  return (
179
199
  <div
180
200
  key={model.id}
181
- ref={rootRef}
201
+ role='none'
182
202
  tabIndex={0}
183
- {...slots?.root}
184
- {...(editorMode !== 'vim' && tabsterDOMAttribute)}
185
203
  onKeyUp={handleKeyUp}
204
+ {...slots.root}
205
+ {...(editorMode !== 'vim' && tabsterDOMAttribute)}
206
+ ref={rootRef}
186
207
  />
187
208
  );
188
209
  },
189
210
  );
190
211
 
212
+ // TODO(burdon): Single-line/scroll.
191
213
  export const TextEditor = forwardRef<EditorView, TextEditorProps>(
192
- ({ readonly, extensions = [], slots, ...props }, forwardedRef) => {
214
+ ({ readonly, placeholder, lineWrapping, theme = textTheme, slots, extensions = [], ...props }, forwardedRef) => {
193
215
  const { themeMode } = useThemeContext();
194
216
  const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
195
217
  return (
196
218
  <BaseTextEditor
197
219
  ref={forwardedRef}
198
220
  readonly={readonly}
199
- extensions={[
200
- basicBundle({ readonly, themeMode, placeholder: updatedSlots?.editor?.placeholder }),
201
- ...extensions,
202
- ]}
221
+ extensions={[basicBundle({ themeMode, placeholder, lineWrapping }), ...extensions]}
222
+ theme={theme}
203
223
  slots={updatedSlots}
204
224
  {...props}
205
225
  />
@@ -207,19 +227,16 @@ export const TextEditor = forwardRef<EditorView, TextEditorProps>(
207
227
  },
208
228
  );
209
229
 
210
- // TODO(burdon): Remove (Just provide bundle, slots).
211
230
  export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
212
- ({ readonly, extensions = [], slots, ...props }, forwardedRef) => {
231
+ ({ readonly, placeholder, theme = markdownTheme, slots, extensions = [], ...props }, forwardedRef) => {
213
232
  const { themeMode } = useThemeContext();
214
233
  const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
215
234
  return (
216
235
  <BaseTextEditor
217
236
  ref={forwardedRef}
218
237
  readonly={readonly}
219
- extensions={[
220
- markdownBundle({ readonly, themeMode, placeholder: updatedSlots?.editor?.placeholder }),
221
- ...extensions,
222
- ]}
238
+ extensions={[markdownBundle({ themeMode, readonly, placeholder }), ...extensions]}
239
+ theme={theme}
223
240
  slots={updatedSlots}
224
241
  {...props}
225
242
  />
@@ -229,20 +246,18 @@ export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
229
246
 
230
247
  export const defaultSlots: TextEditorSlots = {
231
248
  root: {
232
- 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',
233
254
  },
234
255
  };
235
256
 
236
257
  export const defaultTextSlots: TextEditorSlots = {
237
258
  ...defaultSlots,
238
- editor: {
239
- theme: textTheme,
240
- },
241
259
  };
242
260
 
243
261
  export const defaultMarkdownSlots: TextEditorSlots = {
244
262
  ...defaultSlots,
245
- editor: {
246
- theme: markdownTheme,
247
- },
248
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?