@dxos/react-ui-editor 0.3.11-main.8be6c8a → 0.3.11-main.90a8b71
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 +146 -282
- 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 +2 -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 +21 -31
- 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 -50
- 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,15 +41,12 @@ 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;
|
|
@@ -58,6 +55,8 @@ export type TextEditorProps = {
|
|
|
58
55
|
comments?: CommentRange[]; // TODO(burdon): Move into extension.
|
|
59
56
|
extensions?: Extension[];
|
|
60
57
|
editorMode?: EditorMode;
|
|
58
|
+
placeholder?: string;
|
|
59
|
+
theme?: ThemeStyles;
|
|
61
60
|
slots?: TextEditorSlots;
|
|
62
61
|
};
|
|
63
62
|
|
|
@@ -66,7 +65,7 @@ export type TextEditorProps = {
|
|
|
66
65
|
*/
|
|
67
66
|
export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
68
67
|
(
|
|
69
|
-
{ model, focus, selection, readonly, comments, extensions = [], editorMode, slots = defaultSlots },
|
|
68
|
+
{ model, focus, selection, readonly, comments, extensions = [], editorMode, theme, slots = defaultSlots },
|
|
70
69
|
forwardedRef,
|
|
71
70
|
) => {
|
|
72
71
|
const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
|
|
@@ -98,7 +97,7 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
98
97
|
|
|
99
98
|
// TODO(burdon): Factor out as extension.
|
|
100
99
|
useEffect(() => {
|
|
101
|
-
if (view && comments
|
|
100
|
+
if (view && comments !== undefined) {
|
|
102
101
|
view.dispatch({
|
|
103
102
|
effects: setCommentRange.of({ model, comments }),
|
|
104
103
|
});
|
|
@@ -110,10 +109,10 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
110
109
|
return;
|
|
111
110
|
}
|
|
112
111
|
|
|
113
|
-
// TODO(burdon): Remember cursor position.
|
|
114
112
|
// https://codemirror.net/docs/ref/#state.EditorStateConfig
|
|
115
113
|
const state = EditorState.create({
|
|
116
114
|
doc: model.text(),
|
|
115
|
+
// TODO(burdon): Composer should store and set selection when switching documents.
|
|
117
116
|
selection,
|
|
118
117
|
extensions: [
|
|
119
118
|
readonly && EditorState.readOnly.of(readonly),
|
|
@@ -124,7 +123,7 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
124
123
|
// Theme.
|
|
125
124
|
// TODO(burdon): Make theme configurable.
|
|
126
125
|
EditorView.baseTheme(defaultTheme),
|
|
127
|
-
EditorView.theme(
|
|
126
|
+
EditorView.theme(theme ?? {}),
|
|
128
127
|
EditorView.darkTheme.of(themeMode === 'dark'),
|
|
129
128
|
|
|
130
129
|
// Storage and replication.
|
|
@@ -160,7 +159,7 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
160
159
|
(event: KeyboardEvent) => {
|
|
161
160
|
const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
|
|
162
161
|
switch (key) {
|
|
163
|
-
// TODO(burdon):
|
|
162
|
+
// TODO(burdon): Is this required (for vim mode?)
|
|
164
163
|
// case 'Enter': {
|
|
165
164
|
// view?.contentDOM.focus();
|
|
166
165
|
// break;
|
|
@@ -178,28 +177,27 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
178
177
|
return (
|
|
179
178
|
<div
|
|
180
179
|
key={model.id}
|
|
180
|
+
role='none'
|
|
181
181
|
ref={rootRef}
|
|
182
182
|
tabIndex={0}
|
|
183
|
-
{...slots?.root}
|
|
184
|
-
{...(editorMode !== 'vim' && tabsterDOMAttribute)}
|
|
185
183
|
onKeyUp={handleKeyUp}
|
|
184
|
+
{...slots.root}
|
|
185
|
+
{...(editorMode !== 'vim' && tabsterDOMAttribute)}
|
|
186
186
|
/>
|
|
187
187
|
);
|
|
188
188
|
},
|
|
189
189
|
);
|
|
190
190
|
|
|
191
191
|
export const TextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
192
|
-
({ readonly, extensions = [], slots, ...props }, forwardedRef) => {
|
|
192
|
+
({ readonly, placeholder, extensions = [], theme = textTheme, slots, ...props }, forwardedRef) => {
|
|
193
193
|
const { themeMode } = useThemeContext();
|
|
194
194
|
const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
|
|
195
195
|
return (
|
|
196
196
|
<BaseTextEditor
|
|
197
197
|
ref={forwardedRef}
|
|
198
198
|
readonly={readonly}
|
|
199
|
-
extensions={[
|
|
200
|
-
|
|
201
|
-
...extensions,
|
|
202
|
-
]}
|
|
199
|
+
extensions={[basicBundle({ readonly, themeMode, placeholder }), ...extensions]}
|
|
200
|
+
theme={theme}
|
|
203
201
|
slots={updatedSlots}
|
|
204
202
|
{...props}
|
|
205
203
|
/>
|
|
@@ -209,17 +207,15 @@ export const TextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
209
207
|
|
|
210
208
|
// TODO(burdon): Remove (Just provide bundle, slots).
|
|
211
209
|
export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
|
|
212
|
-
({ readonly, extensions = [], slots, ...props }, forwardedRef) => {
|
|
210
|
+
({ readonly, placeholder, extensions = [], theme = markdownTheme, slots, ...props }, forwardedRef) => {
|
|
213
211
|
const { themeMode } = useThemeContext();
|
|
214
212
|
const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
|
|
215
213
|
return (
|
|
216
214
|
<BaseTextEditor
|
|
217
215
|
ref={forwardedRef}
|
|
218
216
|
readonly={readonly}
|
|
219
|
-
extensions={[
|
|
220
|
-
|
|
221
|
-
...extensions,
|
|
222
|
-
]}
|
|
217
|
+
extensions={[markdownBundle({ readonly, themeMode, placeholder }), ...extensions]}
|
|
218
|
+
theme={theme}
|
|
223
219
|
slots={updatedSlots}
|
|
224
220
|
{...props}
|
|
225
221
|
/>
|
|
@@ -229,20 +225,14 @@ export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
229
225
|
|
|
230
226
|
export const defaultSlots: TextEditorSlots = {
|
|
231
227
|
root: {
|
|
232
|
-
className: mx('p-2', inputSurface),
|
|
228
|
+
className: mx('p-2 overflow-y-auto', inputSurface),
|
|
233
229
|
},
|
|
234
230
|
};
|
|
235
231
|
|
|
236
232
|
export const defaultTextSlots: TextEditorSlots = {
|
|
237
233
|
...defaultSlots,
|
|
238
|
-
editor: {
|
|
239
|
-
theme: textTheme,
|
|
240
|
-
},
|
|
241
234
|
};
|
|
242
235
|
|
|
243
236
|
export const defaultMarkdownSlots: TextEditorSlots = {
|
|
244
237
|
...defaultSlots,
|
|
245
|
-
editor: {
|
|
246
|
-
theme: markdownTheme,
|
|
247
|
-
},
|
|
248
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;
|