@dxos/react-ui-editor 0.4.3 → 0.4.4-main.01e2847

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 (45) hide show
  1. package/dist/lib/browser/index.mjs +200 -142
  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/hooks.stories.d.ts +1 -1
  10. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  11. package/dist/types/src/components/Toolbar/Toolbar.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/comments.d.ts +15 -7
  15. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  16. package/dist/types/src/extensions/cursor.d.ts +1 -1
  17. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  18. package/dist/types/src/extensions/markdown/code.d.ts.map +1 -1
  19. package/dist/types/src/extensions/markdown/formatting.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/useTextModel.d.ts +3 -0
  23. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  24. package/dist/types/src/index.d.ts +1 -1
  25. package/dist/types/src/index.d.ts.map +1 -1
  26. package/dist/types/src/styles/layout.d.ts +4 -1
  27. package/dist/types/src/styles/layout.d.ts.map +1 -1
  28. package/package.json +24 -24
  29. package/src/components/TextEditor/MarkdownEditor.stories.tsx +3 -2
  30. package/src/components/TextEditor/TextEditor.tsx +3 -3
  31. package/src/components/TextEditor/hooks.stories.tsx +4 -4
  32. package/src/components/Toolbar/Toolbar.stories.tsx +9 -6
  33. package/src/components/Toolbar/Toolbar.tsx +10 -13
  34. package/src/extensions/autocomplete.ts +1 -1
  35. package/src/extensions/automerge/cursor.ts +15 -4
  36. package/src/extensions/automerge/defs.ts +0 -1
  37. package/src/extensions/comments.ts +111 -82
  38. package/src/extensions/cursor.ts +4 -2
  39. package/src/extensions/markdown/code.ts +37 -25
  40. package/src/extensions/markdown/formatting.test.ts +1 -1
  41. package/src/extensions/markdown/formatting.ts +1 -0
  42. package/src/hooks/useEditorView.ts +13 -11
  43. package/src/hooks/useTextModel.ts +7 -7
  44. package/src/index.ts +7 -1
  45. package/src/styles/layout.ts +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-editor",
3
- "version": "0.4.3",
3
+ "version": "0.4.4-main.01e2847",
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",
@@ -36,20 +36,20 @@
36
36
  "lodash.pick": "^4.4.0",
37
37
  "lodash.sortby": "^4.7.0",
38
38
  "style-mod": "^4.1.0",
39
- "@dxos/async": "0.4.3",
40
- "@dxos/automerge": "0.4.3",
41
- "@dxos/context": "0.4.3",
42
- "@dxos/debug": "0.4.3",
43
- "@dxos/display-name": "0.4.3",
44
- "@dxos/echo-schema": "0.4.3",
45
- "@dxos/invariant": "0.4.3",
46
- "@dxos/log": "0.4.3",
47
- "@dxos/protocols": "0.4.3",
48
- "@dxos/react-async": "0.4.3",
49
- "@dxos/react-ui-theme": "0.4.3",
50
- "@dxos/react-ui": "0.4.3",
51
- "@dxos/text-model": "0.4.3",
52
- "@dxos/util": "0.4.3"
39
+ "@dxos/context": "0.4.4-main.01e2847",
40
+ "@dxos/async": "0.4.4-main.01e2847",
41
+ "@dxos/debug": "0.4.4-main.01e2847",
42
+ "@dxos/automerge": "0.4.4-main.01e2847",
43
+ "@dxos/display-name": "0.4.4-main.01e2847",
44
+ "@dxos/log": "0.4.4-main.01e2847",
45
+ "@dxos/echo-schema": "0.4.4-main.01e2847",
46
+ "@dxos/invariant": "0.4.4-main.01e2847",
47
+ "@dxos/protocols": "0.4.4-main.01e2847",
48
+ "@dxos/react-async": "0.4.4-main.01e2847",
49
+ "@dxos/react-ui": "0.4.4-main.01e2847",
50
+ "@dxos/react-ui-theme": "0.4.4-main.01e2847",
51
+ "@dxos/util": "0.4.4-main.01e2847",
52
+ "@dxos/text-model": "0.4.4-main.01e2847"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@automerge/automerge-repo-network-broadcastchannel": "^1.1.1",
@@ -76,20 +76,20 @@
76
76
  "vite-plugin-top-level-await": "^1.4.1",
77
77
  "vite-plugin-wasm": "^3.3.0",
78
78
  "vitest": "^1.2.1",
