@dxos/react-ui-editor 0.3.11-main.008b7c6 → 0.3.11-main.00a28cb
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 +558 -629
- 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 +12 -5
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +12 -17
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +6 -6
- 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/components/TextEditor/yjs.stories.d.ts +1 -1
- 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 +4 -4
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/semaphore.d.ts +4 -9
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
- package/dist/types/src/extensions/code.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +14 -5
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/{util → extensions}/cursor.d.ts +9 -1
- 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/image.d.ts.map +1 -1
- package/dist/types/src/extensions/index.d.ts +2 -1
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/link.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/highlight.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 +5 -1
- 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/index.d.ts +1 -1
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useTextModel.d.ts +0 -5
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/dist/types/src/hooks/yjs.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/styles/markdown.d.ts.map +1 -1
- package/dist/types/src/styles/tokens.d.ts +1 -0
- package/dist/types/src/styles/tokens.d.ts.map +1 -1
- package/dist/types/src/themes/default.d.ts.map +1 -1
- package/dist/types/src/util.d.ts.map +1 -0
- package/package.json +25 -24
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +41 -37
- package/src/components/TextEditor/TextEditor.stories.tsx +12 -46
- package/src/components/TextEditor/TextEditor.tsx +71 -67
- package/src/components/TextEditor/comments.stories.tsx +355 -0
- package/src/extensions/autocomplete.ts +18 -17
- package/src/extensions/automerge/automerge.ts +12 -14
- package/src/extensions/automerge/cursor.ts +2 -1
- package/src/extensions/automerge/defs.ts +13 -9
- package/src/extensions/automerge/semaphore.ts +10 -12
- package/src/extensions/awareness.ts +27 -33
- package/src/extensions/code.ts +28 -23
- package/src/extensions/comments.ts +131 -144
- package/src/extensions/cursor.ts +46 -0
- package/src/extensions/hr.ts +10 -12
- package/src/extensions/image.ts +40 -22
- package/src/extensions/index.ts +2 -1
- package/src/extensions/link.ts +86 -88
- package/src/extensions/markdown/highlight.ts +3 -12
- package/src/extensions/outliner.ts +12 -0
- package/src/extensions/table.ts +10 -4
- package/src/extensions/tasklist.ts +54 -79
- 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/index.ts +2 -1
- package/src/hooks/useTextModel.ts +4 -7
- package/src/hooks/yjs.ts +1 -23
- package/src/index.ts +3 -0
- package/src/styles/markdown.ts +0 -2
- package/src/styles/tokens.ts +3 -0
- package/src/themes/default.ts +51 -78
- package/dist/types/src/extensions/mermaid.d.ts +0 -3
- package/dist/types/src/extensions/mermaid.d.ts.map +0 -1
- 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.map +0 -1
- package/src/extensions/mermaid.ts +0 -11
- package/src/util/cursor.ts +0 -29
- package/src/util/index.ts +0 -6
- /package/dist/types/src/{util/util.d.ts → util.d.ts} +0 -0
- /package/src/{util/util.ts → util.ts} +0 -0
|
@@ -15,6 +15,7 @@ import React, {
|
|
|
15
15
|
useEffect,
|
|
16
16
|
useImperativeHandle,
|
|
17
17
|
useState,
|
|
18
|
+
useRef,
|
|
18
19
|
} from 'react';
|
|
19
20
|
|
|
20
21
|
import { generateName } from '@dxos/display-name';
|
|
@@ -34,50 +35,55 @@ export type CursorInfo = {
|
|
|
34
35
|
to: number;
|
|
35
36
|
line: number;
|
|
36
37
|
lines: number;
|
|
38
|
+
length: number;
|
|
37
39
|
after?: string;
|
|
38
40
|
};
|
|
39
41
|
|
|
40
|
-
export type TextEditorRef = {
|
|
41
|
-
root: HTMLDivElement | null;
|
|
42
|
-
state?: EditorState;
|
|
43
|
-
view?: EditorView;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
42
|
export type TextEditorSlots = {
|
|
47
43
|
root?: Omit<ComponentProps<'div'>, 'ref'>;
|
|
48
|
-
editor?: {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
spellCheck?: boolean;
|
|
52
|
-
tabIndex?: number;
|
|
53
|
-
theme?: ThemeStyles;
|
|
54
|
-
};
|
|
44
|
+
// editor?: {
|
|
45
|
+
// className?: string;
|
|
46
|
+
// };
|
|
55
47
|
};
|
|
56
48
|
|
|
49
|
+
// TODO(burdon): Spellcheck?
|
|
57
50
|
export type TextEditorProps = {
|
|
58
51
|
model: EditorModel;
|
|
52
|
+
autofocus?: boolean;
|
|
59
53
|
readonly?: boolean; // TODO(burdon): Move into model.
|
|
60
|
-
|
|
54
|
+
selection?: { anchor: number; head?: number };
|
|
55
|
+
comments?: CommentRange[]; // TODO(burdon): Move into extension?
|
|
61
56
|
extensions?: Extension[];
|
|
62
57
|
editorMode?: EditorMode;
|
|
58
|
+
placeholder?: string;
|
|
59
|
+
theme?: ThemeStyles;
|
|
63
60
|
slots?: TextEditorSlots;
|
|
64
61
|
};
|
|
65
62
|
|
|
66
63
|
/**
|
|
67
64
|
* Base text editor.
|
|
68
|
-
* NOTE: Rather than adding properties, try to create extensions that can be reused.
|
|
69
65
|
*/
|
|
70
|
-
export const BaseTextEditor = forwardRef<
|
|
71
|
-
(
|
|
72
|
-
|
|
66
|
+
export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
67
|
+
(
|
|
68
|
+
{ model, autofocus, readonly, selection, comments, extensions = [], editorMode, theme, slots = defaultSlots },
|
|
69
|
+
forwardedRef,
|
|
70
|
+
) => {
|
|
73
71
|
const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
>();
|
|
78
|
-
|
|
72
|
+
const { themeMode } = useThemeContext();
|
|
73
|
+
|
|
74
|
+
// The editor view ref should only be used as an escape hatch.
|
|
75
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
76
|
+
const [view, setView] = useState<EditorView | null>(null);
|
|
77
|
+
// NOTE: This does not cause the parent to re-render, so the ref is not available immediately.
|
|
78
|
+
useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (autofocus) {
|
|
82
|
+
view?.focus();
|
|
83
|
+
}
|
|
84
|
+
}, [autofocus, view]);
|
|
79
85
|
|
|
80
|
-
// TODO(burdon): Factor out as extension.
|
|
86
|
+
// TODO(burdon): Factor out as extension/hook.
|
|
81
87
|
const { awareness, peer } = model;
|
|
82
88
|
useEffect(() => {
|
|
83
89
|
if (awareness && peer) {
|
|
@@ -89,9 +95,9 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
|
|
|
89
95
|
}
|
|
90
96
|
}, [awareness, peer, themeMode]);
|
|
91
97
|
|
|
92
|
-
// TODO(burdon): Factor out as extension.
|
|
98
|
+
// TODO(burdon): Factor out as extension/hook.
|
|
93
99
|
useEffect(() => {
|
|
94
|
-
if (view && comments
|
|
100
|
+
if (view && comments !== undefined) {
|
|
95
101
|
view.dispatch({
|
|
96
102
|
effects: setCommentRange.of({ model, comments }),
|
|
97
103
|
});
|
|
@@ -99,14 +105,15 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
|
|
|
99
105
|
}, [view, comments]);
|
|
100
106
|
|
|
101
107
|
useEffect(() => {
|
|
102
|
-
if (!
|
|
108
|
+
if (!model || !rootRef.current) {
|
|
103
109
|
return;
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
// TODO(burdon): Remember cursor position.
|
|
107
112
|
// https://codemirror.net/docs/ref/#state.EditorStateConfig
|
|
108
113
|
const state = EditorState.create({
|
|
109
114
|
doc: model.text(),
|
|
115
|
+
// TODO(burdon): Composer should store and set selection when switching documents.
|
|
116
|
+
selection,
|
|
110
117
|
extensions: [
|
|
111
118
|
readonly && EditorState.readOnly.of(readonly),
|
|
112
119
|
|
|
@@ -116,7 +123,7 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
|
|
|
116
123
|
// Theme.
|
|
117
124
|
// TODO(burdon): Make theme configurable.
|
|
118
125
|
EditorView.baseTheme(defaultTheme),
|
|
119
|
-
EditorView.theme(
|
|
126
|
+
EditorView.theme(theme ?? {}),
|
|
120
127
|
EditorView.darkTheme.of(themeMode === 'dark'),
|
|
121
128
|
|
|
122
129
|
// Storage and replication.
|
|
@@ -131,36 +138,35 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
|
|
|
131
138
|
// If the new state is derived from the old state, it will likely not be visible other than the cursor resetting.
|
|
132
139
|
// Ideally this should not happen except when changing between text objects.
|
|
133
140
|
view?.destroy();
|
|
134
|
-
|
|
141
|
+
const newView = new EditorView({
|
|
142
|
+
parent: rootRef.current,
|
|
135
143
|
state,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
// dispatch: (transaction, view) => {
|
|
142
|
-
// view.update([transaction]);
|
|
143
|
-
// },
|
|
144
|
-
}),
|
|
144
|
+
// NOTE: Uncomment to spy on all transactions.
|
|
145
|
+
// https://codemirror.net/docs/ref/#view.EditorView.dispatch
|
|
146
|
+
// dispatch: (transaction, view) => {
|
|
147
|
+
// view.update([transaction]);
|
|
148
|
+
// },
|
|
145
149
|
});
|
|
146
150
|
|
|
151
|
+
setView(newView);
|
|
147
152
|
return () => {
|
|
148
|
-
|
|
149
|
-
|
|
153
|
+
newView?.destroy();
|
|
154
|
+
setView(null);
|
|
150
155
|
};
|
|
151
|
-
}, [
|
|
156
|
+
}, [rootRef, model, readonly, editorMode, themeMode]);
|
|
152
157
|
|
|
153
158
|
const handleKeyUp = useCallback(
|
|
154
159
|
(event: KeyboardEvent) => {
|
|
155
160
|
const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
|
|
156
161
|
switch (key) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
162
|
+
// TODO(burdon): Is this required (for vim mode?)
|
|
163
|
+
// case 'Enter': {
|
|
164
|
+
// view?.contentDOM.focus();
|
|
165
|
+
// break;
|
|
166
|
+
// }
|
|
161
167
|
|
|
162
168
|
case 'Escape': {
|
|
163
|
-
editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey) &&
|
|
169
|
+
editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey) && rootRef.current?.focus();
|
|
164
170
|
break;
|
|
165
171
|
}
|
|
166
172
|
}
|
|
@@ -171,42 +177,46 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
|
|
|
171
177
|
return (
|
|
172
178
|
<div
|
|
173
179
|
key={model.id}
|
|
174
|
-
|
|
180
|
+
role='none'
|
|
175
181
|
tabIndex={0}
|
|
176
|
-
{...slots?.root}
|
|
177
|
-
{...(editorMode !== 'vim' && tabsterDOMAttribute)}
|
|
178
182
|
onKeyUp={handleKeyUp}
|
|
183
|
+
{...slots.root}
|
|
184
|
+
{...(editorMode !== 'vim' && tabsterDOMAttribute)}
|
|
185
|
+
ref={rootRef}
|
|
179
186
|
/>
|
|
180
187
|
);
|
|
181
188
|
},
|
|
182
189
|
);
|
|
183
190
|
|
|
184
|
-
export const TextEditor = forwardRef<
|
|
185
|
-
({ readonly, extensions = [],
|
|
191
|
+
export const TextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
192
|
+
({ readonly, placeholder, extensions = [], theme = textTheme, slots, ...props }, forwardedRef) => {
|
|
186
193
|
const { themeMode } = useThemeContext();
|
|
187
|
-
const
|
|
194
|
+
const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
|
|
188
195
|
return (
|
|
189
196
|
<BaseTextEditor
|
|
190
197
|
ref={forwardedRef}
|
|
191
198
|
readonly={readonly}
|
|
192
|
-
extensions={[basicBundle({ readonly, themeMode, placeholder
|
|
193
|
-
|
|
199
|
+
extensions={[basicBundle({ readonly, themeMode, placeholder }), ...extensions]}
|
|
200
|
+
theme={theme}
|
|
201
|
+
slots={updatedSlots}
|
|
194
202
|
{...props}
|
|
195
203
|
/>
|
|
196
204
|
);
|
|
197
205
|
},
|
|
198
206
|
);
|
|
199
207
|
|
|
200
|
-
|
|
201
|
-
|
|
208
|
+
// TODO(burdon): Remove (Just provide bundle, slots).
|
|
209
|
+
export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
|
|
210
|
+
({ readonly, placeholder, extensions = [], theme = markdownTheme, slots, ...props }, forwardedRef) => {
|
|
202
211
|
const { themeMode } = useThemeContext();
|
|
203
|
-
const
|
|
212
|
+
const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
|
|
204
213
|
return (
|
|
205
214
|
<BaseTextEditor
|
|
206
215
|
ref={forwardedRef}
|
|
207
216
|
readonly={readonly}
|
|
208
|
-
extensions={[markdownBundle({ readonly, themeMode, placeholder
|
|
209
|
-
|
|
217
|
+
extensions={[markdownBundle({ readonly, themeMode, placeholder }), ...extensions]}
|
|
218
|
+
theme={theme}
|
|
219
|
+
slots={updatedSlots}
|
|
210
220
|
{...props}
|
|
211
221
|
/>
|
|
212
222
|
);
|
|
@@ -215,20 +225,14 @@ export const MarkdownEditor = forwardRef<TextEditorRef, TextEditorProps>(
|
|
|
215
225
|
|
|
216
226
|
export const defaultSlots: TextEditorSlots = {
|
|
217
227
|
root: {
|
|
218
|
-
className: mx('p-2', inputSurface),
|
|
228
|
+
className: mx('w-full p-2 overflow-y-auto', inputSurface),
|
|
219
229
|
},
|
|
220
230
|
};
|
|
221
231
|
|
|
222
232
|
export const defaultTextSlots: TextEditorSlots = {
|
|
223
233
|
...defaultSlots,
|
|
224
|
-
editor: {
|
|
225
|
-
theme: textTheme,
|
|
226
|
-
},
|
|
227
234
|
};
|
|
228
235
|
|
|
229
236
|
export const defaultMarkdownSlots: TextEditorSlots = {
|
|
230
237
|
...defaultSlots,
|
|
231
|
-
editor: {
|
|
232
|
-
theme: markdownTheme,
|
|
233
|
-
},
|
|
234
238
|
};
|
|
@@ -0,0 +1,355 @@
|
|
|
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, Cursor } from '../../extensions';
|
|
21
|
+
import { type CommentRange, 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
|
+
commentSelected?: string;
|
|
32
|
+
commentRanges: CommentRange[];
|
|
33
|
+
onCreateComment: CommentsOptions['onCreate'];
|
|
34
|
+
onDeleteComment: CommentsOptions['onDelete'];
|
|
35
|
+
onUpdateComment: CommentsOptions['onUpdate'];
|
|
36
|
+
onSelectComment: CommentsOptions['onSelect'];
|
|
37
|
+
}> = ({ item, commentSelected, commentRanges, onCreateComment, onDeleteComment, onUpdateComment, onSelectComment }) => {
|
|
38
|
+
const model = useTextModel({ text: item.text });
|
|
39
|
+
const editorRef = useRef<EditorView>(null);
|
|
40
|
+
const [selected, setSelected] = useState<string>();
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!editorRef.current?.hasFocus && commentSelected !== selected) {
|
|
43
|
+
const thread = commentRanges.find((range) => range.id === commentSelected);
|
|
44
|
+
if (thread) {
|
|
45
|
+
const { cursor } = thread;
|
|
46
|
+
const range = Cursor.getRangeFromCursor(editorRef.current!.state.facet(Cursor.converter), cursor);
|
|
47
|
+
if (range) {
|
|
48
|
+
// TODO(burdon): Scroll selection to center of screen?
|
|
49
|
+
editorRef.current?.dispatch({ selection: { anchor: range.from }, scrollIntoView: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setSelected(commentSelected);
|
|
54
|
+
}
|
|
55
|
+
}, [selected, commentRanges, commentSelected]);
|
|
56
|
+
|
|
57
|
+
const extensions = useMemo(() => {
|
|
58
|
+
return [
|
|
59
|
+
comments({
|
|
60
|
+
onCreate: onCreateComment,
|
|
61
|
+
onDelete: onDeleteComment,
|
|
62
|
+
onUpdate: onUpdateComment,
|
|
63
|
+
onSelect: onSelectComment,
|
|
64
|
+
}),
|
|
65
|
+
];
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
if (!model) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// TODO(burdon): Highlight currently selected comment.
|
|
73
|
+
return (
|
|
74
|
+
<div className='flex grow overflow-y-scroll'>
|
|
75
|
+
<MarkdownEditor ref={editorRef} model={model} comments={commentRanges} extensions={extensions} />
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
//
|
|
81
|
+
// Comments thread
|
|
82
|
+
//
|
|
83
|
+
|
|
84
|
+
type CommentThread = {
|
|
85
|
+
id: string;
|
|
86
|
+
range: CommentRange;
|
|
87
|
+
yPos?: number;
|
|
88
|
+
selection?: Range;
|
|
89
|
+
messages: TextObject[];
|
|
90
|
+
deleted?: boolean;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const Thread: FC<{
|
|
94
|
+
thread: CommentThread;
|
|
95
|
+
selected: boolean;
|
|
96
|
+
onSelect: () => void;
|
|
97
|
+
onResolve: () => void;
|
|
98
|
+
}> = ({ thread, selected, onSelect, onResolve }) => {
|
|
99
|
+
const [focus, setFocus] = useState(false);
|
|
100
|
+
const [item, setItem] = useState({ text: new TextObject() });
|
|
101
|
+
const model = useTextModel({ text: item.text });
|
|
102
|
+
const editorRef = useRef<EditorView>(null);
|
|
103
|
+
|
|
104
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (selected) {
|
|
107
|
+
containerRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
108
|
+
if (thread.messages.length === 0) {
|
|
109
|
+
setFocus(true);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}, [selected]);
|
|
113
|
+
|
|
114
|
+
const handleCreateMessage = () => {
|
|
115
|
+
const text = model?.text().trim();
|
|
116
|
+
if (text?.length) {
|
|
117
|
+
thread.messages.push(item.text);
|
|
118
|
+
setItem({ text: new TextObject() });
|
|
119
|
+
setFocus(true);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (!model) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
className={mx(
|
|
130
|
+
'flex flex-col m-1 rounded shadow divide-y bg-white',
|
|
131
|
+
selected && 'ring',
|
|
132
|
+
thread.deleted && 'opacity-50',
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
<div className='flex p-2 gap-2 items-center text-xs font-mono text-neutral-500 font-thin'>
|
|
136
|
+
<span>id:{thread.id.slice(0, 4)}</span>
|
|
137
|
+
<span>from:{thread.selection?.from}</span>
|
|
138
|
+
<span>to:{thread.selection?.to}</span>
|
|
139
|
+
<span>y:{thread.yPos}</span>
|
|
140
|
+
<span className='grow' />
|
|
141
|
+
{thread.deleted && <Trash />}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{thread.messages.map((message, i) => (
|
|
145
|
+
// TODO(burdon): Fix default editor padding so content doesn't jump on creating message.
|
|
146
|
+
<div key={i} className='p-2' onClick={() => onSelect()}>
|
|
147
|
+
{getTextContent(message)}
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
|
|
151
|
+
<div ref={containerRef} onClick={() => onSelect()} className='flex'>
|
|
152
|
+
<TextEditor
|
|
153
|
+
ref={editorRef}
|
|
154
|
+
autofocus={focus}
|
|
155
|
+
model={model}
|
|
156
|
+
placeholder={'Enter comment...'}
|
|
157
|
+
slots={{ root: { className: 'grow p-2 rounded-b' } }}
|
|
158
|
+
extensions={[
|
|
159
|
+
keymap.of([
|
|
160
|
+
{
|
|
161
|
+
key: 'Enter',
|
|
162
|
+
run: () => {
|
|
163
|
+
handleCreateMessage();
|
|
164
|
+
return true;
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
]),
|
|
168
|
+
]}
|
|
169
|
+
/>
|
|
170
|
+
<Button variant='ghost' classNames='px-1' title='Resolve' onClick={onResolve}>
|
|
171
|
+
<Check />
|
|
172
|
+
</Button>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const Sidebar: FC<{
|
|
179
|
+
threads: CommentThread[];
|
|
180
|
+
selected?: string;
|
|
181
|
+
onSelect: (thread: string) => void;
|
|
182
|
+
onResolve: (thread: string) => void;
|
|
183
|
+
}> = ({ threads, selected, onSelect, onResolve }) => {
|
|
184
|
+
// Sort by y-position.
|
|
185
|
+
const sortedThreads = useMemo(() => {
|
|
186
|
+
const sorted = [...threads];
|
|
187
|
+
return sorted.sort(({ yPos: a = Infinity }, { yPos: b = Infinity }) => {
|
|
188
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
189
|
+
});
|
|
190
|
+
}, [threads]);
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<DensityProvider density='fine'>
|
|
194
|
+
<div className='flex flex-col grow overflow-y-scroll py-4 gap-4'>
|
|
195
|
+
{sortedThreads.map((thread) => (
|
|
196
|
+
<Thread
|
|
197
|
+
key={thread.id}
|
|
198
|
+
thread={thread}
|
|
199
|
+
selected={thread.id === selected}
|
|
200
|
+
onSelect={() => onSelect(thread.id)}
|
|
201
|
+
onResolve={() => onResolve(thread.id)}
|
|
202
|
+
/>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
</DensityProvider>
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
//
|
|
210
|
+
// Story container.
|
|
211
|
+
//
|
|
212
|
+
|
|
213
|
+
type StoryProps = {
|
|
214
|
+
text?: string;
|
|
215
|
+
autoCreate?: boolean;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const Story = ({ text, autoCreate }: StoryProps) => {
|
|
219
|
+
const [item] = useState({ text: new TextObject(text) });
|
|
220
|
+
const [threads, setThreads] = useState<CommentThread[]>([]);
|
|
221
|
+
const commentRanges = useMemo(() => threads.map((thread) => thread.range), [threads]);
|
|
222
|
+
const [selected, setSelected] = useState<string>();
|
|
223
|
+
|
|
224
|
+
// Filter by visibility.
|
|
225
|
+
const visibleThreads = useMemo(() => threads.filter((thread) => thread.yPos !== undefined), [threads]);
|
|
226
|
+
|
|
227
|
+
const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
|
|
228
|
+
const id = PublicKey.random().toHex();
|
|
229
|
+
log.info('create', { id: id.slice(0, 4), cursor });
|
|
230
|
+
setThreads((threads) => [
|
|
231
|
+
...threads,
|
|
232
|
+
{
|
|
233
|
+
id,
|
|
234
|
+
range: { id, cursor },
|
|
235
|
+
yPos: location ? Math.floor(location.top) : undefined,
|
|
236
|
+
messages: autoCreate
|
|
237
|
+
? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 1, max: 3 } })
|
|
238
|
+
: [],
|
|
239
|
+
},
|
|
240
|
+
]);
|
|
241
|
+
setSelected(id);
|
|
242
|
+
return id;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const handleDeleteComment: CommentsOptions['onDelete'] = (id) => {
|
|
246
|
+
log.info('delete', { id: id.slice(0, 4) });
|
|
247
|
+
setThreads((threads) =>
|
|
248
|
+
threads.map((thread) => {
|
|
249
|
+
if (thread.id === id) {
|
|
250
|
+
thread.deleted = true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return thread;
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
setSelected((selected) => (selected === id ? undefined : selected));
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const handleUpdateComment: CommentsOptions['onUpdate'] = (id, cursor) => {
|
|
261
|
+
log.info('update', { id: id.slice(0, 4), cursor });
|
|
262
|
+
setThreads((threads) =>
|
|
263
|
+
threads.map((thread) => {
|
|
264
|
+
if (thread.id === id) {
|
|
265
|
+
thread.range.cursor = cursor;
|
|
266
|
+
delete thread.deleted;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return thread;
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const handleSelectComment: CommentsOptions['onSelect'] = ({ active, closest, ranges }) => {
|
|
275
|
+
log.info('select', { active: active?.slice(0, 4), closest: closest?.slice(0, 4) });
|
|
276
|
+
setThreads((threads) =>
|
|
277
|
+
threads.map((thread) => {
|
|
278
|
+
const range = ranges.find((range) => range.id === thread.range.id);
|
|
279
|
+
if (range) {
|
|
280
|
+
thread.yPos = range.location ? Math.floor(range.location.top) : undefined;
|
|
281
|
+
thread.selection = { from: range.from, to: range.to };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return thread;
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
setSelected(active ?? closest);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const handleSelectThread = (id: string) => {
|
|
292
|
+
setSelected(id);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const handleResolveThread = (id: string) => {
|
|
296
|
+
setThreads((threads) => [...threads.filter((thread) => thread.id !== id)]);
|
|
297
|
+
setSelected(undefined);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<div className={mx(fixedInsetFlexLayout, 'bg-neutral-100')}>
|
|
302
|
+
<div className='flex justify-center h-full gap-8'>
|
|
303
|
+
<div className='flex flex-col h-full w-[600px]'>
|
|
304
|
+
<Editor
|
|
305
|
+
item={item}
|
|
306
|
+
commentSelected={selected}
|
|
307
|
+
commentRanges={commentRanges}
|
|
308
|
+
onCreateComment={handleCreateComment}
|
|
309
|
+
onDeleteComment={handleDeleteComment}
|
|
310
|
+
onUpdateComment={handleUpdateComment}
|
|
311
|
+
onSelectComment={handleSelectComment}
|
|
312
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
<div className='flex flex-col h-full w-[300px]'>
|
|
316
|
+
<Sidebar
|
|
317
|
+
threads={visibleThreads}
|
|
318
|
+
selected={selected}
|
|
319
|
+
onSelect={handleSelectThread}
|
|
320
|
+
onResolve={handleResolveThread}
|
|
321
|
+
/>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export default {
|
|
329
|
+
title: 'react-ui-editor/comments',
|
|
330
|
+
component: MarkdownEditor,
|
|
331
|
+
decorators: [withTheme],
|
|
332
|
+
render: (args: StoryProps) => <Story {...args} />,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const str = (...lines: string[]) => lines.join('\n');
|
|
336
|
+
|
|
337
|
+
const document = str(
|
|
338
|
+
'# Comments',
|
|
339
|
+
'',
|
|
340
|
+
str(...faker.helpers.multiple(() => [faker.lorem.paragraph({ min: 5, max: 10 }), ''], { count: 20 }).flat()),
|
|
341
|
+
'',
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
export const Default = {
|
|
345
|
+
args: {
|
|
346
|
+
text: document,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
export const Testing = {
|
|
351
|
+
args: {
|
|
352
|
+
text: document,
|
|
353
|
+
autoCreate: true,
|
|
354
|
+
},
|
|
355
|
+
};
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type CompletionContext,
|
|
14
14
|
type CompletionResult,
|
|
15
15
|
} from '@codemirror/autocomplete';
|
|
16
|
+
import { markdownLanguage } from '@codemirror/lang-markdown';
|
|
16
17
|
import { keymap } from '@codemirror/view';
|
|
17
18
|
|
|
18
19
|
export type AutocompleteResult = Completion;
|
|
@@ -28,32 +29,32 @@ export const autocomplete = ({ onSearch }: AutocompleteOptions) => {
|
|
|
28
29
|
return [
|
|
29
30
|
// https://codemirror.net/docs/ref/#view.keymap
|
|
30
31
|
// https://discuss.codemirror.net/t/how-can-i-replace-the-default-autocompletion-keymap-v6/3322
|
|
32
|
+
// TODO(burdon): Set custom keymap.
|
|
31
33
|
keymap.of(completionKeymap),
|
|
32
34
|
|
|
33
35
|
// https://codemirror.net/examples/autocompletion
|
|
34
36
|
// https://codemirror.net/docs/ref/#autocomplete.autocompletion
|
|
35
37
|
autocompletion({
|
|
36
|
-
|
|
37
|
-
// defaultKeymap: false,
|
|
38
|
+
defaultKeymap: false,
|
|
38
39
|
|
|
39
40
|
// Don't start unless key press.
|
|
40
41
|
activateOnTyping: false,
|
|
42
|
+
}),
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
],
|
|
44
|
+
// TODO(burdon): Option to create new page?
|
|
45
|
+
// TODO(burdon): Optional decoration via addToOptions
|
|
46
|
+
markdownLanguage.data.of({
|
|
47
|
+
autocomplete: (context: CompletionContext): CompletionResult | null => {
|
|
48
|
+
const match = context.matchBefore(/\w*/);
|
|
49
|
+
if (!match || (match.from === match.to && !context.explicit)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
from: match.from,
|
|
55
|
+
options: onSearch(match.text.toLowerCase()),
|
|
56
|
+
};
|
|
57
|
+
},
|
|
57
58
|
}),
|
|
58
59
|
];
|
|
59
60
|
};
|