@dxos/react-ui-editor 0.3.11-main.8a2cfe6 → 0.3.11-main.8a9dd1e
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 +305 -385
- 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 +6 -2
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +12 -17
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +6 -6
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts +26 -0
- package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -0
- package/dist/types/src/components/TextEditor/yjs.stories.d.ts +1 -1
- package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/index.d.ts +1 -0
- package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
- package/dist/types/src/extensions/code.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +6 -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/tasklist.d.ts +5 -1
- package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
- package/dist/types/src/extensions/yjs/cursor.d.ts +1 -1
- package/dist/types/src/extensions/yjs/cursor.d.ts.map +1 -1
- package/dist/types/src/hooks/useTextModel.d.ts +0 -5
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/dist/types/src/hooks/yjs.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +2 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/styles/markdown.d.ts.map +1 -1
- package/dist/types/src/themes/default.d.ts.map +1 -1
- package/dist/types/src/util.d.ts.map +1 -0
- package/package.json +24 -23
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +19 -16
- package/src/components/TextEditor/TextEditor.stories.tsx +12 -46
- package/src/components/TextEditor/TextEditor.tsx +68 -64
- package/src/components/TextEditor/comments.stories.tsx +329 -0
- package/src/extensions/autocomplete.ts +18 -17
- package/src/extensions/automerge/automerge.ts +2 -2
- package/src/extensions/automerge/cursor.ts +2 -1
- package/src/extensions/automerge/index.ts +1 -0
- package/src/extensions/awareness.ts +25 -32
- package/src/extensions/code.ts +10 -2
- package/src/extensions/comments.ts +86 -116
- 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 +6 -3
- package/src/extensions/tasklist.ts +53 -77
- package/src/extensions/yjs/cursor.ts +2 -1
- package/src/extensions/yjs/yjs.test.ts +1 -1
- package/src/extensions/yjs/yjs.ts +2 -2
- package/src/hooks/useTextModel.ts +4 -7
- package/src/hooks/yjs.ts +1 -23
- package/src/index.ts +2 -0
- 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
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
onMoveComment: CommentsOptions['onMove'];
|
|
34
|
+
onSelectComment: CommentsOptions['onSelect'];
|
|
35
|
+
}> = ({ item, commentSelected, commentRanges, onCreateComment, onMoveComment, onSelectComment }) => {
|
|
36
|
+
const model = useTextModel({ text: item.text });
|
|
37
|
+
const editorRef = useRef<EditorView>(null);
|
|
38
|
+
const [selected, setSelected] = useState<string>();
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!editorRef.current?.hasFocus && commentSelected !== selected) {
|
|
41
|
+
const thread = commentRanges.find((range) => range.id === commentSelected);
|
|
42
|
+
if (thread) {
|
|
43
|
+
const { cursor } = thread;
|
|
44
|
+
const range = Cursor.getRangeFromCursor(editorRef.current!.state.facet(Cursor.converter), cursor);
|
|
45
|
+
if (range) {
|
|
46
|
+
// TODO(burdon): Scroll selection to center of screen?
|
|
47
|
+
editorRef.current?.dispatch({ selection: { anchor: range.from }, scrollIntoView: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setSelected(commentSelected);
|
|
52
|
+
}
|
|
53
|
+
}, [selected, commentRanges, commentSelected]);
|
|
54
|
+
|
|
55
|
+
const extensions = useMemo(() => {
|
|
56
|
+
return [
|
|
57
|
+
comments({
|
|
58
|
+
onCreate: onCreateComment,
|
|
59
|
+
onMove: onMoveComment,
|
|
60
|
+
onSelect: onSelectComment,
|
|
61
|
+
}),
|
|
62
|
+
];
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
if (!model) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// TODO(burdon): Highlight currently selected comment.
|
|
70
|
+
return (
|
|
71
|
+
<div className='flex grow overflow-y-scroll'>
|
|
72
|
+
<MarkdownEditor ref={editorRef} model={model} comments={commentRanges} extensions={extensions} />
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
//
|
|
78
|
+
// Comments thread
|
|
79
|
+
//
|
|
80
|
+
|
|
81
|
+
type CommentThread = {
|
|
82
|
+
id: string;
|
|
83
|
+
range: CommentRange;
|
|
84
|
+
yPos?: number;
|
|
85
|
+
selection?: Range;
|
|
86
|
+
messages: TextObject[];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const Thread: FC<{
|
|
90
|
+
thread: CommentThread;
|
|
91
|
+
selected: boolean;
|
|
92
|
+
onSelect: () => void;
|
|
93
|
+
onResolve: () => void;
|
|
94
|
+
}> = ({ thread, selected, onSelect, onResolve }) => {
|
|
95
|
+
const [item, setItem] = useState({ text: new TextObject() });
|
|
96
|
+
const model = useTextModel({ text: item.text });
|
|
97
|
+
const editorRef = useRef<EditorView>(null);
|
|
98
|
+
|
|
99
|
+
// TODO(burdon): Hack to focus (view is recreated when text object changes).
|
|
100
|
+
const focus = () => {
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
editorRef.current?.focus();
|
|
103
|
+
}, 100);
|
|
104
|
+
};
|
|
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
|
+
focus();
|
|
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
|
+
focus();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (!model) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className={mx('flex flex-col m-1 rounded shadow divide-y bg-white', selected && 'ring')}>
|
|
131
|
+
<div className='flex p-2 text-xs font-mono gap-2 text-neutral-500 font-thin'>
|
|
132
|
+
<span>id:{thread.id.slice(0, 4)}</span>
|
|
133
|
+
<span>from:{thread.selection?.from}</span>
|
|
134
|
+
<span>to:{thread.selection?.to}</span>
|
|
135
|
+
<span>y:{thread.yPos}</span>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{thread.messages.map((message, i) => (
|
|
139
|
+
// TODO(burdon): Fix default editor padding so content doesn't jump on creating message.
|
|
140
|
+
<div key={i} className='p-2' onClick={() => onSelect()}>
|
|
141
|
+
{message.text}
|
|
142
|
+
</div>
|
|
143
|
+
))}
|
|
144
|
+
|
|
145
|
+
<div ref={containerRef} onClick={() => onSelect()} className='flex py-1'>
|
|
146
|
+
<div className='grow'>
|
|
147
|
+
<TextEditor
|
|
148
|
+
ref={editorRef}
|
|
149
|
+
model={model}
|
|
150
|
+
placeholder={'Enter comment...'}
|
|
151
|
+
extensions={[
|
|
152
|
+
keymap.of([
|
|
153
|
+
{
|
|
154
|
+
key: 'Enter',
|
|
155
|
+
run: () => {
|
|
156
|
+
handleCreateMessage();
|
|
157
|
+
return true;
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
]),
|
|
161
|
+
]}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
<Button variant='ghost' classNames='px-1 mr-1' title='Resolve' onClick={onResolve}>
|
|
165
|
+
<Check />
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const Sidebar: FC<{
|
|
173
|
+
threads: CommentThread[];
|
|
174
|
+
selected?: string;
|
|
175
|
+
onSelect: (thread: string) => void;
|
|
176
|
+
onResolve: (thread: string) => void;
|
|
177
|
+
}> = ({ threads, selected, onSelect, onResolve }) => {
|
|
178
|
+
// Sort by y-position.
|
|
179
|
+
const sortedThreads = useMemo(() => {
|
|
180
|
+
const sorted = [...threads];
|
|
181
|
+
return sorted.sort(({ yPos: a = Infinity }, { yPos: b = Infinity }) => {
|
|
182
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
183
|
+
});
|
|
184
|
+
}, [threads]);
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<DensityProvider density='fine'>
|
|
188
|
+
<div className='flex flex-col grow overflow-y-scroll py-4 gap-4 pr-4'>
|
|
189
|
+
{sortedThreads.map((thread) => (
|
|
190
|
+
<Thread
|
|
191
|
+
key={thread.id}
|
|
192
|
+
thread={thread}
|
|
193
|
+
selected={thread.id === selected}
|
|
194
|
+
onSelect={() => onSelect(thread.id)}
|
|
195
|
+
onResolve={() => onResolve(thread.id)}
|
|
196
|
+
/>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
</DensityProvider>
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
//
|
|
204
|
+
// Story container.
|
|
205
|
+
//
|
|
206
|
+
|
|
207
|
+
type StoryProps = {
|
|
208
|
+
text?: string;
|
|
209
|
+
autoCreate?: boolean;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const Story = ({ text, autoCreate }: StoryProps) => {
|
|
213
|
+
const [item] = useState({ text: new TextObject(text) });
|
|
214
|
+
const [threads, setThreads] = useState<CommentThread[]>([]);
|
|
215
|
+
const commentRanges = useMemo(() => threads.map((thread) => thread.range), [threads]);
|
|
216
|
+
const [selected, setSelected] = useState<string>();
|
|
217
|
+
|
|
218
|
+
// Filter by visibility.
|
|
219
|
+
const visibleThreads = useMemo(() => threads.filter((thread) => thread.yPos !== undefined), [threads]);
|
|
220
|
+
|
|
221
|
+
const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
|
|
222
|
+
const id = PublicKey.random().toHex();
|
|
223
|
+
setThreads((threads) => [
|
|
224
|
+
...threads,
|
|
225
|
+
{
|
|
226
|
+
id,
|
|
227
|
+
range: { id, cursor },
|
|
228
|
+
yPos: location ? Math.floor(location.top) : undefined,
|
|
229
|
+
messages: autoCreate
|
|
230
|
+
? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 2, max: 5 } })
|
|
231
|
+
: [],
|
|
232
|
+
},
|
|
233
|
+
]);
|
|
234
|
+
setSelected(id);
|
|
235
|
+
return id;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// TODO(burdon): Scroll sidebar on select.
|
|
239
|
+
const handleSelectComment: CommentsOptions['onSelect'] = ({ active, closest, ranges }) => {
|
|
240
|
+
setThreads((threads) =>
|
|
241
|
+
threads.map((thread) => {
|
|
242
|
+
const range = ranges.find((range) => range.id === thread.range.id);
|
|
243
|
+
if (range) {
|
|
244
|
+
thread.yPos = range.location ? Math.floor(range.location.top) : undefined;
|
|
245
|
+
thread.selection = { from: range.from, to: range.to };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return thread;
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
setSelected(active ?? closest);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const handleMoveComment: CommentsOptions['onMove'] = (id, cursor) => {
|
|
256
|
+
setThreads((threads) =>
|
|
257
|
+
threads.map((thread) => {
|
|
258
|
+
if (thread.id === id) {
|
|
259
|
+
thread.range.cursor = cursor;
|
|
260
|
+
}
|
|
261
|
+
return thread;
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const handleSelectThread = (id: string) => {
|
|
267
|
+
setSelected(id);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const handleResolveThread = (id: string) => {
|
|
271
|
+
setThreads((threads) => [...threads.filter((thread) => thread.id !== id)]);
|
|
272
|
+
setSelected(undefined);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<div className={mx(fixedInsetFlexLayout, 'bg-neutral-100')}>
|
|
277
|
+
<div className='flex justify-center h-full gap-8'>
|
|
278
|
+
<div className='flex flex-col h-full w-[600px]'>
|
|
279
|
+
<Editor
|
|
280
|
+
item={item}
|
|
281
|
+
commentSelected={selected}
|
|
282
|
+
commentRanges={commentRanges}
|
|
283
|
+
onCreateComment={handleCreateComment}
|
|
284
|
+
onMoveComment={handleMoveComment}
|
|
285
|
+
onSelectComment={handleSelectComment}
|
|
286
|
+
/>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<div className='flex flex-col h-full w-[300px]'>
|
|
290
|
+
<Sidebar
|
|
291
|
+
threads={visibleThreads}
|
|
292
|
+
selected={selected}
|
|
293
|
+
onSelect={handleSelectThread}
|
|
294
|
+
onResolve={handleResolveThread}
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
export default {
|
|
303
|
+
title: 'react-ui-editor/comments',
|
|
304
|
+
component: MarkdownEditor,
|
|
305
|
+
decorators: [withTheme],
|
|
306
|
+
render: (args: StoryProps) => <Story {...args} />,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const str = (...lines: string[]) => lines.join('\n');
|
|
310
|
+
|
|
311
|
+
const document = str(
|
|
312
|
+
'# Comments',
|
|
313
|
+
'',
|
|
314
|
+
str(...faker.helpers.multiple(() => [faker.lorem.paragraph({ min: 5, max: 10 }), ''], { count: 20 }).flat()),
|
|
315
|
+
'',
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
export const Default = {
|
|
319
|
+
args: {
|
|
320
|
+
text: document,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
export const Demo = {
|
|
325
|
+
args: {
|
|
326
|
+
text: document,
|
|
327
|
+
autoCreate: true,
|
|
328
|
+
},
|
|
329
|
+
};
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type CompletionContext,
|
|
14
14
|
type CompletionResult,
|
|
15
15
|
} from '@codemirror/autocomplete';
|
|
16
|
+
import { markdownLanguage } from '@codemirror/lang-markdown';
|
|
16
17
|
import { keymap } from '@codemirror/view';
|
|
17
18
|
|
|
18
19
|
export type AutocompleteResult = Completion;
|
|
@@ -28,32 +29,32 @@ export const autocomplete = ({ onSearch }: AutocompleteOptions) => {
|
|
|
28
29
|
return [
|
|
29
30
|
// https://codemirror.net/docs/ref/#view.keymap
|
|
30
31
|
// https://discuss.codemirror.net/t/how-can-i-replace-the-default-autocompletion-keymap-v6/3322
|
|
32
|
+
// TODO(burdon): Set custom keymap.
|
|
31
33
|
keymap.of(completionKeymap),
|
|
32
34
|
|
|
33
35
|
// https://codemirror.net/examples/autocompletion
|
|
34
36
|
// https://codemirror.net/docs/ref/#autocomplete.autocompletion
|
|
35
37
|
autocompletion({
|
|
36
|
-
|
|
37
|
-
// defaultKeymap: false,
|
|
38
|
+
defaultKeymap: false,
|
|
38
39
|
|
|
39
40
|
// Don't start unless key press.
|
|
40
41
|
activateOnTyping: false,
|
|
42
|
+
}),
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
],
|
|
44
|
+
// TODO(burdon): Option to create new page?
|
|
45
|
+
// TODO(burdon): Optional decoration via addToOptions
|
|
46
|
+
markdownLanguage.data.of({
|
|
47
|
+
autocomplete: (context: CompletionContext): CompletionResult | null => {
|
|
48
|
+
const match = context.matchBefore(/\w*/);
|
|
49
|
+
if (!match || (match.from === match.to && !context.explicit)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
from: match.from,
|
|
55
|
+
options: onSearch(match.text.toLowerCase()),
|
|
56
|
+
};
|
|
57
|
+
},
|
|
57
58
|
}),
|
|
58
59
|
];
|
|
59
60
|
};
|
|
@@ -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;
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { Event } from '@dxos/async';
|
|
17
17
|
import { Context } from '@dxos/context';
|
|
18
18
|
|
|
19
|
-
import { CursorConverter } from '
|
|
19
|
+
import { Cursor, type CursorConverter } from './cursor';
|
|
20
20
|
|
|
21
21
|
export interface AwarenessProvider {
|
|
22
22
|
remoteStateChange: Event<void>;
|
|
@@ -91,7 +91,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
91
91
|
private _lastHead?: number = undefined;
|
|
92
92
|
|
|
93
93
|
constructor(view: EditorView) {
|
|
94
|
-
this._cursorConverter = view.state.facet(
|
|
94
|
+
this._cursorConverter = view.state.facet(Cursor.converter);
|
|
95
95
|
this._provider = view.state.facet(AwarenessProvider);
|
|
96
96
|
this._provider.open();
|
|
97
97
|
this._provider.remoteStateChange.on(this._ctx, () => {
|
|
@@ -156,7 +156,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
156
156
|
to: end,
|
|
157
157
|
value: Decoration.mark({
|
|
158
158
|
attributes: { style: `background-color: ${lightColor}` },
|
|
159
|
-
class: 'cm-
|
|
159
|
+
class: 'cm-collab-selection',
|
|
160
160
|
}),
|
|
161
161
|
});
|
|
162
162
|
} else {
|
|
@@ -166,7 +166,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
166
166
|
to: startLine.from + startLine.length,
|
|
167
167
|
value: Decoration.mark({
|
|
168
168
|
attributes: { style: `background-color: ${color}` },
|
|
169
|
-
class: 'cm-
|
|
169
|
+
class: 'cm-collab-selection',
|
|
170
170
|
}),
|
|
171
171
|
});
|
|
172
172
|
|
|
@@ -176,7 +176,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
176
176
|
to: end,
|
|
177
177
|
value: Decoration.mark({
|
|
178
178
|
attributes: { style: `background-color: ${color}` },
|
|
179
|
-
class: 'cm-
|
|
179
|
+
class: 'cm-collab-selection',
|
|
180
180
|
}),
|
|
181
181
|
});
|
|
182
182
|
|
|
@@ -186,7 +186,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
186
186
|
from: linePos,
|
|
187
187
|
to: linePos,
|
|
188
188
|
value: Decoration.line({
|
|
189
|
-
attributes: { style: `background-color: ${color}`, class: 'cm-
|
|
189
|
+
attributes: { style: `background-color: ${color}`, class: 'cm-collab-selectionLine' },
|
|
190
190
|
}),
|
|
191
191
|
});
|
|
192
192
|
}
|
|
@@ -208,24 +208,22 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
class RemoteCaretWidget extends WidgetType {
|
|
211
|
-
constructor(
|
|
211
|
+
constructor(private readonly _name: string, private readonly _color: string) {
|
|
212
212
|
super();
|
|
213
|
-
this.name = name;
|
|
214
|
-
this.color = color;
|
|
215
213
|
}
|
|
216
214
|
|
|
217
|
-
toDOM(): HTMLElement {
|
|
215
|
+
override toDOM(): HTMLElement {
|
|
218
216
|
const span = document.createElement('span');
|
|
219
|
-
span.className = 'cm-
|
|
220
|
-
span.style.backgroundColor = this.
|
|
221
|
-
span.style.borderColor = this.
|
|
217
|
+
span.className = 'cm-collab-selectionCaret';
|
|
218
|
+
span.style.backgroundColor = this._color;
|
|
219
|
+
span.style.borderColor = this._color;
|
|
222
220
|
|
|
223
221
|
const dot = document.createElement('div');
|
|
224
|
-
dot.className = 'cm-
|
|
222
|
+
dot.className = 'cm-collab-selectionCaretDot';
|
|
225
223
|
|
|
226
224
|
const info = document.createElement('div');
|
|
227
|
-
info.className = 'cm-
|
|
228
|
-
info.innerText = this.
|
|
225
|
+
info.className = 'cm-collab-selectionInfo';
|
|
226
|
+
info.innerText = this._name;
|
|
229
227
|
|
|
230
228
|
span.appendChild(document.createTextNode('\u2060'));
|
|
231
229
|
span.appendChild(dot);
|
|
@@ -236,18 +234,14 @@ class RemoteCaretWidget extends WidgetType {
|
|
|
236
234
|
return span;
|
|
237
235
|
}
|
|
238
236
|
|
|
239
|
-
override eq(widget: this) {
|
|
240
|
-
return widget.color === this.color;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
compare(widget: this) {
|
|
244
|
-
return widget.color === this.color;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
237
|
override updateDOM() {
|
|
248
238
|
return false;
|
|
249
239
|
}
|
|
250
240
|
|
|
241
|
+
override eq(widget: this) {
|
|
242
|
+
return widget._color === this._color;
|
|
243
|
+
}
|
|
244
|
+
|
|
251
245
|
override get estimatedHeight() {
|
|
252
246
|
return -1;
|
|
253
247
|
}
|
|
@@ -257,14 +251,13 @@ class RemoteCaretWidget extends WidgetType {
|
|
|
257
251
|
}
|
|
258
252
|
}
|
|
259
253
|
|
|
260
|
-
// TODO(burdon): Rename prefix (y for yjs?)
|
|
261
254
|
const styles = EditorView.baseTheme({
|
|
262
|
-
'.cm-
|
|
263
|
-
'.cm-
|
|
255
|
+
'.cm-collab-selection': {},
|
|
256
|
+
'.cm-collab-selectionLine': {
|
|
264
257
|
padding: 0,
|
|
265
258
|
margin: '0px 2px 0px 4px',
|
|
266
259
|
},
|
|
267
|
-
'.cm-
|
|
260
|
+
'.cm-collab-selectionCaret': {
|
|
268
261
|
position: 'relative',
|
|
269
262
|
borderLeft: '1px solid black',
|
|
270
263
|
borderRight: '1px solid black',
|
|
@@ -273,7 +266,7 @@ const styles = EditorView.baseTheme({
|
|
|
273
266
|
boxSizing: 'border-box',
|
|
274
267
|
display: 'inline',
|
|
275
268
|
},
|
|
276
|
-
'.cm-
|
|
269
|
+
'.cm-collab-selectionCaretDot': {
|
|
277
270
|
borderRadius: '50%',
|
|
278
271
|
position: 'absolute',
|
|
279
272
|
width: '.4em',
|
|
@@ -284,11 +277,11 @@ const styles = EditorView.baseTheme({
|
|
|
284
277
|
transition: 'transform .3s ease-in-out',
|
|
285
278
|
boxSizing: 'border-box',
|
|
286
279
|
},
|
|
287
|
-
'.cm-
|
|
280
|
+
'.cm-collab-selectionCaret:hover > .cm-collab-selectionCaretDot': {
|
|
288
281
|
transformOrigin: 'bottom center',
|
|
289
282
|
transform: 'scale(0)',
|
|
290
283
|
},
|
|
291
|
-
'.cm-
|
|
284
|
+
'.cm-collab-selectionInfo': {
|
|
292
285
|
position: 'absolute',
|
|
293
286
|
top: '-1.05em',
|
|
294
287
|
left: '-1px',
|
|
@@ -309,7 +302,7 @@ const styles = EditorView.baseTheme({
|
|
|
309
302
|
transitionDelay: '0s',
|
|
310
303
|
whiteSpace: 'nowrap',
|
|
311
304
|
},
|
|
312
|
-
'.cm-
|
|
305
|
+
'.cm-collab-selectionCaret:hover > .cm-collab-selectionInfo': {
|
|
313
306
|
opacity: 1,
|
|
314
307
|
transitionDelay: '0s',
|
|
315
308
|
},
|
package/src/extensions/code.ts
CHANGED
|
@@ -55,7 +55,7 @@ const getLineRange = (lines: BlockInfo[], from: number, to: number) => {
|
|
|
55
55
|
export const code = () => {
|
|
56
56
|
const getDecorations = (view: EditorView) => {
|
|
57
57
|
const decorations: Range<Decoration>[] = [];
|
|
58
|
-
if (view.state.readOnly) {
|
|
58
|
+
if (!view.hasFocus || view.state.readOnly) {
|
|
59
59
|
const text = view.state.doc.sliceString(0);
|
|
60
60
|
const matches = text.matchAll(CODE_REGEX);
|
|
61
61
|
const blocks = view.viewportLineBlocks;
|
|
@@ -78,13 +78,21 @@ export const code = () => {
|
|
|
78
78
|
return [
|
|
79
79
|
ViewPlugin.fromClass(
|
|
80
80
|
class {
|
|
81
|
+
hasFocus = false;
|
|
81
82
|
decorations: DecorationSet;
|
|
82
83
|
constructor(view: EditorView) {
|
|
83
84
|
this.decorations = getDecorations(view);
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
update(update: ViewUpdate) {
|
|
87
|
-
if (
|
|
88
|
+
if (
|
|
89
|
+
// TODO(burdon): Generalize for other extensions.
|
|
90
|
+
this.hasFocus !== update.view.hasFocus ||
|
|
91
|
+
update.startState.readOnly !== update.view.state.readOnly ||
|
|
92
|
+
update.docChanged ||
|
|
93
|
+
update.viewportChanged
|
|
94
|
+
) {
|
|
95
|
+
this.hasFocus = update.view.hasFocus;
|
|
88
96
|
this.decorations = getDecorations(update.view);
|
|
89
97
|
}
|
|
90
98
|
}
|