@dxos/react-ui-editor 0.3.11-main.42059c4 → 0.3.11-main.47875f3

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.
@@ -6,12 +6,11 @@ import '@dxosTheme';
6
6
 
7
7
  import { type EditorView, keymap } from '@codemirror/view';
8
8
  import { faker } from '@faker-js/faker';
9
- import { Check, Trash } from '@phosphor-icons/react';
9
+ import { Check } from '@phosphor-icons/react';
10
10
  import React, { type FC, useEffect, useMemo, useRef, useState } from 'react';
11
11
 
12
- import { getTextContent, TextObject } from '@dxos/echo-schema';
12
+ import { TextObject } from '@dxos/echo-schema';
13
13
  import { PublicKey } from '@dxos/keys';
14
- import { log } from '@dxos/log';
15
14
  import { Button, DensityProvider } from '@dxos/react-ui';
16
15
  import { fixedInsetFlexLayout, mx } from '@dxos/react-ui-theme';
17
16
  import { withTheme } from '@dxos/storybook-utils';
@@ -31,10 +30,9 @@ const Editor: FC<{
31
30
  commentSelected?: string;
32
31
  commentRanges: CommentRange[];
33
32
  onCreateComment: CommentsOptions['onCreate'];
34
- onDeleteComment: CommentsOptions['onDelete'];
35
- onUpdateComment: CommentsOptions['onUpdate'];
33
+ onMoveComment: CommentsOptions['onMove'];
36
34
  onSelectComment: CommentsOptions['onSelect'];
37
- }> = ({ item, commentSelected, commentRanges, onCreateComment, onDeleteComment, onUpdateComment, onSelectComment }) => {
35
+ }> = ({ item, commentSelected, commentRanges, onCreateComment, onMoveComment, onSelectComment }) => {
38
36
  const model = useTextModel({ text: item.text });
39
37
  const editorRef = useRef<EditorView>(null);
40
38
  const [selected, setSelected] = useState<string>();
@@ -58,8 +56,7 @@ const Editor: FC<{
58
56
  return [
59
57
  comments({
60
58
  onCreate: onCreateComment,
61
- onDelete: onDeleteComment,
62
- onUpdate: onUpdateComment,
59
+ onMove: onMoveComment,
63
60
  onSelect: onSelectComment,
64
61
  }),
65
62
  ];
@@ -87,7 +84,6 @@ type CommentThread = {
87
84
  yPos?: number;
88
85
  selection?: Range;
89
86
  messages: TextObject[];
90
- deleted?: boolean;
91
87
  };
92
88
 
93
89
  const Thread: FC<{
@@ -96,17 +92,23 @@ const Thread: FC<{
96
92
  onSelect: () => void;
97
93
  onResolve: () => void;
98
94
  }> = ({ thread, selected, onSelect, onResolve }) => {
99
- const [focus, setFocus] = useState(false);
100
95
  const [item, setItem] = useState({ text: new TextObject() });
101
96
  const model = useTextModel({ text: item.text });
102
97
  const editorRef = useRef<EditorView>(null);
103
98
 
99
+ // TODO(burdon): Hack to focus (view is recreated when text object changes).
100
+ const focus = () => {
101
+ setTimeout(() => {
102
+ editorRef.current?.focus();
103
+ }, 100);
104
+ };
105
+
104
106
  const containerRef = useRef<HTMLDivElement>(null);
105
107
  useEffect(() => {
106
108
  if (selected) {
107
109
  containerRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
108
110
  if (thread.messages.length === 0) {
109
- setFocus(true);
111
+ focus();
110
112
  }
111
113
  }
112
114
  }, [selected]);
@@ -116,7 +118,7 @@ const Thread: FC<{
116
118
  if (text?.length) {
117
119
  thread.messages.push(item.text);
118
120
  setItem({ text: new TextObject() });
119
- setFocus(true);
121
+ focus();
120
122
  }
121
123
  };
122
124
 
@@ -125,49 +127,41 @@ const Thread: FC<{
125
127
  }
126
128
 
127
129
  return (
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
+ <div className={mx('flex flex-col m-1 rounded shadow divide-y bg-white', selected && 'ring')}>
131
+ <div className='flex p-2 text-xs font-mono gap-2 text-neutral-500 font-thin'>
136
132
  <span>id:{thread.id.slice(0, 4)}</span>
137
133
  <span>from:{thread.selection?.from}</span>
138
134
  <span>to:{thread.selection?.to}</span>
139
135
  <span>y:{thread.yPos}</span>
140
- <span className='grow' />
141
- {thread.deleted && <Trash />}
142
136
  </div>
143
137
 
144
138
  {thread.messages.map((message, i) => (
145
139
  // TODO(burdon): Fix default editor padding so content doesn't jump on creating message.
146
140
  <div key={i} className='p-2' onClick={() => onSelect()}>
147
- {getTextContent(message)}
141
+ {message.text}
148
142
  </div>
149
143
  ))}
150
144
 
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;
145
+ <div ref={containerRef} onClick={() => onSelect()} className='flex py-1'>
146
+ <div className='grow'>
147
+ <TextEditor
148
+ ref={editorRef}
149
+ model={model}
150
+ placeholder={'Enter comment...'}
151
+ extensions={[
152
+ keymap.of([
153
+ {
154
+ key: 'Enter',
155
+ run: () => {
156
+ handleCreateMessage();
157
+ return true;
158
+ },
165
159
  },
166
- },
167
- ]),
168
- ]}
169
- />
170
- <Button variant='ghost' classNames='px-1' title='Resolve' onClick={onResolve}>
160
+ ]),
161
+ ]}
162
+ />
163
+ </div>
164
+ <Button variant='ghost' classNames='px-1 mr-1' title='Resolve' onClick={onResolve}>
171
165
  <Check />
