@dxos/react-ui-editor 0.3.11-main.d56f337 → 0.3.11-main.d8b8a39

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 (115) hide show
  1. package/dist/lib/browser/index.mjs +595 -665
  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 +13 -14
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +14 -6
  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/comments.stories.d.ts +1 -1
  10. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
  11. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  12. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  13. package/dist/types/src/extensions/automerge/defs.d.ts +7 -9
  14. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  15. package/dist/types/src/extensions/automerge/index.d.ts +0 -1
  16. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
  17. package/dist/types/src/extensions/automerge/semaphore.d.ts +7 -12
  18. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  19. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -2
  20. package/dist/types/src/extensions/awareness.d.ts +1 -1
  21. package/dist/types/src/extensions/awareness.d.ts.map +1 -1
  22. package/dist/types/src/extensions/basic.d.ts +4 -5
  23. package/dist/types/src/extensions/basic.d.ts.map +1 -1
  24. package/dist/types/src/extensions/code.d.ts.map +1 -1
  25. package/dist/types/src/extensions/comments.d.ts +27 -18
  26. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  27. package/dist/types/src/extensions/cursor.d.ts +3 -3
  28. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  29. package/dist/types/src/extensions/hr.d.ts.map +1 -1
  30. package/dist/types/src/extensions/index.d.ts +1 -1
  31. package/dist/types/src/extensions/index.d.ts.map +1 -1
  32. package/dist/types/src/extensions/markdown/bundle.d.ts +2 -5
  33. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  34. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  35. package/dist/types/src/extensions/outliner.d.ts +4 -0
  36. package/dist/types/src/extensions/outliner.d.ts.map +1 -0
  37. package/dist/types/src/extensions/tasklist.d.ts +1 -5
  38. package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
  39. package/dist/types/src/hooks/awareness-provider.d.ts +7 -4
  40. package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -1
  41. package/dist/types/src/hooks/defs.d.ts +25 -0
  42. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  43. package/dist/types/src/hooks/index.d.ts +2 -2
  44. package/dist/types/src/hooks/index.d.ts.map +1 -1
  45. package/dist/types/src/hooks/useTextEditor.d.ts +17 -0
  46. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
  47. package/dist/types/src/hooks/useTextModel.d.ts +2 -27
  48. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  49. package/dist/types/src/util.d.ts +2 -0
  50. package/dist/types/src/util.d.ts.map +1 -1
  51. package/package.json +31 -34
  52. package/src/components/TextEditor/MarkdownEditor.stories.tsx +64 -69
  53. package/src/components/TextEditor/TextEditor.tsx +95 -70
  54. package/src/components/TextEditor/comments.stories.tsx +104 -76
  55. package/src/extensions/autocomplete.ts +6 -3
  56. package/src/extensions/automerge/automerge.ts +39 -45
  57. package/src/extensions/automerge/defs.ts +10 -31
  58. package/src/extensions/automerge/index.ts +0 -1
  59. package/src/extensions/automerge/semaphore.ts +23 -20
  60. package/src/extensions/automerge/update-automerge.ts +2 -2
  61. package/src/extensions/awareness.ts +15 -14
  62. package/src/extensions/basic.ts +22 -17
  63. package/src/extensions/code.ts +15 -17
  64. package/src/extensions/comments.ts +199 -161
  65. package/src/extensions/cursor.ts +7 -3
  66. package/src/extensions/hr.ts +49 -50
  67. package/src/extensions/index.ts +1 -1
  68. package/src/extensions/link.ts +3 -3
  69. package/src/extensions/markdown/bundle.ts +18 -24
  70. package/src/extensions/mention.ts +9 -2
  71. package/src/extensions/outliner.ts +12 -0
  72. package/src/extensions/tasklist.ts +49 -33
  73. package/src/hooks/awareness-provider.ts +24 -20
  74. package/src/hooks/defs.ts +42 -0
  75. package/src/hooks/index.ts +3 -2
  76. package/src/hooks/useTextEditor.ts +29 -0
  77. package/src/hooks/useTextModel.ts +47 -56
  78. package/src/util.ts +26 -3
  79. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
  80. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
  81. package/dist/types/src/components/TextEditor/yjs.stories.d.ts +0 -12
  82. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  83. package/dist/types/src/extensions/yjs/cursor.d.ts +0 -4
  84. package/dist/types/src/extensions/yjs/cursor.d.ts.map +0 -1
  85. package/dist/types/src/extensions/yjs/index.d.ts +0 -2
  86. package/dist/types/src/extensions/yjs/index.d.ts.map +0 -1
  87. package/dist/types/src/extensions/yjs/yjs.d.ts +0 -4
  88. package/dist/types/src/extensions/yjs/yjs.d.ts.map +0 -1
  89. package/dist/types/src/extensions/yjs/yjs.test.d.ts +0 -2
  90. package/dist/types/src/extensions/yjs/yjs.test.d.ts.map +0 -1
  91. package/dist/types/src/hooks/automerge.d.ts +0 -3
  92. package/dist/types/src/hooks/automerge.d.ts.map +0 -1
  93. package/dist/types/src/hooks/yjs.d.ts +0 -27
  94. package/dist/types/src/hooks/yjs.d.ts.map +0 -1
  95. package/dist/types/src/testing/index.d.ts +0 -3
  96. package/dist/types/src/testing/index.d.ts.map +0 -1
  97. package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
  98. package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
  99. package/dist/types/src/testing/proto/index.d.ts +0 -2
  100. package/dist/types/src/testing/proto/index.d.ts.map +0 -1
  101. package/dist/types/src/testing/replicator.d.ts +0 -45
  102. package/dist/types/src/testing/replicator.d.ts.map +0 -1
  103. package/src/components/TextEditor/codemirror.stories.ts +0 -115
  104. package/src/components/TextEditor/yjs.stories.tsx +0 -56
  105. package/src/extensions/yjs/cursor.ts +0 -22
  106. package/src/extensions/yjs/index.ts +0 -5
  107. package/src/extensions/yjs/yjs.test.ts +0 -35
  108. package/src/extensions/yjs/yjs.ts +0 -16
  109. package/src/hooks/automerge.ts +0 -52
  110. package/src/hooks/yjs.ts +0 -156
  111. package/src/testing/index.ts +0 -6
  112. package/src/testing/proto/gen/schema.ts +0 -49
  113. package/src/testing/proto/index.ts +0 -5
  114. package/src/testing/proto/schema.proto +0 -15
  115. package/src/testing/replicator.ts +0 -184
