@dxos/react-ui-editor 0.3.11-main.c95bc86 → 0.3.11-main.cb8120f
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 +434 -210
- 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.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/Toolbar/Toolbar.d.ts +9 -0
- package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -0
- package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +14 -0
- package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.spec.d.ts +2 -0
- package/dist/types/src/extensions/automerge/automerge.spec.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/automerge.test.d.ts +2 -0
- package/dist/types/src/extensions/automerge/automerge.test.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/defs.d.ts +5 -5
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/semaphore.d.ts +2 -1
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/awareness.d.ts +1 -1
- package/dist/types/src/extensions/comments.d.ts +5 -1
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/code.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/formatting.d.ts +11 -0
- package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/index.d.ts +1 -0
- package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +1 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useActionHandler.d.ts +4 -0
- package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -0
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/package.json +36 -24
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +8 -13
- package/src/components/TextEditor/TextEditor.tsx +3 -1
- package/src/components/Toolbar/Toolbar.stories.tsx +86 -0
- package/src/components/Toolbar/Toolbar.tsx +115 -0
- package/src/extensions/automerge/automerge.spec.tsx +76 -0
- package/src/extensions/automerge/automerge.test.ts +13 -0
- package/src/extensions/automerge/automerge.ts +26 -11
- package/src/extensions/automerge/cursor.ts +3 -3
- package/src/extensions/automerge/defs.ts +9 -9
- package/src/extensions/automerge/semaphore.ts +17 -19
- package/src/extensions/automerge/update-automerge.ts +12 -6
- package/src/extensions/automerge/update-codemirror.ts +5 -5
- package/src/extensions/awareness.ts +3 -3
- package/src/extensions/comments.ts +112 -64
- package/src/extensions/markdown/code.ts +24 -9
- package/src/extensions/markdown/formatting.ts +159 -0
- package/src/extensions/markdown/heading.ts +1 -1
- package/src/extensions/markdown/index.ts +1 -0
- package/src/extensions/markdown/link.ts +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useActionHandler.ts +45 -0
- package/src/hooks/useTextModel.ts +11 -8
- package/src/themes/default.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/react-ui-editor",
|
|
3
|
-
"version": "0.3.11-main.
|
|
3
|
+
"version": "0.3.11-main.cb8120f",
|
|
4
4
|
"description": "Document editing experience within a DXOS shell.",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -35,53 +35,65 @@
|
|
|
35
35
|
"lodash.pick": "^4.4.0",
|
|
36
36
|
"lodash.sortby": "^4.7.0",
|
|
37
37
|
"style-mod": "^4.1.0",
|
|
38
|
-
"@dxos/async": "0.3.11-main.
|
|
39
|
-
"@dxos/
|
|
40
|
-
"@dxos/
|
|
41
|
-
"@dxos/
|
|
42
|
-
"@dxos/
|
|
43
|
-
"@dxos/
|
|
44
|
-
"@dxos/
|
|
45
|
-
"@dxos/
|
|
46
|
-
"@dxos/
|
|
47
|
-
"@dxos/react-
|
|
48
|
-
"@dxos/react-ui
|
|
49
|
-
"@dxos/util": "0.3.11-main.
|
|
50
|
-
"@dxos/
|
|
51
|
-
"@dxos/
|
|
38
|
+
"@dxos/async": "0.3.11-main.cb8120f",
|
|
39
|
+
"@dxos/context": "0.3.11-main.cb8120f",
|
|
40
|
+
"@dxos/automerge": "0.3.11-main.cb8120f",
|
|
41
|
+
"@dxos/display-name": "0.3.11-main.cb8120f",
|
|
42
|
+
"@dxos/invariant": "0.3.11-main.cb8120f",
|
|
43
|
+
"@dxos/echo-schema": "0.3.11-main.cb8120f",
|
|
44
|
+
"@dxos/debug": "0.3.11-main.cb8120f",
|
|
45
|
+
"@dxos/log": "0.3.11-main.cb8120f",
|
|
46
|
+
"@dxos/protocols": "0.3.11-main.cb8120f",
|
|
47
|
+
"@dxos/react-async": "0.3.11-main.cb8120f",
|
|
48
|
+
"@dxos/react-ui": "0.3.11-main.cb8120f",
|
|
49
|
+
"@dxos/util": "0.3.11-main.cb8120f",
|
|
50
|
+
"@dxos/text-model": "0.3.11-main.cb8120f",
|
|
51
|
+
"@dxos/react-ui-theme": "0.3.11-main.cb8120f"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@automerge/automerge-repo-network-broadcastchannel": "^1.1.0",
|
|
55
55
|
"@faker-js/faker": "^8.3.1",
|
|
56
56
|
"@phosphor-icons/react": "^2.0.5",
|
|
57
57
|
"@preact/signals-react": "^1.3.6",
|
|
58
|
+
"@testing-library/dom": "^8.17.1",
|
|
59
|
+
"@testing-library/react": "^13.4.0",
|
|
60
|
+
"@testing-library/user-event": "^14.4.3",
|
|
58
61
|
"@types/lodash.defaultsdeep": "^4.6.6",
|
|
59
62
|
"@types/lodash.get": "^4.4.7",
|
|
60
63
|
"@types/lodash.pick": "^4.4.7",
|
|
61
64
|
"@types/lodash.sortby": "^4.7.7",
|
|
62
65
|
"@types/react": "^18.0.21",
|
|
63
66
|
"@types/react-dom": "^18.0.6",
|
|
67
|
+
"@types/react-test-renderer": "^17.0.2",
|
|
68
|
+
"@vitest/ui": "^1.2.1",
|
|
64
69
|
"deepsignal": "1.4.0-shallow.0",
|
|
70
|
+
"happy-dom": "^13.3.1",
|
|
71
|
+
"jsdom": "^20.0.1",
|
|
65
72
|
"react": "^18.2.0",
|
|
66
73
|
"react-dom": "^18.2.0",
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"@dxos/
|
|
73
|
-
"@dxos/
|
|
74
|
+
"react-test-renderer": "^18.2.0",
|
|
75
|
+
"vite": "^5.0.12",
|
|
76
|
+
"vite-plugin-top-level-await": "^1.4.1",
|
|
77
|
+
"vite-plugin-wasm": "^3.3.0",
|
|
78
|
+
"vitest": "^1.2.1",
|
|
79
|
+
"@dxos/config": "0.3.11-main.cb8120f",
|
|
80
|
+
"@dxos/echo-signals": "0.3.11-main.cb8120f",
|
|
81
|
+
"@dxos/echo-typegen": "0.3.11-main.cb8120f",
|
|
82
|
+
"@dxos/keyboard": "0.3.11-main.cb8120f",
|
|
83
|
+
"@dxos/react-ui": "0.3.11-main.cb8120f",
|
|
84
|
+
"@dxos/react-client": "0.3.11-main.cb8120f",
|
|
85
|
+
"@dxos/storybook-utils": "0.3.11-main.cb8120f"
|
|
74
86
|
},
|
|
75
87
|
"peerDependencies": {
|
|
76
88
|
"@phosphor-icons/react": "^2.0.5",
|
|
77
89
|
"react": "^18.2.0",
|
|
78
90
|
"react-dom": "^18.2.0",
|
|
79
|
-
"@dxos/react-client": "0.3.11-main.
|
|
91
|
+
"@dxos/react-client": "0.3.11-main.cb8120f"
|
|
80
92
|
},
|
|
81
93
|
"publishConfig": {
|
|
82
94
|
"access": "public"
|
|
83
95
|
},
|
|
84
96
|
"scripts": {
|
|
85
|
-
"
|
|
97
|
+
"vitest": "vitest --ui --dir ./src/extensions/automerge"
|
|
86
98
|
}
|
|
87
99
|
}
|
|
@@ -19,11 +19,14 @@ import { withTheme } from '@dxos/storybook-utils';
|
|
|
19
19
|
|
|
20
20
|
import { MarkdownEditor, type TextEditorProps } from './TextEditor';
|
|
21
21
|
import {
|
|
22
|
+
type CommentsOptions,
|
|
23
|
+
type LinkOptions,
|
|
22
24
|
autocomplete,
|
|
23
25
|
blast,
|
|
24
26
|
code,
|
|
25
27
|
comments,
|
|
26
28
|
defaultOptions,
|
|
29
|
+
heading,
|
|
27
30
|
hr,
|
|
28
31
|
image,
|
|
29
32
|
link,
|
|
@@ -31,17 +34,11 @@ import {
|
|
|
31
34
|
table,
|
|
32
35
|
tasklist,
|
|
33
36
|
typewriter,
|
|
34
|
-
type CommentsOptions,
|
|
35
|
-
type LinkOptions,
|
|
36
37
|
useComments,
|
|
37
|
-
|
|
38
|
+
formatting,
|
|
38
39
|
} from '../../extensions';
|
|
39
40
|
import { type Comment, useTextModel } from '../../hooks';
|
|
40
41
|
|
|
41
|
-
// Extensions:
|
|
42
|
-
// TODO(burdon): Table of contents.
|
|
43
|
-
// TODO(burdon): Front-matter
|
|
44
|
-
|
|
45
42
|
faker.seed(101);
|
|
46
43
|
|
|
47
44
|
const str = (...lines: string[]) => lines.join('\n');
|
|
@@ -249,13 +246,11 @@ const defaults = [
|
|
|
249
246
|
onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
|
|
250
247
|
}),
|
|
251
248
|
code(),
|
|
249
|
+
formatting(),
|
|
252
250
|
heading(),
|
|
253
251
|
hr(),
|
|
254
252
|
image(),
|
|
255
253
|
link({ onRender: onRenderLink, onHover: onHoverLinkTooltip }),
|
|
256
|
-
// mention({
|
|
257
|
-
// onSearch: (text) => names.filter((name) => name.toLowerCase().startsWith(text.toLowerCase())),
|
|
258
|
-
// }),
|
|
259
254
|
table(),
|
|
260
255
|
tasklist(),
|
|
261
256
|
];
|
|
@@ -347,18 +342,18 @@ export const Mention = {
|
|
|
347
342
|
|
|
348
343
|
export const Comments = {
|
|
349
344
|
render: () => {
|
|
350
|
-
const [
|
|
345
|
+
const [_comments, setComments] = useState<Comment[]>([]);
|
|
351
346
|
|
|
352
347
|
return (
|
|
353
348
|
<Story
|
|
354
349
|
text={str('# Comments', '', text.paragraphs, text.footer)}
|
|
355
|
-
comments={
|
|
350
|
+
comments={_comments}
|
|
356
351
|
extensions={[
|
|
357
352
|
comments({
|
|
358
353
|
onHover: onCommentsHover,
|
|
359
354
|
onCreate: (range) => {
|
|
360
355
|
const id = PublicKey.random().toHex();
|
|
361
|
-
|
|
356
|
+
setComments((commentRanges) => [...commentRanges, { id, cursor: range }]);
|
|
362
357
|
return id;
|
|
363
358
|
},
|
|
364
359
|
onSelect: (state) => {
|
|
@@ -21,6 +21,7 @@ import React, {
|
|
|
21
21
|
import { log } from '@dxos/log';
|
|
22
22
|
import { useThemeContext } from '@dxos/react-ui';
|
|
23
23
|
import { attentionSurface, mx } from '@dxos/react-ui-theme';
|
|
24
|
+
import { nonNullable } from '@dxos/util';
|
|
24
25
|
|
|
25
26
|
import { basicBundle, markdownBundle } from '../../extensions';
|
|
26
27
|
import { type EditorModel } from '../../hooks';
|
|
@@ -132,6 +133,7 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
132
133
|
EditorView.contentAttributes.of({ class: slots.content?.className ?? '' }),
|
|
133
134
|
|
|
134
135
|
// State.
|
|
136
|
+
EditorView.editable.of(!readonly),
|
|
135
137
|
EditorState.readOnly.of(!!readonly),
|
|
136
138
|
|
|
137
139
|
// Storage and replication.
|
|
@@ -143,7 +145,7 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
|
|
|
143
145
|
|
|
144
146
|
// Custom.
|
|
145
147
|
...extensions,
|
|
146
|
-
].filter(
|
|
148
|
+
].filter(nonNullable) as Extension[], // TODO(burdon): ???
|
|
147
149
|
});
|
|
148
150
|
|
|
149
151
|
//
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxosTheme';
|
|
6
|
+
|
|
7
|
+
import { faker } from '@faker-js/faker';
|
|
8
|
+
import React, { type FC, useMemo, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { TextObject } from '@dxos/echo-schema';
|
|
11
|
+
import { PublicKey } from '@dxos/keys';
|
|
12
|
+
import { fixedInsetFlexLayout, groupSurface, mx } from '@dxos/react-ui-theme';
|
|
13
|
+
import { withTheme } from '@dxos/storybook-utils';
|
|
14
|
+
|
|
15
|
+
import { Toolbar } from './Toolbar';
|
|
16
|
+
import { code, comments, formatting, heading, image, table, tasklist, useComments } from '../../extensions';
|
|
17
|
+
import { type Comment, useActionHandler, useTextEditor, useTextModel } from '../../hooks';
|
|
18
|
+
import { MarkdownEditor } from '../TextEditor';
|
|
19
|
+
|
|
20
|
+
faker.seed(101);
|
|
21
|
+
|
|
22
|
+
const Story: FC<{ content: string }> = ({ content }) => {
|
|
23
|
+
const [item] = useState({ text: new TextObject(content) });
|
|
24
|
+
const [_comments, setComments] = useState<Comment[]>([]);
|
|
25
|
+
const [editorRef, editorView] = useTextEditor();
|
|
26
|
+
const model = useTextModel({ text: item.text });
|
|
27
|
+
const extensions = useMemo(
|
|
28
|
+
() => [
|
|
29
|
+
code(),
|
|
30
|
+
comments({
|
|
31
|
+
onCreate: (cursor) => {
|
|
32
|
+
const id = PublicKey.random().toHex();
|
|
33
|
+
setComments((comments) => [...comments, { id, cursor }]);
|
|
34
|
+
return id;
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
formatting(),
|
|
38
|
+
heading(),
|
|
39
|
+
image(),
|
|
40
|
+
table(),
|
|
41
|
+
tasklist(),
|
|
42
|
+
],
|
|
43
|
+
[],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
useComments(editorView, _comments);
|
|
47
|
+
const handleAction = useActionHandler(editorView);
|
|
48
|
+
|
|
49
|
+
if (!model) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={mx(fixedInsetFlexLayout, groupSurface)}>
|
|
55
|
+
<div className='flex h-full justify-center'>
|
|
56
|
+
<div className='flex flex-col h-full w-[800px]'>
|
|
57
|
+
<Toolbar onAction={handleAction} />
|
|
58
|
+
<MarkdownEditor ref={editorRef} model={model} extensions={extensions} />
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export default {
|
|
66
|
+
title: 'react-ui-editor/Toolbar',
|
|
67
|
+
component: Toolbar,
|
|
68
|
+
render: (args: any) => <Story {...args} />,
|
|
69
|
+
decorators: [withTheme],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const content = [
|
|
73
|
+
'Demo',
|
|
74
|
+
'',
|
|
75
|
+
'The editor supports **Markdown** styles.',
|
|
76
|
+
'',
|
|
77
|
+
faker.lorem.paragraph({ min: 5, max: 8 }),
|
|
78
|
+
'',
|
|
79
|
+
'',
|
|
80
|
+
].join('\n');
|
|
81
|
+
|
|
82
|
+
export const Default = {
|
|
83
|
+
args: {
|
|
84
|
+
content,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Icon,
|
|
7
|
+
At,
|
|
8
|
+
Brain,
|
|
9
|
+
CaretRight,
|
|
10
|
+
ChatText,
|
|
11
|
+
Code,
|
|
12
|
+
Image,
|
|
13
|
+
Link,
|
|
14
|
+
ListBullets,
|
|
15
|
+
ListChecks,
|
|
16
|
+
ListNumbers,
|
|
17
|
+
TextStrikethrough,
|
|
18
|
+
Table,
|
|
19
|
+
TextB,
|
|
20
|
+
TextItalic,
|
|
21
|
+
} from '@phosphor-icons/react';
|
|
22
|
+
import React, { type FC } from 'react';
|
|
23
|
+
|
|
24
|
+
import { Button, type ButtonProps, DensityProvider, Select } from '@dxos/react-ui';
|
|
25
|
+
import { getSize } from '@dxos/react-ui-theme';
|
|
26
|
+
|
|
27
|
+
export type Action =
|
|
28
|
+
| 'blockquote'
|
|
29
|
+
| 'bold'
|
|
30
|
+
| 'code'
|
|
31
|
+
| 'comment'
|
|
32
|
+
| 'heading'
|
|
33
|
+
| 'image'
|
|
34
|
+
| 'italic'
|
|
35
|
+
| 'link'
|
|
36
|
+
| 'list'
|
|
37
|
+
| 'list-ordered'
|
|
38
|
+
| 'list-tasks'
|
|
39
|
+
| 'mention'
|
|
40
|
+
| 'prompt'
|
|
41
|
+
| 'strikethrough'
|
|
42
|
+
| 'table';
|
|
43
|
+
|
|
44
|
+
export type ToolbarProps = {
|
|
45
|
+
onAction?: (action: { type: Action; data?: any }) => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const IconButton: FC<{ Icon: Icon; title: string } & NonNullable<Pick<ButtonProps, 'onClick'>>> = ({
|
|
49
|
+
Icon,
|
|
50
|
+
title,
|
|
51
|
+
onClick,
|
|
52
|
+
}) => (
|
|
53
|
+
<Button variant='ghost' classNames='p-2' onClick={onClick} title={title}>
|
|
54
|
+
<Icon className={getSize(5)} />
|
|
55
|
+
</Button>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// TODO(burdon): Update state based on current selection?
|
|
59
|
+
export const Toolbar = ({ onAction }: ToolbarProps) => {
|
|
60
|
+
return (
|
|
61
|
+
<DensityProvider density='fine'>
|
|
62
|
+
<div role='toolbar' className='flex w-full p-2 items-center gap-4'>
|
|
63
|
+
<div>
|
|
64
|
+
<Select.Root
|
|
65
|
+
defaultValue='0'
|
|
66
|
+
onValueChange={(value) => onAction?.({ type: 'heading', data: parseInt(value) })}
|
|
67
|
+
>
|
|
68
|
+
<Select.TriggerButton classNames='w-[8rem]' />
|
|
69
|
+
<Select.Portal>
|
|
70
|
+
<Select.Content>
|
|
71
|
+
<Select.Viewport>
|
|
72
|
+
<Select.Option value='0'>Paragraph</Select.Option>
|
|
73
|
+
{[1, 2, 3, 4, 5, 6].map((level) => (
|
|
74
|
+
<Select.Option key={level} value={String(level)}>
|
|
75
|
+
Heading {level}
|
|
76
|
+
</Select.Option>
|
|
77
|
+
))}
|
|
78
|
+
</Select.Viewport>
|
|
79
|
+
</Select.Content>
|
|
80
|
+
</Select.Portal>
|
|
81
|
+
</Select.Root>
|
|
82
|
+
</div>
|
|
83
|
+
<div>
|
|
84
|
+
<IconButton Icon={TextB} title='Bold' onClick={() => onAction?.({ type: 'bold' })} />
|
|
85
|
+
<IconButton Icon={TextItalic} title='Italic' onClick={() => onAction?.({ type: 'italic' })} />
|
|
86
|
+
<IconButton
|
|
87
|
+
Icon={TextStrikethrough}
|
|
88
|
+
title='Strike-through'
|
|
89
|
+
onClick={() => onAction?.({ type: 'strikethrough' })}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
<div>
|
|
93
|
+
<IconButton Icon={ListBullets} title='Bullet list' onClick={() => onAction?.({ type: 'list' })} />
|
|
94
|
+
<IconButton Icon={ListNumbers} title='Numbered list' onClick={() => onAction?.({ type: 'list-ordered' })} />
|
|
95
|
+
<IconButton Icon={ListChecks} title='Task list' onClick={() => onAction?.({ type: 'list-tasks' })} />
|
|
96
|
+
</div>
|
|
97
|
+
<div>
|
|
98
|
+
<IconButton Icon={CaretRight} title='Block quite' onClick={() => onAction?.({ type: 'blockquote' })} />
|
|
99
|
+
<IconButton Icon={Code} title='Code block' onClick={() => onAction?.({ type: 'code' })} />
|
|
100
|
+
<IconButton Icon={Table} title='Table' onClick={() => onAction?.({ type: 'table' })} />
|
|
101
|
+
</div>
|
|
102
|
+
<div>
|
|
103
|
+
<IconButton Icon={Link} title='Link' onClick={() => onAction?.({ type: 'link' })} />
|
|
104
|
+
<IconButton Icon={At} title='Mention' onClick={() => onAction?.({ type: 'mention' })} />
|
|
105
|
+
<IconButton Icon={Image} title='Image' onClick={() => onAction?.({ type: 'image' })} />
|
|
106
|
+
</div>
|
|
107
|
+
<div className='grow' />
|
|
108
|
+
<div>
|
|
109
|
+
<IconButton Icon={Brain} title='AI prompt' onClick={() => onAction?.({ type: 'prompt' })} />
|
|
110
|
+
<IconButton Icon={ChatText} title='Create comment' onClick={() => onAction?.({ type: 'comment' })} />
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</DensityProvider>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorState } from '@codemirror/state';
|
|
6
|
+
import { EditorView } from '@codemirror/view';
|
|
7
|
+
import { render, screen } from '@testing-library/react';
|
|
8
|
+
import chai, { expect } from 'chai';
|
|
9
|
+
import chaiDom from 'chai-dom';
|
|
10
|
+
import get from 'lodash.get';
|
|
11
|
+
import React, { type FC, useEffect, useRef, useState } from 'react';
|
|
12
|
+
import { describe, test } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import { type DocHandle, Repo } from '@dxos/automerge/automerge-repo';
|
|
15
|
+
|
|
16
|
+
import { automerge } from './automerge';
|
|
17
|
+
|
|
18
|
+
type TestObject = {
|
|
19
|
+
text: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const path = ['text'];
|
|
23
|
+
|
|
24
|
+
class Generator {
|
|
25
|
+
constructor(private readonly _handle: DocHandle<TestObject>) {}
|
|
26
|
+
update(text: string) {
|
|
27
|
+
this._handle.change((doc: TestObject) => {
|
|
28
|
+
doc.text = text;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const Test: FC<{ handle: DocHandle<TestObject>; generator: Generator }> = ({ handle, generator }) => {
|
|
34
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
35
|
+
const [view, setView] = useState<EditorView>();
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const extensions = [
|
|
38
|
+
automerge({ handle, path }),
|
|
39
|
+
EditorView.updateListener.of((update) => {
|
|
40
|
+
if (view.state.doc.toString() === 'hello!') {
|
|
41
|
+
// Update editor.
|
|
42
|
+
update.view.dispatch({ changes: { from: view.state.doc.length - 1, insert: ' world' } });
|
|
43
|
+
}
|
|
44
|
+
}),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const view = new EditorView({
|
|
48
|
+
state: EditorState.create({ doc: get(handle.docSync()!, path), extensions }),
|
|
49
|
+
parent: ref.current!,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
setView(view);
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {}, [view]);
|
|
56
|
+
|
|
57
|
+
return <div ref={ref} />;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
chai.use(chaiDom);
|
|
61
|
+
|
|
62
|
+
describe('Automerge', () => {
|
|
63
|
+
test('basic sync', () => {
|
|
64
|
+
const repo = new Repo({ network: [] });
|
|
65
|
+
const handle = repo.create<TestObject>();
|
|
66
|
+
const generator = new Generator(handle);
|
|
67
|
+
render(<Test handle={handle} generator={generator} />);
|
|
68
|
+
expect(screen.getByRole('textbox')).to.have.text('');
|
|
69
|
+
|
|
70
|
+
generator.update('hello!');
|
|
71
|
+
expect(screen.getByRole('textbox')).to.have.text('hello world!');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// TODO(burdon): Test history/undo.
|
|
75
|
+
// TODO(burdon): https://testing-library.com/docs/react-testing-library/example-intro/
|
|
76
|
+
});
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
// Ref: https://github.com/automerge/automerge-codemirror
|
|
5
5
|
//
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { invertedEffects } from '@codemirror/commands';
|
|
8
|
+
import { StateField, type Extension, type StateEffect } from '@codemirror/state';
|
|
8
9
|
import { EditorView, ViewPlugin } from '@codemirror/view';
|
|
9
10
|
|
|
10
|
-
import {
|
|
11
|
-
import { invariant } from '@dxos/invariant';
|
|
11
|
+
import { next as A, type Prop } from '@dxos/automerge/automerge';
|
|
12
12
|
|
|
13
13
|
import { cursorConverter } from './cursor';
|
|
14
|
-
import {
|
|
14
|
+
import { updateHeadsEffect, type IDocHandle, isReconcile, type State } from './defs';
|
|
15
15
|
import { PatchSemaphore } from './semaphore';
|
|
16
16
|
import { Cursor } from '../cursor';
|
|
17
17
|
|
|
@@ -21,7 +21,7 @@ export type AutomergeOptions = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
24
|
-
const
|
|
24
|
+
const syncState = StateField.define<State>({
|
|
25
25
|
create: () => ({
|
|
26
26
|
path: path.slice(),
|
|
27
27
|
lastHeads: A.getHeads(handle.docSync()!),
|
|
@@ -37,7 +37,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
37
37
|
|
|
38
38
|
let clearUnreconciled = false;
|
|
39
39
|
for (const effect of tr.effects) {
|
|
40
|
-
if (effect.is(
|
|
40
|
+
if (effect.is(updateHeadsEffect)) {
|
|
41
41
|
result.lastHeads = effect.value.newHeads;
|
|
42
42
|
clearUnreconciled = true;
|
|
43
43
|
}
|
|
@@ -46,7 +46,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
46
46
|
if (clearUnreconciled) {
|
|
47
47
|
result.unreconciledTransactions = [];
|
|
48
48
|
} else {
|
|
49
|
-
if (!
|
|
49
|
+
if (!isReconcile(tr)) {
|
|
50
50
|
result.unreconciledTransactions.push(tr);
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -55,15 +55,16 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
55
55
|
},
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
const semaphore = new PatchSemaphore(handle,
|
|
58
|
+
const semaphore = new PatchSemaphore(handle, syncState);
|
|
59
59
|
|
|
60
60
|
return [
|
|
61
61
|
Cursor.converter.of(cursorConverter(handle, path)),
|
|
62
|
+
syncState,
|
|
63
|
+
|
|
62
64
|
// Reconcile external updates.
|
|
63
65
|
ViewPlugin.fromClass(
|
|
64
66
|
class {
|
|
65
67
|
constructor(private readonly _view: EditorView) {
|
|
66
|
-
invariant(this._view);
|
|
67
68
|
handle.addListener('change', this._handleChange.bind(this));
|
|
68
69
|
}
|
|
69
70
|
|
|
@@ -76,13 +77,27 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
|
|
|
76
77
|
}
|
|
77
78
|
},
|
|
78
79
|
),
|
|
80
|
+
|
|
79
81
|
// Reconcile local updates.
|
|
80
82
|
EditorView.updateListener.of(({ view, changes }) => {
|
|
81
83
|
if (!changes.empty) {
|
|
82
|
-
// TODO(burdon): Loses cursor position if auto closing brackets. Call explicitly?
|
|
83
84
|
semaphore.reconcile(view);
|
|
84
85
|
}
|
|
85
86
|
}),
|
|
86
|
-
|
|
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
|
+
}),
|
|
87
102
|
];
|
|
88
103
|
};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import get from 'lodash.get';
|
|
6
6
|
|
|
7
7
|
import type { Prop } from '@dxos/automerge/automerge';
|
|
8
|
-
import { next as
|
|
8
|
+
import { next as A } from '@dxos/automerge/automerge';
|
|
9
9
|
|
|
10
10
|
import { type IDocHandle } from './defs';
|
|
11
11
|
import { type CursorConverter } from '../cursor';
|
|
@@ -24,7 +24,7 @@ export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConvert
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// NOTE: Slice is needed because getCursor mutates the array.
|
|
27
|
-
return
|
|
27
|
+
return A.getCursor(doc, path.slice(), pos);
|
|
28
28
|
},
|
|
29
29
|
|
|
30
30
|
fromCursor: (cursor) => {
|
|
@@ -47,6 +47,6 @@ export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConvert
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// NOTE: Slice is needed because getCursor mutates the array.
|
|
50
|
-
return
|
|
50
|
+
return A.getCursorPosition(doc, path.slice(), cursor);
|
|
51
51
|
},
|
|
52
52
|
});
|
|
@@ -8,28 +8,28 @@ import { Annotation, StateEffect, type StateField, type EditorState, type Transa
|
|
|
8
8
|
|
|
9
9
|
import { type ChangeFn, type ChangeOptions, type Doc, type Heads, type Prop } from '@dxos/automerge/automerge';
|
|
10
10
|
|
|
11
|
-
// TODO(burdon): Rename.
|
|
12
11
|
export type State = {
|
|
13
12
|
path: Prop[];
|
|
14
13
|
lastHeads: Heads;
|
|
15
14
|
unreconciledTransactions: Transaction[];
|
|
16
15
|
};
|
|
17
16
|
|
|
17
|
+
export const getPath = (state: EditorState, field: StateField<State>): Prop[] => state.field(field).path;
|
|
18
|
+
export const getLastHeads = (state: EditorState, field: StateField<State>): Heads => state.field(field).lastHeads;
|
|
19
|
+
|
|
18
20
|
export type UpdateHeads = {
|
|
19
21
|
newHeads: Heads;
|
|
20
22
|
};
|
|
21
23
|
|
|
22
|
-
export const
|
|
23
|
-
|
|
24
|
-
export const reconcileAnnotationType = Annotation.define<unknown>();
|
|
24
|
+
export const updateHeadsEffect = StateEffect.define<UpdateHeads>({});
|
|
25
25
|
|
|
26
|
-
export const
|
|
27
|
-
|
|
28
|
-
export const getLastHeads = (state: EditorState, field: StateField<State>): Heads => state.field(field).lastHeads;
|
|
26
|
+
export const updateHeads = (newHeads: Heads): StateEffect<UpdateHeads> => updateHeadsEffect.of({ newHeads });
|
|
29
27
|
|
|
30
|
-
export const
|
|
28
|
+
export const reconcileAnnotation = Annotation.define<boolean>();
|
|
31
29
|
|
|
32
|
-
export const
|
|
30
|
+
export const isReconcile = (tr: Transaction): boolean => {
|
|
31
|
+
return !!tr.annotation(reconcileAnnotation);
|
|
32
|
+
};
|
|
33
33
|
|
|
34
34
|
export type IDocHandle<T = any> = {
|
|
35
35
|
docSync(): Doc<T> | undefined;
|