79
- "@dxos/config": "0.4.3",
80
- "@dxos/echo-signals": "0.4.3",
81
- "@dxos/echo-typegen": "0.4.3",
82
- "@dxos/keyboard": "0.4.3",
83
- "@dxos/react-ui": "0.4.3",
84
- "@dxos/react-client": "0.4.3",
85
- "@dxos/random": "0.4.3",
86
- "@dxos/storybook-utils": "0.4.3"
79
+ "@dxos/echo-signals": "0.4.4-main.01e2847",
80
+ "@dxos/echo-typegen": "0.4.4-main.01e2847",
81
+ "@dxos/keyboard": "0.4.4-main.01e2847",
82
+ "@dxos/react-client": "0.4.4-main.01e2847",
83
+ "@dxos/react-ui": "0.4.4-main.01e2847",
84
+ "@dxos/storybook-utils": "0.4.4-main.01e2847",
85
+ "@dxos/random": "0.4.4-main.01e2847",
86
+ "@dxos/config": "0.4.4-main.01e2847"
87
87
  },
88
88
  "peerDependencies": {
89
89
  "@phosphor-icons/react": "^2.0.5",
90
90
  "react": "^18.2.0",
91
91
  "react-dom": "^18.2.0",
92
- "@dxos/react-client": "0.4.3"
92
+ "@dxos/react-client": "0.4.4-main.01e2847"
93
93
  },
