@dxos/react-ui-editor 0.3.11-main.c86e105 → 0.3.11-main.cd64fe0
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 +84 -96
- 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 +3 -0
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +2 -2
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/defs.d.ts +4 -4
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/semaphore.d.ts +4 -9
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
- package/dist/types/src/extensions/code.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +9 -4
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/extensions/hr.d.ts.map +1 -1
- 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/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/index.d.ts +1 -1
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/package.json +23 -23
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +16 -14
- package/src/components/TextEditor/TextEditor.tsx +12 -12
- package/src/components/TextEditor/comments.stories.tsx +74 -48
- package/src/extensions/automerge/automerge.ts +10 -12
- package/src/extensions/automerge/defs.ts +13 -9
- package/src/extensions/automerge/semaphore.ts +10 -12
- package/src/extensions/awareness.ts +2 -1
- package/src/extensions/code.ts +15 -17
- package/src/extensions/comments.ts +68 -50
- package/src/extensions/hr.ts +6 -7
- package/src/extensions/index.ts +1 -0
- package/src/extensions/link.ts +3 -3
- package/src/extensions/outliner.ts +12 -0
- package/src/extensions/tasklist.ts +1 -0
- package/src/hooks/index.ts +2 -1
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
defaultOptions,
|
|
27
27
|
hr,
|
|
28
28
|
image,
|
|
29
|
-
listener,
|
|
30
29
|
link,
|
|
31
30
|
mention,
|
|
32
31
|
table,
|
|
@@ -34,6 +33,7 @@ import {
|
|
|
34
33
|
typewriter,
|
|
35
34
|
type CommentsOptions,
|
|
36
35
|
type LinkOptions,
|
|
36
|
+
outliner,
|
|
37
37
|
} from '../../extensions';
|
|
38
38
|
import { type CommentRange, useTextModel } from '../../hooks';
|
|
39
39
|
|
|
@@ -304,17 +304,14 @@ export const Image = {
|
|
|
304
304
|
};
|
|
305
305
|
|
|
306
306
|
export const Tasklist = {
|
|
307
|
+
render: () => <Story text={str(text.tasks, '', text.list, text.footer)} extensions={[tasklist()]} />,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export const Outliner = {
|
|
307
311
|
render: () => (
|
|
308
312
|
<Story
|
|
309
|
-
text={str(
|
|
310
|
-
extensions={[
|
|
311
|
-
tasklist(),
|
|
312
|
-
listener({
|
|
313
|
-
onChange: (text) => {
|
|
314
|
-
console.log(text);
|
|
315
|
-
},
|
|
316
|
-
}),
|
|
317
|
-
]}
|
|
313
|
+
text={str('# Outliner', '', 'Block', ': this is a block', ': with multiple lines', text.footer)}
|
|
314
|
+
extensions={[outliner()]}
|
|
318
315
|
/>
|
|
319
316
|
),
|
|
320
317
|
};
|
|
@@ -384,8 +381,15 @@ export const Comments = {
|
|
|
384
381
|
},
|
|
385
382
|
};
|
|
386
383
|
|
|
384
|
+
const typewriterItems = localStorage.getItem('dxos.org/plugin/markdown/typewriter')?.split(',');
|
|
385
|
+
|
|
387
386
|
export const Typewriter = {
|
|
388
|
-
render: () =>
|
|
387
|
+
render: () => (
|
|
388
|
+
<Story
|
|
389
|
+
text={str('# Typewriter', '', text.paragraphs, text.footer)}
|
|
390
|
+
extensions={[typewriter({ items: typewriterItems })]}
|
|
391
|
+
/>
|
|
392
|
+
),
|
|
389
393
|
};
|
|
390
394
|
|
|
391
395
|
export const Blast = {
|
|
@@ -393,9 +397,7 @@ export const Blast = {
|
|
|
393
397
|
<Story
|
|
394
398
|
text={str('# Blast', '', text.paragraphs, text.code, text.paragraphs)}
|
|
395
399
|
extensions={[
|
|
396
|
-
typewriter({
|
|
397
|
-
items: localStorage.getItem('dxos.composer.extension.demo')?.split(','),
|
|
398
|
-
}),
|
|
400
|
+
typewriter({ items: typewriterItems }),
|
|
399
401
|
blast(
|
|
400
402
|
defaultsDeep(
|
|
401
403
|
{
|
|
@@ -49,10 +49,10 @@ export type TextEditorSlots = {
|
|
|
49
49
|
// TODO(burdon): Spellcheck?
|
|
50
50
|
export type TextEditorProps = {
|
|
51
51
|
model: EditorModel;
|
|
52
|
-
|
|
53
|
-
selection?: { anchor: number; head?: number };
|
|
52
|
+
autofocus?: boolean;
|
|
54
53
|
readonly?: boolean; // TODO(burdon): Move into model.
|
|
55
|
-
|
|
54
|
+
selection?: { anchor: number; head?: number };
|
|
55
|
+
comments?: CommentRange[]; // TODO(burdon): Move into extension?
|
|
56
56
|
extensions?: Extension[];
|
|
57
57
|
editorMode?: EditorMode;
|
|
58
58
|
placeholder?: string;
|
|
@@ -65,7 +65,7 @@ export type TextEditorProps = {
|
|
|
65
65
|
*/
|
|
66
66
|
export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
67
67
|
(
|
|
68
|
-
{ model,
|
|
68
|
+
{ model, autofocus, readonly, selection, comments, extensions = [], editorMode, theme, slots = defaultSlots },
|
|
69
69
|
forwardedRef,
|
|
70
70
|
) => {
|
|
71
71
|
const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
|
|
@@ -74,16 +74,16 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
74
74
|
// The editor view ref should only be used as an escape hatch.
|
|
75
75
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
76
76
|
const [view, setView] = useState<EditorView | null>(null);
|
|
77
|
+
// NOTE: This does not cause the parent to re-render, so the ref is not available immediately.
|
|
77
78
|
useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
|
|
78
79
|
|
|
79
|
-
// Focus.
|
|
80
80
|
useEffect(() => {
|
|
81
|
-
if (
|
|
82
|
-
view
|
|
81
|
+
if (autofocus) {
|
|
82
|
+
view?.focus();
|
|
83
83
|
}
|
|
84
|
-
}, [
|
|
84
|
+
}, [autofocus, view]);
|
|
85
85
|
|
|
86
|
-
// TODO(burdon): Factor out as extension.
|
|
86
|
+
// TODO(burdon): Factor out as extension/hook.
|
|
87
87
|
const { awareness, peer } = model;
|
|
88
88
|
useEffect(() => {
|
|
89
89
|
if (awareness && peer) {
|
|
@@ -95,7 +95,7 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
95
95
|
}
|
|
96
96
|
}, [awareness, peer, themeMode]);
|
|
97
97
|
|
|
98
|
-
// TODO(burdon): Factor out as extension.
|
|
98
|
+
// TODO(burdon): Factor out as extension/hook.
|
|
99
99
|
useEffect(() => {
|
|
100
100
|
if (view && comments !== undefined) {
|
|
101
101
|
view.dispatch({
|
|
@@ -178,11 +178,11 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
178
178
|
<div
|
|
179
179
|
key={model.id}
|
|
180
180
|
role='none'
|
|
181
|
-
ref={rootRef}
|
|
182
181
|
tabIndex={0}
|
|
183
182
|
onKeyUp={handleKeyUp}
|
|
184
183
|
{...slots.root}
|
|
185
184
|
{...(editorMode !== 'vim' && tabsterDOMAttribute)}
|
|
185
|
+
ref={rootRef}
|
|
186
186
|
/>
|
|
187
187
|
);
|
|
188
188
|
},
|
|
@@ -225,7 +225,7 @@ export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
225
225
|
|
|
226
226
|
export const defaultSlots: TextEditorSlots = {
|
|
227
227
|
root: {
|
|
228
|
-
className: mx('p-2 overflow-y-auto', inputSurface),
|
|
228
|
+
className: mx('w-full p-2 overflow-y-auto', inputSurface),
|
|
229
229
|
},
|
|
230
230
|
};
|
|
231
231
|
|
|
@@ -6,11 +6,12 @@ import '@dxosTheme';
|
|
|
6
6
|
|
|
7
7
|
import { type EditorView, keymap } from '@codemirror/view';
|
|
8
8
|
import { faker } from '@faker-js/faker';
|
|
9
|
-
import { Check } from '@phosphor-icons/react';
|
|
9
|
+
import { Check, Trash } from '@phosphor-icons/react';
|
|
10
10
|
import React, { type FC, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
11
|
|
|
12
|
-
import { TextObject } from '@dxos/echo-schema';
|
|
12
|
+
import { getTextContent, TextObject } from '@dxos/echo-schema';
|
|
13
13
|
import { PublicKey } from '@dxos/keys';
|
|
14
|
+
import { log } from '@dxos/log';
|
|
14
15
|
import { Button, DensityProvider } from '@dxos/react-ui';
|
|
15
16
|
import { fixedInsetFlexLayout, mx } from '@dxos/react-ui-theme';
|
|
16
17
|
import { withTheme } from '@dxos/storybook-utils';
|
|
@@ -30,9 +31,10 @@ const Editor: FC<{
|
|
|
30
31
|
commentSelected?: string;
|
|
31
32
|
commentRanges: CommentRange[];
|
|
32
33
|
onCreateComment: CommentsOptions['onCreate'];
|
|
33
|
-
|
|
34
|
+
onDeleteComment: CommentsOptions['onDelete'];
|
|
35
|
+
onUpdateComment: CommentsOptions['onUpdate'];
|
|
34
36
|
onSelectComment: CommentsOptions['onSelect'];
|
|
35
|
-
}> = ({ item, commentSelected, commentRanges, onCreateComment,
|
|
37
|
+
}> = ({ item, commentSelected, commentRanges, onCreateComment, onDeleteComment, onUpdateComment, onSelectComment }) => {
|
|
36
38
|
const model = useTextModel({ text: item.text });
|
|
37
39
|
const editorRef = useRef<EditorView>(null);
|
|
38
40
|
const [selected, setSelected] = useState<string>();
|
|
@@ -56,7 +58,8 @@ const Editor: FC<{
|
|
|
56
58
|
return [
|
|
57
59
|
comments({
|
|
58
60
|
onCreate: onCreateComment,
|
|
59
|
-
|
|
61
|
+
onDelete: onDeleteComment,
|
|
62
|
+
onUpdate: onUpdateComment,
|
|
60
63
|
onSelect: onSelectComment,
|
|
61
64
|
}),
|
|
62
65
|
];
|
|
@@ -84,6 +87,7 @@ type CommentThread = {
|
|
|
84
87
|
yPos?: number;
|
|
85
88
|
selection?: Range;
|
|
86
89
|
messages: TextObject[];
|
|
90
|
+
deleted?: boolean;
|
|
87
91
|
};
|
|
88
92
|
|
|
89
93
|
const Thread: FC<{
|
|
@@ -92,23 +96,17 @@ const Thread: FC<{
|
|
|
92
96
|
onSelect: () => void;
|
|
93
97
|
onResolve: () => void;
|
|
94
98
|
}> = ({ thread, selected, onSelect, onResolve }) => {
|
|
99
|
+
const [focus, setFocus] = useState(false);
|
|
95
100
|
const [item, setItem] = useState({ text: new TextObject() });
|
|
96
101
|
const model = useTextModel({ text: item.text });
|
|
97
102
|
const editorRef = useRef<EditorView>(null);
|
|
98
103
|
|
|
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
104
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
107
105
|
useEffect(() => {
|
|
108
106
|
if (selected) {
|
|
109
107
|
containerRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
110
108
|
if (thread.messages.length === 0) {
|
|
111
|
-
|
|
109
|
+
setFocus(true);
|
|
112
110
|
}
|
|
113
111
|
}
|
|
114
112
|
}, [selected]);
|
|
@@ -118,7 +116,7 @@ const Thread: FC<{
|
|
|
118
116
|
if (text?.length) {
|
|
119
117
|
thread.messages.push(item.text);
|
|
120
118
|
setItem({ text: new TextObject() });
|
|
121
|
-
|
|
119
|
+
setFocus(true);
|
|
122
120
|
}
|
|
123
121
|
};
|
|
124
122
|
|
|
@@ -127,41 +125,49 @@ const Thread: FC<{
|
|
|
127
125
|
}
|
|
128
126
|
|
|
129
127
|
return (
|
|
130
|
-
<div
|
|
131
|
-
|
|
128
|
+
<div
|
|
129
|
+
className={mx(
|
|
130
|
+
'flex flex-col m-1 rounded shadow divide-y bg-white',
|
|
131
|
+
selected && 'ring',
|
|
132
|
+
thread.deleted && 'opacity-50',
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
<div className='flex p-2 gap-2 items-center text-xs font-mono text-neutral-500 font-thin'>
|
|
132
136
|
<span>id:{thread.id.slice(0, 4)}</span>
|
|
133
137
|
<span>from:{thread.selection?.from}</span>
|
|
134
138
|
<span>to:{thread.selection?.to}</span>
|
|
135
139
|
<span>y:{thread.yPos}</span>
|
|
140
|
+
<span className='grow' />
|
|
141
|
+
{thread.deleted && <Trash />}
|
|
136
142
|
</div>
|
|
137
143
|
|
|
138
144
|
{thread.messages.map((message, i) => (
|
|
139
145
|
// TODO(burdon): Fix default editor padding so content doesn't jump on creating message.
|
|
140
146
|
<div key={i} className='p-2' onClick={() => onSelect()}>
|
|
141
|
-
{message
|
|
147
|
+
{getTextContent(message)}
|
|
142
148
|
</div>
|
|
143
149
|
))}
|
|
144
150
|
|
|
145
|
-
<div ref={containerRef} onClick={() => onSelect()} className='flex
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
151
|
+
<div ref={containerRef} onClick={() => onSelect()} className='flex'>
|
|
152
|
+
<TextEditor
|
|
153
|
+
ref={editorRef}
|
|
154
|
+
autofocus={focus}
|
|
155
|
+
model={model}
|
|
156
|
+
placeholder={'Enter comment...'}
|
|
157
|
+
slots={{ root: { className: 'grow p-2 rounded-b' } }}
|
|
158
|
+
extensions={[
|
|
159
|
+
keymap.of([
|
|
160
|
+
{
|
|
161
|
+
key: 'Enter',
|
|
162
|
+
run: () => {
|
|
163
|
+
handleCreateMessage();
|
|
164
|
+
return true;
|
|
159
165
|
},
|
|
160
|
-
|
|
161
|
-
]
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
<Button variant='ghost' classNames='px-1
|
|
166
|
+
},
|
|
167
|
+
]),
|
|
168
|
+
]}
|
|
169
|
+
/>
|
|
170
|
+
<Button variant='ghost' classNames='px-1' title='Resolve' onClick={onResolve}>
|
|
165
171
|
<Check />
|
|
166
172
|
</Button>
|
|
167
173
|
</div>
|
|
@@ -185,7 +191,7 @@ const Sidebar: FC<{
|
|
|
185
191
|
|
|
186
192
|
return (
|
|
187
193
|
<DensityProvider density='fine'>
|
|
188
|
-
<div className='flex flex-col grow overflow-y-scroll py-4 gap-4
|
|
194
|
+
<div className='flex flex-col grow overflow-y-scroll py-4 gap-4'>
|
|
189
195
|
{sortedThreads.map((thread) => (
|
|
190
196
|
<Thread
|
|
191
197
|
key={thread.id}
|
|
@@ -220,6 +226,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
220
226
|
|
|
221
227
|
const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
|
|
222
228
|
const id = PublicKey.random().toHex();
|
|
229
|
+
log.info('create', { id: id.slice(0, 4), cursor });
|
|
223
230
|
setThreads((threads) => [
|
|
224
231
|
...threads,
|
|
225
232
|
{
|
|
@@ -227,7 +234,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
227
234
|
range: { id, cursor },
|
|
228
235
|
yPos: location ? Math.floor(location.top) : undefined,
|
|
229
236
|
messages: autoCreate
|
|
230
|
-
? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min:
|
|
237
|
+
? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 1, max: 3 } })
|
|
231
238
|
: [],
|
|
232
239
|
},
|
|
233
240
|
]);
|
|
@@ -235,32 +242,50 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
235
242
|
return id;
|
|
236
243
|
};
|
|
237
244
|
|
|
238
|
-
|
|
239
|
-
|
|
245
|
+
const handleDeleteComment: CommentsOptions['onDelete'] = (id) => {
|
|
246
|
+
log.info('delete', { id: id.slice(0, 4) });
|
|
240
247
|
setThreads((threads) =>
|
|
241
248
|
threads.map((thread) => {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
thread.yPos = range.location ? Math.floor(range.location.top) : undefined;
|
|
245
|
-
thread.selection = { from: range.from, to: range.to };
|
|
249
|
+
if (thread.id === id) {
|
|
250
|
+
thread.deleted = true;
|
|
246
251
|
}
|
|
247
252
|
|
|
248
253
|
return thread;
|
|
249
254
|
}),
|
|
250
255
|
);
|
|
251
256
|
|
|
252
|
-
setSelected(
|
|
257
|
+
setSelected((selected) => (selected === id ? undefined : selected));
|
|
253
258
|
};
|
|
254
259
|
|
|
255
|
-
const
|
|
260
|
+
const handleUpdateComment: CommentsOptions['onUpdate'] = (id, cursor) => {
|
|
261
|
+
log.info('update', { id: id.slice(0, 4), cursor });
|
|
256
262
|
setThreads((threads) =>
|
|
257
263
|
threads.map((thread) => {
|
|
258
264
|
if (thread.id === id) {
|
|
259
265
|
thread.range.cursor = cursor;
|
|
266
|
+
delete thread.deleted;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return thread;
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const handleSelectComment: CommentsOptions['onSelect'] = ({ active, closest, ranges }) => {
|
|
275
|
+
log.info('select', { active: active?.slice(0, 4), closest: closest?.slice(0, 4) });
|
|
276
|
+
setThreads((threads) =>
|
|
277
|
+
threads.map((thread) => {
|
|
278
|
+
const range = ranges.find((range) => range.id === thread.range.id);
|
|
279
|
+
if (range) {
|
|
280
|
+
thread.yPos = range.location ? Math.floor(range.location.top) : undefined;
|
|
281
|
+
thread.selection = { from: range.from, to: range.to };
|
|
260
282
|
}
|
|
283
|
+
|
|
261
284
|
return thread;
|
|
262
285
|
}),
|
|
263
286
|
);
|
|
287
|
+
|
|
288
|
+
setSelected(active ?? closest);
|
|
264
289
|
};
|
|
265
290
|
|
|
266
291
|
const handleSelectThread = (id: string) => {
|
|
@@ -281,7 +306,8 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
281
306
|
commentSelected={selected}
|
|
282
307
|
commentRanges={commentRanges}
|
|
283
308
|
onCreateComment={handleCreateComment}
|
|
284
|
-
|
|
309
|
+
onDeleteComment={handleDeleteComment}
|
|
310
|
+
onUpdateComment={handleUpdateComment}
|
|
285
311
|
onSelectComment={handleSelectComment}
|
|
286
312
|
/>
|
|
287
313
|
</div>
|
|
@@ -321,7 +347,7 @@ export const Default = {
|
|
|
321
347
|
},
|
|
322
348
|
};
|
|
323
349
|
|
|
324
|
-
export const
|
|
350
|
+
export const Testing = {
|
|
325
351
|
args: {
|
|
326
352
|
text: document,
|
|
327
353
|
autoCreate: true,
|
|
@@ -4,40 +4,37 @@
|
|
|
4
4
|
// Ref: https://github.com/automerge/automerge-codemirror
|
|
5
5
|
//
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { StateField, type Extension, type Transaction } from '@codemirror/state';
|
|
8
8
|
import { ViewPlugin, type EditorView, type PluginValue, type ViewUpdate } from '@codemirror/view';
|
|
9
9
|
|
|
10
10
|
import { next as A } from '@dxos/automerge/automerge';
|
|
11
11
|
import { type Prop } from '@dxos/automerge/automerge';
|
|
12
12
|
|
|
13
13
|
import { cursorConverter } from './cursor';
|
|
14
|
-
import { effectType, type IDocHandle, isReconcileTx, type Value } from './defs';
|
|
14
|
+
import { effectType, type IDocHandle, isReconcileTx, semaphoreFacet, type Value } from './defs';
|
|
15
15
|
import { PatchSemaphore } from './semaphore';
|
|
16
16
|
import { Cursor } from '../cursor';
|
|
17
17
|
|
|
18
|
-
const semaphoreFacet = Facet.define<PatchSemaphore, PatchSemaphore>({
|
|
19
|
-
combine: (values) => values.at(-1)!, // Take last.
|
|
20
|
-
});
|
|
21
|
-
|
|
22
18
|
export type AutomergeOptions = {
|
|
23
19
|
handle: IDocHandle;
|
|
24
20
|
path: Prop[];
|
|
25
21
|
};
|
|
26
22
|
|
|
27
23
|
export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
28
|
-
// TODO(burdon): Rename.
|
|
29
24
|
const stateField: StateField<Value> = StateField.define({
|
|
30
25
|
create: () => ({
|
|
31
26
|
lastHeads: A.getHeads(handle.docSync()!),
|
|
32
27
|
unreconciledTransactions: [],
|
|
33
28
|
path: path.slice(),
|
|
34
29
|
}),
|
|
30
|
+
|
|
35
31
|
update: (value: Value, tr: Transaction) => {
|
|
36
32
|
const result: Value = {
|
|
37
33
|
lastHeads: value.lastHeads,
|
|
38
34
|
unreconciledTransactions: value.unreconciledTransactions.slice(),
|
|
39
35
|
path: path.slice(),
|
|
40
36
|
};
|
|
37
|
+
|
|
41
38
|
let clearUnreconciled = false;
|
|
42
39
|
for (const effect of tr.effects) {
|
|
43
40
|
if (effect.is(effectType)) {
|
|
@@ -45,6 +42,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
45
42
|
clearUnreconciled = true;
|
|
46
43
|
}
|
|
47
44
|
}
|
|
45
|
+
|
|
48
46
|
if (clearUnreconciled) {
|
|
49
47
|
result.unreconciledTransactions = [];
|
|
50
48
|
} else {
|
|
@@ -52,23 +50,23 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
52
50
|
result.unreconciledTransactions.push(tr);
|
|
53
51
|
}
|
|
54
52
|
}
|
|
53
|
+
|
|
55
54
|
return result;
|
|
56
55
|
},
|
|
57
56
|
});
|
|
58
57
|
|
|
59
|
-
const semaphore = new PatchSemaphore(stateField);
|
|
58
|
+
const semaphore = new PatchSemaphore(handle, stateField);
|
|
60
59
|
|
|
61
60
|
const viewPlugin = ViewPlugin.fromClass(
|
|
62
|
-
class
|
|
61
|
+
class AutomergeViewPlugin implements PluginValue {
|
|
63
62
|
constructor(private readonly _view: EditorView) {
|
|
64
63
|
handle.addListener('change', this._handleChange);
|
|
65
64
|
}
|
|
66
65
|
|
|
67
66
|
update(update: ViewUpdate) {
|
|
68
67
|
if (update.transactions.length > 0 && update.transactions.some((tr) => !isReconcileTx(tr))) {
|
|
69
|
-
// TODO(burdon): This is a hack to ensure that the view is updated after the transaction is applied.
|
|
70
68
|
queueMicrotask(() => {
|
|
71
|
-
this._view.state.facet(semaphoreFacet).reconcile(
|
|
69
|
+
this._view.state.facet(semaphoreFacet).reconcile(this._view);
|
|
72
70
|
});
|
|
73
71
|
}
|
|
74
72
|
}
|
|
@@ -78,7 +76,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
private _handleChange = () => {
|
|
81
|
-
this._view.state.facet(semaphoreFacet).reconcile(
|
|
79
|
+
this._view.state.facet(semaphoreFacet).reconcile(this._view);
|
|
82
80
|
};
|
|
83
81
|
},
|
|
84
82
|
);
|
|
@@ -1,26 +1,22 @@
|
|
|
1
1
|
//
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
|
+
// Copyright 2024 Automerge
|
|
3
4
|
// Ref: https://github.com/automerge/automerge-codemirror
|
|
4
5
|
//
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
-
Annotation,
|
|
8
|
-
StateEffect,
|
|
9
|
-
type StateField,
|
|
10
|
-
type EditorState,
|
|
11
|
-
type Transaction,
|
|
12
|
-
type TransactionSpec,
|
|
13
|
-
} from '@codemirror/state';
|
|
7
|
+
import { Annotation, StateEffect, type StateField, type EditorState, type Transaction, Facet } from '@codemirror/state';
|
|
14
8
|
|
|
15
9
|
import { type ChangeFn, type ChangeOptions, type Doc, type Heads, type Prop } from '@dxos/automerge/automerge';
|
|
16
10
|
|
|
11
|
+
import { type PatchSemaphore } from './semaphore';
|
|
12
|
+
|
|
17
13
|
export type Value = {
|
|
18
14
|
lastHeads: Heads;
|
|
19
15
|
path: Prop[];
|
|
20
16
|
unreconciledTransactions: Transaction[];
|
|
21
17
|
};
|
|
22
18
|
|
|
23
|
-
type UpdateHeads = {
|
|
19
|
+
export type UpdateHeads = {
|
|
24
20
|
newHeads: Heads;
|
|
25
21
|
};
|
|
26
22
|
|
|
@@ -36,6 +32,12 @@ export const reconcileAnnotationType = Annotation.define<unknown>();
|
|
|
36
32
|
|
|
37
33
|
export const isReconcileTx = (tr: Transaction): boolean => !!tr.annotation(reconcileAnnotationType);
|
|
38
34
|
|
|
35
|
+
// TODO(burdon): Rename sync/create generic def (like cursor) to decouple automerge deps.
|
|
36
|
+
export const semaphoreFacet = Facet.define<PatchSemaphore, PatchSemaphore>({
|
|
37
|
+
combine: (values) => values.at(-1)!, // Take last.
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/*
|
|
39
41
|
export const makeReconcile = (tr: TransactionSpec) => {
|
|
40
42
|
if (tr.annotations != null) {
|
|
41
43
|
if (tr.annotations instanceof Array) {
|
|
@@ -46,11 +48,13 @@ export const makeReconcile = (tr: TransactionSpec) => {
|
|
|
46
48
|
} else {
|
|
47
49
|
tr.annotations = [reconcileAnnotationType.of({})];
|
|
48
50
|
}
|
|
51
|
+
|
|
49
52
|
// return {
|
|
50
53
|
// ...tr,
|
|
51
54
|
// annotations: reconcileAnnotationType.of({})
|
|
52
55
|
// }
|
|
53
56
|
};
|
|
57
|
+
*/
|
|
54
58
|
|
|
55
59
|
export type IDocHandle<T = any> = {
|
|
56
60
|
docSync(): Doc<T> | undefined;
|
|
@@ -21,27 +21,23 @@ import {
|
|
|
21
21
|
import { updateAutomerge } from './update-automerge';
|
|
22
22
|
import { updateCodeMirror } from './update-codemirror';
|
|
23
23
|
|
|
24
|
-
type Doc<T> = automerge.Doc<T>;
|
|
25
|
-
type Heads = automerge.Heads;
|
|
26
|
-
|
|
27
|
-
type ChangeFn = (atHeads: Heads, change: (doc: Doc<unknown>) => void) => Heads | undefined;
|
|
28
|
-
|
|
29
24
|
/**
|
|
30
|
-
*
|
|
25
|
+
* Implements three-way merge (on each mutation/key-stroke).
|
|
31
26
|
*/
|
|
32
27
|
export class PatchSemaphore {
|
|
33
28
|
_inReconcile = false;
|
|
34
|
-
_queue: Array<ChangeFn> = [];
|
|
35
29
|
|
|
36
|
-
constructor(private readonly _field: StateField<Value>) {}
|
|
30
|
+
constructor(private readonly _handle: IDocHandle, private readonly _field: StateField<Value>) {}
|
|
37
31
|
|
|
38
|
-
reconcile = (
|
|
32
|
+
reconcile = (view: EditorView) => {
|
|
39
33
|
if (this._inReconcile) {
|
|
40
34
|
return;
|
|
41
35
|
}
|
|
42
36
|
this._inReconcile = true;
|
|
43
37
|
|
|
44
38
|
const path = getPath(view.state, this._field);
|
|
39
|
+
|
|
40
|
+
// Get the heads before the unreconciled transactions are applied.
|
|
45
41
|
const oldHeads = getLastHeads(view.state, this._field);
|
|
46
42
|
let selection = view.state.selection;
|
|
47
43
|
|
|
@@ -58,16 +54,18 @@ export class PatchSemaphore {
|
|
|
58
54
|
}
|
|
59
55
|
|
|
60
56
|
// Apply the unreconciled transactions to the document.
|
|
61
|
-
let newHeads = updateAutomerge(this._field,
|
|
57
|
+
let newHeads = updateAutomerge(this._field, this._handle, transactions, view.state);
|
|
62
58
|
|
|
63
59
|
// NOTE: null and undefined each come from automerge and repo respectively.
|
|
64
60
|
if (newHeads === null || newHeads === undefined) {
|
|
65
61
|
// TODO: @alexjg this is the call that's resetting the editor state on click
|
|
66
|
-
newHeads = automerge.getHeads(
|
|
62
|
+
newHeads = automerge.getHeads(this._handle.docSync()!);
|
|
67
63
|
}
|
|
68
64
|
|
|
69
65
|
// Now get the diff between the updated state of the document and the heads and apply that to the codemirror doc.
|
|
70
|
-
const diff = automerge.equals(oldHeads, newHeads)
|
|
66
|
+
const diff = automerge.equals(oldHeads, newHeads)
|
|
67
|
+
? []
|
|
68
|
+
: automerge.diff(this._handle.docSync()!, oldHeads, newHeads);
|
|
71
69
|
updateCodeMirror(view, selection, path, diff);
|
|
72
70
|
|
|
73
71
|
view.dispatch({
|
|
@@ -251,6 +251,7 @@ class RemoteCaretWidget extends WidgetType {
|
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
// TODO(burdon): Mixed case?
|
|
254
255
|
const styles = EditorView.baseTheme({
|
|
255
256
|
'.cm-collab-selection': {},
|
|
256
257
|
'.cm-collab-selectionLine': {
|
|
@@ -297,7 +298,7 @@ const styles = EditorView.baseTheme({
|
|
|
297
298
|
zIndex: 101,
|
|
298
299
|
transition: 'opacity .3s ease-in-out',
|
|
299
300
|
backgroundColor: 'inherit',
|
|
300
|
-
//
|
|
301
|
+
// These should be separate.
|
|
301
302
|
opacity: 0,
|
|
302
303
|
transitionDelay: '0s',
|
|
303
304
|
whiteSpace: 'nowrap',
|
package/src/extensions/code.ts
CHANGED
|
@@ -53,22 +53,20 @@ const getLineRange = (lines: BlockInfo[], from: number, to: number) => {
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
export const code = () => {
|
|
56
|
-
const
|
|
56
|
+
const buildDecorations = (view: EditorView) => {
|
|
57
57
|
const decorations: Range<Decoration>[] = [];
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
71
|
-
}
|
|
58
|
+
const text = view.state.doc.sliceString(0);
|
|
59
|
+
const matches = text.matchAll(CODE_REGEX);
|
|
60
|
+
const blocks = view.viewportLineBlocks;
|
|
61
|
+
for (const match of matches) {
|
|
62
|
+
const range = getLineRange(blocks, match.index!, match.index! + match[0].length);
|
|
63
|
+
for (let i = range[0]; i <= range[1]; i++) {
|
|
64
|
+
const block = blocks[i];
|
|
65
|
+
decorations.push(
|
|
66
|
+
Decoration.line({
|
|
67
|
+
class: mx('cm-codeblock', i === range[0] && 'cm-codeblock-first', i === range[1] && 'cm-codeblock-last'),
|
|
68
|
+
}).range(block.from),
|
|
69
|
+
);
|
|
72
70
|
}
|
|
73
71
|
}
|
|
74
72
|
|
|
@@ -81,7 +79,7 @@ export const code = () => {
|
|
|
81
79
|
hasFocus = false;
|
|
82
80
|
decorations: DecorationSet;
|
|
83
81
|
constructor(view: EditorView) {
|
|
84
|
-
this.decorations =
|
|
82
|
+
this.decorations = buildDecorations(view);
|
|
85
83
|
}
|
|
86
84
|
|
|
87
85
|
update(update: ViewUpdate) {
|
|
@@ -93,7 +91,7 @@ export const code = () => {
|
|
|
93
91
|
update.viewportChanged
|
|
94
92
|
) {
|
|
95
93
|
this.hasFocus = update.view.hasFocus;
|
|
96
|
-
this.decorations =
|
|
94
|
+
this.decorations = buildDecorations(update.view);
|
|
97
95
|
}
|
|
98
96
|
}
|
|
99
97
|
},
|