@dxos/react-ui-editor 0.4.1 → 0.4.2-main.0e5b9e5
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 +237 -181
- 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 +4 -1
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/automerge.stories.d.ts +2 -2
- package/dist/types/src/components/TextEditor/automerge.stories.d.ts.map +1 -1
- package/dist/types/src/extensions/annotations.d.ts +6 -0
- package/dist/types/src/extensions/annotations.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/{semaphore.d.ts → sync.d.ts} +6 -5
- package/dist/types/src/extensions/automerge/sync.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/package.json +24 -24
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +17 -4
- package/src/components/TextEditor/TextEditor.stories.tsx +8 -2
- package/src/components/TextEditor/TextEditor.tsx +1 -0
- package/src/components/TextEditor/automerge.stories.tsx +39 -47
- package/src/components/Toolbar/Toolbar.stories.tsx +1 -1
- package/src/extensions/annotations.ts +78 -0
- package/src/extensions/automerge/automerge.ts +5 -21
- package/src/extensions/automerge/{semaphore.ts → sync.ts} +32 -34
- package/src/extensions/index.ts +1 -0
- package/src/hooks/useTextModel.ts +5 -5
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +0 -1
|
@@ -5,11 +5,10 @@
|
|
|
5
5
|
import '@dxosTheme';
|
|
6
6
|
|
|
7
7
|
import { BroadcastChannelNetworkAdapter } from '@automerge/automerge-repo-network-broadcastchannel';
|
|
8
|
-
import
|
|
9
|
-
import '@
|
|
10
|
-
import { basicSetup } from 'codemirror';
|
|
8
|
+
import '@preact/signals-react';
|
|
9
|
+
import { EditorView } from '@codemirror/view'; // Register react integration.
|
|
11
10
|
import get from 'lodash.get';
|
|
12
|
-
import React, { useEffect,
|
|
11
|
+
import React, { useEffect, useState } from 'react';
|
|
13
12
|
|
|
14
13
|
import { type Prop } from '@dxos/automerge/automerge';
|
|
15
14
|
import { Repo, type DocHandle } from '@dxos/automerge/automerge-repo';
|
|
@@ -21,38 +20,41 @@ import { ClientRepeater } from '@dxos/react-client/testing';
|
|
|
21
20
|
import { withTheme } from '@dxos/storybook-utils';
|
|
22
21
|
|
|
23
22
|
import { MarkdownEditor } from './TextEditor';
|
|
24
|
-
import { type IDocHandle, automerge, awareness } from '../../extensions';
|
|
25
|
-
import { useTextModel } from '../../hooks';
|
|
23
|
+
import { type IDocHandle, automerge, awareness, createBasicBundle } from '../../extensions';
|
|
24
|
+
import { useTextEditor, useTextModel } from '../../hooks';
|
|
25
|
+
import { defaultTheme } from '../../themes';
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const initialContent = 'Hello world!';
|
|
28
|
+
|
|
29
|
+
type TestObject = {
|
|
30
|
+
text: string;
|
|
31
|
+
};
|
|
28
32
|
|
|
29
33
|
type EditorProps = {
|
|
30
34
|
handle: IDocHandle;
|
|
31
|
-
path
|
|
35
|
+
path?: Prop[];
|
|
32
36
|
};
|
|
33
37
|
|
|
34
|
-
const Editor = ({ handle, path }: EditorProps) => {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
};
|
|
48
|
-
}, []);
|
|
38
|
+
const Editor = ({ handle, path = ['text'] }: EditorProps) => {
|
|
39
|
+
const { parentRef } = useTextEditor({
|
|
40
|
+
autoFocus: true,
|
|
41
|
+
doc: get(handle.docSync()!, path),
|
|
42
|
+
extensions: [
|
|
43
|
+
//
|
|
44
|
+
EditorView.baseTheme(defaultTheme),
|
|
45
|
+
EditorView.editorAttributes.of({ class: 'p-2 bg-white' }),
|
|
46
|
+
createBasicBundle({ placeholder: 'Type here...' }),
|
|
47
|
+
automerge({ handle, path }),
|
|
48
|
+
awareness(),
|
|
49
|
+
],
|
|
50
|
+
});
|
|
49
51
|
|
|
50
|
-
return <div
|
|
52
|
+
return <div ref={parentRef} />;
|
|
51
53
|
};
|
|
52
54
|
|
|
53
55
|
const Story = () => {
|
|
54
|
-
const [object1, setObject1] = useState<DocHandle<
|
|
55
|
-
const [object2, setObject2] = useState<DocHandle<
|
|
56
|
+
const [object1, setObject1] = useState<DocHandle<TestObject>>();
|
|
57
|
+
const [object2, setObject2] = useState<DocHandle<TestObject>>();
|
|
56
58
|
|
|
57
59
|
useEffect(() => {
|
|
58
60
|
queueMicrotask(async () => {
|
|
@@ -64,8 +66,8 @@ const Story = () => {
|
|
|
64
66
|
});
|
|
65
67
|
|
|
66
68
|
const object1 = repo1.create();
|
|
67
|
-
object1.change((doc:
|
|
68
|
-
doc.text =
|
|
69
|
+
object1.change((doc: TestObject) => {
|
|
70
|
+
doc.text = initialContent;
|
|
69
71
|
});
|
|
70
72
|
|
|
71
73
|
const object2 = repo2.find(object1.url);
|
|
@@ -81,13 +83,9 @@ const Story = () => {
|
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
return (
|
|
84
|
-
<div
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
</div>
|
|
88
|
-
<div>
|
|
89
|
-
<Editor handle={object2} path={['text']} />
|
|
90
|
-
</div>
|
|
86
|
+
<div className='grid grid-cols-2 gap-4'>
|
|
87
|
+
<Editor handle={object1} />
|
|
88
|
+
<Editor handle={object2} />
|
|
91
89
|
</div>
|
|
92
90
|
);
|
|
93
91
|
};
|
|
@@ -100,16 +98,15 @@ export default {
|
|
|
100
98
|
|
|
101
99
|
export const Default = {};
|
|
102
100
|
|
|
103
|
-
const EchoStory = ({
|
|
101
|
+
const EchoStory = ({ spaceKey }: { spaceKey: PublicKey }) => {
|
|
104
102
|
const identity = useIdentity();
|
|
105
103
|
const space = useSpace(spaceKey);
|
|
106
104
|
// TODO(dmaretskyi): useQuery doesn't work.
|
|
107
105
|
const [obj] = space?.db.query(Filter.from({ type: 'test' })).objects ?? [];
|
|
108
|
-
|
|
109
106
|
const model = useTextModel({
|
|
110
|
-
text: obj?.content,
|
|
111
107
|
identity,
|
|
112
108
|
space,
|
|
109
|
+
text: obj?.content,
|
|
113
110
|
});
|
|
114
111
|
|
|
115
112
|
if (!model) {
|
|
@@ -117,18 +114,14 @@ const EchoStory = ({ id, spaceKey }: { id: number; spaceKey: PublicKey }) => {
|
|
|
117
114
|
}
|
|
118
115
|
|
|
119
116
|
return (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
<div className='flex flex-col w-[800px] py-16'>
|
|
123
|
-
<MarkdownEditor model={model} />
|
|
124
|
-
<div className='flex shrink-0 h-[300px]'></div>
|
|
125
|
-
</div>
|
|
117
|
+
<div className='flex flex-col w-full p-2'>
|
|
118
|
+
<MarkdownEditor model={model} slots={{ editor: { className: 'bg-white p-2' } }} />
|
|
126
119
|
</div>
|
|
127
|
-
// </div>
|
|
128
120
|
);
|
|
129
121
|
};
|
|
130
122
|
|
|
131
123
|
export const WithEcho = {
|
|
124
|
+
decorators: [withTheme],
|
|
132
125
|
render: () => {
|
|
133
126
|
return (
|
|
134
127
|
<ClientRepeater
|
|
@@ -138,7 +131,7 @@ export const WithEcho = {
|
|
|
138
131
|
space.db.add(
|
|
139
132
|
new Expando({
|
|
140
133
|
type: 'test',
|
|
141
|
-
content: new TextObject(
|
|
134
|
+
content: new TextObject(initialContent),
|
|
142
135
|
}),
|
|
143
136
|
);
|
|
144
137
|
}}
|
|
@@ -146,5 +139,4 @@ export const WithEcho = {
|
|
|
146
139
|
/>
|
|
147
140
|
);
|
|
148
141
|
},
|
|
149
|
-
decorators: [withTheme],
|
|
150
142
|
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type EditorState, type Extension, StateField } from '@codemirror/state';
|
|
6
|
+
import { Decoration, EditorView } from '@codemirror/view';
|
|
7
|
+
|
|
8
|
+
import { isNotFalsy } from '@dxos/util';
|
|
9
|
+
|
|
10
|
+
import { Cursor } from './cursor';
|
|
11
|
+
|
|
12
|
+
type Annotation = {
|
|
13
|
+
cursor: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type AnnotationOptions = {
|
|
17
|
+
match?: RegExp; // TODO(burdon): Update via hook (e.g., for search).
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const annotationMark = Decoration.mark({ class: 'cm-annotation' });
|
|
21
|
+
|
|
22
|
+
export const annotations = (options: AnnotationOptions = {}): Extension => {
|
|
23
|
+
// TODO(burdon): Build index of matches and cursors (in background function).
|
|
24
|
+
// Define annotation action in prompt. E.g., extract company names. Find links, etc.
|
|
25
|
+
// Show popover card. A16Z chain demo. Identify, extract, research, link. Multi-agent.
|
|
26
|
+
const match = (state: EditorState) => {
|
|
27
|
+
const annotations: Annotation[] = [];
|
|
28
|
+
const text = state.doc.toString();
|
|
29
|
+
if (options.match) {
|
|
30
|
+
const matches = text.matchAll(options.match);
|
|
31
|
+
for (const match of matches) {
|
|
32
|
+
const from = match.index!;
|
|
33
|
+
const to = from + match[0].length;
|
|
34
|
+
const cursor = Cursor.getCursorFromRange(state, { from, to });
|
|
35
|
+
annotations.push({ cursor });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return annotations;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const annotationsState = StateField.define<Annotation[]>({
|
|
43
|
+
create: (state) => {
|
|
44
|
+
return match(state);
|
|
45
|
+
},
|
|
46
|
+
update: (value, tr) => {
|
|
47
|
+
if (!tr.changes.empty) {
|
|
48
|
+
return match(tr.state);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return value;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return [
|
|
56
|
+
annotationsState,
|
|
57
|
+
EditorView.decorations.compute([annotationsState], (state) => {
|
|
58
|
+
const annotations = state.field(annotationsState);
|
|
59
|
+
const decorations = annotations
|
|
60
|
+
.map((annotation) => {
|
|
61
|
+
const range = Cursor.getRangeFromCursor(state, annotation.cursor);
|
|
62
|
+
return range && annotationMark.range(range.from, range.to);
|
|
63
|
+
})
|
|
64
|
+
.filter(isNotFalsy);
|
|
65
|
+
|
|
66
|
+
return Decoration.set(decorations);
|
|
67
|
+
}),
|
|
68
|
+
styles,
|
|
69
|
+
];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const styles = EditorView.baseTheme({
|
|
73
|
+
'.cm-annotation': {
|
|
74
|
+
textDecoration: 'underline',
|
|
75
|
+
textDecorationStyle: 'wavy',
|
|
76
|
+
textDecorationColor: 'red',
|
|
77
|
+
},
|
|
78
|
+
});
|
|
@@ -4,15 +4,14 @@
|
|
|
4
4
|
// Ref: https://github.com/automerge/automerge-codemirror
|
|
5
5
|
//
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import { StateField, type Extension, type StateEffect } from '@codemirror/state';
|
|
7
|
+
import { StateField, type Extension } from '@codemirror/state';
|
|
9
8
|
import { EditorView, ViewPlugin } from '@codemirror/view';
|
|
10
9
|
|
|
11
10
|
import { next as A, type Prop } from '@dxos/automerge/automerge';
|
|
12
11
|
|
|
13
12
|
import { cursorConverter } from './cursor';
|
|
14
13
|
import { updateHeadsEffect, type IDocHandle, isReconcile, type State } from './defs';
|
|
15
|
-
import {
|
|
14
|
+
import { Syncer } from './sync';
|
|
16
15
|
import { Cursor } from '../cursor';
|
|
17
16
|
|
|
18
17
|
export type AutomergeOptions = {
|
|
@@ -55,7 +54,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
55
54
|
},
|
|
56
55
|
});
|
|
57
56
|
|
|
58
|
-
const
|
|
57
|
+
const syncer = new Syncer(handle, syncState);
|
|
59
58
|
|
|
60
59
|
return [
|
|
61
60
|
Cursor.converter.of(cursorConverter(handle, path)),
|
|
@@ -73,7 +72,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
_handleChange() {
|
|
76
|
-
|
|
75
|
+
syncer.reconcile(this._view, false);
|
|
77
76
|
}
|
|
78
77
|
},
|
|
79
78
|
),
|
|
@@ -81,23 +80,8 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
81
80
|
// Reconcile local updates.
|
|
82
81
|
EditorView.updateListener.of(({ view, changes }) => {
|
|
83
82
|
if (!changes.empty) {
|
|
84
|
-
|
|
83
|
+
syncer.reconcile(view, true);
|
|
85
84
|
}
|
|
86
85
|
}),
|
|
87
|
-
|
|
88
|
-
// TODO(burdon): Record undo transactions.
|
|
89
|
-
// See https://github.com/yjs/y-codemirror.next/tree/main
|
|
90
|
-
// https://codemirror.net/examples/inverted-effect
|
|
91
|
-
invertedEffects.of((tr) => {
|
|
92
|
-
// Each change results it three transactions: insert, delete, insert.
|
|
93
|
-
const effects: StateEffect<any>[] = [];
|
|
94
|
-
if (!tr.changes.empty) {
|
|
95
|
-
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
|
96
|
-
// effects.push(effectType.of({});
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return effects;
|
|
101
|
-
}),
|
|
102
86
|
];
|
|
103
87
|
};
|
|
@@ -12,11 +12,11 @@ import { next as A } from '@dxos/automerge/automerge';
|
|
|
12
12
|
import {
|
|
13
13
|
getLastHeads,
|
|
14
14
|
getPath,
|
|
15
|
-
isReconcile,
|
|
16
|
-
reconcileAnnotation,
|
|
17
|
-
updateHeads,
|
|
18
15
|
type IDocHandle,
|
|
19
16
|
type State,
|
|
17
|
+
reconcileAnnotation,
|
|
18
|
+
updateHeads,
|
|
19
|
+
isReconcile,
|
|
20
20
|
} from './defs';
|
|
21
21
|
import { updateAutomerge } from './update-automerge';
|
|
22
22
|
import { updateCodeMirror } from './update-codemirror';
|
|
@@ -24,8 +24,8 @@ import { updateCodeMirror } from './update-codemirror';
|
|
|
24
24
|
/**
|
|
25
25
|
* Implements three-way merge (on each mutation).
|
|
26
26
|
*/
|
|
27
|
-
export class
|
|
28
|
-
private
|
|
27
|
+
export class Syncer {
|
|
28
|
+
private _pending = false;
|
|
29
29
|
|
|
30
30
|
// prettier-ignore
|
|
31
31
|
constructor(
|
|
@@ -33,46 +33,44 @@ export class PatchSemaphore {
|
|
|
33
33
|
private readonly _state: StateField<State>
|
|
34
34
|
) {}
|
|
35
35
|
|
|
36
|
-
reconcile(view: EditorView) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this._inReconcile = false;
|
|
36
|
+
reconcile(view: EditorView, editor: boolean) {
|
|
37
|
+
// TODO(burdon): Better way to do mutex?
|
|
38
|
+
if (this._pending) {
|
|
39
|
+
return;
|
|
41
40
|
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
private doReconcile(view: EditorView) {
|
|
45
|
-
const path = getPath(view.state, this._state);
|
|
46
41
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
this._pending = true;
|
|
43
|
+
if (editor) {
|
|
44
|
+
this.onEditorChange(view);
|
|
45
|
+
} else {
|
|
46
|
+
this.onAutomergeChange(view);
|
|
47
|
+
}
|
|
48
|
+
this._pending = false;
|
|
49
|
+
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
onEditorChange(view: EditorView) {
|
|
52
|
+
// Apply the unreconciled transactions to the document.
|
|
52
53
|
const transactions = view.state.field(this._state).unreconciledTransactions.filter((tx) => !isReconcile(tx));
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
selection = selection.map(inverted);
|
|
54
|
+
const newHeads = updateAutomerge(this._state, this._handle, transactions, view.state);
|
|
55
|
+
|
|
56
|
+
if (newHeads) {
|
|
57
57
|
view.dispatch({
|
|
58
|
-
|
|
59
|
-
annotations: reconcileAnnotation.of(
|
|
58
|
+
effects: updateHeads(newHeads),
|
|
59
|
+
annotations: reconcileAnnotation.of(false),
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
+
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// TODO(alexjg): this is the call that's resetting the editor state on click.
|
|
68
|
-
newHeads = A.getHeads(this._handle.docSync()!);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Now get the diff between the updated state of the document and the heads and apply that to the codemirror doc.
|
|
64
|
+
onAutomergeChange(view: EditorView) {
|
|
65
|
+
// Get the diff between the updated state of the document and the heads and apply that to the codemirror doc.
|
|
66
|
+
const oldHeads = getLastHeads(view.state, this._state);
|
|
67
|
+
const newHeads = A.getHeads(this._handle.docSync()!);
|
|
72
68
|
const diff = A.equals(oldHeads, newHeads) ? [] : A.diff(this._handle.docSync()!, oldHeads, newHeads);
|
|
69
|
+
|
|
70
|
+
const selection = view.state.selection;
|
|
71
|
+
const path = getPath(view.state, this._state);
|
|
73
72
|
updateCodeMirror(view, selection, path, diff);
|
|
74
73
|
|
|
75
|
-
// Update automerge state.
|
|
76
74
|
view.dispatch({
|
|
77
75
|
effects: updateHeads(newHeads),
|
|
78
76
|
annotations: reconcileAnnotation.of(false),
|
package/src/extensions/index.ts
CHANGED
|
@@ -47,6 +47,10 @@ export const useInMemoryTextModel = ({
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
const createModel = ({ space, identity, text }: UseTextModelProps) => {
|
|
50
|
+
if (!text) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
50
54
|
invariant(isAutomergeObject(text));
|
|
51
55
|
const obj = text as any as AutomergeTextCompat;
|
|
52
56
|
const doc = getRawDoc(obj, [obj.field]);
|
|
@@ -64,11 +68,7 @@ const createModel = ({ space, identity, text }: UseTextModelProps) => {
|
|
|
64
68
|
peerId: identity?.identityKey.toHex() ?? 'Anonymous',
|
|
65
69
|
});
|
|
66
70
|
|
|
67
|
-
const extensions = [
|
|
68
|
-
//
|
|
69
|
-
modelState.init(() => model),
|
|
70
|
-
automerge({ handle: doc.handle, path: doc.path }),
|
|
71
|
-
];
|
|
71
|
+
const extensions = [modelState.init(() => model), automerge({ handle: doc.handle, path: doc.path })];
|
|
72
72
|
if (awarenessProvider) {
|
|
73
73
|
extensions.push(awareness(awarenessProvider));
|
|
74
74
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"semaphore.d.ts","sourceRoot":"","sources":["../../../../../src/extensions/automerge/semaphore.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAInD,OAAO,EAML,KAAK,UAAU,EACf,KAAK,KAAK,EACX,MAAM,QAAQ,CAAC;AAIhB;;GAEG;AACH,qBAAa,cAAc;IAKvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IALzB,OAAO,CAAC,YAAY,CAAS;gBAIV,OAAO,EAAE,UAAU,EACnB,MAAM,EAAE,UAAU,CAAC,KAAK,CAAC;IAG5C,SAAS,CAAC,IAAI,EAAE,UAAU;IAQ1B,OAAO,CAAC,WAAW;CAqCpB"}
|