94
94
  "publishConfig": {
95
95
  "access": "public"
@@ -211,15 +211,16 @@ const onRenderLink: LinkOptions['onRender'] = (el, url) => {
211
211
  };
212
212
 
213
213
  type StoryProps = {
214
+ id?: string;
214
215
  text?: string;
215
216
  comments?: Comment[];
216
217
  } & Pick<TextEditorProps, 'readonly' | 'placeholder' | 'extensions'>;
217
218
 
218
- const Story = ({ text, comments, placeholder = 'New document.', ...props }: StoryProps) => {
219
+ const Story = ({ id = 'test', text, comments, placeholder = 'New document.', ...props }: StoryProps) => {
219
220
  const [item] = useState({ text: new TextObject(text) });
220
221
  const view = useRef<EditorView>(null);
221
222
  const model = useTextModel({ text: item.text });
222
- useComments(view.current, comments);
223
+ useComments(view.current, id, comments);
223
224
  if (!model) {
224
225
  return null;
225
226
  }
@@ -65,7 +65,7 @@ export type TextEditorProps = {
65
65
  * Base text editor.
66
66
  */
67
67
  // TODO(burdon): Replace with useTextEditor.
68
- export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
68
+ export const BaseTextEditor = forwardRef<EditorView | null, TextEditorProps>(
69
69
  (
70
70
  { model, readonly, autoFocus, scrollTo, selection, theme, slots = defaultSlots, extensions = [], debug },
71
71
  forwardedRef,
@@ -202,7 +202,7 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
202
202
  },
203
203
  );
204
204
 
205
- export const TextEditor = forwardRef<EditorView, TextEditorProps>(
205
+ export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
206
206
  ({ readonly, placeholder, lineWrapping, theme = textTheme, slots, extensions = [], ...props }, forwardedRef) => {
207
207
  const { themeMode } = useThemeContext();
208
208
  const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
@@ -219,7 +219,7 @@ export const TextEditor = forwardRef<EditorView, TextEditorProps>(
219
219
  },
220
220
  );
221
221
 
222
- export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
222
+ export const MarkdownEditor = forwardRef<EditorView | null, TextEditorProps>(
223
223
  ({ readonly, placeholder, theme = markdownTheme, slots, extensions = [], ...props }, forwardedRef) => {
224
224
  const { themeMode } = useThemeContext();
225
225
  const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
@@ -7,7 +7,7 @@ import '@dxosTheme';
7
7
  import React from 'react';
8
8
 
9
9
  import { useThemeContext } from '@dxos/react-ui';
10
- import { mx, textBlockWidth } from '@dxos/react-ui-theme';
10
+ import { attentionSurface, mx, textBlockWidth } from '@dxos/react-ui-theme';
11
11
  import { withTheme } from '@dxos/storybook-utils';
12
12
 
13
13
  import { TextEditor } from './TextEditor';
@@ -24,7 +24,7 @@ import {
24
24
  useFormattingState,
25
25
  } from '../../extensions';
26
26
  import { createDataExtensions, createThemeExtensions, useActionHandler, useTextEditor } from '../../hooks';
27
- import { editorWithToolbarLayout } from '../../styles/layout';
27
+ import { editorFillLayoutEditor, editorFillLayoutRoot, editorWithToolbarLayout } from '../../styles';
28
28
  import { markdownTheme } from '../../themes';
29
29
  import translations from '../../translations';
30
30
  import { Toolbar } from '../Toolbar';
@@ -55,7 +55,7 @@ const Story = ({ autoFocus, placeholder, doc, readonly }: StoryProps) => {
55
55
  themeMode,
56
56
  theme: markdownTheme,
57
57
  slots: {
58
- editor: { className: 'min-bs-full' },
58
+ editor: { className: editorFillLayoutEditor },
59
59
  },
60
60
  }),
61
61
  // TODO(burdon): Move lineWrapping.
@@ -85,7 +85,7 @@ const Story = ({ autoFocus, placeholder, doc, readonly }: StoryProps) => {
85
85
  <div role='none' className='overflow-y-auto'>
86
86
  <div
87
87
  role='textbox'
88
- className={mx(textBlockWidth, 'min-bs-full p-4 surface-attention fg-base')}
88
+ className={mx(textBlockWidth, attentionSurface, editorFillLayoutRoot, 'p-4')}
89
89
  ref={parentRef}
90
90
  />
91
91
  </div>
@@ -25,16 +25,16 @@ import {
25
25
  useFormattingState,
26
26
  } from '../../extensions';
27
27
  import { type Comment, useActionHandler, useEditorView, useTextModel } from '../../hooks';
28
- import { editorWithToolbarLayout } from '../../styles';
28
+ import { editorFillLayoutEditor, editorFillLayoutRoot, editorWithToolbarLayout } from '../../styles';
29
29
  import translations from '../../translations';
30
30
  import { MarkdownEditor } from '../TextEditor';
31
31
 
32
32
  faker.seed(101);
33
33
 
34
- const Story: FC<{ content: string }> = ({ content }) => {
34
+ const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) => {
35
35
  const [item] = useState({ text: new TextObject(content) });
36
36
  const [_comments, setComments] = useState<Comment[]>([]);
37
- const [editorRef, editorView] = useEditorView();
37
+ const [editorRef, viewInvalidated] = useEditorView(id);
38
38
  const model = useTextModel({ text: item.text });
39
39
  const [formattingState, formattingObserver] = useFormattingState();
40
40
  const extensions = useMemo(
@@ -57,8 +57,8 @@ const Story: FC<{ content: string }> = ({ content }) => {
57
57
  [],
58
58
  );
59
59
 
60
- useComments(editorView, _comments);
61
- const handleAction = useActionHandler(editorView);
60
+ useComments(viewInvalidated ? null : editorRef.current, id, _comments);
61
+ const handleAction = useActionHandler(editorRef.current);
62
62
 
63
63
  if (!model) {
64
64
  return null;
@@ -75,7 +75,10 @@ const Story: FC<{ content: string }> = ({ content }) => {
75
75
  ref={editorRef}
76
76
  model={model}
77
77
  extensions={extensions}
78
- slots={{ root: { className: mx(textBlockWidth, 'pli-2') } }}
78
+ slots={{
79
+ root: { className: mx(textBlockWidth, editorFillLayoutRoot, 'pli-2') },
80
+ editor: { className: editorFillLayoutEditor },
81
+ }}
79
82
  />
80
83
  </div>
81
84
  );
@@ -122,7 +122,7 @@ const ToolbarButton = ({ Icon, children, ...props }: ToolbarButtonProps) => {
122
122
  const ToolbarSeparator = () => <div role='separator' className='grow' />;
123
123
 
124
124
  const MarkdownHeading = () => {
125
- // const { t } = useTranslation(translationKey);
125
+ const { t } = useTranslation(translationKey);
126
126
  const { onAction, state } = useToolbarContext('MarkdownFormatting');
127
127
  const blockType = state ? state.blockType : 'paragraph';
128
128
  const header = blockType && /heading(\d)/.exec(blockType);
@@ -134,15 +134,12 @@ const MarkdownHeading = () => {
134
134
  value={value ?? '0'}
135
135
  onValueChange={(value) => onAction?.({ type: 'heading', data: parseInt(value) })}
136
136
  >
137
- <Select.TriggerButton>
138
- <HeadingIcon className={iconStyles} />
139
- {/* TODO(burdon): Incorrectly positioned. */}
140
- {/* <NaturalToolbar.Button variant='ghost' classNames={buttonStyles}> */}
141
- {/* <span className='sr-only'>{t('heading label')}</span> */}
142
- {/* <HeadingIcon className={iconStyles} /> */}
143
- {/* <CaretDown className={getSize(2)} weight='bold' /> */}
144
- {/* </NaturalToolbar.Button> */}
145
- </Select.TriggerButton>
137
+ <NaturalToolbar.Button asChild>
138
+ <Select.TriggerButton variant='ghost' classNames={buttonStyles}>
139
+ <span className='sr-only'>{t('heading label')}</span>
140
+ <HeadingIcon className={iconStyles} />
141
+ </Select.TriggerButton>
142
+ </NaturalToolbar.Button>
146
143
  <Select.Portal>
147
144
  <Select.Content>
148
145
  <Select.ScrollUpButton />
@@ -211,7 +208,7 @@ const MarkdownLists = () => {
211
208
  const { onAction, state } = useToolbarContext('MarkdownStyles');
212
209
  const { t } = useTranslation(translationKey);
213
210
  return (
214
- <NaturalToolbar.ToggleGroup type='single' value={state?.listStyle ?? undefined}>
211
+ <NaturalToolbar.ToggleGroup type='single' value={state?.listStyle ? `list-${state.listStyle}` : ''}>
215
212
  {markdownLists.map(({ type, getState, Icon }) => (
216
213
  <ToolbarButton
217
214
  key={type}
@@ -235,7 +232,7 @@ const markdownBlocks: ButtonProps[] = [
235
232
  {
236
233
  type: 'codeblock',
237
234
  Icon: CodeBlock,
238
- getState: (state) => state.blockQuote,
235
+ getState: (state) => state.blockType === 'codeblock',
239
236
  disabled: (state) => !state.blankLine,
240
237
  },
241
238
  {
@@ -251,7 +248,7 @@ const MarkdownBlocks = () => {
251
248
  const { t } = useTranslation(translationKey);
252
249
  const value = markdownBlocks.find(({ getState }) => state && getState(state));
253
250
  return (
254
- <NaturalToolbar.ToggleGroup type='single' value={value?.type}>
251
+ <NaturalToolbar.ToggleGroup type='single' value={value?.type ?? ''}>
255
252
  {markdownBlocks.map(({ type, disabled, getState, Icon }) => (
256
253
  <ToolbarButton
257
254
  key={type}
@@ -35,7 +35,7 @@ export const autocomplete = ({ onSearch }: AutocompleteOptions) => {
35
35
  // https://codemirror.net/examples/autocompletion
36
36
  // https://codemirror.net/docs/ref/#autocomplete.autocompletion
37
37
  autocompletion({
38
- activateOnTyping: true,
38
+ activateOnTyping: false,
39
39
 
40
40
  // closeOnBlur: false,
41
41
  // defaultKeymap: false,
@@ -6,6 +6,7 @@ import get from 'lodash.get';
6
6
 
7
7
  import type { Prop } from '@dxos/automerge/automerge';
8
8
  import { next as A } from '@dxos/automerge/automerge';
9
+ import { log } from '@dxos/log';
9
10
 
10
11
  import { type IDocHandle } from './defs';
11
12
  import { type CursorConverter } from '../cursor';
@@ -23,8 +24,13 @@ export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConvert
23
24
  return 'end';
24
25
  }
25
26
 
26
- // NOTE: Slice is needed because getCursor mutates the array.
27
- return A.getCursor(doc, path.slice(), pos);
27
+ try {
28
+ // NOTE: Slice is needed because getCursor mutates the array.
29
+ return A.getCursor(doc, path.slice(), pos);
30
+ } catch (err) {
31
+ log.catch(err);
32
+ return ''; // In case of invalid request (e.g., wrong document).
33
+ }
28
34
  },
29
35
 
30
36
  fromCursor: (cursor) => {
@@ -46,7 +52,12 @@ export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConvert
46
52
  }
47
53
  }
48
54
 
49
- // NOTE: Slice is needed because getCursor mutates the array.
50
- return A.getCursorPosition(doc, path.slice(), cursor);
55
+ try {
56
+ // NOTE: Slice is needed because getCursor mutates the array.
57
+ return A.getCursorPosition(doc, path.slice(), cursor);
58
+ } catch (err) {
59
+ log.catch(err);
60
+ return 0; // In case of invalid request (e.g., wrong document).
61
+ }
51
62
  },
52
63
  });
@@ -35,7 +35,6 @@ export type IDocHandle<T = any> = {
35
35
  docSync(): Doc<T> | undefined;
36
36
  change(callback: ChangeFn<T>, options?: ChangeOptions<T>): void;
37
37
  changeAt(heads: Heads, callback: ChangeFn<T>, options?: ChangeOptions<T>): string[] | undefined;
38
-
39
38
  addListener(event: 'change', listener: () => void): void;
40
39
  removeListener(event: 'change', listener: () => void): void;
41
40
  };
@@ -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) => {