@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.
Files changed (28) hide show
  1. package/dist/lib/browser/index.mjs +237 -181
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +4 -1
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  7. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/automerge.stories.d.ts +2 -2
  9. package/dist/types/src/components/TextEditor/automerge.stories.d.ts.map +1 -1
  10. package/dist/types/src/extensions/annotations.d.ts +6 -0
  11. package/dist/types/src/extensions/annotations.d.ts.map +1 -0
  12. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  13. package/dist/types/src/extensions/automerge/{semaphore.d.ts → sync.d.ts} +6 -5
  14. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -0
  15. package/dist/types/src/extensions/index.d.ts +1 -0
  16. package/dist/types/src/extensions/index.d.ts.map +1 -1
  17. package/package.json +24 -24
  18. package/src/components/TextEditor/MarkdownEditor.stories.tsx +17 -4
  19. package/src/components/TextEditor/TextEditor.stories.tsx +8 -2
  20. package/src/components/TextEditor/TextEditor.tsx +1 -0
  21. package/src/components/TextEditor/automerge.stories.tsx +39 -47
  22. package/src/components/Toolbar/Toolbar.stories.tsx +1 -1
  23. package/src/extensions/annotations.ts +78 -0
  24. package/src/extensions/automerge/automerge.ts +5 -21
  25. package/src/extensions/automerge/{semaphore.ts → sync.ts} +32 -34
  26. package/src/extensions/index.ts +1 -0
  27. package/src/hooks/useTextModel.ts +5 -5
  28. 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 { EditorView } from '@codemirror/view';
9
- import '@preact/signals-react'; // Register react integration
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, useRef, useState } from 'react';
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
- // TODO(burdon): Move to components.
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: Prop[];
35
+ path?: Prop[];
32
36
  };
33
37
 
34
- const Editor = ({ handle, path }: EditorProps) => {
35
- const containerRef = useRef<HTMLDivElement | null>(null);
36
- const editorRoot = useRef<EditorView>();
37
-
38
- useEffect(() => {
39
- const view = (editorRoot.current = new EditorView({
40
- doc: get(handle.docSync()!, path),
41
- extensions: [basicSetup, automerge({ handle, path }), awareness()],
42
- parent: containerRef.current as any,
43
- }));
44
-
45
- return () => {
46
- view.destroy();
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 className='codemirror-editor' ref={containerRef} onKeyDown={(evt) => evt.stopPropagation()} />;
52
+ return <div ref={parentRef} />;
51
53
  };
52
54
 
53
55
  const Story = () => {
54
- const [object1, setObject1] = useState<DocHandle<any> | null>(null);
55
- const [object2, setObject2] = useState<DocHandle<any> | null>(null);
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: any) => {
68
- doc.text = 'Hello world!';
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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', width: '100vw' }}>
85
- <div>
86
- <Editor handle={object1} path={['text']} />
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 = ({ id, spaceKey }: { id: number; spaceKey: PublicKey }) => {
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
- // <div className={mx(fixedInsetFlexLayout, groupSurface)}>
121
- <div className='flex justify-center overflow-y-scroll'>
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('Hello world!'),
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
  };
@@ -86,7 +86,7 @@ export default {
86
86
  };
87
87
 
88
88
  const content = [
89
- 'Demo',
89
+ '# Demo',
90
90
  '',
91
91
  'The editor supports **Markdown** styles.',
92
92
  '',
@@ -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 { invertedEffects } from '@codemirror/commands';
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 { PatchSemaphore } from './semaphore';
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 semaphore = new PatchSemaphore(handle, syncState);
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
- semaphore.reconcile(this._view);
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
- semaphore.reconcile(view);
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 PatchSemaphore {
28
- private _inReconcile = false;
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
- if (!this._inReconcile) {
38
- this._inReconcile = true;
39
- this.doReconcile(view);
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
- // Get the heads before the unreconciled transactions are applied.
48
- const oldHeads = getLastHeads(view.state, this._state);
49
- let selection = view.state.selection;
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
- // First undo all the unreconciled transactions.
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 toInvert = transactions.slice().reverse();
54
- for (const tr of toInvert) {
55
- const inverted = tr.changes.invert(tr.startState.doc);
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
- changes: inverted,
59
- annotations: reconcileAnnotation.of(true),
58
+ effects: updateHeads(newHeads),
59
+ annotations: reconcileAnnotation.of(false),
60
60
  });
61
61
  }
62
+ }
62
63
 
63
- // Apply the unreconciled transactions to the document.
64
- // NOTE: null and undefined each come from automerge and repo respectively.
65
- let newHeads = updateAutomerge(this._state, this._handle, transactions, view.state);
66
- if (newHeads === null || newHeads === undefined) {
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),
@@ -2,6 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ export * from './annotations';
5
6
  export * from './autocomplete';
6
7
  export * from './automerge';
7
8
  export * from './awareness';
@@ -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"}