@dxos/react-ui-editor 0.3.11-main.9d68491 → 0.3.11-main.9de2c34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/browser/index.mjs +743 -896
- 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 +13 -10
- 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 +1 -1
- 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 +32 -19
- 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 -1
- 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/hooks/awareness-provider.d.ts +7 -4
- package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -1
- package/dist/types/src/hooks/defs.d.ts +25 -0
- package/dist/types/src/hooks/defs.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +2 -2
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useTextEditor.d.ts +17 -0
- package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
- 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/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 +33 -35
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +75 -80
- package/src/components/TextEditor/TextEditor.stories.tsx +10 -43
- package/src/components/TextEditor/TextEditor.tsx +101 -86
- package/src/components/TextEditor/comments.stories.tsx +357 -0
- package/src/extensions/autocomplete.ts +6 -3
- package/src/extensions/automerge/automerge.ts +40 -46
- 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 +40 -46
- package/src/extensions/basic.ts +22 -17
- package/src/extensions/code.ts +24 -18
- package/src/extensions/comments.ts +246 -238
- 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 -1
- 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/hooks/awareness-provider.ts +24 -20
- package/src/hooks/defs.ts +42 -0
- package/src/hooks/index.ts +3 -2
- package/src/hooks/useTextEditor.ts +29 -0
- package/src/hooks/useTextModel.ts +47 -59
- package/src/styles/markdown.ts +0 -2
- package/src/themes/default.ts +17 -50
- package/src/util.ts +41 -0
- package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
- package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
- package/dist/types/src/components/TextEditor/yjs.stories.d.ts +0 -12
- package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
- package/dist/types/src/extensions/yjs/cursor.d.ts +0 -4
- package/dist/types/src/extensions/yjs/cursor.d.ts.map +0 -1
- package/dist/types/src/extensions/yjs/index.d.ts +0 -2
- package/dist/types/src/extensions/yjs/index.d.ts.map +0 -1
- package/dist/types/src/extensions/yjs/yjs.d.ts +0 -4
- package/dist/types/src/extensions/yjs/yjs.d.ts.map +0 -1
- package/dist/types/src/extensions/yjs/yjs.test.d.ts +0 -2
- package/dist/types/src/extensions/yjs/yjs.test.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge.d.ts +0 -3
- package/dist/types/src/hooks/automerge.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs.d.ts +0 -27
- package/dist/types/src/hooks/yjs.d.ts.map +0 -1
- package/dist/types/src/testing/index.d.ts +0 -3
- package/dist/types/src/testing/index.d.ts.map +0 -1
- package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
- package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
- package/dist/types/src/testing/proto/index.d.ts +0 -2
- package/dist/types/src/testing/proto/index.d.ts.map +0 -1
- package/dist/types/src/testing/replicator.d.ts +0 -45
- package/dist/types/src/testing/replicator.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 +0 -5
- package/dist/types/src/util/util.d.ts.map +0 -1
- package/src/components/TextEditor/codemirror.stories.ts +0 -115
- package/src/components/TextEditor/yjs.stories.tsx +0 -56
- package/src/extensions/yjs/cursor.ts +0 -21
- package/src/extensions/yjs/index.ts +0 -5
- package/src/extensions/yjs/yjs.test.ts +0 -35
- package/src/extensions/yjs/yjs.ts +0 -16
- package/src/hooks/automerge.ts +0 -52
- package/src/hooks/yjs.ts +0 -178
- package/src/testing/index.ts +0 -6
- package/src/testing/proto/gen/schema.ts +0 -49
- package/src/testing/proto/index.ts +0 -5
- package/src/testing/proto/schema.proto +0 -15
- package/src/testing/replicator.ts +0 -184
- 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 { attentionSurface, mx } from '@dxos/react-ui-theme';
|
|
24
24
|
|
|
25
|
-
import { basicBundle, markdownBundle
|
|
26
|
-
import { type
|
|
25
|
+
import { basicBundle, markdownBundle } from '../../extensions';
|
|
26
|
+
import { type EditorModel } from '../../hooks';
|
|
27
27
|
import { type ThemeStyles } from '../../styles';
|
|
28
28
|
import { defaultTheme, markdownTheme, textTheme } from '../../themes';
|
|
29
|
+
import { logChanges } from '../../util';
|
|
29
30
|
|
|
31
|
+
// TODO(burdon): Change to enum?
|
|
30
32
|
export const EditorModes = ['default', 'vim'] as const;
|
|
31
33
|
export type EditorMode = (typeof EditorModes)[number];
|
|
32
34
|
|
|
@@ -43,22 +45,26 @@ export type TextEditorSlots = {
|
|
|
43
45
|
root?: Omit<ComponentProps<'div'>, 'ref'>;
|
|
44
46
|
editor?: {
|
|
45
47
|
className?: string;
|
|
46
|
-
|
|
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
|
-
model: EditorModel;
|
|
55
|
-
focus?: boolean;
|
|
56
|
-
selection?: { anchor: number; head?: number };
|
|
56
|
+
model: EditorModel; // TODO(burdon): Optional (e.g., just provide content if readonly).
|
|
57
57
|
readonly?: boolean; // TODO(burdon): Move into model.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
autoFocus?: boolean;
|
|
59
|
+
lineWrapping?: boolean;
|
|
60
|
+
scrollTo?: StateEffect<any>; // TODO(burdon): Restore scroll position: scrollTo EditorView.scrollSnapshot().
|
|
61
|
+
selection?: { anchor: number; head?: number };
|
|
62
|
+
editorMode?: EditorMode; // TODO(burdon): Factor out.
|
|
63
|
+
placeholder?: string;
|
|
64
|
+
theme?: ThemeStyles;
|
|
61
65
|
slots?: TextEditorSlots;
|
|
66
|
+
extensions?: Extension[];
|
|
67
|
+
debug?: boolean;
|
|
62
68
|
};
|
|
63
69
|
|
|
64
70
|
/**
|
|
@@ -66,108 +72,122 @@ export type TextEditorProps = {
|
|
|
66
72
|
*/
|
|
67
73
|
export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
68
74
|
(
|
|
69
|
-
{
|
|
75
|
+
{
|
|
76
|
+
model,
|
|
77
|
+
readonly,
|
|
78
|
+
autoFocus,
|
|
79
|
+
scrollTo,
|
|
80
|
+
selection,
|
|
81
|
+
editorMode,
|
|
82
|
+
theme,
|
|
83
|
+
slots = defaultSlots,
|
|
84
|
+
extensions = [],
|
|
85
|
+
debug,
|
|
86
|
+
},
|
|
70
87
|
forwardedRef,
|
|
71
88
|
) => {
|
|
72
89
|
const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
|
|
73
90
|
const { themeMode } = useThemeContext();
|
|
74
91
|
|
|
75
|
-
// The editor view ref should only be used as an escape hatch.
|
|
76
92
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
77
93
|
const [view, setView] = useState<EditorView | null>(null);
|
|
78
|
-
useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
|
|
79
|
-
|
|
80
|
-
// Focus.
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (view && focus) {
|
|
83
|
-
view.focus();
|
|
84
|
-
}
|
|
85
|
-
}, [view, focus]);
|
|
86
94
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (awareness && peer) {
|
|
91
|
-
awareness.setLocalStateField('user', {
|
|
92
|
-
name: peer.name ?? generateName(peer.id),
|
|
93
|
-
color: getColorForValue({ value: peer.id, type: 'color' }),
|
|
94
|
-
colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
}, [awareness, peer, themeMode]);
|
|
95
|
+
// The view ref can be used to focus the editor.
|
|
96
|
+
// NOTE: This does not cause the parent to re-render, so the ref is not available immediately.
|
|
97
|
+
useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
|
|
98
98
|
|
|
99
|
-
//
|
|
99
|
+
// Set focus.
|
|
100
100
|
useEffect(() => {
|
|
101
|
-
if (
|
|
102
|
-
view
|
|
103
|
-
effects: setCommentRange.of({ model, comments }),
|
|
104
|
-
});
|
|
101
|
+
if (autoFocus) {
|
|
102
|
+
view?.focus();
|
|
105
103
|
}
|
|
106
|
-
}, [view,
|
|
104
|
+
}, [view, autoFocus]);
|
|
107
105
|
|
|
106
|
+
// Create editor state and view.
|
|
107
|
+
// The view is recreated if the model or extensions are changed.
|
|
108
108
|
useEffect(() => {
|
|
109
109
|
if (!model || !rootRef.current) {
|
|
110
110
|
return;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
//
|
|
113
|
+
//
|
|
114
|
+
// EditorState
|
|
114
115
|
// https://codemirror.net/docs/ref/#state.EditorStateConfig
|
|
116
|
+
//
|
|
115
117
|
const state = EditorState.create({
|
|
116
118
|
doc: model.text(),
|
|
117
119
|
selection,
|
|
118
120
|
extensions: [
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// TODO(burdon): Doesn't catch errors in keymap functions.
|
|
122
|
+
EditorView.exceptionSink.of((err) => {
|
|
123
|
+
log.catch(err);
|
|
124
|
+
}),
|
|
123
125
|
|
|
124
126
|
// Theme.
|
|
125
|
-
// TODO(burdon): Make
|
|
127
|
+
// TODO(burdon): Make configurable.
|
|
126
128
|
EditorView.baseTheme(defaultTheme),
|
|
127
|
-
EditorView.theme(
|
|
129
|
+
EditorView.theme(theme ?? {}),
|
|
128
130
|
EditorView.darkTheme.of(themeMode === 'dark'),
|
|
131
|
+
EditorView.editorAttributes.of({ class: slots.editor?.className ?? '' }),
|
|
132
|
+
EditorView.contentAttributes.of({ class: slots.content?.className ?? '' }),
|
|
133
|
+
|
|
134
|
+
// State.
|
|
135
|
+
EditorState.readOnly.of(!!readonly),
|
|
129
136
|
|
|
130
137
|
// Storage and replication.
|
|
138
|
+
// NOTE: This must come before user extensions.
|
|
131
139
|
model.extension,
|
|
132
140
|
|
|
141
|
+
// TODO(burdon): Factor out (requires special handling for Escape/focus).
|
|
142
|
+
editorMode === 'vim' && vim(),
|
|
143
|
+
|
|
133
144
|
// Custom.
|
|
134
145
|
...extensions,
|
|
135
146
|
].filter(Boolean) as Extension[],
|
|
136
147
|
});
|
|
137
148
|
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
149
|
+
//
|
|
150
|
+
// EditorView
|
|
151
|
+
// https://codemirror.net/docs/ref/#view.EditorViewConfig
|
|
152
|
+
//
|
|
142
153
|
const newView = new EditorView({
|
|
143
|
-
parent: rootRef.current,
|
|
144
154
|
state,
|
|
145
|
-
|
|
155
|
+
parent: rootRef.current,
|
|
156
|
+
scrollTo,
|
|
157
|
+
// NOTE: Uncomment to debug/monitor all transactions.
|
|
146
158
|
// https://codemirror.net/docs/ref/#view.EditorView.dispatch
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
159
|
+
dispatchTransactions: (trs, view) => {
|
|
160
|
+
if (debug) {
|
|
161
|
+
logChanges(trs);
|
|
162
|
+
}
|
|
163
|
+
view.update(trs);
|
|
164
|
+
},
|
|
150
165
|
});
|
|
151
166
|
|
|
167
|
+
view?.destroy();
|
|
152
168
|
setView(newView);
|
|
169
|
+
|
|
153
170
|
return () => {
|
|
154
171
|
newView?.destroy();
|
|
155
172
|
setView(null);
|
|
156
173
|
};
|
|
157
174
|
}, [rootRef, model, readonly, editorMode, themeMode]);
|
|
158
175
|
|
|
176
|
+
// Handles tab/focus.
|
|
177
|
+
// Pressing Escape focuses the outer div (to support tab navigation); pressing Enter refocuses the editor.
|
|
159
178
|
const handleKeyUp = useCallback(
|
|
160
179
|
(event: KeyboardEvent) => {
|
|
161
180
|
const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
|
|
162
181
|
switch (key) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// }
|
|
182
|
+
case 'Enter': {
|
|
183
|
+
view?.focus();
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
168
186
|
|
|
169
187
|
case 'Escape': {
|
|
170
|
-
editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)
|
|
188
|
+
if (editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)) {
|
|
189
|
+
rootRef.current?.focus();
|
|
190
|
+
}
|
|
171
191
|
break;
|
|
172
192
|
}
|
|
173
193
|
}
|
|
@@ -178,28 +198,28 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
178
198
|
return (
|
|
179
199
|
<div
|
|
180
200
|
key={model.id}
|
|
181
|
-
|
|
201
|
+
role='none'
|
|
182
202
|
tabIndex={0}
|
|
183
|
-
{...slots?.root}
|
|
184
|
-
{...(editorMode !== 'vim' && tabsterDOMAttribute)}
|
|
185
203
|
onKeyUp={handleKeyUp}
|
|
204
|
+
{...slots.root}
|
|
205
|
+
{...(editorMode !== 'vim' && tabsterDOMAttribute)}
|
|
206
|
+
ref={rootRef}
|
|
186
207
|
/>
|
|
187
208
|
);
|
|
188
209
|
},
|
|
189
210
|
);
|
|
190
211
|
|
|
212
|
+
// TODO(burdon): Single-line/scroll.
|
|
191
213
|
export const TextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
192
|
-
({ readonly, extensions = [],
|
|
214
|
+
({ readonly, placeholder, lineWrapping, theme = textTheme, slots, extensions = [], ...props }, forwardedRef) => {
|
|
193
215
|
const { themeMode } = useThemeContext();
|
|
194
216
|
const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
|
|
195
217
|
return (
|
|
196
218
|
<BaseTextEditor
|
|
197
219
|
ref={forwardedRef}
|
|
198
220
|
readonly={readonly}
|
|
199
|
-
extensions={[
|
|
200
|
-
|
|
201
|
-
...extensions,
|
|
202
|
-
]}
|
|
221
|
+
extensions={[basicBundle({ themeMode, placeholder, lineWrapping }), ...extensions]}
|
|
222
|
+
theme={theme}
|
|
203
223
|
slots={updatedSlots}
|
|
204
224
|
{...props}
|
|
205
225
|
/>
|
|
@@ -207,19 +227,16 @@ export const TextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
207
227
|
},
|
|
208
228
|
);
|
|
209
229
|
|
|
210
|
-
// TODO(burdon): Remove (Just provide bundle, slots).
|
|
211
230
|
export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
|
|
212
|
-
({ readonly, extensions = [],
|
|
231
|
+
({ readonly, placeholder, theme = markdownTheme, slots, extensions = [], ...props }, forwardedRef) => {
|
|
213
232
|
const { themeMode } = useThemeContext();
|
|
214
233
|
const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
|
|
215
234
|
return (
|
|
216
235
|
<BaseTextEditor
|
|
217
236
|
ref={forwardedRef}
|
|
218
237
|
readonly={readonly}
|
|
219
|
-
extensions={[
|
|
220
|
-
|
|
221
|
-
...extensions,
|
|
222
|
-
]}
|
|
238
|
+
extensions={[markdownBundle({ themeMode, readonly, placeholder }), ...extensions]}
|
|
239
|
+
theme={theme}
|
|
223
240
|
slots={updatedSlots}
|
|
224
241
|
{...props}
|
|
225
242
|
/>
|
|
@@ -229,20 +246,18 @@ export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
229
246
|
|
|
230
247
|
export const defaultSlots: TextEditorSlots = {
|
|
231
248
|
root: {
|
|
232
|
-
|
|
249
|
+
// TODO(burdon): Add focusRing by default/as property?
|
|
250
|
+
className: mx('flex flex-col grow overflow-y-auto', attentionSurface),
|
|
251
|
+
},
|
|
252
|
+
editor: {
|
|
253
|
+
className: 'h-full p-2',
|
|
233
254
|
},
|
|
234
255
|
};
|
|
235
256
|
|
|
236
257
|
export const defaultTextSlots: TextEditorSlots = {
|
|
237
258
|
...defaultSlots,
|
|
238
|
-
editor: {
|
|
239
|
-
theme: textTheme,
|
|
240
|
-
},
|
|
241
259
|
};
|
|
242
260
|
|
|
243
261
|
export const defaultMarkdownSlots: TextEditorSlots = {
|
|
244
262
|
...defaultSlots,
|
|
245
|
-
editor: {
|
|
246
|
-
theme: markdownTheme,
|
|
247
|
-
},
|
|
248
263
|
};
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxosTheme';
|
|
6
|
+
|
|
7
|
+
import { type EditorView, keymap } from '@codemirror/view';
|
|
8
|
+
import { faker } from '@faker-js/faker';
|
|
9
|
+
import { Check, Trash } from '@phosphor-icons/react';
|
|
10
|
+
import React, { type FC, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import { getTextContent, TextObject } from '@dxos/echo-schema';
|
|
13
|
+
import { PublicKey } from '@dxos/keys';
|
|
14
|
+
import { log } from '@dxos/log';
|
|
15
|
+
import { Button, DensityProvider } from '@dxos/react-ui';
|
|
16
|
+
import { fixedInsetFlexLayout, mx } from '@dxos/react-ui-theme';
|
|
17
|
+
import { withTheme } from '@dxos/storybook-utils';
|
|
18
|
+
|
|
19
|
+
import { MarkdownEditor, TextEditor } from './TextEditor';
|
|
20
|
+
import { comments, type CommentsOptions, setFocus, useComments } from '../../extensions';
|
|
21
|
+
import { type Comment, type Range, useTextModel } from '../../hooks';
|
|
22
|
+
|
|
23
|
+
faker.seed(101);
|
|
24
|
+
|
|
25
|
+
//
|
|
26
|
+
// Editor
|
|
27
|
+
//
|
|
28
|
+
|
|
29
|
+
const Editor: FC<{
|
|
30
|
+
item: { text: TextObject };
|
|
31
|
+
comments: Comment[];
|
|
32
|
+
selected?: string;
|
|
33
|
+
onCreateComment: CommentsOptions['onCreate'];
|
|
34
|
+
onDeleteComment: CommentsOptions['onDelete'];
|
|
35
|
+
onUpdateComment: CommentsOptions['onUpdate'];
|
|
36
|
+
onSelectComment: CommentsOptions['onSelect'];
|
|
37
|
+
}> = ({
|
|
38
|
+
item,
|
|
39
|
+
selected: selectedValue,
|
|
40
|
+
comments: commentRanges,
|
|
41
|
+
onCreateComment,
|
|
42
|
+
onDeleteComment,
|
|
43
|
+
onUpdateComment,
|
|
44
|
+
onSelectComment,
|
|
45
|
+
}) => {
|
|
46
|
+
const model = useTextModel({ text: item.text });
|
|
47
|
+
const view = useRef<EditorView>(null);
|
|
48
|
+
const [selected, setSelected] = useState<string>();
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!view.current?.hasFocus && selectedValue !== selected) {
|
|
51
|
+
setSelected(selectedValue);
|
|
52
|
+
if (selectedValue) {
|
|
53
|
+
setFocus(view.current!, selectedValue);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}, [selected, commentRanges, selectedValue]);
|
|
57
|
+
|
|
58
|
+
useComments(view.current, commentRanges);
|
|
59
|
+
|
|
60
|
+
const extensions = useMemo(() => {
|
|
61
|
+
return [
|
|
62
|
+
comments({
|
|
63
|
+
onCreate: onCreateComment,
|
|
64
|
+
onDelete: onDeleteComment,
|
|
65
|
+
onUpdate: onUpdateComment,
|
|
66
|
+
onSelect: onSelectComment,
|
|
67
|
+
}),
|
|
68
|
+
];
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
if (!model) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// TODO(burdon): Highlight currently selected comment.
|
|
76
|
+
return (
|
|
77
|
+
<div className='flex grow overflow-y-scroll'>
|
|
78
|
+
<MarkdownEditor ref={view} model={model} extensions={extensions} />
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
//
|
|
84
|
+
// Comments thread
|
|
85
|
+
//
|
|
86
|
+
|
|
87
|
+
type CommentThread = {
|
|
88
|
+
id: string;
|
|
89
|
+
cursor?: string;
|
|
90
|
+
range?: Range;
|
|
91
|
+
yPos?: number;
|
|
92
|
+
messages: TextObject[];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const Thread: FC<{
|
|
96
|
+
thread: CommentThread;
|
|
97
|
+
selected: boolean;
|
|
98
|
+
onSelect: () => void;
|
|
99
|
+
onResolve: () => void;
|
|
100
|
+
}> = ({ thread, selected, onSelect, onResolve }) => {
|
|
101
|
+
const [focus, setFocus] = useState(false);
|
|
102
|
+
const [item, setItem] = useState({ text: new TextObject() });
|
|
103
|
+
const model = useTextModel({ text: item.text });
|
|
104
|
+
const editorRef = useRef<EditorView>(null);
|
|
105
|
+
|
|
106
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (selected) {
|
|
109
|
+
containerRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
110
|
+
if (thread.messages.length === 0) {
|
|
111
|
+
setFocus(true);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}, [selected]);
|
|
115
|
+
|
|
116
|
+
const handleCreateMessage = () => {
|
|
117
|
+
const text = model?.text().trim();
|
|
118
|
+
if (text?.length) {
|
|
119
|
+
thread.messages.push(item.text);
|
|
120
|
+
setItem({ text: new TextObject() });
|
|
121
|
+
setFocus(true);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (!model) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div
|
|
131
|
+
className={mx(
|
|
132
|
+
'flex flex-col m-1 rounded shadow divide-y bg-white',
|
|
133
|
+
selected && 'ring',
|
|
134
|
+
!thread.cursor && 'opacity-50',
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
137
|
+
<div className='flex p-2 gap-2 items-center text-xs font-mono text-neutral-500 font-thin'>
|
|
138
|
+
<span>id:{thread.id.slice(0, 4)}</span>
|
|
139
|
+
<span>from:{thread.range?.from}</span>
|
|
140
|
+
<span>to:{thread.range?.to}</span>
|
|
141
|
+
<span>y:{thread.yPos}</span>
|
|
142
|
+
<span className='grow' />
|
|
143
|
+
{!thread.cursor && <Trash />}
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{thread.messages.map((message, i) => (
|
|
147
|
+
// TODO(burdon): Fix default editor padding so content doesn't jump on creating message.
|
|
148
|
+
<div key={i} className='p-2' onClick={() => onSelect()}>
|
|
149
|
+
{getTextContent(message)}
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
|
|
153
|
+
<div ref={containerRef} onClick={() => onSelect()} className='flex'>
|
|
154
|
+
<TextEditor
|
|
155
|
+
ref={editorRef}
|
|
156
|
+
autoFocus={focus}
|
|
157
|
+
model={model}
|
|
158
|
+
placeholder={'Enter comment...'}
|
|
159
|
+
slots={{ root: { className: 'grow rounded-b' } }}
|
|
160
|
+
extensions={[
|
|
161
|
+
keymap.of([
|
|
162
|
+
{
|
|
163
|
+
key: 'Enter',
|
|
164
|
+
run: () => {
|
|
165
|
+
handleCreateMessage();
|
|
166
|
+
return true;
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
]),
|
|
170
|
+
]}
|
|
171
|
+
/>
|
|
172
|
+
<Button variant='ghost' classNames='px-1' title='Resolve' onClick={onResolve}>
|
|
173
|
+
<Check />
|
|
174
|
+
</Button>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const Sidebar: FC<{
|
|
181
|
+
threads: CommentThread[];
|
|
182
|
+
selected?: string;
|
|
183
|
+
onSelect: (thread: string) => void;
|
|
184
|
+
onResolve: (thread: string) => void;
|
|
185
|
+
}> = ({ threads, selected, onSelect, onResolve }) => {
|
|
186
|
+
// Sort by y-position.
|
|
187
|
+
const sortedThreads = useMemo(() => {
|
|
188
|
+
const sorted = [...threads];
|
|
189
|
+
return sorted.sort(({ yPos: a = Infinity }, { yPos: b = Infinity }) => {
|
|
190
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
191
|
+
});
|
|
192
|
+
}, [threads]);
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<DensityProvider density='fine'>
|
|
196
|
+
<div className='flex flex-col grow overflow-y-scroll py-4 gap-4'>
|
|
197
|
+
{sortedThreads.map((thread) => (
|
|
198
|
+
<Thread
|
|
199
|
+
key={thread.id}
|
|
200
|
+
thread={thread}
|
|
201
|
+
selected={thread.id === selected}
|
|
202
|
+
onSelect={() => onSelect(thread.id)}
|
|
203
|
+
onResolve={() => onResolve(thread.id)}
|
|
204
|
+
/>
|
|
205
|
+
))}
|
|
206
|
+
</div>
|
|
207
|
+
</DensityProvider>
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
//
|
|
212
|
+
// Story container.
|
|
213
|
+
//
|
|
214
|
+
|
|
215
|
+
type StoryProps = {
|
|
216
|
+
text?: string;
|
|
217
|
+
autoCreate?: boolean;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const Story = ({ text, autoCreate }: StoryProps) => {
|
|
221
|
+
const [item] = useState({ text: new TextObject(text) });
|
|
222
|
+
const [threads, setThreads] = useState<CommentThread[]>([]);
|
|
223
|
+
const [selected, setSelected] = useState<string>();
|
|
224
|
+
|
|
225
|
+
const comments = useMemo<Comment[]>(() => threads.map(({ id, cursor }) => ({ id, cursor })), [threads]);
|
|
226
|
+
|
|
227
|
+
// Filter by visibility.
|
|
228
|
+
const visibleThreads = useMemo(() => threads.filter((thread) => thread.yPos !== undefined), [threads]);
|
|
229
|
+
|
|
230
|
+
const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
|
|
231
|
+
const id = PublicKey.random().toHex();
|
|
232
|
+
log.info('create', { id: id.slice(0, 4), cursor });
|
|
233
|
+
setThreads((threads) => [
|
|
234
|
+
...threads,
|
|
235
|
+
{
|
|
236
|
+
id,
|
|
237
|
+
cursor,
|
|
238
|
+
yPos: location ? Math.floor(location.top) : undefined,
|
|
239
|
+
messages: autoCreate
|
|
240
|
+
? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 1, max: 3 } })
|
|
241
|
+
: [],
|
|
242
|
+
},
|
|
243
|
+
]);
|
|
244
|
+
setSelected(id); // TODO(burdon): Not required.
|
|
245
|
+
return id;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const handleDeleteComment: CommentsOptions['onDelete'] = (id) => {
|
|
249
|
+
log.info('delete', { id: id.slice(0, 4) });
|
|
250
|
+
setThreads((threads) =>
|
|
251
|
+
threads.map((thread) => {
|
|
252
|
+
if (thread.id === id) {
|
|
253
|
+
thread.cursor = undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return thread;
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
setSelected((selected) => (selected === id ? undefined : selected));
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const handleUpdateComment: CommentsOptions['onUpdate'] = (id, cursor) => {
|
|
264
|
+
log.info('update', { id: id.slice(0, 4), cursor });
|
|
265
|
+
setThreads((threads) =>
|
|
266
|
+
threads.map((thread) => {
|
|
267
|
+
if (thread.id === id) {
|
|
268
|
+
thread.cursor = cursor;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return thread;
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const handleSelectComment: CommentsOptions['onSelect'] = ({ comments, selection: { current, closest } }) => {
|
|
277
|
+
log.info('select', { active: current?.slice(0, 4), closest: closest?.slice(0, 4) });
|
|
278
|
+
setThreads((threads) =>
|
|
279
|
+
threads.map((thread) => {
|
|
280
|
+
const comment = comments.find(({ comment }) => comment.id === thread.id);
|
|
281
|
+
if (comment) {
|
|
282
|
+
thread.yPos = comment.location ? Math.floor(comment.location.top) : undefined;
|
|
283
|
+
thread.range = { from: comment.range.from, to: comment.range.to };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return thread;
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
setSelected(current ?? closest);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const handleSelectThread = (id: string) => {
|
|
294
|
+
setSelected(id);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const handleResolveThread = (id: string) => {
|
|
298
|
+
setThreads((threads) => [...threads.filter((thread) => thread.id !== id)]);
|
|
299
|
+
setSelected(undefined);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<div className={mx(fixedInsetFlexLayout, 'bg-neutral-100')}>
|
|
304
|
+
<div className='flex justify-center h-full gap-8'>
|
|
305
|
+
<div className='flex flex-col h-full w-[600px]'>
|
|
306
|
+
<Editor
|
|
307
|
+
item={item}
|
|
308
|
+
selected={selected}
|
|
309
|
+
comments={comments}
|
|
310
|
+
onCreateComment={handleCreateComment}
|
|
311
|
+
onDeleteComment={handleDeleteComment}
|
|
312
|
+
onUpdateComment={handleUpdateComment}
|
|
313
|
+
onSelectComment={handleSelectComment}
|
|
314
|
+
/>
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
<div className='flex flex-col h-full w-[300px]'>
|
|
318
|
+
<Sidebar
|
|
319
|
+
threads={visibleThreads}
|
|
320
|
+
selected={selected}
|
|
321
|
+
onSelect={handleSelectThread}
|
|
322
|
+
onResolve={handleResolveThread}
|
|
323
|
+
/>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
export default {
|
|
331
|
+
title: 'react-ui-editor/comments',
|
|
332
|
+
component: MarkdownEditor,
|
|
333
|
+
decorators: [withTheme],
|
|
334
|
+
render: (args: StoryProps) => <Story {...args} />,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const str = (...lines: string[]) => lines.join('\n');
|
|
338
|
+
|
|
339
|
+
const document = str(
|
|
340
|
+
'# Comments',
|
|
341
|
+
'',
|
|
342
|
+
str(...faker.helpers.multiple(() => [faker.lorem.paragraph({ min: 5, max: 10 }), ''], { count: 20 }).flat()),
|
|
343
|
+
'',
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
export const Default = {
|
|
347
|
+
args: {
|
|
348
|
+
text: document,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
export const Testing = {
|
|
353
|
+
args: {
|
|
354
|
+
text: document,
|
|
355
|
+
autoCreate: true,
|
|
356
|
+
},
|
|
357
|
+
};
|
|
@@ -35,10 +35,13 @@ export const autocomplete = ({ onSearch }: AutocompleteOptions) => {
|
|
|
35
35
|
// https://codemirror.net/examples/autocompletion
|
|
36
36
|
// https://codemirror.net/docs/ref/#autocomplete.autocompletion
|
|
37
37
|
autocompletion({
|
|
38
|
-
|
|
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?
|