@dxos/react-ui-editor 0.3.11-main.3d4adea → 0.3.11-main.42059c4
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 +148 -111
- package/dist/lib/browser/index.mjs.map +3 -3
- package/dist/lib/browser/meta.json +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/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 +4 -9
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +12 -3
- package/dist/types/src/extensions/comments.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/hooks/index.d.ts +1 -1
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/package.json +25 -25
- package/src/components/TextEditor/TextEditor.tsx +10 -10
- package/src/components/TextEditor/comments.stories.tsx +78 -38
- package/src/extensions/automerge/automerge.ts +11 -11
- package/src/extensions/automerge/defs.ts +13 -9
- package/src/extensions/automerge/index.ts +0 -1
- package/src/extensions/automerge/semaphore.ts +10 -12
- package/src/extensions/comments.ts +104 -27
- package/src/extensions/tasklist.ts +51 -75
- package/src/extensions/yjs/yjs.test.ts +1 -1
- package/src/hooks/index.ts +2 -1
|
@@ -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,8 +31,10 @@ const Editor: FC<{
|
|
|
30
31
|
commentSelected?: string;
|
|
31
32
|
commentRanges: CommentRange[];
|
|
32
33
|
onCreateComment: CommentsOptions['onCreate'];
|
|
34
|
+
onDeleteComment: CommentsOptions['onDelete'];
|
|
35
|
+
onUpdateComment: CommentsOptions['onUpdate'];
|
|
33
36
|
onSelectComment: CommentsOptions['onSelect'];
|
|
34
|
-
}> = ({ item, commentSelected, commentRanges, onCreateComment, onSelectComment }) => {
|
|
37
|
+
}> = ({ item, commentSelected, commentRanges, onCreateComment, onDeleteComment, onUpdateComment, onSelectComment }) => {
|
|
35
38
|
const model = useTextModel({ text: item.text });
|
|
36
39
|
const editorRef = useRef<EditorView>(null);
|
|
37
40
|
const [selected, setSelected] = useState<string>();
|
|
@@ -55,6 +58,8 @@ const Editor: FC<{
|
|
|
55
58
|
return [
|
|
56
59
|
comments({
|
|
57
60
|
onCreate: onCreateComment,
|
|
61
|
+
onDelete: onDeleteComment,
|
|
62
|
+
onUpdate: onUpdateComment,
|
|
58
63
|
onSelect: onSelectComment,
|
|
59
64
|
}),
|
|
60
65
|
];
|
|
@@ -82,6 +87,7 @@ type CommentThread = {
|
|
|
82
87
|
yPos?: number;
|
|
83
88
|
selection?: Range;
|
|
84
89
|
messages: TextObject[];
|
|
90
|
+
deleted?: boolean;
|
|
85
91
|
};
|
|
86
92
|
|
|
87
93
|
const Thread: FC<{
|
|
@@ -90,23 +96,17 @@ const Thread: FC<{
|
|
|
90
96
|
onSelect: () => void;
|
|
91
97
|
onResolve: () => void;
|
|
92
98
|
}> = ({ thread, selected, onSelect, onResolve }) => {
|
|
99
|
+
const [focus, setFocus] = useState(false);
|
|
93
100
|
const [item, setItem] = useState({ text: new TextObject() });
|
|
94
101
|
const model = useTextModel({ text: item.text });
|
|
95
102
|
const editorRef = useRef<EditorView>(null);
|
|
96
103
|
|
|
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
104
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
105
105
|
useEffect(() => {
|
|
106
106
|
if (selected) {
|
|
107
107
|
containerRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
108
108
|
if (thread.messages.length === 0) {
|
|
109
|
-
|
|
109
|
+
setFocus(true);
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
}, [selected]);
|
|
@@ -116,7 +116,7 @@ const Thread: FC<{
|
|
|
116
116
|
if (text?.length) {
|
|
117
117
|
thread.messages.push(item.text);
|
|
118
118
|
setItem({ text: new TextObject() });
|
|
119
|
-
|
|
119
|
+
setFocus(true);
|
|
120
120
|
}
|
|
121
121
|
};
|
|
122
122
|
|
|
@@ -125,41 +125,49 @@ const Thread: FC<{
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
return (
|
|
128
|
-
<div
|
|
129
|
-
|
|
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'>
|
|
130
136
|
<span>id:{thread.id.slice(0, 4)}</span>
|
|
131
137
|
<span>from:{thread.selection?.from}</span>
|
|
132
138
|
<span>to:{thread.selection?.to}</span>
|
|
133
139
|
<span>y:{thread.yPos}</span>
|
|
140
|
+
<span className='grow' />
|
|
141
|
+
{thread.deleted && <Trash />}
|
|
134
142
|
</div>
|
|
135
143
|
|
|
136
144
|
{thread.messages.map((message, i) => (
|
|
137
145
|
// TODO(burdon): Fix default editor padding so content doesn't jump on creating message.
|
|
138
146
|
<div key={i} className='p-2' onClick={() => onSelect()}>
|
|
139
|
-
{message
|
|
147
|
+
{getTextContent(message)}
|
|
140
148
|
</div>
|
|
141
149
|
))}
|
|
142
150
|
|
|
143
|
-
<div ref={containerRef} onClick={() => onSelect()} className='flex
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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;
|
|
157
165
|
},
|
|
158
|
-
|
|
159
|
-
]
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
<Button variant='ghost' classNames='px-1
|
|
166
|
+
},
|
|
167
|
+
]),
|
|
168
|
+
]}
|
|
169
|
+
/>
|
|
170
|
+
<Button variant='ghost' classNames='px-1' title='Resolve' onClick={onResolve}>
|
|
163
171
|
<Check />
|
|
164
172
|
</Button>
|
|
165
173
|
</div>
|
|
@@ -183,7 +191,7 @@ const Sidebar: FC<{
|
|
|
183
191
|
|
|
184
192
|
return (
|
|
185
193
|
<DensityProvider density='fine'>
|
|
186
|
-
<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'>
|
|
187
195
|
{sortedThreads.map((thread) => (
|
|
188
196
|
<Thread
|
|
189
197
|
key={thread.id}
|
|
@@ -218,6 +226,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
218
226
|
|
|
219
227
|
const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
|
|
220
228
|
const id = PublicKey.random().toHex();
|
|
229
|
+
log.info('create', { id: id.slice(0, 4), cursor });
|
|
221
230
|
setThreads((threads) => [
|
|
222
231
|
...threads,
|
|
223
232
|
{
|
|
@@ -225,7 +234,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
225
234
|
range: { id, cursor },
|
|
226
235
|
yPos: location ? Math.floor(location.top) : undefined,
|
|
227
236
|
messages: autoCreate
|
|
228
|
-
? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min:
|
|
237
|
+
? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 1, max: 3 } })
|
|
229
238
|
: [],
|
|
230
239
|
},
|
|
231
240
|
]);
|
|
@@ -233,8 +242,37 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
233
242
|
return id;
|
|
234
243
|
};
|
|
235
244
|
|
|
236
|
-
|
|
245
|
+
const handleDeleteComment: CommentsOptions['onDelete'] = (id) => {
|
|
246
|
+
log.info('delete', { id: id.slice(0, 4) });
|
|
247
|
+
setThreads((threads) =>
|
|
248
|
+
threads.map((thread) => {
|
|
249
|
+
if (thread.id === id) {
|
|
250
|
+
thread.deleted = true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return thread;
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
setSelected((selected) => (selected === id ? undefined : selected));
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const handleUpdateComment: CommentsOptions['onUpdate'] = (id, cursor) => {
|
|
261
|
+
log.info('update', { id: id.slice(0, 4), cursor });
|
|
262
|
+
setThreads((threads) =>
|
|
263
|
+
threads.map((thread) => {
|
|
264
|
+
if (thread.id === id) {
|
|
265
|
+
thread.range.cursor = cursor;
|
|
266
|
+
delete thread.deleted;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return thread;
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
};
|
|
273
|
+
|
|
237
274
|
const handleSelectComment: CommentsOptions['onSelect'] = ({ active, closest, ranges }) => {
|
|
275
|
+
log.info('select', { active: active?.slice(0, 4), closest: closest?.slice(0, 4) });
|
|
238
276
|
setThreads((threads) =>
|
|
239
277
|
threads.map((thread) => {
|
|
240
278
|
const range = ranges.find((range) => range.id === thread.range.id);
|
|
@@ -268,6 +306,8 @@ const Story = ({ text, autoCreate }: StoryProps) => {
|
|
|
268
306
|
commentSelected={selected}
|
|
269
307
|
commentRanges={commentRanges}
|
|
270
308
|
onCreateComment={handleCreateComment}
|
|
309
|
+
onDeleteComment={handleDeleteComment}
|
|
310
|
+
onUpdateComment={handleUpdateComment}
|
|
271
311
|
onSelectComment={handleSelectComment}
|
|
272
312
|
/>
|
|
273
313
|
</div>
|
|
@@ -307,7 +347,7 @@ export const Default = {
|
|
|
307
347
|
},
|
|
308
348
|
};
|
|
309
349
|
|
|
310
|
-
export const
|
|
350
|
+
export const Testing = {
|
|
311
351
|
args: {
|
|
312
352
|
text: document,
|
|
313
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,14 +50,15 @@ 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
|
}
|
|
@@ -67,8 +66,9 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
67
66
|
update(update: ViewUpdate) {
|
|
68
67
|
if (update.transactions.length > 0 && update.transactions.some((tr) => !isReconcileTx(tr))) {
|
|
69
68
|
// TODO(burdon): This is a hack to ensure that the view is updated after the transaction is applied.
|
|
69
|
+
// Cannot trigger update while update is in progress.
|
|
70
70
|
queueMicrotask(() => {
|
|
71
|
-
this._view.state.facet(semaphoreFacet).reconcile(
|
|
71
|
+
this._view.state.facet(semaphoreFacet).reconcile(this._view);
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -78,7 +78,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
private _handleChange = () => {
|
|
81
|
-
this._view.state.facet(semaphoreFacet).reconcile(
|
|
81
|
+
this._view.state.facet(semaphoreFacet).reconcile(this._view);
|
|
82
82
|
};
|
|
83
83
|
},
|
|
84
84
|
);
|
|
@@ -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({
|
|
@@ -2,23 +2,20 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { type Extension, StateEffect, StateField } from '@codemirror/state';
|
|
5
|
+
import { type Extension, StateEffect, StateField, type Text } from '@codemirror/state';
|
|
6
6
|
import { hoverTooltip, keymap, type Command, Decoration, EditorView, type Rect } from '@codemirror/view';
|
|
7
7
|
import sortBy from 'lodash.sortby';
|
|
8
8
|
|
|
9
9
|
import { debounce } from '@dxos/async';
|
|
10
10
|
import { invariant } from '@dxos/invariant';
|
|
11
|
-
import { log } from '@dxos/log';
|
|
12
11
|
import { nonNullable } from '@dxos/util';
|
|
13
12
|
|
|
13
|
+
import { semaphoreFacet } from './automerge/defs';
|
|
14
14
|
import { Cursor } from './cursor';
|
|
15
15
|
import { type CommentRange, type EditorModel, type Range } from '../hooks';
|
|
16
16
|
import { getToken } from '../styles';
|
|
17
17
|
import { callbackWrapper } from '../util';
|
|
18
18
|
|
|
19
|
-
// TODO(burdon): Handle delete, cut, copy, and paste (separately) text that includes comment range.
|
|
20
|
-
// TODO(burdon): Consider breaking into separate plugin (since not standalone)? Like mermaid?
|
|
21
|
-
|
|
22
19
|
// TODO(burdon): Reconcile with theme.
|
|
23
20
|
const styles = EditorView.baseTheme({
|
|
24
21
|
'& .cm-comment': {
|
|
@@ -60,7 +57,7 @@ export const setFocus = (view: EditorView, thread: string) => {
|
|
|
60
57
|
view.dispatch({
|
|
61
58
|
effects: setSelection.of({ active: thread }),
|
|
62
59
|
selection: { anchor: range.from },
|
|
63
|
-
scrollIntoView: true,
|
|
60
|
+
scrollIntoView: true, // TODO(burdon): Scroll to y-position (or center of screen?)
|
|
64
61
|
});
|
|
65
62
|
};
|
|
66
63
|
|
|
@@ -72,8 +69,7 @@ const setCommentState = StateEffect.define<CommentsState>();
|
|
|
72
69
|
|
|
73
70
|
/**
|
|
74
71
|
* State field (reducer) that tracks comment ranges.
|
|
75
|
-
* The ranges are tracked as
|
|
76
|
-
* therefore must be updated when the document changes.
|
|
72
|
+
* The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
|
|
77
73
|
*/
|
|
78
74
|
const commentsStateField = StateField.define<CommentsState>({
|
|
79
75
|
create: () => ({ ranges: [] }),
|
|
@@ -87,11 +83,12 @@ const commentsStateField = StateField.define<CommentsState>({
|
|
|
87
83
|
}
|
|
88
84
|
|
|
89
85
|
// Update range from store.
|
|
90
|
-
if (effect.is(setCommentRange)) {
|
|
86
|
+
if (effect.is(setCommentRange) && effect.value.comments.length > 0) {
|
|
91
87
|
const { comments } = effect.value;
|
|
92
88
|
const ranges: ExtendedCommentRange[] = comments
|
|
93
89
|
.map((comment) => {
|
|
94
90
|
const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
|
|
91
|
+
// console.log('update', JSON.stringify({ range, cursor: comment.cursor }, undefined, 2));
|
|
95
92
|
return range && { ...comment, ...range };
|
|
96
93
|
})
|
|
97
94
|
.filter(nonNullable);
|
|
@@ -135,12 +132,22 @@ const highlightDecorations = EditorView.decorations.compute([commentsStateField]
|
|
|
135
132
|
});
|
|
136
133
|
|
|
137
134
|
export type CommentsOptions = {
|
|
135
|
+
/**
|
|
136
|
+
* Key shortcut to create a new thread.
|
|
137
|
+
*/
|
|
138
138
|
key?: string;
|
|
139
|
-
footnote?: boolean;
|
|
140
139
|
/**
|
|
141
140
|
* Called to create a new thread and return the thread id.
|
|
142
141
|
*/
|
|
143
142
|
onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
|
|
143
|
+
/**
|
|
144
|
+
* Selection cut/deleted.
|
|
145
|
+
*/
|
|
146
|
+
onDelete?: (thread: string) => void;
|
|
147
|
+
/**
|
|
148
|
+
* Called when a comment is moved.
|
|
149
|
+
*/
|
|
150
|
+
onUpdate?: (thread: string, cursor: string) => void;
|
|
144
151
|
/**
|
|
145
152
|
* Called to notify which thread is currently closest to the cursor.
|
|
146
153
|
*/
|
|
@@ -151,6 +158,85 @@ export type CommentsOptions = {
|
|
|
151
158
|
onHover?: (el: Element, shortcut: string) => void;
|
|
152
159
|
};
|
|
153
160
|
|
|
161
|
+
// TODO(burdon): Handle cut/restore via undo (need to integrate with history?)
|
|
162
|
+
const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
|
|
163
|
+
// Cut/deleted comments.
|
|
164
|
+
// Tracks indexed selections within text.
|
|
165
|
+
let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
|
|
166
|
+
|
|
167
|
+
// Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
|
|
168
|
+
const handleTrack = (event: Event, view: EditorView) => {
|
|
169
|
+
const comments = view.state.field(commentsStateField);
|
|
170
|
+
const { main } = view.state.selection;
|
|
171
|
+
const selectedRanges = comments.ranges.filter(
|
|
172
|
+
(range) => range.from >= main.from && range.to <= main.to && range.from < range.to,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (!selectedRanges.length) {
|
|
176
|
+
tracked = null;
|
|
177
|
+
} else {
|
|
178
|
+
tracked = {
|
|
179
|
+
text: view.state.doc.slice(main.from, main.to),
|
|
180
|
+
comments: selectedRanges.map((range) => ({
|
|
181
|
+
id: range.id,
|
|
182
|
+
from: range.from - main.from,
|
|
183
|
+
to: range.to - main.from,
|
|
184
|
+
})),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return [
|
|
190
|
+
EditorView.domEventHandlers({
|
|
191
|
+
cut: handleTrack,
|
|
192
|
+
copy: handleTrack,
|
|
193
|
+
}),
|
|
194
|
+
|
|
195
|
+
// Handle paste.
|
|
196
|
+
EditorView.updateListener.of(({ view, state, transactions }) => {
|
|
197
|
+
if (tracked) {
|
|
198
|
+
const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
|
|
199
|
+
if (paste) {
|
|
200
|
+
let found = -1;
|
|
201
|
+
paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
|
|
202
|
+
if (text.eq(tracked!.text)) {
|
|
203
|
+
for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
|
|
204
|
+
fromB = transactions[i].changes.mapPos(fromB);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
found = fromB;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (found > -1) {
|
|
212
|
+
const comments = tracked.comments;
|
|
213
|
+
|
|
214
|
+
// Sync before recomputing cursor/range.
|
|
215
|
+
// TODO(burdon): Decouple from automerge?
|
|
216
|
+
view.state.facet(semaphoreFacet).reconcile(view);
|
|
217
|
+
|
|
218
|
+
const active = state.field(commentsStateField).ranges;
|
|
219
|
+
for (const moved of comments) {
|
|
220
|
+
if (active.some((range) => range.id === moved.id && range.from === range.to)) {
|
|
221
|
+
const range = {
|
|
222
|
+
from: found + moved.from,
|
|
223
|
+
to: found + moved.to,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const cursor = Cursor.getCursorFromRange(state.facet(Cursor.converter), range);
|
|
227
|
+
// console.log('paste', JSON.stringify({ moved, range, cursor }, undefined, 2));
|
|
228
|
+
onUpdate(moved.id, cursor);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
tracked = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}),
|
|
237
|
+
];
|
|
238
|
+
};
|
|
239
|
+
|
|
154
240
|
/**
|
|
155
241
|
* Comment threads.
|
|
156
242
|
* 1). Updates the EditorModel to store relative selections for a set of comments threads.
|
|
@@ -160,8 +246,7 @@ export type CommentsOptions = {
|
|
|
160
246
|
* 4). Tracks the current cursor position to:
|
|
161
247
|
* a). Update the decoration to show if the cursor is within a current selection.
|
|
162
248
|
* b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
|
|
163
|
-
* 5). Optionally,
|
|
164
|
-
* 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
249
|
+
* 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
165
250
|
*/
|
|
166
251
|
export const comments = (options: CommentsOptions = {}): Extension => {
|
|
167
252
|
const { key: shortcut = "meta-'" } = options;
|
|
@@ -175,7 +260,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
175
260
|
const cursorConverter = view.state.facet(Cursor.converter);
|
|
176
261
|
|
|
177
262
|
invariant(options.onCreate);
|
|
178
|
-
const {
|
|
263
|
+
const { from, to } = view.state.selection.main;
|
|
179
264
|
if (from === to) {
|
|
180
265
|
return false;
|
|
181
266
|
}
|
|
@@ -191,15 +276,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
191
276
|
selection: { anchor: from },
|
|
192
277
|
});
|
|
193
278
|
|
|
194
|
-
// Insert footnote.
|
|
195
|
-
if (options.footnote) {
|
|
196
|
-
const tag = `[^${id}]`;
|
|
197
|
-
view.dispatch({
|
|
198
|
-
changes: { from: head, insert: tag },
|
|
199
|
-
selection: { anchor: head + tag.length },
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
|
|
203
279
|
return true;
|
|
204
280
|
}
|
|
205
281
|
}
|
|
@@ -238,7 +314,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
238
314
|
end: selection.to,
|
|
239
315
|
above: true,
|
|
240
316
|
create: () => {
|
|
241
|
-
// TODO(burdon): Dispatch to react callback to render (or use SSR)?
|
|
242
317
|
const el = document.createElement('div');
|
|
243
318
|
options.onHover?.(el, shortcut);
|
|
244
319
|
return { dom: el, offset: { x: 0, y: 8 } };
|
|
@@ -266,12 +341,12 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
266
341
|
let mod = false;
|
|
267
342
|
const { active, ranges } = state.field(commentsStateField);
|
|
268
343
|
changes.iterChanges((from, to, from2, to2) => {
|
|
344
|
+
// TODO(burdon): Skip deleted (tracked) ranges.
|
|
269
345
|
ranges.forEach((range) => {
|
|
270
346
|
if (from2 === to2) {
|
|
271
347
|
const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
|
|
272
348
|
if (!newRange || newRange.to - newRange.from === 0) {
|
|
273
|
-
|
|
274
|
-
log.info('deleted comment', { thread: range.id });
|
|
349
|
+
options.onDelete?.(range.id);
|
|
275
350
|
}
|
|
276
351
|
}
|
|
277
352
|
|
|
@@ -297,6 +372,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
297
372
|
let min = Infinity;
|
|
298
373
|
const { active, closest, ranges } = state.field(commentsStateField);
|
|
299
374
|
const selected: CommentSelected = { active: undefined, closest: undefined };
|
|
375
|
+
|
|
376
|
+
// TODO(burdon): Skip deleted (tracked) ranges.
|
|
300
377
|
ranges.forEach((comment) => {
|
|
301
378
|
const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
|
|
302
379
|
if (head >= comment.from && head <= comment.to) {
|
|
@@ -308,8 +385,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
308
385
|
}
|
|
309
386
|
});
|
|
310
387
|
|
|
311
|
-
// TODO(burdon): Fire debounced callback if range selected (to show hint).
|
|
312
|
-
|
|
313
388
|
if (selected.active !== active || selected.closest !== closest) {
|
|
314
389
|
view.dispatch({ effects: setSelection.of(selected) });
|
|
315
390
|
|
|
@@ -321,5 +396,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
321
396
|
}
|
|
322
397
|
}
|
|
323
398
|
}),
|
|
399
|
+
|
|
400
|
+
options.onUpdate ? trackPastedComments(options.onUpdate) : [],
|
|
324
401
|
];
|
|
325
402
|
};
|