@dxos/react-ui-editor 0.3.11-main.1d8382a → 0.3.11-main.264ec26
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.
- package/dist/lib/browser/index.mjs +736 -737
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +13 -14
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +15 -9
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +4 -4
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts +26 -0
- package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -0
- package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/defs.d.ts +7 -9
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/index.d.ts +0 -1
- package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/semaphore.d.ts +7 -12
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
- package/dist/types/src/extensions/awareness.d.ts +2 -0
- package/dist/types/src/extensions/awareness.d.ts.map +1 -1
- package/dist/types/src/extensions/basic.d.ts +4 -5
- package/dist/types/src/extensions/basic.d.ts.map +1 -1
- package/dist/types/src/extensions/code.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +31 -18
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/{util → extensions}/cursor.d.ts +10 -2
- package/dist/types/src/extensions/cursor.d.ts.map +1 -0
- package/dist/types/src/extensions/hr.d.ts.map +1 -1
- package/dist/types/src/extensions/index.d.ts +2 -0
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/bundle.d.ts +2 -5
- package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
- package/dist/types/src/extensions/mention.d.ts.map +1 -1
- package/dist/types/src/extensions/outliner.d.ts +4 -0
- package/dist/types/src/extensions/outliner.d.ts.map +1 -0
- package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
- package/dist/types/src/extensions/yjs/cursor.d.ts +1 -1
- package/dist/types/src/extensions/yjs/cursor.d.ts.map +1 -1
- package/dist/types/src/hooks/automerge.d.ts +2 -1
- package/dist/types/src/hooks/automerge.d.ts.map +1 -1
- package/dist/types/src/hooks/defs.d.ts +28 -0
- package/dist/types/src/hooks/defs.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +2 -1
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useTextModel.d.ts +2 -32
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/dist/types/src/hooks/yjs.d.ts +2 -1
- package/dist/types/src/hooks/yjs.d.ts.map +1 -1
- package/dist/types/src/styles/markdown.d.ts.map +1 -1
- package/dist/types/src/themes/default.d.ts.map +1 -1
- package/dist/types/src/util.d.ts +7 -0
- package/dist/types/src/util.d.ts.map +1 -0
- package/package.json +32 -31
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +75 -80
- package/src/components/TextEditor/TextEditor.stories.tsx +10 -43
- package/src/components/TextEditor/TextEditor.tsx +96 -81
- package/src/components/TextEditor/comments.stories.tsx +357 -0
- package/src/extensions/autocomplete.ts +6 -3
- package/src/extensions/automerge/automerge.ts +24 -49
- package/src/extensions/automerge/cursor.ts +2 -1
- package/src/extensions/automerge/defs.ts +10 -31
- package/src/extensions/automerge/index.ts +0 -1
- package/src/extensions/automerge/semaphore.ts +23 -20
- package/src/extensions/automerge/update-automerge.ts +2 -2
- package/src/extensions/awareness.ts +45 -34
- package/src/extensions/basic.ts +22 -17
- package/src/extensions/code.ts +24 -18
- package/src/extensions/comments.ts +242 -234
- package/src/extensions/cursor.ts +50 -0
- package/src/extensions/hr.ts +49 -50
- package/src/extensions/image.ts +2 -2
- package/src/extensions/index.ts +2 -0
- package/src/extensions/link.ts +7 -7
- package/src/extensions/markdown/bundle.ts +18 -24
- package/src/extensions/markdown/highlight.ts +0 -8
- package/src/extensions/mention.ts +9 -2
- package/src/extensions/outliner.ts +12 -0
- package/src/extensions/table.ts +2 -2
- package/src/extensions/tasklist.ts +78 -86
- package/src/extensions/yjs/cursor.ts +2 -1
- package/src/extensions/yjs/yjs.test.ts +1 -1
- package/src/extensions/yjs/yjs.ts +2 -2
- package/src/hooks/automerge.ts +2 -1
- package/src/hooks/defs.ts +47 -0
- package/src/hooks/index.ts +3 -1
- package/src/hooks/useTextModel.ts +2 -48
- package/src/hooks/yjs.ts +2 -23
- package/src/styles/markdown.ts +0 -2
- package/src/themes/default.ts +17 -50
- package/src/util.ts +41 -0
- package/dist/types/src/util/cursor.d.ts.map +0 -1
- package/dist/types/src/util/index.d.ts +0 -3
- package/dist/types/src/util/index.d.ts.map +0 -1
- package/dist/types/src/util/util.d.ts +0 -5
- package/dist/types/src/util/util.d.ts.map +0 -1
- package/src/util/cursor.ts +0 -29
- package/src/util/index.ts +0 -6
- 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 {
|
|
21
|
+
import { log } from '@dxos/log';
|
|
22
22
|
import { useThemeContext } from '@dxos/react-ui';
|
|
23
|
-
import {
|
|
23
|
+
import { inputSurface, mx } from '@dxos/react-ui-theme';
|
|
24
24
|
|
|
25
|
-
import { basicBundle, markdownBundle,
|
|
26
|
-
import { type
|
|
25
|
+
import { basicBundle, markdownBundle, useAwareness } 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,141 @@ export type TextEditorSlots = {
|
|
|
43
45
|
root?: Omit<ComponentProps<'div'>, 'ref'>;
|
|
44
46
|
editor?: {
|
|
45
47
|
className?: string;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
56
|
model: EditorModel;
|
|
55
|
-
focus?: boolean;
|
|
56
57
|
readonly?: boolean; // TODO(burdon): Move into model.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
autofocus?: boolean;
|
|
59
|
+
multiline?: 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[];
|
|
61
67
|
};
|
|
62
68
|
|
|
63
69
|
/**
|
|
64
70
|
* Base text editor.
|
|
65
71
|
*/
|
|
66
72
|
export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
67
|
-
(
|
|
73
|
+
(
|
|
74
|
+
{ model, readonly, autofocus, scrollTo, selection, editorMode, theme, slots = defaultSlots, extensions = [] },
|
|
75
|
+
forwardedRef,
|
|
76
|
+
) => {
|
|
68
77
|
const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
|
|
69
78
|
const { themeMode } = useThemeContext();
|
|
79
|
+
|
|
70
80
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
71
81
|
const [view, setView] = useState<EditorView | null>(null);
|
|
72
|
-
|
|
82
|
+
|
|
83
|
+
// The view ref can be used to focus the editor.
|
|
84
|
+
// NOTE: This does not cause the parent to re-render, so the ref is not available immediately.
|
|
73
85
|
useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
if (focus && view) {
|
|
76
|
-
view.focus();
|
|
77
|
-
}
|
|
78
|
-
}, [focus, view]);
|
|
79
86
|
|
|
80
|
-
//
|
|
81
|
-
const { awareness, peer } = model;
|
|
87
|
+
// Set focus.
|
|
82
88
|
useEffect(() => {
|
|
83
|
-
if (
|
|
84
|
-
|
|
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
|
+
if (autofocus) {
|
|
90
|
+
view?.focus();
|
|
89
91
|
}
|
|
90
|
-
}, [
|
|
92
|
+
}, [view, autofocus]);
|
|
91
93
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
if (view && comments?.length) {
|
|
95
|
-
view.dispatch({
|
|
96
|
-
effects: setCommentRange.of({ model, comments }),
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
}, [view, comments]);
|
|
94
|
+
// Monitor awareness.
|
|
95
|
+
useAwareness(model);
|
|
100
96
|
|
|
97
|
+
// Create editor state and view.
|
|
98
|
+
// The view is recreated if the model or extensions are changed.
|
|
101
99
|
useEffect(() => {
|
|
102
100
|
if (!model || !rootRef.current) {
|
|
103
101
|
return;
|
|
104
102
|
}
|
|
105
103
|
|
|
106
|
-
//
|
|
104
|
+
//
|
|
105
|
+
// EditorState
|
|
107
106
|
// https://codemirror.net/docs/ref/#state.EditorStateConfig
|
|
107
|
+
//
|
|
108
108
|
const state = EditorState.create({
|
|
109
109
|
doc: model.text(),
|
|
110
|
+
selection,
|
|
110
111
|
extensions: [
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
// TODO(burdon): Doesn't catch errors in keymap functions.
|
|
113
|
+
EditorView.exceptionSink.of((err) => {
|
|
114
|
+
log.catch(err);
|
|
115
|
+
}),
|
|
115
116
|
|
|
116
117
|
// Theme.
|
|
117
|
-
// TODO(burdon): Make
|
|
118
|
+
// TODO(burdon): Make configurable.
|
|
118
119
|
EditorView.baseTheme(defaultTheme),
|
|
119
|
-
EditorView.theme(
|
|
120
|
+
EditorView.theme(theme ?? {}),
|
|
120
121
|
EditorView.darkTheme.of(themeMode === 'dark'),
|
|
122
|
+
EditorView.editorAttributes.of({ class: slots.editor?.className ?? '' }),
|
|
123
|
+
EditorView.contentAttributes.of({ class: slots.content?.className ?? '' }),
|
|
124
|
+
|
|
125
|
+
// State.
|
|
126
|
+
EditorState.readOnly.of(!!readonly),
|
|
121
127
|
|
|
122
128
|
// Storage and replication.
|
|
129
|
+
// NOTE: This must come before user extensions.
|
|
123
130
|
model.extension,
|
|
124
131
|
|
|
132
|
+
// TODO(burdon): Factor out (requires special handling for Escape/focus).
|
|
133
|
+
editorMode === 'vim' && vim(),
|
|
134
|
+
|
|
125
135
|
// Custom.
|
|
126
136
|
...extensions,
|
|
127
137
|
].filter(Boolean) as Extension[],
|
|
128
138
|
});
|
|
129
139
|
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
140
|
+
//
|
|
141
|
+
// EditorView
|
|
142
|
+
// https://codemirror.net/docs/ref/#view.EditorViewConfig
|
|
143
|
+
//
|
|
134
144
|
const newView = new EditorView({
|
|
135
|
-
parent: rootRef.current,
|
|
136
145
|
state,
|
|
137
|
-
|
|
146
|
+
parent: rootRef.current,
|
|
147
|
+
scrollTo,
|
|
148
|
+
// NOTE: Uncomment to debug/monitor all transactions.
|
|
138
149
|
// https://codemirror.net/docs/ref/#view.EditorView.dispatch
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
dispatchTransactions: (trs, view) => {
|
|
151
|
+
view.update(trs);
|
|
152
|
+
const debug = false;
|
|
153
|
+
if (debug) {
|
|
154
|
+
logChanges(trs);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
142
157
|
});
|
|
143
158
|
|
|
159
|
+
view?.destroy();
|
|
144
160
|
setView(newView);
|
|
161
|
+
|
|
145
162
|
return () => {
|
|
146
163
|
newView?.destroy();
|
|
147
164
|
setView(null);
|
|
148
165
|
};
|
|
149
166
|
}, [rootRef, model, readonly, editorMode, themeMode]);
|
|
150
167
|
|
|
168
|
+
// Handles tab/focus.
|
|
169
|
+
// Pressing Escape focuses the outer div (to support tab navigation); pressing Enter refocuses the editor.
|
|
151
170
|
const handleKeyUp = useCallback(
|
|
152
171
|
(event: KeyboardEvent) => {
|
|
153
172
|
const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
|
|
154
173
|
switch (key) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// }
|
|
174
|
+
case 'Enter': {
|
|
175
|
+
view?.focus();
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
160
178
|
|
|
161
179
|
case 'Escape': {
|
|
162
|
-
editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)
|
|
180
|
+
if (editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)) {
|
|
181
|
+
rootRef.current?.focus();
|
|
182
|
+
}
|
|
163
183
|
break;
|
|
164
184
|
}
|
|
165
185
|
}
|
|
@@ -170,28 +190,28 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
170
190
|
return (
|
|
171
191
|
<div
|
|
172
192
|
key={model.id}
|
|
173
|
-
|
|
193
|
+
role='none'
|
|
174
194
|
tabIndex={0}
|
|
175
|
-
{...slots?.root}
|
|
176
|
-
{...(editorMode !== 'vim' && tabsterDOMAttribute)}
|
|
177
195
|
onKeyUp={handleKeyUp}
|
|
196
|
+
{...slots.root}
|
|
197
|
+
{...(editorMode !== 'vim' && tabsterDOMAttribute)}
|
|
198
|
+
ref={rootRef}
|
|
178
199
|
/>
|
|
179
200
|
);
|
|
180
201
|
},
|
|
181
202
|
);
|
|
182
203
|
|
|
204
|
+
// TODO(burdon): Single-line/scroll.
|
|
183
205
|
export const TextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
184
|
-
({ readonly, extensions = [],
|
|
206
|
+
({ readonly, placeholder, multiline, theme = textTheme, slots, extensions = [], ...props }, forwardedRef) => {
|
|
185
207
|
const { themeMode } = useThemeContext();
|
|
186
208
|
const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
|
|
187
209
|
return (
|
|
188
210
|
<BaseTextEditor
|
|
189
211
|
ref={forwardedRef}
|
|
190
212
|
readonly={readonly}
|
|
191
|
-
extensions={[
|
|
192
|
-
|
|
193
|
-
...extensions,
|
|
194
|
-
]}
|
|
213
|
+
extensions={[basicBundle({ themeMode, placeholder, multiline }), ...extensions]}
|
|
214
|
+
theme={theme}
|
|
195
215
|
slots={updatedSlots}
|
|
196
216
|
{...props}
|
|
197
217
|
/>
|
|
@@ -199,19 +219,16 @@ export const TextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
199
219
|
},
|
|
200
220
|
);
|
|
201
221
|
|
|
202
|
-
// TODO(burdon): Remove (Just provide bundle, slots).
|
|
203
222
|
export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
|
|
204
|
-
({ readonly, extensions = [],
|
|
223
|
+
({ readonly, placeholder, theme = markdownTheme, slots, extensions = [], ...props }, forwardedRef) => {
|
|
205
224
|
const { themeMode } = useThemeContext();
|
|
206
225
|
const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
|
|
207
226
|
return (
|
|
208
227
|
<BaseTextEditor
|
|
209
228
|
ref={forwardedRef}
|
|
210
229
|
readonly={readonly}
|
|
211
|
-
extensions={[
|
|
212
|
-
|
|
213
|
-
...extensions,
|
|
214
|
-
]}
|
|
230
|
+
extensions={[markdownBundle({ themeMode, readonly, placeholder }), ...extensions]}
|
|
231
|
+
theme={theme}
|
|
215
232
|
slots={updatedSlots}
|
|
216
233
|
{...props}
|
|
217
234
|
/>
|
|
@@ -221,20 +238,18 @@ export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
221
238
|
|
|
222
239
|
export const defaultSlots: TextEditorSlots = {
|
|
223
240
|
root: {
|
|
224
|
-
|
|
241
|
+
// TODO(burdon): Add focusRing by default/as property?
|
|
242
|
+
className: mx('flex flex-col grow overflow-y-auto', inputSurface),
|
|
243
|
+
},
|
|
244
|
+
editor: {
|
|
245
|
+
className: 'h-full p-2',
|
|
225
246
|
},
|
|
226
247
|
};
|
|
227
248
|
|
|
228
249
|
export const defaultTextSlots: TextEditorSlots = {
|
|
229
250
|
...defaultSlots,
|
|
230
|
-
editor: {
|
|
231
|
-
theme: textTheme,
|
|
232
|
-
},
|
|
233
251
|
};
|
|
234
252
|
|
|
235
253
|
export const defaultMarkdownSlots: TextEditorSlots = {
|
|
236
254
|
...defaultSlots,
|
|
237
|
-
editor: {
|
|
238
|
-
theme: markdownTheme,
|
|
239
|
-
},
|
|
240
255
|
};
|
|
@@ -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);
|
|
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: { active, closest } }) => {
|
|
277
|
+
log.info('select', { active: active?.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(active ?? 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
|
-
|
|
38
|
+
activateOnTyping: true,
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
|
|
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?
|