@dxos/react-ui-editor 0.3.11-main.008b7c6 → 0.3.11-main.08776d7

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 (102) hide show
  1. package/dist/lib/browser/index.mjs +745 -810
  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 +12 -5
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +16 -19
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +6 -6
  9. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/TextEditor/comments.stories.d.ts +26 -0
  11. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -0
  12. package/dist/types/src/components/TextEditor/yjs.stories.d.ts +1 -1
  13. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  14. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  15. package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
  16. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  17. package/dist/types/src/extensions/automerge/defs.d.ts +6 -8
  18. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  19. package/dist/types/src/extensions/automerge/semaphore.d.ts +7 -11
  20. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  21. package/dist/types/src/extensions/code.d.ts.map +1 -1
  22. package/dist/types/src/extensions/comments.d.ts +26 -17
  23. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  24. package/dist/types/src/{util → extensions}/cursor.d.ts +10 -2
  25. package/dist/types/src/extensions/cursor.d.ts.map +1 -0
  26. package/dist/types/src/extensions/hr.d.ts.map +1 -1
  27. package/dist/types/src/extensions/image.d.ts.map +1 -1
  28. package/dist/types/src/extensions/index.d.ts +3 -1
  29. package/dist/types/src/extensions/index.d.ts.map +1 -1
  30. package/dist/types/src/extensions/link.d.ts.map +1 -1
  31. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  32. package/dist/types/src/extensions/outliner.d.ts +4 -0
  33. package/dist/types/src/extensions/outliner.d.ts.map +1 -0
  34. package/dist/types/src/extensions/sync.d.ts +7 -0
  35. package/dist/types/src/extensions/sync.d.ts.map +1 -0
  36. package/dist/types/src/extensions/tasklist.d.ts +5 -1
  37. package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
  38. package/dist/types/src/extensions/yjs/cursor.d.ts +1 -1
  39. package/dist/types/src/extensions/yjs/cursor.d.ts.map +1 -1
  40. package/dist/types/src/hooks/automerge.d.ts +2 -1
  41. package/dist/types/src/hooks/automerge.d.ts.map +1 -1
  42. package/dist/types/src/hooks/defs.d.ts +28 -0
  43. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  44. package/dist/types/src/hooks/index.d.ts +2 -1
  45. package/dist/types/src/hooks/index.d.ts.map +1 -1
  46. package/dist/types/src/hooks/useTextModel.d.ts +2 -32
  47. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  48. package/dist/types/src/hooks/yjs.d.ts +2 -1
  49. package/dist/types/src/hooks/yjs.d.ts.map +1 -1
  50. package/dist/types/src/index.d.ts +3 -0
  51. package/dist/types/src/index.d.ts.map +1 -1
  52. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  53. package/dist/types/src/styles/tokens.d.ts +1 -0
  54. package/dist/types/src/styles/tokens.d.ts.map +1 -1
  55. package/dist/types/src/themes/default.d.ts.map +1 -1
  56. package/dist/types/src/util.d.ts.map +1 -0
  57. package/package.json +25 -24
  58. package/src/components/TextEditor/MarkdownEditor.stories.tsx +45 -41
  59. package/src/components/TextEditor/TextEditor.stories.tsx +12 -46
  60. package/src/components/TextEditor/TextEditor.tsx +101 -89
  61. package/src/components/TextEditor/comments.stories.tsx +362 -0
  62. package/src/extensions/autocomplete.ts +18 -17
  63. package/src/extensions/automerge/automerge.ts +15 -46
  64. package/src/extensions/automerge/cursor.ts +2 -1
  65. package/src/extensions/automerge/defs.ts +13 -14
  66. package/src/extensions/automerge/semaphore.ts +21 -16
  67. package/src/extensions/awareness.ts +27 -33
  68. package/src/extensions/code.ts +28 -23
  69. package/src/extensions/comments.ts +234 -236
  70. package/src/extensions/cursor.ts +50 -0
  71. package/src/extensions/hr.ts +10 -12
  72. package/src/extensions/image.ts +40 -22
  73. package/src/extensions/index.ts +3 -1
  74. package/src/extensions/link.ts +86 -88
  75. package/src/extensions/markdown/highlight.ts +3 -12
  76. package/src/extensions/outliner.ts +12 -0
  77. package/src/extensions/sync.ts +15 -0
  78. package/src/extensions/table.ts +10 -4
  79. package/src/extensions/tasklist.ts +54 -79
  80. package/src/extensions/yjs/cursor.ts +2 -1
  81. package/src/extensions/yjs/yjs.test.ts +1 -1
  82. package/src/extensions/yjs/yjs.ts +2 -2
  83. package/src/hooks/automerge.ts +2 -1
  84. package/src/hooks/defs.ts +47 -0
  85. package/src/hooks/index.ts +3 -1
  86. package/src/hooks/useTextModel.ts +3 -49
  87. package/src/hooks/yjs.ts +2 -23
  88. package/src/index.ts +3 -0
  89. package/src/styles/markdown.ts +0 -2
  90. package/src/styles/tokens.ts +3 -0
  91. package/src/themes/default.ts +51 -78
  92. package/dist/types/src/extensions/mermaid.d.ts +0 -3
  93. package/dist/types/src/extensions/mermaid.d.ts.map +0 -1
  94. package/dist/types/src/util/cursor.d.ts.map +0 -1
  95. package/dist/types/src/util/index.d.ts +0 -3
  96. package/dist/types/src/util/index.d.ts.map +0 -1
  97. package/dist/types/src/util/util.d.ts.map +0 -1
  98. package/src/extensions/mermaid.ts +0 -11
  99. package/src/util/cursor.ts +0 -29
  100. package/src/util/index.ts +0 -6
  101. /package/dist/types/src/{util/util.d.ts → util.d.ts} +0 -0
  102. /package/src/{util/util.ts → util.ts} +0 -0
