@dxos/react-ui-editor 0.3.11-main.d3a2438 → 0.3.11-main.d7d4c52
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 +150 -278
- 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.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +6 -7
- 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/automerge/cursor.d.ts +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
- package/dist/types/src/extensions/code.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +2 -2
- 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/index.d.ts +1 -0
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/highlight.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/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/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.map +1 -0
- package/package.json +23 -22
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +12 -12
- package/src/components/TextEditor/TextEditor.stories.tsx +10 -43
- package/src/components/TextEditor/TextEditor.tsx +32 -34
- package/src/components/TextEditor/comments.stories.tsx +315 -0
- package/src/extensions/automerge/automerge.ts +2 -2
- package/src/extensions/automerge/cursor.ts +2 -1
- package/src/extensions/awareness.ts +25 -32
- package/src/extensions/code.ts +10 -2
- package/src/extensions/comments.ts +20 -115
- package/src/extensions/cursor.ts +46 -0
- package/src/extensions/hr.ts +2 -2
- package/src/extensions/image.ts +2 -2
- package/src/extensions/index.ts +1 -0
- package/src/extensions/link.ts +4 -4
- package/src/extensions/markdown/highlight.ts +0 -8
- package/src/extensions/table.ts +2 -2
- package/src/extensions/tasklist.ts +2 -2
- package/src/extensions/yjs/cursor.ts +2 -1
- package/src/extensions/yjs/yjs.ts +2 -2
- package/src/hooks/useTextModel.ts +3 -6
- package/src/hooks/yjs.ts +1 -23
- package/src/styles/markdown.ts +0 -2
- package/src/themes/default.ts +17 -42
- 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/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
|
@@ -12,6 +12,7 @@ import React, { type FC, StrictMode, useState } from 'react';
|
|
|
12
12
|
import { createRoot } from 'react-dom/client';
|
|
13
13
|
|
|
14
14
|
import { TextObject } from '@dxos/echo-schema';
|
|
15
|
+
import { keySymbols, parseShortcut } from '@dxos/keyboard';
|
|
15
16
|
import { PublicKey } from '@dxos/keys';
|
|
16
17
|
import { fixedInsetFlexLayout, getSize, groupSurface, mx } from '@dxos/react-ui-theme';
|
|
17
18
|
import { withTheme } from '@dxos/storybook-utils';
|
|
@@ -182,15 +183,15 @@ const Key: FC<{ char: string }> = ({ char }) => (
|
|
|
182
183
|
</span>
|
|
183
184
|
);
|
|
184
185
|
|
|
185
|
-
const onCommentsHover: CommentsOptions['onHover'] = (el) => {
|
|
186
|
+
const onCommentsHover: CommentsOptions['onHover'] = (el, shortcut) => {
|
|
186
187
|
createRoot(el).render(
|
|
187
188
|
<StrictMode>
|
|
188
189
|
<div className='flex items-center gap-2 px-2 py-2 bg-neutral-700 text-white text-xs rounded'>
|
|
189
190
|
<div>Create comment</div>
|
|
190
|
-
{/* TODO(burdon): Unify shortcuts. */}
|
|
191
191
|
<div className='flex gap-1'>
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
{keySymbols(parseShortcut(shortcut)).map((char) => (
|
|
193
|
+
<Key key={char} char={char} />
|
|
194
|
+
))}
|
|
194
195
|
</div>
|
|
195
196
|
</div>
|
|
196
197
|
</StrictMode>,
|
|
@@ -201,7 +202,7 @@ const onRenderLink: LinkOptions['onRender'] = (el, url) => {
|
|
|
201
202
|
createRoot(el).render(
|
|
202
203
|
<StrictMode>
|
|
203
204
|
<a href={url} target='_blank' rel='noreferrer' className={hover}>
|
|
204
|
-
<ArrowSquareOut weight='bold' className={mx(getSize(4), 'inline-block leading-none mis-1 mb-
|
|
205
|
+
<ArrowSquareOut weight='bold' className={mx(getSize(4), 'inline-block leading-none mis-1 mb-[2px]')} />
|
|
205
206
|
</a>
|
|
206
207
|
</StrictMode>,
|
|
207
208
|
);
|
|
@@ -221,10 +222,9 @@ const Story = ({ text, automerge, ...props }: StoryProps) => {
|
|
|
221
222
|
|
|
222
223
|
return (
|
|
223
224
|
<div className={mx(fixedInsetFlexLayout, groupSurface)}>
|
|
224
|
-
<div className='flex justify-center
|
|
225
|
-
<div className='flex flex-col w-[800px]
|
|
225
|
+
<div className='flex h-full justify-center'>
|
|
226
|
+
<div className='flex flex-col h-full w-[800px]'>
|
|
226
227
|
<MarkdownEditor model={model} {...props} />
|
|
227
|
-
<div className='flex shrink-0 h-[300px]'></div>
|
|
228
228
|
</div>
|
|
229
229
|
</div>
|
|
230
230
|
</div>
|
|
@@ -350,18 +350,18 @@ export const Mention = {
|
|
|
350
350
|
|
|
351
351
|
export const Comments = {
|
|
352
352
|
render: () => {
|
|
353
|
-
const [
|
|
353
|
+
const [commentRanges, setCommentRanges] = useState<CommentRange[]>([]);
|
|
354
354
|
|
|
355
355
|
return (
|
|
356
356
|
<Story
|
|
357
357
|
text={str('# Comments', '', text.paragraphs, text.footer)}
|
|
358
|
-
comments={
|
|
358
|
+
comments={commentRanges}
|
|
359
359
|
extensions={[
|
|
360
360
|
comments({
|
|
361
361
|
onHover: onCommentsHover,
|
|
362
|
-
onCreate: (
|
|
362
|
+
onCreate: (range) => {
|
|
363
363
|
const id = PublicKey.random().toHex();
|
|
364
|
-
|
|
364
|
+
setCommentRanges((commentRanges) => [...commentRanges, { id, cursor: range }]);
|
|
365
365
|
return id;
|
|
366
366
|
},
|
|
367
367
|
onSelect: (state) => {
|
|
@@ -4,17 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import '@dxosTheme';
|
|
6
6
|
|
|
7
|
-
import defaultsDeep from 'lodash.defaultsdeep';
|
|
8
7
|
import React, { useState } from 'react';
|
|
9
8
|
|
|
10
9
|
import { TextObject } from '@dxos/echo-schema';
|
|
11
10
|
import { fixedInsetFlexLayout, groupSurface, mx } from '@dxos/react-ui-theme';
|
|
12
11
|
import { withTheme } from '@dxos/storybook-utils';
|
|
13
12
|
|
|
14
|
-
import {
|
|
13
|
+
import { TextEditor, type TextEditorProps } from './TextEditor';
|
|
15
14
|
import { listener } from '../../extensions';
|
|
16
15
|
import { useTextModel } from '../../hooks';
|
|
17
|
-
import { textTheme } from '../../themes';
|
|
18
16
|
|
|
19
17
|
const initialText = [
|
|
20
18
|
'# TextEditor',
|
|
@@ -30,7 +28,7 @@ const Story = ({
|
|
|
30
28
|
text,
|
|
31
29
|
automerge,
|
|
32
30
|
...props
|
|
33
|
-
}: { text?: string; automerge?: boolean } & Pick<TextEditorProps, 'extensions' | 'slots'>) => {
|
|
31
|
+
}: { text?: string; automerge?: boolean } & Pick<TextEditorProps, 'extensions' | 'placeholder' | 'slots'>) => {
|
|
34
32
|
const [item] = useState({ text: new TextObject(text, undefined, undefined, { automerge }) });
|
|
35
33
|
const model = useTextModel({ text: item.text });
|
|
36
34
|
if (!model) {
|
|
@@ -39,10 +37,9 @@ const Story = ({
|
|
|
39
37
|
|
|
40
38
|
return (
|
|
41
39
|
<div className={mx(fixedInsetFlexLayout, groupSurface)}>
|
|
42
|
-
<div className='flex justify-center
|
|
40
|
+
<div className='flex justify-center'>
|
|
43
41
|
<div className='flex flex-col w-[800px] py-16'>
|
|
44
42
|
<TextEditor model={model} {...props} />
|
|
45
|
-
<div className='flex shrink-0 h-[300px]'></div>
|
|
46
43
|
</div>
|
|
47
44
|
</div>
|
|
48
45
|
</div>
|
|
@@ -57,49 +54,19 @@ export default {
|
|
|
57
54
|
};
|
|
58
55
|
|
|
59
56
|
export const Default = {
|
|
60
|
-
render: () =>
|
|
61
|
-
<Story
|
|
62
|
-
slots={defaultsDeep(
|
|
63
|
-
{ editor: { theme: textTheme, placeholder: 'Enter text...' } } satisfies TextEditorSlots,
|
|
64
|
-
defaultSlots,
|
|
65
|
-
)}
|
|
66
|
-
/>
|
|
67
|
-
),
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export const Listener = {
|
|
71
|
-
render: () => (
|
|
72
|
-
<Story
|
|
73
|
-
slots={defaultsDeep(
|
|
74
|
-
{ editor: { theme: textTheme, placeholder: 'Enter text...' } } satisfies TextEditorSlots,
|
|
75
|
-
defaultSlots,
|
|
76
|
-
)}
|
|
77
|
-
extensions={[listener({ onChange: (text) => console.log(text) })]}
|
|
78
|
-
/>
|
|
79
|
-
),
|
|
57
|
+
render: () => <Story placeholder='Enter text...' />,
|
|
80
58
|
};
|
|
81
59
|
|
|
82
60
|
export const Text = {
|
|
83
|
-
render: () =>
|
|
84
|
-
<Story
|
|
85
|
-
text={initialText}
|
|
86
|
-
slots={defaultsDeep(
|
|
87
|
-
{ editor: { theme: textTheme, placeholder: 'Enter text...' } } satisfies TextEditorSlots,
|
|
88
|
-
defaultSlots,
|
|
89
|
-
)}
|
|
90
|
-
/>
|
|
91
|
-
),
|
|
61
|
+
render: () => <Story text={initialText} placeholder='Enter text...' />,
|
|
92
62
|
};
|
|
93
63
|
|
|
94
64
|
export const Automerge = {
|
|
65
|
+
render: () => <Story text={initialText} placeholder='Enter text...' automerge />,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const Listener = {
|
|
95
69
|
render: () => (
|
|
96
|
-
<Story
|
|
97
|
-
automerge
|
|
98
|
-
text={initialText}
|
|
99
|
-
slots={defaultsDeep(
|
|
100
|
-
{ editor: { theme: textTheme, placeholder: 'Enter text...' } } satisfies TextEditorSlots,
|
|
101
|
-
defaultSlots,
|
|
102
|
-
)}
|
|
103
|
-
/>
|
|
70
|
+
<Story placeholder='Enter text...' extensions={[listener({ onChange: (text) => console.log(text) })]} />
|
|
104
71
|
),
|
|
105
72
|
};
|
|
@@ -41,22 +41,22 @@ export type CursorInfo = {
|
|
|
41
41
|
|
|
42
42
|
export type TextEditorSlots = {
|
|
43
43
|
root?: Omit<ComponentProps<'div'>, 'ref'>;
|
|
44
|
-
editor?: {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
spellCheck?: boolean;
|
|
48
|
-
tabIndex?: number;
|
|
49
|
-
theme?: ThemeStyles;
|
|
50
|
-
};
|
|
44
|
+
// editor?: {
|
|
45
|
+
// className?: string;
|
|
46
|
+
// };
|
|
51
47
|
};
|
|
52
48
|
|
|
49
|
+
// TODO(burdon): Spellcheck?
|
|
53
50
|
export type TextEditorProps = {
|
|
54
51
|
model: EditorModel;
|
|
55
52
|
focus?: boolean;
|
|
53
|
+
selection?: { anchor: number; head?: number };
|
|
56
54
|
readonly?: boolean; // TODO(burdon): Move into model.
|
|
57
55
|
comments?: CommentRange[]; // TODO(burdon): Move into extension.
|
|
58
56
|
extensions?: Extension[];
|
|
59
57
|
editorMode?: EditorMode;
|
|
58
|
+
placeholder?: string;
|
|
59
|
+
theme?: ThemeStyles;
|
|
60
60
|
slots?: TextEditorSlots;
|
|
61
61
|
};
|
|
62
62
|
|
|
@@ -64,18 +64,24 @@ export type TextEditorProps = {
|
|
|
64
64
|
* Base text editor.
|
|
65
65
|
*/
|
|
66
66
|
export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
67
|
-
(
|
|
67
|
+
(
|
|
68
|
+
{ model, focus, selection, readonly, comments, extensions = [], editorMode, theme, slots = defaultSlots },
|
|
69
|
+
forwardedRef,
|
|
70
|
+
) => {
|
|
68
71
|
const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
|
|
69
72
|
const { themeMode } = useThemeContext();
|
|
73
|
+
|
|
74
|
+
// The editor view ref should only be used as an escape hatch.
|
|
70
75
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
71
76
|
const [view, setView] = useState<EditorView | null>(null);
|
|
72
|
-
// NOTE: This doesn't update useRef.
|
|
73
77
|
useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
|
|
78
|
+
|
|
79
|
+
// Focus.
|
|
74
80
|
useEffect(() => {
|
|
75
|
-
if (
|
|
81
|
+
if (view && focus) {
|
|
76
82
|
view.focus();
|
|
77
83
|
}
|
|
78
|
-
}, [
|
|
84
|
+
}, [view, focus]);
|
|
79
85
|
|
|
80
86
|
// TODO(burdon): Factor out as extension.
|
|
81
87
|
const { awareness, peer } = model;
|
|
@@ -91,7 +97,7 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
91
97
|
|
|
92
98
|
// TODO(burdon): Factor out as extension.
|
|
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
|
});
|
|
@@ -103,10 +109,11 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
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<EditorView, 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.
|
|
@@ -152,7 +159,7 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
152
159
|
(event: KeyboardEvent) => {
|
|
153
160
|
const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
|
|
154
161
|
switch (key) {
|
|
155
|
-
// TODO(burdon):
|
|
162
|
+
// TODO(burdon): Is this required (for vim mode?)
|
|
156
163
|
// case 'Enter': {
|
|
157
164
|
// view?.contentDOM.focus();
|
|
158
165
|
// break;
|
|
@@ -170,28 +177,27 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
170
177
|
return (
|
|
171
178
|
<div
|
|
172
179
|
key={model.id}
|
|
180
|
+
role='none'
|
|
173
181
|
ref={rootRef}
|
|
174
182
|
tabIndex={0}
|
|
175
|
-
{...slots?.root}
|
|
176
|
-
{...(editorMode !== 'vim' && tabsterDOMAttribute)}
|
|
177
183
|
onKeyUp={handleKeyUp}
|
|
184
|
+
{...slots.root}
|
|
185
|
+
{...(editorMode !== 'vim' && tabsterDOMAttribute)}
|
|
178
186
|
/>
|
|
179
187
|
);
|
|
180
188
|
},
|
|
181
189
|
);
|
|
182
190
|
|
|
183
191
|
export const TextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
184
|
-
({ readonly, extensions = [], slots, ...props }, forwardedRef) => {
|
|
192
|
+
({ readonly, placeholder, extensions = [], theme = textTheme, slots, ...props }, forwardedRef) => {
|
|
185
193
|
const { themeMode } = useThemeContext();
|
|
186
194
|
const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
|
|
187
195
|
return (
|
|
188
196
|
<BaseTextEditor
|
|
189
197
|
ref={forwardedRef}
|
|
190
198
|
readonly={readonly}
|
|
191
|
-
extensions={[
|
|
192
|
-
|
|
193
|
-
...extensions,
|
|
194
|
-
]}
|
|
199
|
+
extensions={[basicBundle({ readonly, themeMode, placeholder }), ...extensions]}
|
|
200
|
+
theme={theme}
|
|
195
201
|
slots={updatedSlots}
|
|
196
202
|
{...props}
|
|
197
203
|
/>
|
|
@@ -201,17 +207,15 @@ export const TextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
201
207
|
|
|
202
208
|
// TODO(burdon): Remove (Just provide bundle, slots).
|
|
203
209
|
export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
|
|
204
|
-
({ readonly, extensions = [], slots, ...props }, forwardedRef) => {
|
|
210
|
+
({ readonly, placeholder, extensions = [], theme = markdownTheme, slots, ...props }, forwardedRef) => {
|
|
205
211
|
const { themeMode } = useThemeContext();
|
|
206
212
|
const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
|
|
207
213
|
return (
|
|
208
214
|
<BaseTextEditor
|
|
209
215
|
ref={forwardedRef}
|
|
210
216
|
readonly={readonly}
|
|
211
|
-
extensions={[
|
|
212
|
-
|
|
213
|
-
...extensions,
|
|
214
|
-
]}
|
|
217
|
+
extensions={[markdownBundle({ readonly, themeMode, placeholder }), ...extensions]}
|
|
218
|
+
theme={theme}
|
|
215
219
|
slots={updatedSlots}
|
|
216
220
|
{...props}
|
|
217
221
|
/>
|
|
@@ -221,20 +225,14 @@ export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
221
225
|
|
|
222
226
|
export const defaultSlots: TextEditorSlots = {
|
|
223
227
|
root: {
|
|
224
|
-
className: mx('p-2', inputSurface),
|
|
228
|
+
className: mx('p-2 overflow-y-auto', inputSurface),
|
|
225
229
|
},
|
|
226
230
|
};
|
|
227
231
|
|
|
228
232
|
export const defaultTextSlots: TextEditorSlots = {
|
|
229
233
|
...defaultSlots,
|
|
230
|
-
editor: {
|
|
231
|
-
theme: textTheme,
|
|
232
|
-
},
|
|
233
234
|
};
|
|
234
235
|
|
|
235
236
|
export const defaultMarkdownSlots: TextEditorSlots = {
|
|
236
237
|
...defaultSlots,
|
|
237
|
-
editor: {
|
|
238
|
-
theme: markdownTheme,
|
|
239
|
-
},
|
|
240
238
|
};
|
|
@@ -0,0 +1,315 @@
|
|
|
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 } from '@phosphor-icons/react';
|
|
10
|
+
import React, { type FC, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import { TextObject } from '@dxos/echo-schema';
|
|
13
|
+
import { PublicKey } from '@dxos/keys';
|
|
14
|
+
import { Button, DensityProvider } from '@dxos/react-ui';
|
|
15
|
+
import { fixedInsetFlexLayout, mx } from '@dxos/react-ui-theme';
|
|
16
|
+
import { withTheme } from '@dxos/storybook-utils';
|
|
17
|
+
|
|
18
|
+
import { MarkdownEditor, TextEditor } from './TextEditor';
|
|
19
|
+
import { comments, type CommentsOptions, Cursor } from '../../extensions';
|
|
20
|
+
import { type CommentRange, type Range, useTextModel } from '../../hooks';
|
|
21
|
+
|
|
22
|
+
faker.seed(101);
|
|
23
|
+
|
|
24
|
+
//
|
|
25
|
+
// Editor
|
|
26
|
+
//
|
|
27
|
+
|
|
28
|
+
const Editor: FC<{
|
|
29
|
+
item: { text: TextObject };
|
|
30
|
+
commentSelected?: string;
|
|
31
|
+
commentRanges: CommentRange[];
|
|
32
|
+
onCreateComment: CommentsOptions['onCreate'];
|
|
33
|
+
onSelectComment: CommentsOptions['onSelect'];
|
|
34
|
+
}> = ({ item, commentSelected, commentRanges, onCreateComment, onSelectComment }) => {
|
|
35
|
+
const model = useTextModel({ text: item.text });
|
|
36
|
+
const editorRef = useRef<EditorView>(null);
|
|
37
|
+
const [selected, setSelected] = useState<string>();
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!editorRef.current?.hasFocus && commentSelected !== selected) {
|
|
40
|
+
const thread = commentRanges.find((range) => range.id === commentSelected);
|
|
41
|
+
if (thread) {
|
|
42
|
+
const { cursor } = thread;
|
|
43
|
+
const range = Cursor.getRangeFromCursor(editorRef.current!.state.facet(Cursor.converter), cursor);
|
|
44
|
+
if (range) {
|
|
45
|
+
// TODO(burdon): Scroll selection to center of screen?
|
|
46
|
+
editorRef.current?.dispatch({ selection: { anchor: range.from }, scrollIntoView: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setSelected(commentSelected);
|
|
51
|
+
}
|
|
52
|
+
}, [selected, commentRanges, commentSelected]);
|
|
53
|
+
|
|
54
|
+
const extensions = useMemo(() => {
|
|
55
|
+
return [
|
|
56
|
+
comments({
|
|
57
|
+
onCreate: onCreateComment,
|
|
58
|
+
onSelect: onSelectComment,
|
|
59
|
+
}),
|
|
60
|
+
];
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
if (!model) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// TODO(burdon): Highlight currently selected comment.
|
|
68
|
+
return (
|
|
69
|
+
<div className='flex grow overflow-y-scroll'>
|
|
70
|
+
<MarkdownEditor ref={editorRef} model={model} comments={commentRanges} extensions={extensions} />
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
//
|
|
76
|
+
// Comments thread
|
|
77
|
+
//
|
|
78
|
+
|
|
79
|
+
type CommentThread = {
|
|
80
|
+
id: string;
|
|
81
|
+
range: CommentRange;
|
|
82
|
+
yPos?: number;
|
|
83
|
+
selection?: Range;
|
|
84
|
+
messages: TextObject[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const Thread: FC<{
|
|
88
|
+
thread: CommentThread;
|
|
89
|
+
selected: boolean;
|
|
90
|
+
onSelect: () => void;
|
|
91
|
+
onResolve: () => void;
|
|
92
|
+
}> = ({ thread, selected, onSelect, onResolve }) => {
|
|
93
|
+
const [item, setItem] = useState({ text: new TextObject() });
|
|
94
|
+
const model = useTextModel({ text: item.text });
|
|
95
|
+
const editorRef = useRef<EditorView>(null);
|
|
96
|
+
|
|
97
|
+
// TODO(burdon): Hack to focus (view is recreated when text object changes).
|
|
98
|
+
const focus = () => {
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
editorRef.current?.focus();
|
|
101
|
+
}, 100);
|
|
102
|
+
};
|
|
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
|
+
focus();
|
|
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
|
+
focus();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (!model) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className={mx('flex flex-col m-1 rounded shadow divide-y bg-white', selected && 'ring')}>
|
|
129
|
+
<div className='flex p-2 text-xs font-mono gap-2 text-neutral-500 font-thin'>
|
|
130
|
+
<span>id:{thread.id.slice(0, 4)}</span>
|
|
131
|
+
<span>from:{thread.selection?.from}</span>
|
|
132
|
+
<span>to:{thread.selection?.to}</span>
|
|
133
|
+
<span>y:{thread.yPos}</span>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{thread.messages.map((message, i) => (
|
|
137
|
+
// TODO(burdon): Fix default editor padding so content doesn't jump on creating message.
|
|
138
|
+
<div key={i} className='p-2' onClick={() => onSelect()}>
|
|
139
|
+
{message.text}
|
|
140
|
+
</div>
|
|
141
|
+
))}
|
|
142
|
+
|
|
143
|
+
<div ref={containerRef} onClick={() => onSelect()} className='flex py-1'>
|
|
144
|
+
<div className='grow'>
|
|
145
|
+
<TextEditor
|
|
146
|
+
ref={editorRef}
|
|
147
|
+
model={model}
|
|
148
|
+
placeholder={'Enter comment...'}
|
|
149
|
+
extensions={[
|
|
150
|
+
keymap.of([
|
|
151
|
+
{
|
|
152
|
+
key: 'Enter',
|
|
153
|
+
run: () => {
|
|
154
|
+
handleCreateMessage();
|
|
155
|
+
return true;
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
]),
|
|
159
|
+
]}
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
<Button variant='ghost' classNames='px-1 mr-1' title='Resolve' onClick={onResolve}>
|
|
163
|
+
<Check />
|
|
164
|
+
</Button>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const Sidebar: FC<{
|
|
171
|
+
threads: CommentThread[];
|
|
172
|
+
selected?: string;
|
|
173
|
+
onSelect: (thread: string) => void;
|
|
174
|
+
onResolve: (thread: string) => void;
|
|
175
|
+
}> = ({ threads, selected, onSelect, onResolve }) => {
|
|
176
|
+
// Sort by y-position.
|
|
177
|
+
const sortedThreads = useMemo(() => {
|
|
178
|
+
const sorted = [...threads];
|
|
179
|
+
return sorted.sort(({ yPos: a = Infinity }, { yPos: b = Infinity }) => {
|
|
180
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
181
|
+
});
|
|
182
|
+
}, [threads]);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<DensityProvider density='fine'>
|
|
186
|
+
<div className='flex flex-col grow overflow-y-scroll py-4 gap-4 pr-4'>
|
|
187
|
+
{sortedThreads.map((thread) => (
|
|
188
|
+
<Thread
|
|
189
|
+
key={thread.id}
|
|
190
|
+
thread={thread}
|
|
191
|
+
selected={thread.id === selected}
|
|
192
|
+
onSelect={() => onSelect(thread.id)}
|
|
193
|
+
onResolve={() => onResolve(thread.id)}
|
|
194
|
+
/>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
</DensityProvider>
|
|
198
|
+
);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
//
|
|
202
|
+
// Story container.
|
|
203
|
+
//
|
|
204
|
+
|
|
205
|
+
type StoryProps = {
|
|
206
|
+
text?: string;
|
|
207
|
+
autoCreate?: boolean;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const Story = ({ text, autoCreate }: StoryProps) => {
|
|
211
|
+
const [item] = useState({ text: new TextObject(text) });
|
|
212
|
+
const [threads, setThreads] = useState<CommentThread[]>([]);
|
|
213
|
+
const commentRanges = useMemo(() => threads.map((thread) => thread.range), [threads]);
|
|
214
|
+
const [selected, setSelected] = useState<string>();
|
|
215
|
+
|
|
216
|
+
// Filter by visibility.
|
|
217
|
+
const visibleThreads = useMemo(() => threads.filter((thread) => thread.yPos !== undefined), [threads]);
|
|
218
|
+
|
|
219
|
+
const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
|
|
220
|
+
const id = PublicKey.random().toHex();
|
|
221
|
+
setThreads((threads) => [
|
|
222
|
+
...threads,
|
|
223
|
+
{
|
|
224
|
+
id,
|
|
225
|
+
range: { id, cursor },
|
|
226
|
+
yPos: location ? Math.floor(location.top) : undefined,
|
|
227
|
+
messages: autoCreate
|
|
228
|
+
? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 2, max: 5 } })
|
|
229
|
+
: [],
|
|
230
|
+
},
|
|
231
|
+
]);
|
|
232
|
+
setSelected(id);
|
|
233
|
+
return id;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// TODO(burdon): Scroll sidebar on select.
|
|
237
|
+
const handleSelectComment: CommentsOptions['onSelect'] = ({ active, closest, ranges }) => {
|
|
238
|
+
setThreads((threads) =>
|
|
239
|
+
threads.map((thread) => {
|
|
240
|
+
const range = ranges.find((range) => range.id === thread.range.id);
|
|
241
|
+
if (range) {
|
|
242
|
+
thread.yPos = range.location ? Math.floor(range.location.top) : undefined;
|
|
243
|
+
thread.selection = { from: range.from, to: range.to };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return thread;
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
setSelected(active ?? closest);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const handleSelectThread = (id: string) => {
|
|
254
|
+
setSelected(id);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const handleResolveThread = (id: string) => {
|
|
258
|
+
setThreads((threads) => [...threads.filter((thread) => thread.id !== id)]);
|
|
259
|
+
setSelected(undefined);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<div className={mx(fixedInsetFlexLayout, 'bg-neutral-100')}>
|
|
264
|
+
<div className='flex justify-center h-full gap-8'>
|
|
265
|
+
<div className='flex flex-col h-full w-[600px]'>
|
|
266
|
+
<Editor
|
|
267
|
+
item={item}
|
|
268
|
+
commentSelected={selected}
|
|
269
|
+
commentRanges={commentRanges}
|
|
270
|
+
onCreateComment={handleCreateComment}
|
|
271
|
+
onSelectComment={handleSelectComment}
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div className='flex flex-col h-full w-[300px]'>
|
|
276
|
+
<Sidebar
|
|
277
|
+
threads={visibleThreads}
|
|
278
|
+
selected={selected}
|
|
279
|
+
onSelect={handleSelectThread}
|
|
280
|
+
onResolve={handleResolveThread}
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export default {
|
|
289
|
+
title: 'react-ui-editor/comments',
|
|
290
|
+
component: MarkdownEditor,
|
|
291
|
+
decorators: [withTheme],
|
|
292
|
+
render: (args: StoryProps) => <Story {...args} />,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const str = (...lines: string[]) => lines.join('\n');
|
|
296
|
+
|
|
297
|
+
const document = str(
|
|
298
|
+
'# Comments',
|
|
299
|
+
'',
|
|
300
|
+
str(...faker.helpers.multiple(() => [faker.lorem.paragraph({ min: 5, max: 10 }), ''], { count: 20 }).flat()),
|
|
301
|
+
'',
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
export const Default = {
|
|
305
|
+
args: {
|
|
306
|
+
text: document,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export const Demo = {
|
|
311
|
+
args: {
|
|
312
|
+
text: document,
|
|
313
|
+
autoCreate: true,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
@@ -13,7 +13,7 @@ import { type Prop } from '@dxos/automerge/automerge';
|
|
|
13
13
|
import { cursorConverter } from './cursor';
|
|
14
14
|
import { effectType, type IDocHandle, isReconcileTx, type Value } from './defs';
|
|
15
15
|
import { PatchSemaphore } from './semaphore';
|
|
16
|
-
import {
|
|
16
|
+
import { Cursor } from '../cursor';
|
|
17
17
|
|
|
18
18
|
const semaphoreFacet = Facet.define<PatchSemaphore, PatchSemaphore>({
|
|
19
19
|
combine: (values) => values.at(-1)!, // Take last.
|
|
@@ -85,7 +85,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
85
85
|
|
|
86
86
|
return {
|
|
87
87
|
extension: [
|
|
88
|
-
|
|
88
|
+
Cursor.converter.of(cursorConverter(handle, path)),
|
|
89
89
|
semaphoreFacet.of(semaphore),
|
|
90
90
|
stateField,
|
|
91
91
|
viewPlugin,
|
|
@@ -8,7 +8,7 @@ import type { Prop } from '@dxos/automerge/automerge';
|
|
|
8
8
|
import { next as automerge } from '@dxos/automerge/automerge';
|
|
9
9
|
|
|
10
10
|
import { type IDocHandle } from './defs';
|
|
11
|
-
import { type CursorConverter } from '
|
|
11
|
+
import { type CursorConverter } from '../cursor';
|
|
12
12
|
|
|
13
13
|
export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConverter => ({
|
|
14
14
|
// TODO(burdon): Handle assoc to associate with a previous character.
|
|
@@ -26,6 +26,7 @@ export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConvert
|
|
|
26
26
|
// NOTE: Slice is needed because getCursor mutates the array.
|
|
27
27
|
return automerge.getCursor(doc, path.slice(), pos);
|
|
28
28
|
},
|
|
29
|
+
|
|
29
30
|
fromCursor: (cursor) => {
|
|
30
31
|
if (cursor === '') {
|
|
31
32
|
return 0;
|