@@ -4,11 +4,11 @@
4
4
 
5
5
  import '@dxosTheme';
6
6
 
7
- import { EditorView } from '@codemirror/view';
7
+ import { type 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, useState } from 'react';
11
+ import React, { type FC, StrictMode, useRef, useState } from 'react';
12
12
  import { createRoot } from 'react-dom/client';
13
13
 
14
14
  import { TextObject } from '@dxos/echo-schema';
@@ -26,7 +26,6 @@ import {
26
26
  defaultOptions,
27
27
  hr,
28
28
  image,
29
- listener,
30
29
  link,
31
30
  mention,
32
31
  table,
@@ -34,8 +33,9 @@ import {
34
33
  typewriter,
35
34
  type CommentsOptions,
36
35
  type LinkOptions,
36
+ useComments,
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.
@@ -55,8 +55,8 @@ const text = {
55
55
  '- [x] decorator',
56
56
  '- [ ] checkbox',
57
57
  ' - [ ] state',
58
- ' - [ ] indent',
59
- ' - [x] style',
58
+ ' - [ ] indent',
59
+ ' - [x] style',
60
60
  '',
61
61
  ),
62
62
 
@@ -162,6 +162,8 @@ const links = [
162
162
  { label: 'StackEdit', apply: '[StackEdit](https://stackedit.io/app)' },
163
163
  ];
164
164
 
165
+ const names = ['adam', 'alice', 'alison', 'bob', 'carol', 'charlie', 'sayuri', 'shoko'];
166
+
165
167
  const hover =
166
168
  'rounded-sm text-base text-primary-600 hover:text-primary-500 dark:text-primary-300 hover:dark:text-primary-200';
167
169
 
@@ -210,12 +212,15 @@ const onRenderLink: LinkOptions['onRender'] = (el, url) => {
210
212
 
211
213
  type StoryProps = {
212
214
  text?: string;
215
+ comments?: Comment[];
213
216
  automerge?: boolean;
214
- } & Pick<TextEditorProps, 'comments' | 'readonly' | 'extensions' | 'slots'>;
217
+ } & Pick<TextEditorProps, 'readonly' | 'placeholder' | 'slots' | 'extensions'>;
215
218
 
216
- const Story = ({ text, automerge, ...props }: StoryProps) => {
219
+ const Story = ({ text, comments, automerge, placeholder = 'New document.', ...props }: StoryProps) => {
217
220
  const [item] = useState({ text: new TextObject(text, undefined, undefined, { automerge }) });
221
+ const view = useRef<EditorView>(null);
218
222
  const model = useTextModel({ text: item.text });
223
+ useComments(view.current, comments);
219
224
  if (!model) {
220
225
  return null;
221
226
  }
@@ -224,7 +229,7 @@ const Story = ({ text, automerge, ...props }: StoryProps) => {
224
229
  <div className={mx(fixedInsetFlexLayout, groupSurface)}>
225
230
  <div className='flex h-full justify-center'>
226
231
  <div className='flex flex-col h-full w-[800px]'>
227
- <MarkdownEditor model={model} {...props} />
232
+ <MarkdownEditor ref={view} model={model} placeholder={placeholder} {...props} />
228
233
  </div>
229
234
  </div>
230
235
  </div>
@@ -238,7 +243,7 @@ export default {
238
243
  render: Story,
239
244
  };
240
245
 
241
- const extensions = [
246
+ const defaults = [
242
247
  autocomplete({
243
248
  onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
244
249
  }),
@@ -246,40 +251,33 @@ const extensions = [
246
251
  hr(),
247
252
  image(),
248
253
  link({ onRender: onRenderLink, onHover: onHoverLinkTooltip }),
254
+ // mention({
255
+ // onSearch: (text) => names.filter((name) => name.toLowerCase().startsWith(text.toLowerCase())),
256
+ // }),
249
257
  table(),
250
258
  tasklist(),
251
259
  ];
252
260
 
253
261
  export const Default = {
254
- render: () => <Story text={document} extensions={extensions} />,
262
+ render: () => <Story text={document} extensions={defaults} />,
255
263
  };
256
264
 
257
265
  export const Readonly = {
258
- render: () => <Story text={document} extensions={extensions} readonly />,
266
+ render: () => <Story text={document} extensions={defaults} readonly />,
259
267
  };
260
268
 
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={[]} />,
269
+ export const NoExtensions = {
270
+ render: () => <Story text={document} />,
265
271
  };
266
272
 
273
+ const large = faker.helpers.multiple(() => faker.lorem.paragraph({ min: 8, max: 16 }), { count: 20 }).join('\n\n');
274
+
267
275
  export const Empty = {
268
276
  render: () => <Story />,
269
277
  };
270
278
 
271
- export const NoExtensions = {
272
- render: () => <Story text={document} />,
273
- };
274
-
275
- // TODO(burdon): Cursor bug if no newline after BR (cursor down just loops back to start of each paragraph).
276
- export const HorizontalRule = {
277
- render: () => (
278
- <Story
279
- text={str('# Horizontal Rule', '', text.paragraphs, '---', '', text.paragraphs, '---', '', text.paragraphs)}
280
- extensions={[hr()]}
281
- />
282
- ),
279
+ export const Scrolling = {
280
+ render: () => <Story text={str('# Large Document', '', large)} extensions={[]} />,
283
281
  };
284
282
 
285
283
  export const Links = {
@@ -295,34 +293,33 @@ export const Code = {
295
293
  render: () => <Story text={str(text.code, text.footer)} extensions={[code()]} readonly />,
296
294
  };
297
295
 
298
- export const Table = {
299
- render: () => <Story text={str(text.table, text.footer)} extensions={[table()]} />,
300
- };
301
-
302
296
  export const Image = {
303
297
  render: () => <Story text={str(text.image, text.footer)} readonly extensions={[image()]} />,
304
298
  };
305
299
 
306
- export const Tasklist = {
300
+ export const Lists = {
307
301
  render: () => (
308
- <Story
309
- text={str(text.tasks, '', text.list, text.footer)}
310
- extensions={[
311
- tasklist(),
312
- listener({
313
- onChange: (text) => {
314
- console.log(text);
315
- },
316
- }),
317
- ]}
318
- />
302
+ <Story text={str(text.tasks, '', text.list, '', text.numbered, text.footer)} extensions={[tasklist()]} />
319
303
  ),
320
304
  };
321
305
 
306
+ export const Table = {
307
+ render: () => <Story text={str(text.table, text.footer)} extensions={[table()]} />,
308
+ };
309
+
310
+ // export const Outliner = {
311
+ // render: () => (
312
+ // <Story
313
+ // text={str('# Outliner', '', 'Block', ': this is a block', ': with multiple lines', text.footer)}
314
+ // extensions={[outliner()]}
315
+ // />
316
+ // ),
317
+ // };
318
+
322
319
  export const Autocomplete = {
323
320
  render: () => (
324
321
  <Story
325
- text={str('# Autocomplete', '', 'Press CTRL-SPACE', text.footer)}
322
+ text={str('# Autocomplete', '', 'Press Ctrl-Space...', text.footer)}
326
323
  extensions={[
327
324
  link({ onRender: onRenderLink }),
328
325
  autocomplete({
@@ -333,8 +330,6 @@ export const Autocomplete = {
333
330
  ),
334
331
  };
335
332
 
336
- const names = ['adam', 'alice', 'alison', 'bob', 'carol', 'charlie', 'sayuri', 'shoko'];
337
-
338
333
  export const Mention = {
339
334
  render: () => (
340
335
  <Story
@@ -350,7 +345,7 @@ export const Mention = {
350
345
 
351
346
  export const Comments = {
352
347
  render: () => {
353
- const [commentRanges, setCommentRanges] = useState<CommentRange[]>([]);
348
+ const [commentRanges, setCommentRanges] = useState<Comment[]>([]);
354
349
 
355
350
  return (
356
351
  <Story
@@ -370,9 +365,9 @@ export const Comments = {
370
365
  console.log(
371
366
  'update',
372
367
  JSON.stringify({
373
- active: state.active?.slice(0, 8),
374
- closest: state.closest?.slice(0, 8),
375
- ranges: state.ranges.length,
368
+ comments: state.comments.length,
369
+ active: state.selection.current?.slice(0, 8),
370
+ closest: state.selection.closest?.slice(0, 8),
376
371
  }),
377
372
  );
378
373
  }
@@ -384,8 +379,24 @@ export const Comments = {
384
379
  },
385
380
  };
386
381
 
382
+ export const HorizontalRule = {
383
+ render: () => (
384
+ <Story
385
+ text={str('# Horizontal Rule', '', text.paragraphs, '---', text.paragraphs, '---', text.paragraphs)}
386
+ extensions={[hr()]}
387
+ />
388
+ ),
389
+ };
390
+
391
+ const typewriterItems = localStorage.getItem('dxos.org/plugin/markdown/typewriter')?.split(',');
392
+
387
393
  export const Typewriter = {
388
- render: () => <Story text={str('# Typewriter', '', text.paragraphs, text.footer)} extensions={[typewriter()]} />,
394
+ render: () => (
395
+ <Story
396
+ text={str('# Typewriter', '', text.paragraphs, text.footer)}
397
+ extensions={[typewriter({ items: typewriterItems })]}
398
+ />
399
+ ),
389
400
  };
390
401
 
391
402
  export const Blast = {
@@ -393,9 +404,7 @@ export const Blast = {
393
404
  <Story
394
405
  text={str('# Blast', '', text.paragraphs, text.code, text.paragraphs)}
395
406
  extensions={[
396
- typewriter({
397
- items: localStorage.getItem('dxos.composer.extension.demo')?.split(','),
398
- }),
407
+ typewriter({ items: typewriterItems }),
399
408
  blast(
400
409
  defaultsDeep(
401
410
  {
@@ -412,17 +421,3 @@ export const Blast = {
412
421
  />
413
422
  ),
414
423
  };
415
-
416
- export const Diagnostics = {
417
- render: () => (
418
- <Story
419
- text={document}
420
- extensions={[
421
- // Cursor moved.
422
- EditorView.updateListener.of((update) => {
423
- console.log('update', update.view.state.selection.main.head);
424
- }),
425
- ]}
426
- />
427
- ),
428
- };
@@ -2,7 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { EditorState, type Extension } from '@codemirror/state';
5
+ import { EditorState, type Extension, type StateEffect } from '@codemirror/state';
6
6
  import { EditorView } from '@codemirror/view';
7
7
  import { useFocusableGroup } from '@fluentui/react-tabster';
8
8
  import { vim } from '@replit/codemirror-vim';
@@ -14,19 +14,21 @@ import React, {
14
14
  useCallback,
15
15
  useEffect,
16
16
  useImperativeHandle,
17
- useState,
18
17
  useRef,
18
+ useState,
19
19
  } from 'react';
20
20
 
21
- import { generateName } from '@dxos/display-name';
21
+ import { log } from '@dxos/log';
22
22
  import { useThemeContext } from '@dxos/react-ui';
23
- import { getColorForValue, inputSurface, mx } from '@dxos/react-ui-theme';
23
+ import { attentionSurface, mx } from '@dxos/react-ui-theme';
24
24
 
25
- import { basicBundle, markdownBundle, setCommentRange } from '../../extensions';
26
- import { type CommentRange, type EditorModel } from '../../hooks';
25
+ import { basicBundle, markdownBundle } from '../../extensions';
26
+ import { type EditorModel } from '../../hooks';
27
27
  import { type ThemeStyles } from '../../styles';
28
28
  import { defaultTheme, markdownTheme, textTheme } from '../../themes';
29
+ import { logChanges } from '../../util';
29
30
 
31
+ // TODO(burdon): Change to enum?
30
32
  export const EditorModes = ['default', 'vim'] as const;
31
33
  export type EditorMode = (typeof EditorModes)[number];
32
34
 
@@ -41,23 +43,28 @@ export type CursorInfo = {
41
43
 
42
44
  export type TextEditorSlots = {
43
45
  root?: Omit<ComponentProps<'div'>, 'ref'>;
44
- // editor?: {
45
- // className?: string;
46
- // };
46
+ editor?: {
47
+ className?: string;
48
+ };
49
+ content?: {
50
+ className?: string;
51
+ };
47
52
  };
48
53
 
49
54
  // TODO(burdon): Spellcheck?
50
55
  export type TextEditorProps = {
51
- model: EditorModel;
52
- focus?: boolean;
53
- selection?: { anchor: number; head?: number };
56
+ model: EditorModel; // TODO(burdon): Optional (e.g., just provide content if readonly).
54
57
  readonly?: boolean; // TODO(burdon): Move into model.
55
- comments?: CommentRange[]; // TODO(burdon): Move into extension.
56
- extensions?: Extension[];
57
- editorMode?: EditorMode;
58
+ autoFocus?: boolean;
59
+ lineWrapping?: boolean;
60
+ scrollTo?: StateEffect<any>; // TODO(burdon): Restore scroll position: scrollTo EditorView.scrollSnapshot().
61
+ selection?: { anchor: number; head?: number };
62
+ editorMode?: EditorMode; // TODO(burdon): Factor out.
58
63
  placeholder?: string;
59
64
  theme?: ThemeStyles;
60
65
  slots?: TextEditorSlots;
66
+ extensions?: Extension[];
67
+ debug?: boolean;
61
68
  };
62
69
 
63
70
  /**
@@ -65,108 +72,122 @@ export type TextEditorProps = {
65
72
  */
66
73
  export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
67
74
  (
68
- { model, focus, selection, readonly, comments, extensions = [], editorMode, theme, slots = defaultSlots },
75
+ {
76
+ model,
77
+ readonly,
78
+ autoFocus,
79
+ scrollTo,
80
+ selection,
81
+ editorMode,
82
+ theme,
83
+ slots = defaultSlots,
84
+ extensions = [],
85
+ debug,
86
+ },
69
87
  forwardedRef,
70
88
  ) => {
71
89
  const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
72
90
  const { themeMode } = useThemeContext();
73
91
 
74
- // The editor view ref should only be used as an escape hatch.
75
92
  const rootRef = useRef<HTMLDivElement>(null);
76
93
  const [view, setView] = useState<EditorView | null>(null);
77
- useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
78
94
 
79
- // Focus.
80
- useEffect(() => {
81
- if (view && focus) {
82
- view.focus();
83
- }
84
- }, [view, focus]);
85
-
86
- // TODO(burdon): Factor out as extension.
87
- const { awareness, peer } = model;
88
- useEffect(() => {
89
- if (awareness && peer) {
90
- awareness.setLocalStateField('user', {
91
- name: peer.name ?? generateName(peer.id),
92
- color: getColorForValue({ value: peer.id, type: 'color' }),
93
- colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
94
- });
95
- }
96
- }, [awareness, peer, themeMode]);
95
+ // The view ref can be used to focus the editor.
96
+ // NOTE: This does not cause the parent to re-render, so the ref is not available immediately.
97
+ useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
97
98
 
98
- // TODO(burdon): Factor out as extension.
99
+ // Set focus.
99
100
  useEffect(() => {
100
- if (view && comments !== undefined) {
101
- view.dispatch({
102
- effects: setCommentRange.of({ model, comments }),
103
- });
101
+ if (autoFocus) {
102
+ view?.focus();
104
103
  }
105
- }, [view, comments]);
104
+ }, [view, autoFocus]);
106
105
 
106
+ // Create editor state and view.
107
+ // The view is recreated if the model or extensions are changed.
107
108
  useEffect(() => {
108
109
  if (!model || !rootRef.current) {
109
110
  return;
110
111
  }
111
112
 
113
+ //
114
+ // EditorState
112
115
  // https://codemirror.net/docs/ref/#state.EditorStateConfig
116
+ //
113
117
  const state = EditorState.create({
114
118
  doc: model.text(),
115
- // TODO(burdon): Composer should store and set selection when switching documents.
116
119
  selection,
117
120
  extensions: [
118
- readonly && EditorState.readOnly.of(readonly),
119
-
120
- // TODO(burdon): Factor out VIM mode? (manage via MarkdownPlugin).
121
- editorMode === 'vim' && vim(),
121
+ // TODO(burdon): Doesn't catch errors in keymap functions.
122
+ EditorView.exceptionSink.of((err) => {
123
+ log.catch(err);
124
+ }),
122
125
 
123
126
  // Theme.
124
- // TODO(burdon): Make theme configurable.
127
+ // TODO(burdon): Make configurable.
125
128
  EditorView.baseTheme(defaultTheme),
126
129
  EditorView.theme(theme ?? {}),
127
130
  EditorView.darkTheme.of(themeMode === 'dark'),
131
+ EditorView.editorAttributes.of({ class: slots.editor?.className ?? '' }),
132
+ EditorView.contentAttributes.of({ class: slots.content?.className ?? '' }),
133
+
134
+ // State.
135
+ EditorState.readOnly.of(!!readonly),
128
136
 
129
137
  // Storage and replication.
138
+ // NOTE: This must come before user extensions.
130
139
  model.extension,
131
140
 
141
+ // TODO(burdon): Factor out (requires special handling for Escape/focus).
142
+ editorMode === 'vim' && vim(),
143
+
132
144
  // Custom.
133
145
  ...extensions,
134
146
  ].filter(Boolean) as Extension[],
135
147
  });
136
148
 
137
- // NOTE: This repaints the editor.
138
- // If the new state is derived from the old state, it will likely not be visible other than the cursor resetting.
139
- // Ideally this should not happen except when changing between text objects.
140
- view?.destroy();
149
+ //
150
+ // EditorView
151
+ // https://codemirror.net/docs/ref/#view.EditorViewConfig
152
+ //
141
153
  const newView = new EditorView({
142
- parent: rootRef.current,
143
154
  state,
144
- // NOTE: Uncomment to spy on all transactions.
155
+ parent: rootRef.current,
156
+ scrollTo,
157
+ // NOTE: Uncomment to debug/monitor all transactions.
145
158
  // https://codemirror.net/docs/ref/#view.EditorView.dispatch
146
- // dispatch: (transaction, view) => {
147
- // view.update([transaction]);
148
- // },
159
+ dispatchTransactions: (trs, view) => {
160
+ if (debug) {
161
+ logChanges(trs);
162
+ }
163
+ view.update(trs);
164
+ },
149
165
  });
150
166
 
167
+ view?.destroy();
151
168
  setView(newView);
169
+
152
170
  return () => {
153
171
  newView?.destroy();
154
172
  setView(null);
155
173
  };
156
174
  }, [rootRef, model, readonly, editorMode, themeMode]);
157
175
 
176
+ // Handles tab/focus.
177
+ // Pressing Escape focuses the outer div (to support tab navigation); pressing Enter refocuses the editor.
158
178
  const handleKeyUp = useCallback(
159
179
  (event: KeyboardEvent) => {
160
180
  const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
161
181
  switch (key) {
162
- // TODO(burdon): Is this required (for vim mode?)
163
- // case 'Enter': {
164
- // view?.contentDOM.focus();
165
- // break;
166
- // }
182
+ case 'Enter': {
183
+ view?.focus();
184
+ break;
185
+ }
167
186
 
168
187
  case 'Escape': {
169
- editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey) && rootRef.current?.focus();
188
+ if (editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)) {
189
+ rootRef.current?.focus();
190
+ }
170
191
  break;
171
192
  }
172
193
  }
@@ -178,25 +199,26 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
178
199
  <div
179
200
  key={model.id}
180
201
  role='none'
181
- ref={rootRef}
182
202
  tabIndex={0}
183
203
  onKeyUp={handleKeyUp}
184
204
  {...slots.root}
185
205
  {...(editorMode !== 'vim' && tabsterDOMAttribute)}
206
+ ref={rootRef}
186
207
  />
187
208
  );
188
209
  },
189
210
  );
190
211
 
212
+ // TODO(burdon): Single-line/scroll.
191
213
  export const TextEditor = forwardRef<EditorView, TextEditorProps>(
192
- ({ readonly, placeholder, extensions = [], theme = textTheme, slots, ...props }, forwardedRef) => {
214
+ ({ readonly, placeholder, lineWrapping, theme = textTheme, slots, extensions = [], ...props }, forwardedRef) => {
193
215
  const { themeMode } = useThemeContext();
194
216
  const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
195
217
  return (
196
218
  <BaseTextEditor
197
219
  ref={forwardedRef}
198
220
  readonly={readonly}
199
- extensions={[basicBundle({ readonly, themeMode, placeholder }), ...extensions]}
221
+ extensions={[basicBundle({ themeMode, placeholder, lineWrapping }), ...extensions]}
200
222
  theme={theme}
201
223
  slots={updatedSlots}
202
224
  {...props}
@@ -205,16 +227,15 @@ export const TextEditor = forwardRef<EditorView, TextEditorProps>(
205
227
  },
206
228
  );
207
229
 
208
- // TODO(burdon): Remove (Just provide bundle, slots).
209
230
  export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
210
- ({ readonly, placeholder, extensions = [], theme = markdownTheme, slots, ...props }, forwardedRef) => {
231
+ ({ readonly, placeholder, theme = markdownTheme, slots, extensions = [], ...props }, forwardedRef) => {
211
232
  const { themeMode } = useThemeContext();
212
233
  const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
213
234
  return (
214
235
  <BaseTextEditor
215
236
  ref={forwardedRef}
216
237
  readonly={readonly}
217
- extensions={[markdownBundle({ readonly, themeMode, placeholder }), ...extensions]}
238
+ extensions={[markdownBundle({ themeMode, readonly, placeholder }), ...extensions]}
218
239
  theme={theme}
219
240
  slots={updatedSlots}
220
241
  {...props}
@@ -225,7 +246,11 @@ export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
225
246
 
226
247
  export const defaultSlots: TextEditorSlots = {
227
248
  root: {
228
- className: mx('p-2 overflow-y-auto', inputSurface),
249
+ // TODO(burdon): Add focusRing by default/as property?
250
+ className: mx('flex flex-col grow overflow-y-auto', attentionSurface),
251
+ },
252
+ editor: {
253
+ className: 'h-full p-2',
229
254
  },
230
255
  };
231
256