@dxos/react-ui-editor 0.3.11-main.24b414e → 0.3.11-main.264ec26

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 (78) hide show
  1. package/dist/lib/browser/index.mjs +607 -471
  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 +13 -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 +2 -0
  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 +29 -16
  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 -0
  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/automerge.d.ts +2 -1
  40. package/dist/types/src/hooks/automerge.d.ts.map +1 -1
  41. package/dist/types/src/hooks/defs.d.ts +28 -0
  42. package/dist/types/src/hooks/defs.d.ts.map +1 -0
  43. package/dist/types/src/hooks/index.d.ts +2 -1
  44. package/dist/types/src/hooks/index.d.ts.map +1 -1
  45. package/dist/types/src/hooks/useTextModel.d.ts +2 -27
  46. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  47. package/dist/types/src/hooks/yjs.d.ts +2 -1
  48. package/dist/types/src/hooks/yjs.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 -31
  52. package/src/components/TextEditor/MarkdownEditor.stories.tsx +64 -69
  53. package/src/components/TextEditor/TextEditor.tsx +85 -68
  54. package/src/components/TextEditor/comments.stories.tsx +111 -69
  55. package/src/extensions/autocomplete.ts +6 -3
  56. package/src/extensions/automerge/automerge.ts +23 -48
  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 +20 -2
  62. package/src/extensions/basic.ts +22 -17
  63. package/src/extensions/code.ts +15 -17
  64. package/src/extensions/comments.ts +235 -132
  65. package/src/extensions/cursor.ts +7 -3
  66. package/src/extensions/hr.ts +49 -50
  67. package/src/extensions/index.ts +1 -0
  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/automerge.ts +2 -1
  74. package/src/hooks/defs.ts +47 -0
  75. package/src/hooks/index.ts +3 -1
  76. package/src/hooks/useTextModel.ts +2 -45
  77. package/src/hooks/yjs.ts +2 -1
  78. package/src/util.ts +26 -3
@@ -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.active?.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 { inputSurface, 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, useAwareness } 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,27 @@ 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
56
  model: EditorModel;
52
- focus?: boolean;
53
- selection?: { anchor: number; head?: number };
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
+ multiline?: 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[];
61
67
  };
62
68
 
63
69
  /**
@@ -65,108 +71,115 @@ export type TextEditorProps = {
65
71
  */
66
72
  export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
67
73
  (
68
- { model, focus, selection, readonly, comments, extensions = [], editorMode, theme, slots = defaultSlots },
74
+ { model, readonly, autofocus, scrollTo, selection, editorMode, theme, slots = defaultSlots, extensions = [] },
69
75
  forwardedRef,
70
76
  ) => {
71
77
  const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
72
78
  const { themeMode } = useThemeContext();
73
79
 
74
- // The editor view ref should only be used as an escape hatch.
75
80
  const rootRef = useRef<HTMLDivElement>(null);
76
81
  const [view, setView] = useState<EditorView | null>(null);
77
- useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
78
82
 
79
- // Focus.
80
- useEffect(() => {
81
- if (view && focus) {
82
- view.focus();
83
- }
84
- }, [view, focus]);
83
+ // The view ref can be used to focus the editor.
84
+ // NOTE: This does not cause the parent to re-render, so the ref is not available immediately.
85
+ useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
85
86
 
86
- // TODO(burdon): Factor out as extension.
87
- const { awareness, peer } = model;
87
+ // Set focus.
88
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
- });
89
+ if (autofocus) {
90
+ view?.focus();
95
91
  }
96
- }, [awareness, peer, themeMode]);
92
+ }, [view, autofocus]);
97
93
 
98
- // TODO(burdon): Factor out as extension.
99
- useEffect(() => {
100
- if (view && comments !== undefined) {
101
- view.dispatch({
102
- effects: setCommentRange.of({ model, comments }),
103
- });
104
- }
105
- }, [view, comments]);
94
+ // Monitor awareness.
95
+ useAwareness(model);
106
96
 