172
166
  </Button>
173
167
  </div>
@@ -191,7 +185,7 @@ const Sidebar: FC<{
191
185
 
192
186
  return (
193
187
  <DensityProvider density='fine'>
194
- <div className='flex flex-col grow overflow-y-scroll py-4 gap-4'>
188
+ <div className='flex flex-col grow overflow-y-scroll py-4 gap-4 pr-4'>
195
189
  {sortedThreads.map((thread) => (
196
190
  <Thread
197
191
  key={thread.id}
@@ -226,7 +220,6 @@ const Story = ({ text, autoCreate }: StoryProps) => {
226
220
 
227
221
  const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
228
222
  const id = PublicKey.random().toHex();
229
- log.info('create', { id: id.slice(0, 4), cursor });
230
223
  setThreads((threads) => [
231
224
  ...threads,
232
225
  {
@@ -234,7 +227,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
234
227
  range: { id, cursor },
235
228
  yPos: location ? Math.floor(location.top) : undefined,
236
229
  messages: autoCreate
237
- ? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 1, max: 3 } })
230
+ ? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 2, max: 5 } })
238
231
  : [],
239
232
  },
240
233
  ]);
@@ -242,50 +235,32 @@ const Story = ({ text, autoCreate }: StoryProps) => {
242
235
  return id;
243
236
  };
244
237
 
