@dxos/react-ui-editor 0.4.4-main.2a45fa8 → 0.4.4-main.2c7ecda

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 (43) hide show
  1. package/dist/lib/browser/index.mjs +187 -139
  2. package/dist/lib/browser/index.mjs.map +3 -3
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +3 -2
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +3 -3
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +1 -1
  9. package/dist/types/src/components/TextEditor/automerge.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/TextEditor/hooks.stories.d.ts +1 -1
  11. package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +1 -1
  12. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  13. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  14. package/dist/types/src/extensions/basic.d.ts +2 -1
  15. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  16. package/dist/types/src/extensions/comments.d.ts +15 -7
  17. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  18. package/dist/types/src/extensions/cursor.d.ts +1 -1
  19. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  20. package/dist/types/src/hooks/useEditorView.d.ts +6 -4
  21. package/dist/types/src/hooks/useEditorView.d.ts.map +1 -1
  22. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  23. package/dist/types/src/hooks/useTextModel.d.ts +3 -0
  24. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  25. package/dist/types/src/styles/layout.d.ts +1 -1
  26. package/dist/types/src/styles/layout.d.ts.map +1 -1
  27. package/package.json +26 -26
  28. package/src/components/TextEditor/MarkdownEditor.stories.tsx +9 -2
  29. package/src/components/TextEditor/TextEditor.tsx +21 -8
  30. package/src/components/TextEditor/automerge.stories.tsx +10 -7
  31. package/src/components/TextEditor/hooks.stories.tsx +44 -10
  32. package/src/components/Toolbar/Toolbar.stories.tsx +4 -4
  33. package/src/extensions/autocomplete.ts +1 -1
  34. package/src/extensions/automerge/cursor.ts +15 -4
  35. package/src/extensions/automerge/defs.ts +0 -1
  36. package/src/extensions/basic.ts +3 -1
  37. package/src/extensions/comments.ts +111 -82
  38. package/src/extensions/cursor.ts +4 -2
  39. package/src/extensions/markdown/image.ts +1 -1
  40. package/src/hooks/useEditorView.ts +13 -11
  41. package/src/hooks/useTextEditor.ts +6 -2
  42. package/src/hooks/useTextModel.ts +7 -7
  43. package/src/styles/layout.ts +1 -1
@@ -16,31 +16,11 @@ import { type Comment, type Range } from '../hooks';
16
16
  import { getToken } from '../styles';
17
17
  import { callbackWrapper } from '../util';
18
18
 
19
- // TODO(burdon): Reconcile with theme.
20
- const styles = EditorView.baseTheme({
21
- '&light .cm-comment, &light .cm-comment-current': { mixBlendMode: 'darken' },
22
- '&dark .cm-comment, &dark .cm-comment-current': { mixBlendMode: 'plus-lighter' },
23
- '&light .cm-comment': {
24
- backgroundColor: getToken('extend.colors.yellow.50'),
25
- },
26
- '&light .cm-comment-current': {
27
- backgroundColor: getToken('extend.colors.yellow.100'),
28
- },
29
- '&dark .cm-comment': {
30
- color: getToken('extend.colors.yellow.50'),
31
- backgroundColor: getToken('extend.colors.yellow.900'),
32
- },
33
- '&dark .cm-comment-current': {
34
- color: getToken('extend.colors.yellow.100'),
35
- backgroundColor: getToken('extend.colors.yellow.950'),
36
- },
37
- });
19
+ //
20
+ // State management.
21
+ //
38
22
 
39
- const commentMark = Decoration.mark({ class: 'cm-comment', attributes: { 'data-testid': 'cm-comment' } });
40
- const commentCurrentMark = Decoration.mark({
41
- class: 'cm-comment-current',
42
- attributes: { 'data-testid': 'cm-comment' },
43
- });
23
+ const documentId = Facet.define<string | undefined, string | undefined>({ combine: (values) => values[0] });
44
24
 