@@ -8,15 +8,16 @@ import { EditorView } from '@codemirror/view';
8
8
  import { faker } from '@faker-js/faker';
9
9
  import { ArrowSquareOut } from '@phosphor-icons/react';
10
10
  import defaultsDeep from 'lodash.defaultsdeep';
11
- import React, { type FC, StrictMode, useRef, useState } from 'react';
11
+ import React, { type FC, StrictMode, useState } from 'react';
12
12
  import { createRoot } from 'react-dom/client';
13
13
 
14
14
  import { TextObject } from '@dxos/echo-schema';
15
+ import { keySymbols, parseShortcut } from '@dxos/keyboard';
15
16
  import { PublicKey } from '@dxos/keys';
16
17
  import { fixedInsetFlexLayout, getSize, groupSurface, mx } from '@dxos/react-ui-theme';
17
18
  import { withTheme } from '@dxos/storybook-utils';
18
19
 
19
- import { MarkdownEditor, type TextEditorProps, type TextEditorRef } from './TextEditor';
20
+ import { MarkdownEditor, type TextEditorProps } from './TextEditor';
20
21
  import {
21
22
  autocomplete,
22
23
  blast,
@@ -25,7 +26,6 @@ import {
25
26
  defaultOptions,
26
27
  hr,
27
28
  image,
28
- listener,
29
29
  link,
30
30
  mention,
31
31
  table,
@@ -33,9 +33,9 @@ import {
33
33
  typewriter,
34
34
  type CommentsOptions,
35
35
  type LinkOptions,
36
- mermaid,
36
+ outliner,
37
37
  } from '../../extensions';
38
- import { type CommentRange, useTextModel } from '../../hooks';
38
+ import { type Comment, useTextModel } from '../../hooks';
39
39
 
40
40
  // Extensions:
41
41
  // TODO(burdon): Table of contents.
@@ -123,8 +123,6 @@ const text = {
123
123
 
124
124
  paragraphs: str(...faker.helpers.multiple(() => [faker.lorem.paragraph(), ''], { count: 3 }).flat()),
125
125
 
126
- mermaid: str('```mermaid', 'graph TD;', 'A-->B;', 'A-->C;', 'B-->D;', 'C-->D;', '```'),
127
-
128
126
  footer: str('', '', '', '', '')
129
127
  };
130
128
 
@@ -185,15 +183,15 @@ const Key: FC<{ char: string }> = ({ char }) => (
185
183
  </span>
186
184
  );
187
185
 
188
- const onCommentsHover: CommentsOptions['onHover'] = (el) => {
186
+ const onCommentsHover: CommentsOptions['onHover'] = (el, shortcut) => {
189
187
  createRoot(el).render(
190
188
  <StrictMode>
191
189
  <div className='flex items-center gap-2 px-2 py-2 bg-neutral-700 text-white text-xs rounded'>
192
190
  <div>Create comment</div>
193
- {/* TODO(burdon): Unify shortcuts. */}
194
191
  <div className='flex gap-1'>
195
- <Key char='⌘' />
196
- <Key char="'" />
192
+ {keySymbols(parseShortcut(shortcut)).map((char) => (
193
+ <Key key={char} char={char} />
194
+ ))}
197
195
  </div>
198
196
  </div>
199
197
  </StrictMode>,
@@ -204,7 +202,7 @@ const onRenderLink: LinkOptions['onRender'] = (el, url) => {
204
202
  createRoot(el).render(
205
203
  <StrictMode>
206
204
  <a href={url} target='_blank' rel='noreferrer' className={hover}>
207
- <ArrowSquareOut weight='bold' className={mx(getSize(4), 'inline-block leading-none mis-1 mb-1')} />
205
+ <ArrowSquareOut weight='bold' className={mx(getSize(4), 'inline-block leading-none mis-1 mb-[2px]')} />
208
206
  </a>
209
207
  </StrictMode>,
210
208
  );
@@ -216,7 +214,6 @@ type StoryProps = {
216
214
  } & Pick<TextEditorProps, 'comments' | 'readonly' | 'extensions' | 'slots'>;
217
215
 
218
216
  const Story = ({ text, automerge, ...props }: StoryProps) => {
219
- const ref = useRef<TextEditorRef>(null);
220
217
  const [item] = useState({ text: new TextObject(text, undefined, undefined, { automerge }) });
221
218
  const model = useTextModel({ text: item.text });
222
219
  if (!model) {
@@ -225,10 +222,9 @@ const Story = ({ text, automerge, ...props }: StoryProps) => {
225
222
 
226
223
  return (
227
224
  <div className={mx(fixedInsetFlexLayout, groupSurface)}>
228
- <div className='flex justify-center overflow-y-scroll'>
229
- <div className='flex flex-col w-[800px] py-16'>
230
- <MarkdownEditor ref={ref} model={model} {...props} />
231
- <div className='flex shrink-0 h-[300px]'></div>
225
+ <div className='flex h-full justify-center'>
226
+ <div className='flex flex-col h-full w-[800px]'>
227
+ <MarkdownEditor model={model} {...props} />
232
228
  </div>
233
229
  </div>
234
230
  </div>
@@ -262,6 +258,16 @@ export const Readonly = {
262
258
  render: () => <Story text={document} extensions={extensions} readonly />,
263
259
  };
264
260
 
261
+ const large = faker.helpers.multiple(() => faker.lorem.paragraph({ min: 8, max: 16 }), { count: 20 }).join('\n\n');
262
+
263
+ export const Large = {
264
+ render: () => <Story text={str('# Large Document', '', large)} extensions={[]} />,
265
+ };
266
+
267
+ export const Empty = {
268
+ render: () => <Story />,
269
+ };
270
+
265
271
  export const NoExtensions = {
266
272
  render: () => <Story text={document} />,
267
273
  };
@@ -298,17 +304,14 @@ export const Image = {
298
304
  };
299
305
 
300
306
  export const Tasklist = {
307
+ render: () => <Story text={str(text.tasks, '', text.list, text.footer)} extensions={[tasklist()]} />,
308
+ };
309
+
310
+ export const Outliner = {
301
311
  render: () => (
302
312
  <Story
303
- text={str(text.tasks, '', text.list, text.footer)}
304
- extensions={[
305
- tasklist(),
306
- listener({
307
- onChange: (text) => {
308
- console.log(text);
309
- },
310
- }),
311
- ]}
313
+ text={str('# Outliner', '', 'Block', ': this is a block', ': with multiple lines', text.footer)}
314
+ extensions={[outliner()]}
312
315
  />
313
316
  ),
314
317
  };
@@ -342,24 +345,20 @@ export const Mention = {
342
345
  ),
343
346
  };
344
347
 
345
- export const Mermaid = {
346
- render: () => <Story text={str('# Mermaid', '', text.mermaid, text.footer)} extensions={[code(), mermaid()]} />,
347
- };
348
-
349
348
  export const Comments = {
350
349
  render: () => {
351
- const [commentsStates, setCommentStates] = useState<CommentRange[]>([]);
350
+ const [commentRanges, setCommentRanges] = useState<Comment[]>([]);
352
351
 
353
352
  return (
354
353
  <Story
355
354
  text={str('# Comments', '', text.paragraphs, text.footer)}
356
- comments={commentsStates}
355
+ comments={commentRanges}
357
356
  extensions={[
358
357
  comments({
359
358
  onHover: onCommentsHover,
360
- onCreate: (relPos) => {
359
+ onCreate: (range) => {
361
360
  const id = PublicKey.random().toHex();
362
- setCommentStates((comments) => [...comments, { id, cursor: relPos }]);
361
+ setCommentRanges((commentRanges) => [...commentRanges, { id, cursor: range }]);
363
362
  return id;
364
363
  },
365
364
  onSelect: (state) => {
@@ -368,9 +367,9 @@ export const Comments = {
368
367
  console.log(
369
368
  'update',
370
369
  JSON.stringify({
371
- active: state.active?.slice(0, 8),
372
- closest: state.closest?.slice(0, 8),
373
- ranges: state.ranges.length,
370
+ comments: state.comments.length,
371
+ active: state.selection.active?.slice(0, 8),
372
+ closest: state.selection.closest?.slice(0, 8),
374
373
  }),
375
374
  );
376
375
  }
@@ -382,8 +381,15 @@ export const Comments = {
382
381
  },
383
382
  };
384
383
 
384
+ const typewriterItems = localStorage.getItem('dxos.org/plugin/markdown/typewriter')?.split(',');
385
+
385
386
  export const Typewriter = {
386
- render: () => <Story text={str('# Typewriter', '', text.paragraphs, text.footer)} extensions={[typewriter()]} />,
387
+ render: () => (
388
+ <Story
389
+ text={str('# Typewriter', '', text.paragraphs, text.footer)}
390
+ extensions={[typewriter({ items: typewriterItems })]}
391
+ />
392
+ ),
387
393
  };
388
394
 
389
395
  export const Blast = {
@@ -391,9 +397,7 @@ export const Blast = {
391
397
  <Story
392
398
  text={str('# Blast', '', text.paragraphs, text.code, text.paragraphs)}
393
399
  extensions={[
394
- typewriter({
395
- items: localStorage.getItem('dxos.composer.extension.demo')?.split(','),
396
- }),
400
+ typewriter({ items: typewriterItems }),
397
401
  blast(
398
402
  defaultsDeep(
399
403
  {
@@ -4,17 +4,15 @@
4
4
 
5
5
  import '@dxosTheme';
6
6
 
7
- import defaultsDeep from 'lodash.defaultsdeep';
8
- import React, { useRef, useState } from 'react';
7
+ import React, { useState } from 'react';
9
8
 
10
9
  import { TextObject } from '@dxos/echo-schema';
11
10
  import { fixedInsetFlexLayout, groupSurface, mx } from '@dxos/react-ui-theme';
12
11
  import { withTheme } from '@dxos/storybook-utils';
13
12
 
14
- import { defaultSlots, TextEditor, type TextEditorProps, type TextEditorRef, type TextEditorSlots } from './TextEditor';
13
+ import { TextEditor, type TextEditorProps } from './TextEditor';
15
14
  import { listener } from '../../extensions';
16
15
  import { useTextModel } from '../../hooks';
17
- import { textTheme } from '../../themes';
18
16
 
19
17
  const initialText = [
20
18
  '# TextEditor',
@@ -30,8 +28,7 @@ const Story = ({
30
28
  text,
31
29
  automerge,
32
30
  ...props
33
- }: { text?: string; automerge?: boolean } & Pick<TextEditorProps, 'extensions' | 'slots'>) => {
34
- const ref = useRef<TextEditorRef>(null);
31
+ }: { text?: string; automerge?: boolean } & Pick<TextEditorProps, 'extensions' | 'placeholder' | 'slots'>) => {
35
32
  const [item] = useState({ text: new TextObject(text, undefined, undefined, { automerge }) });
36
33
  const model = useTextModel({ text: item.text });
37
34
  if (!model) {
@@ -40,10 +37,9 @@ const Story = ({
40
37
 
41
38
  return (
42
39
  <div className={mx(fixedInsetFlexLayout, groupSurface)}>
43
- <div className='flex justify-center overflow-y-scroll'>
40
+ <div className='flex justify-center'>
44
41
  <div className='flex flex-col w-[800px] py-16'>
45
- <TextEditor ref={ref} model={model} {...props} />
46
- <div className='flex shrink-0 h-[300px]'></div>
42
+ <TextEditor model={model} {...props} />
47
43
  </div>
48
44
  </div>
49
45
  </div>
@@ -58,49 +54,19 @@ export default {
58
54
  };
59
55
 
60
56
  export const Default = {
61
- render: () => (
62
- <Story
63
- slots={defaultsDeep(
64
- { editor: { theme: textTheme, placeholder: 'Enter text...' } } satisfies TextEditorSlots,
65
- defaultSlots,
66
- )}
67
- />
68
- ),
69
- };
70
-
71
- export const Listener = {
72
- render: () => (
73
- <Story
74
- slots={defaultsDeep(
75
- { editor: { theme: textTheme, placeholder: 'Enter text...' } } satisfies TextEditorSlots,
76
- defaultSlots,
77
- )}
78
- extensions={[listener({ onChange: (text) => console.log(text) })]}
79
- />
80
- ),
57
+ render: () => <Story placeholder='Enter text...' />,
81
58
  };
82
59
 
83
60
  export const Text = {
84
- render: () => (
85
- <Story
86
- text={initialText}
87
- slots={defaultsDeep(
88
- { editor: { theme: textTheme, placeholder: 'Enter text...' } } satisfies TextEditorSlots,
89
- defaultSlots,
90
- )}
91
- />
92
- ),
61
+ render: () => <Story text={initialText} placeholder='Enter text...' />,
93
62
  };
94
63
 
95
64
  export const Automerge = {
65
+ render: () => <Story text={initialText} placeholder='Enter text...' automerge />,
66
+ };
67
+
68
+ export const Listener = {
96
69
  render: () => (
97
- <Story
98
- automerge
99
- text={initialText}
100
- slots={defaultsDeep(
101
- { editor: { theme: textTheme, placeholder: 'Enter text...' } } satisfies TextEditorSlots,
102
- defaultSlots,
103
- )}
104
- />
70
+ <Story placeholder='Enter text...' extensions={[listener({ onChange: (text) => console.log(text) })]} />
105
71
  ),
106
72
  };
@@ -15,14 +15,15 @@ import React, {
15
15
  useEffect,
16
16
  useImperativeHandle,
17
17
  useState,
18
+ useRef,
18
19
  } from 'react';
19
20
 
20
21
  import { generateName } from '@dxos/display-name';
21
22
  import { useThemeContext } from '@dxos/react-ui';
22
23
  import { getColorForValue, inputSurface, mx } from '@dxos/react-ui-theme';
23
24
 
24
- import { basicBundle, markdownBundle, setCommentRange } from '../../extensions';
25
- import { type CommentRange, type EditorModel } from '../../hooks';
25
+ import { basicBundle, markdownBundle, setComments, syncFacet } from '../../extensions';
26
+ import { type Comment, type EditorModel } from '../../hooks';
26
27
  import { type ThemeStyles } from '../../styles';
27
28
  import { defaultTheme, markdownTheme, textTheme } from '../../themes';
28
29
 
@@ -34,92 +35,106 @@ export type CursorInfo = {
34
35
  to: number;
35
36
  line: number;
36
37
  lines: number;
38
+ length: number;
37
39
  after?: string;
38
40
  };
39
41
 
40
- export type TextEditorRef = {
41
- root: HTMLDivElement | null;
42
- state?: EditorState;
43
- view?: EditorView;
44
- };
45
-
46
42
  export type TextEditorSlots = {
47
43
  root?: Omit<ComponentProps<'div'>, 'ref'>;
48
- editor?: {
49
- className?: string;
50
- placeholder?: string;
51
- spellCheck?: boolean;
52
- tabIndex?: number;
53
- theme?: ThemeStyles;
54
- };
55
44
  };
56
45
 
46
+ // TODO(burdon): Spellcheck?
57
47
  export type TextEditorProps = {
58
48
  model: EditorModel;
49
+ autofocus?: boolean;
59
50
  readonly?: boolean; // TODO(burdon): Move into model.
60
- comments?: CommentRange[]; // TODO(burdon): Move into extension.
51
+ selection?: { anchor: number; head?: number };
52
+ comments?: Comment[]; // TODO(burdon): Move into extension?
61
53
  extensions?: Extension[];
62
54
  editorMode?: EditorMode;
55
+ placeholder?: string;
56
+ theme?: ThemeStyles;
63
57
  slots?: TextEditorSlots;
64
58
  };
65
59
 
60
+ // TODO(burdon): Factor out?
61
+ export const useComments = (view?: EditorView | null, comments?: Comment[]) => {
62
+ useEffect(() => {
63
+ if (view && comments !== undefined) {
64
+ view.dispatch({
65
+ effects: setComments.of(comments),
66
+ });
67
+ }
68
+ }, [view, comments]);
69
+ };
70
+
71
+ // TODO(burdon): Factor out?
72
+ export const useAwareness = ({ awareness, peer }: EditorModel) => {
73
+ const { themeMode } = useThemeContext();
74
+ useEffect(() => {
75
+ if (awareness && peer) {
76
+ awareness.setLocalStateField('user', {
77
+ name: peer.name ?? generateName(peer.id),
78
+ color: getColorForValue({ value: peer.id, type: 'color' }),
79
+ colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
80
+ });
81
+ }
82
+ }, [awareness, peer, themeMode]);
83
+ };
84
+
66
85
  /**
67
86
  * Base text editor.
68
- * NOTE: Rather than adding properties, try to create extensions that can be reused.
69
87
  */
70
- export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
71
- ({ model, readonly, comments, extensions = [], editorMode, slots = defaultSlots }, forwardedRef) => {
72
- const { themeMode } = useThemeContext();
88
+ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
89
+ (
90
+ { model, autofocus, readonly, selection, comments, extensions = [], editorMode, theme, slots = defaultSlots },
91
+ forwardedRef,
92
+ ) => {
73
93
  const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
74
- const [root, setRoot] = useState<HTMLDivElement | null>(null);
75
- const [{ state = undefined, view = undefined } = {}, setEditor] = useState<
76
- { state?: EditorState; view?: EditorView } | undefined
77
- >();
78
- useImperativeHandle(forwardedRef, () => ({ root, state, view }), [view, state, root]);
79
-
80
- // TODO(burdon): Factor out as extension.
81
- const { awareness, peer } = model;
82
- useEffect(() => {
83
- if (awareness && peer) {
84
- awareness.setLocalStateField('user', {
85
- name: peer.name ?? generateName(peer.id),
86
- color: getColorForValue({ value: peer.id, type: 'color' }),
87
- colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
88
- });
89
- }
90
- }, [awareness, peer, themeMode]);
94
+ const { themeMode } = useThemeContext();
95
+
96
+ // The editor view ref should only be used as an escape hatch.
97
+ const rootRef = useRef<HTMLDivElement>(null);
98
+ const [view, setView] = useState<EditorView | null>(null);
99
+ // NOTE: This does not cause the parent to re-render, so the ref is not available immediately.
100
+ useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
91
101
 
92
- // TODO(burdon): Factor out as extension.
102
+ // Focus.
93
103
  useEffect(() => {
94
- if (view && comments?.length) {
95
- view.dispatch({
96
- effects: setCommentRange.of({ model, comments }),
97
- });
104
+ if (autofocus) {
105
+ view?.focus();
98
106
  }
99
- }, [view, comments]);
107
+ }, [view, autofocus]);
108
+
109
+ // TODO(burdon): Factor out as extension/hook.
110
+ useAwareness(model);
111
+
112
+ // TODO(burdon): Factor out as extension/hook.
113
+ useComments(view, comments);
100
114
 
101
115
  useEffect(() => {
102
- if (!root) {
116
+ if (!model || !rootRef.current) {
103
117
  return;
104
118
  }
105
119
 
106
- // TODO(burdon): Remember cursor position.
107
120
  // https://codemirror.net/docs/ref/#state.EditorStateConfig
108
121
  const state = EditorState.create({
109
122
  doc: model.text(),
123
+ // TODO(burdon): Composer should store and set selection when switching documents.
124
+ selection,
110
125
  extensions: [
111
126
  readonly && EditorState.readOnly.of(readonly),
112
127
 
113
- // TODO(burdon): Factor out VIM mode? (manage via MarkdownPlugin).
114
- editorMode === 'vim' && vim(),
115
-
116
128
  // Theme.
117
129
  // TODO(burdon): Make theme configurable.
118
130
  EditorView.baseTheme(defaultTheme),
119
- EditorView.theme(slots?.editor?.theme ?? {}),
131
+ EditorView.theme(theme ?? {}),
120
132
  EditorView.darkTheme.of(themeMode === 'dark'),
121
133
 
122
- // Storage and replication.
134
+ // TODO(burdon): Factor out VIM mode? (manage via MarkdownPlugin).
135
+ editorMode === 'vim' && vim(),
136
+
137
+ // Storage and replication (NOTE: must come before other extensions).
123
138
  model.extension,
124
139
 
125
140
  // Custom.
@@ -131,36 +146,35 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
131
146
  // If the new state is derived from the old state, it will likely not be visible other than the cursor resetting.
132
147
  // Ideally this should not happen except when changing between text objects.
133
148
  view?.destroy();
134
- setEditor({
149
+ const newView = new EditorView({
150
+ parent: rootRef.current,
135
151
  state,
136
- view: new EditorView({
137
- state,
138
- parent: root,
139
- // NOTE: Uncomment to spy on all transactions.
140
- // https://codemirror.net/docs/ref/#view.EditorView.dispatch
141
- // dispatch: (transaction, view) => {
142
- // view.update([transaction]);
143
- // },
144
- }),
152
+ // NOTE: Uncomment to spy on all transactions.
153
+ // https://codemirror.net/docs/ref/#view.EditorView.dispatch
154
+ dispatch: (transaction, view) => {
155
+ view.update([transaction]);
156
+
157
+ // TODO(burdon): Is there a place to register this?
158
+ view.state.facet(syncFacet)?.reconcile(view);
159
+ },
145
160
  });
146
161
 
162
+ setView(newView);
147
163
  return () => {
148
- view?.destroy();
149
- setEditor(undefined);
164
+ newView?.destroy();
165
+ setView(null);
150
166
  };
151
- }, [root, model, readonly, editorMode, themeMode]);
167
+ }, [rootRef, model, readonly, editorMode, themeMode]);
152
168
 
169
+ // TODO(burdon): Replace with keymap?
153
170
  const handleKeyUp = useCallback(
154
171
  (event: KeyboardEvent) => {
155
172
  const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
156
173
  switch (key) {
157
- case 'Enter': {
158
- view?.contentDOM.focus();
159
- break;
160
- }
161
-
162
174
  case 'Escape': {
163
- editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey) && root?.focus();
175
+ if (editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)) {
176
+ rootRef.current?.focus();
177
+ }
164
178
  break;
165
179
  }
166
180
  }
@@ -171,42 +185,46 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
171
185
  return (
172
186
  <div
173
187
  key={model.id}
174
- ref={setRoot}
188
+ role='none'
175
189
  tabIndex={0}
176
- {...slots?.root}
177
- {...(editorMode !== 'vim' && tabsterDOMAttribute)}
178
190
  onKeyUp={handleKeyUp}
191
+ {...slots.root}
192
+ {...(editorMode !== 'vim' && tabsterDOMAttribute)}
193
+ ref={rootRef}
179
194
  />
180
195
  );
181
196
  },
182
197
  );
183
198
 
184
- export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
185
- ({ readonly, extensions = [], slots: _slots, ...props }, forwardedRef) => {
199
+ export const TextEditor = forwardRef<EditorView, TextEditorProps>(
200
+ ({ readonly, placeholder, extensions = [], theme = textTheme, slots, ...props }, forwardedRef) => {
186
201
  const { themeMode } = useThemeContext();
187
- const slots = defaultsDeep({}, _slots, defaultTextSlots);
202
+ const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
188
203
  return (
189
204
  <BaseTextEditor
190
205
  ref={forwardedRef}
191
206
  readonly={readonly}
192
- extensions={[basicBundle({ readonly, themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
193
- slots={slots}
207
+ extensions={[basicBundle({ readonly, themeMode, placeholder }), ...extensions]}
208
+ theme={theme}
209
+ slots={updatedSlots}
194
210
  {...props}
195
211
  />
196
212
  );
197
213
  },
198
214
  );
199
215
 
200
- export const MarkdownEditor = forwardRef<TextEditorRef, TextEditorProps>(
201
- ({ readonly, extensions = [], slots: _slots, ...props }, forwardedRef) => {
216
+ // TODO(burdon): Remove (Just provide bundle, slots).
217
+ export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
218
+ ({ readonly, placeholder, extensions = [], theme = markdownTheme, slots, ...props }, forwardedRef) => {
202
219
  const { themeMode } = useThemeContext();
203
- const slots = defaultsDeep({}, _slots, defaultMarkdownSlots);
220
+ const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
204
221
  return (
205
222
  <BaseTextEditor
206
223
  ref={forwardedRef}
207
224
  readonly={readonly}
208
- extensions={[markdownBundle({ readonly, themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
209
- slots={slots}
225
+ extensions={[markdownBundle({ readonly, themeMode, placeholder }), ...extensions]}
226
+ theme={theme}
227
+ slots={updatedSlots}
210
228
  {...props}
211
229
  />
212
230
  );
@@ -215,20 +233,14 @@ export const MarkdownEditor = forwardRef<TextEditorRef, TextEditorProps>(
215
233
 
216
234
  export const defaultSlots: TextEditorSlots = {
217
235
  root: {
218
- className: mx('p-2', inputSurface),
236
+ className: mx('w-full p-2 overflow-y-auto', inputSurface),
219
237
  },
220
238
  };
221
239
 
222
240
  export const defaultTextSlots: TextEditorSlots = {
223
241
  ...defaultSlots,
224
- editor: {
225
- theme: textTheme,
226
- },
227
242
  };
228
243
 
229
244
  export const defaultMarkdownSlots: TextEditorSlots = {
230
245
  ...defaultSlots,
231
- editor: {
232
- theme: markdownTheme,
233
- },
234
246
  };