245
- const handleDeleteComment: CommentsOptions['onDelete'] = (id) => {
246
- log.info('delete', { id: id.slice(0, 4) });
238
+ // TODO(burdon): Scroll sidebar on select.
239
+ const handleSelectComment: CommentsOptions['onSelect'] = ({ active, closest, ranges }) => {
247
240
  setThreads((threads) =>
248
241
  threads.map((thread) => {
249
- if (thread.id === id) {
250
- thread.deleted = true;
242
+ const range = ranges.find((range) => range.id === thread.range.id);
243
+ if (range) {
244
+ thread.yPos = range.location ? Math.floor(range.location.top) : undefined;
245
+ thread.selection = { from: range.from, to: range.to };
251
246
  }
252
247
 
253
248
  return thread;
254
249
  }),
255
250
  );
256
251
 
257
- setSelected((selected) => (selected === id ? undefined : selected));
252
+ setSelected(active ?? closest);
258
253
  };
259
254
 
260
- const handleUpdateComment: CommentsOptions['onUpdate'] = (id, cursor) => {
261
- log.info('update', { id: id.slice(0, 4), cursor });
255
+ const handleMoveComment: CommentsOptions['onMove'] = (id, cursor) => {
262
256
  setThreads((threads) =>
263
257
  threads.map((thread) => {
264
258
  if (thread.id === id) {
265
259
  thread.range.cursor = cursor;
266
- delete thread.deleted;
267
- }
268
-
269
- return thread;
270
- }),
271
- );
272
- };
273
-
274
- const handleSelectComment: CommentsOptions['onSelect'] = ({ active, closest, ranges }) => {
275
- log.info('select', { active: active?.slice(0, 4), closest: closest?.slice(0, 4) });
276
- setThreads((threads) =>
277
- threads.map((thread) => {
278
- const range = ranges.find((range) => range.id === thread.range.id);
279
- if (range) {
280
- thread.yPos = range.location ? Math.floor(range.location.top) : undefined;
281
- thread.selection = { from: range.from, to: range.to };
282
260
  }
283
-
284
261
  return thread;
285
262
  }),
286
263
  );
287
-
288
- setSelected(active ?? closest);
289
264
  };
290
265
 
291
266
  const handleSelectThread = (id: string) => {
@@ -306,8 +281,7 @@ const Story = ({ text, autoCreate }: StoryProps) => {
306
281
  commentSelected={selected}
307
282
  commentRanges={commentRanges}
308
283
  onCreateComment={handleCreateComment}
309
- onDeleteComment={handleDeleteComment}
310
- onUpdateComment={handleUpdateComment}
284
+ onMoveComment={handleMoveComment}
311
285
  onSelectComment={handleSelectComment}
312
286
  />
313
287
  </div>
@@ -347,7 +321,7 @@ export const Default = {
347
321
  },
348
322
  };
349
323
 
350
- export const Testing = {
324
+ export const Demo = {
351
325
  args: {
352
326
  text: document,
353
327
  autoCreate: true,
@@ -4,37 +4,40 @@
4
4
  // Ref: https://github.com/automerge/automerge-codemirror
5
5
  //
6
6
 
7
- import { StateField, type Extension, type Transaction } from '@codemirror/state';
7
+ import { Facet, 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, semaphoreFacet, type Value } from './defs';
14
+ import { effectType, type IDocHandle, isReconcileTx, 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
+
18
22
  export type AutomergeOptions = {
19
23
  handle: IDocHandle;
20
24
  path: Prop[];
21
25
  };
22
26
 
23
27
  export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
28
+ // TODO(burdon): Rename.
24
29
  const stateField: StateField<Value> = StateField.define({
25
30
  create: () => ({
26
31
  lastHeads: A.getHeads(handle.docSync()!),
27
32
  unreconciledTransactions: [],
28
33
  path: path.slice(),
29
34
  }),
30
-
31
35
  update: (value: Value, tr: Transaction) => {
32
36
  const result: Value = {
33
37
  lastHeads: value.lastHeads,
34
38
  unreconciledTransactions: value.unreconciledTransactions.slice(),
35
39
  path: path.slice(),
36
40
  };
37
-
38
41
  let clearUnreconciled = false;
39
42
  for (const effect of tr.effects) {
40
43
  if (effect.is(effectType)) {
@@ -42,7 +45,6 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
42
45
  clearUnreconciled = true;
43
46
  }
44
47
  }
45
-
46
48
  if (clearUnreconciled) {
47
49
  result.unreconciledTransactions = [];
48
50
  } else {
@@ -50,15 +52,14 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
50
52
  result.unreconciledTransactions.push(tr);
51
53
  }
52
54
  }
53
-
54
55
  return result;
55
56
  },
56
57
  });
57
58
 
58
- const semaphore = new PatchSemaphore(handle, stateField);
59
+ const semaphore = new PatchSemaphore(stateField);
59
60
 
60
61
  const viewPlugin = ViewPlugin.fromClass(
61
- class AutomergeViewPlugin implements PluginValue {
62
+ class AutomergeCodemirrorViewPlugin implements PluginValue {
62
63
  constructor(private readonly _view: EditorView) {
63
64
  handle.addListener('change', this._handleChange);
64
65
  }
@@ -66,9 +67,8 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
66
67
  update(update: ViewUpdate) {
67
68
  if (update.transactions.length > 0 && update.transactions.some((tr) => !isReconcileTx(tr))) {
68
69
  // 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(this._view);
71
+ this._view.state.facet(semaphoreFacet).reconcile(handle, 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(this._view);
81
+ this._view.state.facet(semaphoreFacet).reconcile(handle, this._view);
82
82
  };
83
83
  },
84
84
  );
@@ -1,22 +1,26 @@
1
1
  //
2
2
  // Copyright 2023 DXOS.org
3
- // Copyright 2024 Automerge
4
3
  // Ref: https://github.com/automerge/automerge-codemirror
5
4
  //
6
5
 
7
- import { Annotation, StateEffect, type StateField, type EditorState, type Transaction, Facet } from '@codemirror/state';
6
+ import {
7
+ Annotation,
8
+ StateEffect,
9
+ type StateField,
10
+ type EditorState,
11
+ type Transaction,
12
+ type TransactionSpec,
13
+ } from '@codemirror/state';
8
14
 
9
15
  import { type ChangeFn, type ChangeOptions, type Doc, type Heads, type Prop } from '@dxos/automerge/automerge';
10
16
 
11
- import { type PatchSemaphore } from './semaphore';
12
-
13
17
  export type Value = {
14
18
  lastHeads: Heads;
15
19
  path: Prop[];
16
20
  unreconciledTransactions: Transaction[];
17
21
  };
18
22
 
19
- export type UpdateHeads = {
23
+ type UpdateHeads = {
20
24
  newHeads: Heads;
21
25
  };
22
26
 
@@ -32,12 +36,6 @@ export const reconcileAnnotationType = Annotation.define<unknown>();
32
36
 
33
37
  export const isReconcileTx = (tr: Transaction): boolean => !!tr.annotation(reconcileAnnotationType);
34
38
 
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
- /*
41
39
  export const makeReconcile = (tr: TransactionSpec) => {
42
40
  if (tr.annotations != null) {
43
41
  if (tr.annotations instanceof Array) {
@@ -48,13 +46,11 @@ export const makeReconcile = (tr: TransactionSpec) => {
48
46
  } else {
49
47
  tr.annotations = [reconcileAnnotationType.of({})];
50
48
  }
51
-
52
49
  // return {
53
50
  // ...tr,
54
51
  // annotations: reconcileAnnotationType.of({})
55
52
  // }
56
53
  };
57
- */
58
54
 
59
55
  export type IDocHandle<T = any> = {
60
56
  docSync(): Doc<T> | undefined;
@@ -21,23 +21,27 @@ 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
+
24
29
  /**
25
- * Implements three-way merge (on each mutation/key-stroke).
30
+ * TODO(burdon): Comment.
26
31
  */
27
32
  export class PatchSemaphore {
28
33
  _inReconcile = false;
34
+ _queue: Array<ChangeFn> = [];
29
35
 
30
- constructor(private readonly _handle: IDocHandle, private readonly _field: StateField<Value>) {}
36
+ constructor(private readonly _field: StateField<Value>) {}
31
37
 
32
- reconcile = (view: EditorView) => {
38
+ reconcile = (handle: IDocHandle, view: EditorView) => {
33
39
  if (this._inReconcile) {
34
40
  return;
35
41
  }
36
42
  this._inReconcile = true;
37
43
 
38
44
  const path = getPath(view.state, this._field);
39
-
40
- // Get the heads before the unreconciled transactions are applied.
41
45
  const oldHeads = getLastHeads(view.state, this._field);
42
46
  let selection = view.state.selection;
43
47
 
@@ -54,18 +58,16 @@ export class PatchSemaphore {
54
58
  }
55
59
 
56
60
  // Apply the unreconciled transactions to the document.
57
- let newHeads = updateAutomerge(this._field, this._handle, transactions, view.state);
61
+ let newHeads = updateAutomerge(this._field, handle, transactions, view.state);
58
62
 
59
63
  // NOTE: null and undefined each come from automerge and repo respectively.
60
64
  if (newHeads === null || newHeads === undefined) {
61
65
  // TODO: @alexjg this is the call that's resetting the editor state on click
62
- newHeads = automerge.getHeads(this._handle.docSync()!);
66
+ newHeads = automerge.getHeads(handle.docSync()!);
63
67
  }
64
68
 
65
69
  // Now get the diff between the updated state of the document and the heads and apply that to the codemirror doc.
66
- const diff = automerge.equals(oldHeads, newHeads)
67
- ? []
68
- : automerge.diff(this._handle.docSync()!, oldHeads, newHeads);
70
+ const diff = automerge.equals(oldHeads, newHeads) ? [] : automerge.diff(handle.docSync()!, oldHeads, newHeads);
69
71
  updateCodeMirror(view, selection, path, diff);
70
72
 
71
73
  view.dispatch({