45
25
  type CommentState = {
46
26
  comment: Comment;
@@ -54,30 +34,12 @@ type SelectionState = {
54
34
  };
55
35
 
56
36
  export type CommentsState = {
37
+ id?: string;
57
38
  comments: CommentState[];
58
39
  selection: SelectionState;
59
40
  };
60
41
 
61
- export const focusComment = (view: EditorView, id: string, center = true) => {
62
- const comment = view.state.field(commentsState).comments.find((range) => range.comment.id === id);
63
- if (!comment?.comment.cursor) {
64
- return;
65
- }
66
-
67
- const range = Cursor.getRangeFromCursor(view.state, comment.comment.cursor);
68
- if (range) {
69
- view.dispatch({
70
- selection: { anchor: range.from },
71
- effects: [
72
- //
73
- EditorView.scrollIntoView(range.from, center ? { y: 'center' } : undefined),
74
- setSelection.of({ current: id }),
75
- ],
76
- });
77
- }
78
- };
79
-
80
- export const setComments = StateEffect.define<Comment[]>();
42
+ export const setComments = StateEffect.define<{ id: string; comments: Comment[] }>();
81
43
 
82
44
  export const setSelection = StateEffect.define<SelectionState>();
83
45
 
@@ -88,7 +50,7 @@ const setCommentState = StateEffect.define<CommentsState>();
88
50
  * The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
89
51
  */
90
52
  const commentsState = StateField.define<CommentsState>({
91
- create: () => ({ comments: [], selection: {} }),
53
+ create: (state) => ({ id: state.facet(documentId), comments: [], selection: {} }),
92
54
  update: (value, tr) => {
93
55
  for (const effect of tr.effects) {
94
56
  // Update selection.
@@ -98,7 +60,8 @@ const commentsState = StateField.define<CommentsState>({
98
60
 
99
61
  // Update range from store.
100
62
  if (effect.is(setComments)) {
101
- const comments: CommentState[] = effect.value
63
+ const { comments } = effect.value;
64
+ const commentStates: CommentState[] = comments
102
65
  .map((comment) => {
103
66
  // Skip cut/deleted comments.
104
67
  if (!comment.cursor) {
@@ -110,7 +73,7 @@ const commentsState = StateField.define<CommentsState>({
110
73
  })
111
74
  .filter(nonNullable);
112
75
 
113
- return { ...value, comments };
76
+ return { ...value, comments: commentStates };
114
77
  }
115
78
 
116
79
  // Update entire state.
@@ -123,6 +86,35 @@ const commentsState = StateField.define<CommentsState>({
123
86
  },
124
87
  });
125
88
 
89
+ //
90
+ // UX
91
+ //
92
+
93
+ const styles = EditorView.baseTheme({
94
+ '&light .cm-comment, &light .cm-comment-current': { mixBlendMode: 'darken' },
95
+ '&dark .cm-comment, &dark .cm-comment-current': { mixBlendMode: 'plus-lighter' },
96
+ '&light .cm-comment': {
97
+ backgroundColor: getToken('extend.colors.yellow.50'),
98
+ },
99
+ '&light .cm-comment-current': {
100
+ backgroundColor: getToken('extend.colors.yellow.100'),
101
+ },
102
+ '&dark .cm-comment': {
103
+ color: getToken('extend.colors.yellow.50'),
104
+ backgroundColor: getToken('extend.colors.yellow.900'),
105
+ },
106
+ '&dark .cm-comment-current': {
107
+ color: getToken('extend.colors.yellow.100'),
108
+ backgroundColor: getToken('extend.colors.yellow.950'),
109
+ },
110
+ });
111
+
112
+ const commentMark = Decoration.mark({ class: 'cm-comment', attributes: { 'data-testid': 'cm-comment' } });
113
+ const commentCurrentMark = Decoration.mark({
114
+ class: 'cm-comment-current',
115
+ attributes: { 'data-testid': 'cm-comment' },
116
+ });
117
+
126
118
  /**
127
119
  * Decorate ranges.
128
120
  */
@@ -151,32 +143,9 @@ const commentsDecorations = EditorView.decorations.compute([commentsState], (sta
151
143
  return Decoration.set(decorations);
152
144
  });
153
145
 
154
- export type CommentsOptions = {
155
- /**
156
- * Key shortcut to create a new thread.
157
- */
158
- key?: string;
159
- /**
160
- * Called to create a new thread and return the thread id.
161
- */
162
- onCreate?: (params: { cursor: string; from: number; location?: Rect | null }) => string | undefined;
163
- /**
164
- * Selection cut/deleted.
165
- */
166
- onDelete?: (params: { id: string }) => void;
167
- /**
168
- * Called when a comment is moved.
169
- */
170
- onUpdate?: (params: { id: string; cursor: string }) => void;
171
- /**
172
- * Called to notify which thread is currently closest to the cursor.
173
- */
174
- onSelect?: (state: CommentsState) => void;
175
- /**
176
- * Called to render tooltip.
177
- */
178
- onHover?: (el: Element, shortcut: string) => void;
179
- };
146
+ //
147
+ // Cut-and-paste.
148
+ //
180
149
 
181
150
  type TrackedComment = { id: string; from: number; to: number };
182
151
 
@@ -288,14 +257,12 @@ const mapTrackedComment = (comment: TrackedComment, changes: ChangeDesc) => ({
288
257
  to: changes.mapPos(comment.to, 1),
289
258
  });
290
259
 
291
- // These are attached to undone/redone transactions in the editor for the purpose of restoring comments
292
- // that were deleted by the original changes.
260
+ /**
261
+ * These are attached to undone/redone transactions in the editor for the purpose of restoring comments
262
+ * that were deleted by the original changes.
263
+ */
293
264
  const restoreCommentEffect = StateEffect.define<TrackedComment>({ map: mapTrackedComment });
294
265
 
295
- const optionsFacet = Facet.define<CommentsOptions, CommentsOptions>({
296
- combine: (providers) => providers[0],
297
- });
298
-
299
266
  /**
300
267
  * Create comment thread action.
301
268
  */
@@ -334,6 +301,43 @@ export const createComment: Command = (view) => {
334
301
  return false;
335
302
  };
336
303
 
304
+ //
305
+ // Options
306
+ //
307
+
308
+ export type CommentsOptions = {
309
+ /**
310
+ * Document id.
311
+ */
312
+ id?: string;
313
+ /**
314
+ * Key shortcut to create a new thread.
315
+ */
316
+ key?: string;
317
+ /**
318
+ * Called to create a new thread and return the thread id.
319
+ */
320
+ onCreate?: (params: { cursor: string; from: number; location?: Rect | null }) => string | undefined;
321
+ /**
322
+ * Selection cut/deleted.
323
+ */
324
+ onDelete?: (params: { id: string }) => void;
325
+ /**
326
+ * Called when a comment is moved.
327
+ */
328
+ onUpdate?: (params: { id: string; cursor: string }) => void;
329
+ /**
330
+ * Called to notify which thread is currently closest to the cursor.
331
+ */
332
+ onSelect?: (state: CommentsState) => void;
333
+ /**
334
+ * Called to render tooltip.
335
+ */
336
+ onHover?: (el: Element, shortcut: string) => void;
337
+ };
338
+
339
+ const optionsFacet = Facet.define<CommentsOptions, CommentsOptions>({ combine: (providers) => providers[0] });
340
+
337
341
  /**
338
342
  * Comment threads.
339
343
  * 1). Updates the EditorModel to store relative selections for a set of comments threads.
@@ -352,6 +356,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
352
356
 
353
357
  return [
354
358
  optionsFacet.of(options),
359
+ documentId.of(options.id),
355
360
  commentsState,
356
361
  commentsDecorations,
357
362
  styles,
@@ -462,6 +467,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
462
467
  // Update callback.
463
468
  handleSelect({
464
469
  selection,
470
+ id: state.facet(documentId),
465
471
  comments: comments.map(({ comment, range }) => ({
466
472
  comment,
467
473
  range,
@@ -475,15 +481,38 @@ export const comments = (options: CommentsOptions = {}): Extension => {
475
481
  ];
476
482
  };
477
483
 
484
+ //
485
+ // Utils.
486
+ //
487
+
488
+ export const focusComment = (view: EditorView, id: string, center = true) => {
489
+ const comment = view.state.field(commentsState).comments.find((range) => range.comment.id === id);
490
+ if (!comment?.comment.cursor) {
491
+ return;
492
+ }
493
+
494
+ const range = Cursor.getRangeFromCursor(view.state, comment.comment.cursor);
495
+ if (range) {
496
+ view.dispatch({
497
+ selection: { anchor: range.from },
498
+ effects: [
499
+ //
500
+ EditorView.scrollIntoView(range.from, center ? { y: 'center' } : undefined),
501
+ setSelection.of({ current: id }),
502
+ ],
503
+ });
504
+ }
505
+ };
506
+
478
507
  /**
479
508
  * Update comments state field.
480
509
  */
481
- export const useComments = (view: EditorView | null, comments: Comment[] = []) => {
510
+ export const useComments = (view: EditorView | null, id: string, comments: Comment[] = []) => {
482
511
  useEffect(() => {
483
512
  if (view) {
484
513
  view.dispatch({
485
- effects: setComments.of(comments),
514
+ effects: setComments.of({ id, comments }),
486
515
  });
487
516
  }
488
- }, [view, comments]);
517
+ }, [id, view, comments]);
489
518
  };
@@ -14,7 +14,7 @@ import type { Range } from '../hooks';
14
14
  * - https://github.com/yjs/yjs?tab=readme-ov-file#relative-positions
15
15
  *
16
16
  * @param {assoc} number Negative values will associate the cursor with the previous character
17
- * while positive - with the next one.
17
+ * while positive - with the next one.
18
18
  */
19
19
  export interface CursorConverter {
20
20
  toCursor(position: number, assoc?: -1 | 1 | undefined): string;
@@ -28,7 +28,9 @@ const defaultCursorConverter: CursorConverter = {
28
28
 
29
29
  export class Cursor {
30
30
  static readonly converter = Facet.define<CursorConverter, CursorConverter>({
31
- combine: (providers) => providers[0] ?? defaultCursorConverter,
31
+ combine: (providers) => {
32
+ return providers[0] ?? defaultCursorConverter;
33
+ },
32
34
  });
33
35
 
34
36
  static readonly getCursorFromRange = (state: EditorState, range: Range) => {
@@ -26,7 +26,7 @@ export const image = (options: ImageOptions = {}): Extension => {
26
26
  from = Math.min(from, fromB);
27
27
  to = Math.max(to, toB);
28
28
  });
29
- // Expand to cover lines
29
+ // Expand to cover lines.
30
30
  from = tr.state.doc.lineAt(from).from;
31
31
  to = tr.state.doc.lineAt(to).to;
32
32
  return value.map(tr.changes).update({
@@ -3,27 +3,29 @@
3
3
  //
4
4
 
5
5
  import { type EditorView } from '@codemirror/view';
6
- import { type RefCallback, useState } from 'react';
6
+ import { type MutableRefObject, useRef, useState } from 'react';
7
7
 
8
8
  /**
9
9
  * Hook for accessing the editor view.
10
10
  *
11
11
  * ```tsx
12
12
  * const Test = () => {
13
- * const [editorRef, editorView] = useEditorView();
13
+ * const [editorRef, viewInvalidated] = useEditorView();
14
14
  * useEffect(() => {
15
- * editorView?.focus();
15
+ * if (editorRef.current && !viewInvalidated) {
16
+ * editorView?.focus();
17
+ * }
16
18
  * }, [editorView]);
17
19
  * return <TextEditor ref={editorRef} />;
18
20
  * };
19
21
  * ```
20
22
  */
21
- export const useEditorView = (): [RefCallback<EditorView | null>, EditorView | null] => {
22
- const [view, setView] = useState<EditorView | null>(null);
23
- return [
24
- (ref: EditorView | null) => {
25
- setView(ref);
26
- },
27
- view,
28
- ];
23
+ export const useEditorView = (id: string): [MutableRefObject<EditorView | null>, boolean] => {
24
+ const editorRef = useRef<EditorView | null>(null);
25
+ const [prev, setPrev] = useState<[EditorView | null, string]>([null, '']);
26
+ if (prev[0] !== editorRef.current) {
27
+ setPrev([editorRef.current, id]);
28
+ }
29
+
30
+ return [editorRef, prev[1] !== id];
29
31
  };
@@ -34,6 +34,7 @@ export type UseTextEditor = {
34
34
  /**
35
35
  * Hook for creating editor.
36
36
  */
37
+ // TODO(wittjosiah): Does not work in strict mode.
37
38
  export const useTextEditor = ({
38
39
  autoFocus,
39
40
  scrollTo,
@@ -45,7 +46,9 @@ export const useTextEditor = ({
45
46
  const onUpdate = useRef<() => void>();
46
47
  const [view, setView] = useState<EditorView>();
47
48
  const parentRef = useRef<HTMLDivElement>(null);
49
+
48
50
  useEffect(() => {
51
+ let view: EditorView;
49
52
  if (parentRef.current) {
50
53
  // https://codemirror.net/docs/ref/#state.EditorStateConfig
51
54
  const state = EditorState.create({
@@ -64,7 +67,7 @@ export const useTextEditor = ({
64
67
  });
65
68
 
66
69
  // https://codemirror.net/docs/ref/#view.EditorViewConfig
67
- const view = new EditorView({
70
+ view = new EditorView({
68
71
  parent: parentRef.current,
69
72
  scrollTo,
70
73
  state,
@@ -84,7 +87,8 @@ export const useTextEditor = ({
84
87
  return () => {
85
88
  view?.destroy();
86
89
  };
87
- }, [parentRef]);
90
+ // TODO(wittjosiah): Does `parentRef` ever change? Only `.current` changes?
91
+ }, [parentRef, extensions]);
88
92
 
89
93
  useEffect(() => {
90
94
  if (view) {
@@ -3,7 +3,7 @@
3
3
  //
4
4
 
5
5
  import get from 'lodash.get';
6
- import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
6
+ import { type Dispatch, type SetStateAction, useState, useMemo } from 'react';
7
7
 
8
8
  import { generateName } from '@dxos/display-name';
9
9
  import { type AutomergeTextCompat, getRawDoc } from '@dxos/echo-schema';
@@ -23,12 +23,12 @@ export type UseTextModelProps = {
23
23
  text?: TextObject;
24
24
  };
25
25
 
26
- export const useTextModel = (props: UseTextModelProps): EditorModel | undefined => {
27
- const { identity, space, text } = props;
28
- const [model, setModel] = useState<EditorModel | undefined>();
29
- useEffect(() => setModel(createModel(props)), [identity, space, text]);
30
- return model;
31
- };
26
+ /**
27
+ * @deprecated
28
+ */
29
+ // TODO(burdon): Remove once automerge lands.
30
+ export const useTextModel = (props: UseTextModelProps): EditorModel | undefined =>
31
+ useMemo(() => createModel(props), Object.values(props));
32
32
 
33
33
  /**
34
34
  * For use primarily in stories & tests so the dependence on TextObject can be avoided.
@@ -3,7 +3,7 @@
3
3
  //
4
4
 
5
5
  export const editorWithToolbarLayout =
6
- 'grid grid-cols-1 grid-rows-[min-content_1fr] justify-center content-start overflow-hidden';
6
+ 'grid grid-cols-1 grid-rows-[min-content_1fr] data-[toolbar=disabled]:grid-rows-[1fr] justify-center content-start overflow-hidden';
7
7
 
8
8
  export const editorFillLayoutRoot = 'min-bs-full grid';
9
9
  export const editorFillLayoutEditor = 'min-bs-full';