97
+ // Create editor state and view.
98
+ // The view is recreated if the model or extensions are changed.
107
99
  useEffect(() => {
108
100
  if (!model || !rootRef.current) {
109
101
  return;
110
102
  }
111
103
 
104
+ //
105
+ // EditorState
112
106
  // https://codemirror.net/docs/ref/#state.EditorStateConfig
107
+ //
113
108
  const state = EditorState.create({
114
109
  doc: model.text(),
115
- // TODO(burdon): Composer should store and set selection when switching documents.
116
110
  selection,
117
111
  extensions: [
118
- readonly && EditorState.readOnly.of(readonly),
119
-
120
- // TODO(burdon): Factor out VIM mode? (manage via MarkdownPlugin).
121
- editorMode === 'vim' && vim(),
112
+ // TODO(burdon): Doesn't catch errors in keymap functions.
113
+ EditorView.exceptionSink.of((err) => {
114
+ log.catch(err);
115
+ }),
122
116
 
123
117
  // Theme.
124
- // TODO(burdon): Make theme configurable.
118
+ // TODO(burdon): Make configurable.
125
119
  EditorView.baseTheme(defaultTheme),
126
120
  EditorView.theme(theme ?? {}),
127
121
  EditorView.darkTheme.of(themeMode === 'dark'),
122
+ EditorView.editorAttributes.of({ class: slots.editor?.className ?? '' }),
123
+ EditorView.contentAttributes.of({ class: slots.content?.className ?? '' }),
124
+
125
+ // State.
126
+ EditorState.readOnly.of(!!readonly),
128
127
 
129
128
  // Storage and replication.
129
+ // NOTE: This must come before user extensions.
130
130
  model.extension,
131
131
 
132
+ // TODO(burdon): Factor out (requires special handling for Escape/focus).
133
+ editorMode === 'vim' && vim(),
134
+
132
135
  // Custom.
133
136
  ...extensions,
134
137
  ].filter(Boolean) as Extension[],
135
138
  });
136
139
 
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();
140
+ //
141
+ // EditorView
142
+ // https://codemirror.net/docs/ref/#view.EditorViewConfig
143
+ //
141
144
  const newView = new EditorView({
142
- parent: rootRef.current,
143
145
  state,
144
- // NOTE: Uncomment to spy on all transactions.
146
+ parent: rootRef.current,
147
+ scrollTo,
148
+ // NOTE: Uncomment to debug/monitor all transactions.
145
149
  // https://codemirror.net/docs/ref/#view.EditorView.dispatch
146
- // dispatch: (transaction, view) => {
147
- // view.update([transaction]);
148
- // },
150
+ dispatchTransactions: (trs, view) => {
151
+ view.update(trs);
152
+ const debug = false;
153
+ if (debug) {
154
+ logChanges(trs);
155
+ }
156
+ },
149
157
  });
150
158
 
159
+ view?.destroy();
151
160
  setView(newView);
161
+
152
162
  return () => {
153
163
  newView?.destroy();
154
164
  setView(null);
155
165
  };
156
166
  }, [rootRef, model, readonly, editorMode, themeMode]);
157
167
 
168
+ // Handles tab/focus.
169
+ // Pressing Escape focuses the outer div (to support tab navigation); pressing Enter refocuses the editor.
158
170
  const handleKeyUp = useCallback(
159
171
  (event: KeyboardEvent) => {
160
172
  const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
161
173
  switch (key) {
162
- // TODO(burdon): Is this required (for vim mode?)
163
- // case 'Enter': {
164
- // view?.contentDOM.focus();
165
- // break;
166
- // }
174
+ case 'Enter': {
175
+ view?.focus();
176
+ break;
177
+ }
167
178
 
168
179
  case 'Escape': {
169
- editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey) && rootRef.current?.focus();
180
+ if (editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)) {
181
+ rootRef.current?.focus();
182
+ }
170
183
  break;
171
184
  }
172
185
  }
@@ -178,25 +191,26 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
178
191
  <div
179
192
  key={model.id}
180
193
  role='none'
181
- ref={rootRef}
182
194
  tabIndex={0}
183
195
  onKeyUp={handleKeyUp}
184
196
  {...slots.root}
185
197
  {...(editorMode !== 'vim' && tabsterDOMAttribute)}
198
+ ref={rootRef}
186
199
  />
187
200
  );
188
201
  },
189
202
  );
190
203
 
204
+ // TODO(burdon): Single-line/scroll.
191
205
  export const TextEditor = forwardRef<EditorView, TextEditorProps>(
192
- ({ readonly, placeholder, extensions = [], theme = textTheme, slots, ...props }, forwardedRef) => {
206
+ ({ readonly, placeholder, multiline, theme = textTheme, slots, extensions = [], ...props }, forwardedRef) => {
193
207
  const { themeMode } = useThemeContext();
194
208
  const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
195
209
  return (
196
210
  <BaseTextEditor
197
211
  ref={forwardedRef}
198
212
  readonly={readonly}
199
- extensions={[basicBundle({ readonly, themeMode, placeholder }), ...extensions]}
213
+ extensions={[basicBundle({ themeMode, placeholder, multiline }), ...extensions]}
200
214
  theme={theme}
201
215
  slots={updatedSlots}
202
216
  {...props}
@@ -205,16 +219,15 @@ export const TextEditor = forwardRef<EditorView, TextEditorProps>(
205
219
  },
206
220
  );
207
221
 
208
- // TODO(burdon): Remove (Just provide bundle, slots).
209
222
  export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
210
- ({ readonly, placeholder, extensions = [], theme = markdownTheme, slots, ...props }, forwardedRef) => {
223
+ ({ readonly, placeholder, theme = markdownTheme, slots, extensions = [], ...props }, forwardedRef) => {
211
224
  const { themeMode } = useThemeContext();
212
225
  const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
213
226
  return (
214
227
  <BaseTextEditor
215
228
  ref={forwardedRef}
216
229
  readonly={readonly}
217
- extensions={[markdownBundle({ readonly, themeMode, placeholder }), ...extensions]}
230
+ extensions={[markdownBundle({ themeMode, readonly, placeholder }), ...extensions]}
218
231
  theme={theme}
219
232
  slots={updatedSlots}
220
233
  {...props}
@@ -225,7 +238,11 @@ export const MarkdownEditor = forwardRef<EditorView, TextEditorProps>(
225
238
 
226
239
  export const defaultSlots: TextEditorSlots = {
227
240
  root: {
228
- className: mx('p-2 overflow-y-auto', inputSurface),
241
+ // TODO(burdon): Add focusRing by default/as property?
242
+ className: mx('flex flex-col grow overflow-y-auto', inputSurface),
243
+ },
244
+ editor: {
245
+ className: 'h-full p-2',
229
246
  },
230
247
  };
231
248