@dxos/react-ui-editor 0.4.4-next.e0df51e → 0.4.5-main.17763ce

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 (52) hide show
  1. package/dist/lib/browser/index.mjs +459 -564
  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 +5 -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.map +1 -1
  7. package/dist/types/src/components/TextEditor/automerge.stories.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  10. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  11. package/dist/types/src/extensions/cursor-line-margin.d.ts +8 -0
  12. package/dist/types/src/extensions/cursor-line-margin.d.ts.map +1 -0
  13. package/dist/types/src/extensions/index.d.ts +1 -0
  14. package/dist/types/src/extensions/index.d.ts.map +1 -1
  15. package/dist/types/src/extensions/markdown/decorate.d.ts +5 -0
  16. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -0
  17. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  18. package/dist/types/src/extensions/markdown/index.d.ts +1 -4
  19. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  20. package/dist/types/src/extensions/markdown/link.d.ts +1 -11
  21. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
  22. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  23. package/dist/types/src/styles/layout.d.ts +2 -2
  24. package/dist/types/src/styles/layout.d.ts.map +1 -1
  25. package/package.json +31 -28
  26. package/src/components/TextEditor/MarkdownEditor.stories.tsx +35 -37
  27. package/src/components/TextEditor/TextEditor.tsx +18 -5
  28. package/src/components/TextEditor/automerge.stories.tsx +9 -6
  29. package/src/components/TextEditor/hooks.stories.tsx +49 -27
  30. package/src/components/Toolbar/Toolbar.stories.tsx +4 -7
  31. package/src/components/Toolbar/Toolbar.tsx +0 -1
  32. package/src/extensions/cursor-line-margin.ts +92 -0
  33. package/src/extensions/index.ts +1 -0
  34. package/src/extensions/markdown/decorate.ts +301 -0
  35. package/src/extensions/markdown/formatting.test.ts +17 -0
  36. package/src/extensions/markdown/formatting.ts +67 -76
  37. package/src/extensions/markdown/index.ts +1 -4
  38. package/src/extensions/markdown/link.ts +23 -185
  39. package/src/hooks/useTextEditor.ts +6 -2
  40. package/src/styles/layout.ts +3 -2
  41. package/dist/types/src/extensions/markdown/code.d.ts +0 -2
  42. package/dist/types/src/extensions/markdown/code.d.ts.map +0 -1
  43. package/dist/types/src/extensions/markdown/heading.d.ts +0 -6
  44. package/dist/types/src/extensions/markdown/heading.d.ts.map +0 -1
  45. package/dist/types/src/extensions/markdown/hr.d.ts +0 -2
  46. package/dist/types/src/extensions/markdown/hr.d.ts.map +0 -1
  47. package/dist/types/src/extensions/markdown/tasklist.d.ts +0 -3
  48. package/dist/types/src/extensions/markdown/tasklist.d.ts.map +0 -1
  49. package/src/extensions/markdown/code.ts +0 -162
  50. package/src/extensions/markdown/heading.ts +0 -59
  51. package/src/extensions/markdown/hr.ts +0 -71
  52. package/src/extensions/markdown/tasklist.ts +0 -110
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-editor",
3
- "version": "0.4.4-next.e0df51e",
3
+ "version": "0.4.5-main.17763ce",
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",
@@ -23,7 +23,7 @@
23
23
  "@codemirror/state": "^6.4.0",
24
24
  "@codemirror/theme-one-dark": "^6.1.2",
25
25
  "@codemirror/view": "^6.23.0",
26
- "@fluentui/react-tabster": "^9.18.0",
26
+ "@fluentui/react-tabster": "^9.19.0",
27
27
  "@lezer/common": "^1.2.1",
28
28
  "@lezer/highlight": "^1.2.0",
29
29
  "@lezer/markdown": "^1.2.0",
@@ -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.4-next.e0df51e",
40
- "@dxos/automerge": "0.4.4-next.e0df51e",
41
- "@dxos/debug": "0.4.4-next.e0df51e",
42
- "@dxos/display-name": "0.4.4-next.e0df51e",
43
- "@dxos/context": "0.4.4-next.e0df51e",
44
- "@dxos/echo-schema": "0.4.4-next.e0df51e",
45
- "@dxos/invariant": "0.4.4-next.e0df51e",
46
- "@dxos/log": "0.4.4-next.e0df51e",
47
- "@dxos/react-async": "0.4.4-next.e0df51e",
48
- "@dxos/react-ui": "0.4.4-next.e0df51e",
49
- "@dxos/protocols": "0.4.4-next.e0df51e",
50
- "@dxos/react-ui-theme": "0.4.4-next.e0df51e",
51
- "@dxos/text-model": "0.4.4-next.e0df51e",
52
- "@dxos/util": "0.4.4-next.e0df51e"
39
+ "@dxos/async": "0.4.5-main.17763ce",
40
+ "@dxos/automerge": "0.4.5-main.17763ce",
41
+ "@dxos/display-name": "0.4.5-main.17763ce",
42
+ "@dxos/debug": "0.4.5-main.17763ce",
43
+ "@dxos/echo-schema": "0.4.5-main.17763ce",
44
+ "@dxos/protocols": "0.4.5-main.17763ce",
45
+ "@dxos/invariant": "0.4.5-main.17763ce",
46
+ "@dxos/context": "0.4.5-main.17763ce",
47
+ "@dxos/react-async": "0.4.5-main.17763ce",
48
+ "@dxos/react-ui-theme": "0.4.5-main.17763ce",
49
+ "@dxos/util": "0.4.5-main.17763ce",
50
+ "@dxos/react-ui": "0.4.5-main.17763ce",
51
+ "@dxos/text-model": "0.4.5-main.17763ce",
52
+ "@dxos/log": "0.4.5-main.17763ce"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@automerge/automerge-repo-network-broadcastchannel": "^1.1.1",
@@ -65,31 +65,34 @@
65
65
  "@types/react": "^18.0.21",
66
66
  "@types/react-dom": "^18.0.6",
67
67
  "@types/react-test-renderer": "^17.0.2",
68
- "@vitest/ui": "^1.2.1",
68
+ "@vitest/browser": "^1.2.2",
69
+ "@vitest/ui": "^1.2.2",
69
70
  "deepsignal": "^1.5.0",
70
71
  "happy-dom": "^13.3.1",
71
72
  "jsdom": "^20.0.1",
72
73
  "react": "^18.2.0",
73
74
  "react-dom": "^18.2.0",
74
75
  "react-test-renderer": "^18.2.0",
75
- "vite": "^5.0.12",
76
+ "vite": "^5.1.3",
76
77
  "vite-plugin-top-level-await": "^1.4.1",
77
78
  "vite-plugin-wasm": "^3.3.0",
78
- "vitest": "^1.2.1",
79
- "@dxos/echo-signals": "0.4.4-next.e0df51e",
80
- "@dxos/config": "0.4.4-next.e0df51e",
81
- "@dxos/keyboard": "0.4.4-next.e0df51e",
82
- "@dxos/random": "0.4.4-next.e0df51e",
83
- "@dxos/echo-typegen": "0.4.4-next.e0df51e",
84
- "@dxos/react-ui": "0.4.4-next.e0df51e",
85
- "@dxos/storybook-utils": "0.4.4-next.e0df51e",
86
- "@dxos/react-client": "0.4.4-next.e0df51e"
79
+ "vitest": "^1.2.2",
80
+ "@dxos/config": "0.4.5-main.17763ce",
81
+ "@dxos/echo-signals": "0.4.5-main.17763ce",
82
+ "@dxos/echo-typegen": "0.4.5-main.17763ce",
83
+ "@dxos/random": "0.4.5-main.17763ce",
84
+ "@dxos/react-client": "0.4.5-main.17763ce",
85
+ "@dxos/react-ui": "0.4.5-main.17763ce",
86
+ "@dxos/storybook-utils": "0.4.5-main.17763ce",
87
+ "@dxos/keyboard": "0.4.5-main.17763ce"
87
88
  },
88
89
  "peerDependencies": {
89
90
  "@phosphor-icons/react": "^2.0.5",
91
+ "@vitest/browser": "^1.2.2",
92
+ "@vitest/ui": "^1.2.2",
90
93
  "react": "^18.2.0",
91
94
  "react-dom": "^18.2.0",
92
- "@dxos/react-client": "0.4.4-next.e0df51e"
95
+ "@dxos/react-client": "0.4.5-main.17763ce"
93
96
  },
94
97
  "publishConfig": {
95
98
  "access": "public"
@@ -19,19 +19,15 @@ import { withTheme } from '@dxos/storybook-utils';
19
19
  import { MarkdownEditor, type TextEditorProps } from './TextEditor';
20
20
  import {
21
21
  type CommentsOptions,
22
- type LinkOptions,
23
22
  autocomplete,
24
23
  blast,
25
- code,
24
+ decorateMarkdown,
26
25
  comments,
27
26
  defaultOptions,
28
- heading,
29
- hr,
30
27
  image,
31
- link,
28
+ linkTooltip,
32
29
  mention,
33
30
  table,
34
- tasklist,
35
31
  typewriter,
36
32
  useComments,
37
33
  formatting,
@@ -47,9 +43,11 @@ const str = (...lines: string[]) => lines.join('\n');
47
43
 
48
44
  const num = () => faker.number.int({ min: 0, max: 9999 }).toLocaleString();
49
45
 
50
- // prettier-ignore
46
+ const img = '![dxos](https://pbs.twimg.com/profile_banners/1268328127673044992/1684766689/1500x500)';
47
+
51
48
  const text = {
52
49
  tasks: str(
50
+ //
53
51
  '## Tasks',
54
52
  '',
55
53
  '- [x] decorator',
@@ -61,6 +59,7 @@ const text = {
61
59
  ),
62
60
 
63
61
  list: str(
62
+ //
64
63
  '## List',
65
64
  '',
66
65
  '- new york',
@@ -70,12 +69,13 @@ const text = {
70
69
  ),
71
70
 
72
71
  numbered: str(
72
+ //
73
73
  '## Numbered',
74
74
  '',
75
75
  '1. one',
76
76
  '2. two',
77
77
  '3. three',
78
- ''
78
+ '',
79
79
  ),
80
80
 
81
81
  code: str(
@@ -115,7 +115,7 @@ const text = {
115
115
  '',
116
116
  ),
117
117
 
118
- image: str('# Image', '', '![dxos](https://pbs.twimg.com/profile_banners/1268328127673044992/1684766689/1500x500)'),
118
+ image: str('# Image', '', img),
119
119
 
120
120
  headings: str(
121
121
  ...[1, 2, 3, 4, 5, 6].map((level) => ['#'.repeat(level) + ` Heading ${level}`, faker.lorem.sentences(), '']).flat(),
@@ -123,7 +123,7 @@ const text = {
123
123
 
124
124
  paragraphs: str(...faker.helpers.multiple(() => [faker.lorem.paragraph(), ''], { count: 3 }).flat()),
125
125
 
126
- footer: str('', '', '', '', '')
126
+ footer: str('', '', '', '', ''),
127
127
  };
128
128
 
129
129
  const document = str(
@@ -131,6 +131,12 @@ const document = str(
131
131
  '',
132
132
  '> This is a block quote.',
133
133
  '',
134
+ '> This is a long wrapping block quote. Neque reiciendis ullam quae error labore sit, at, et, nulla, aut at nostrum omnis quas nostrum, at consectetur vitae eos asperiores non omnis ullam in beatae at vitae deserunt asperiores sapiente.',
135
+ '',
136
+ '> This is',
137
+ '> a multi-line',
138
+ '> block quote.',
139
+ '',
134
140
  'This is all about https://dxos.org and related technologies.',
135
141
  '',
136
142
  'This this is **bold**, ~~strikethrough~~, _italic_, and `f(INLINE)`.',
@@ -167,7 +173,7 @@ const names = ['adam', 'alice', 'alison', 'bob', 'carol', 'charlie', 'sayuri', '
167
173
  const hover =
168
174
  'rounded-sm text-base text-primary-600 hover:text-primary-500 dark:text-primary-300 hover:dark:text-primary-200';
169
175
 
170
- const onHoverLinkTooltip: LinkOptions['onHover'] = (el, url) => {
176
+ const renderLinkTooltip = (el: Element, url: string) => {
171
177
  const web = new URL(url);
172
178
  createRoot(el).render(
173
179
  <StrictMode>
@@ -200,7 +206,7 @@ const onCommentsHover: CommentsOptions['onHover'] = (el, shortcut) => {
200
206
  );
201
207
  };
202
208
 
203
- const onRenderLink: LinkOptions['onRender'] = (el, url) => {
209
+ const renderLinkButton = (el: Element, url: string) => {
204
210
  createRoot(el).render(
205
211
  <StrictMode>
206
212
  <a href={url} target='_blank' rel='noreferrer' className={hover}>
@@ -251,14 +257,11 @@ const defaults = [
251
257
  autocomplete({
252
258
  onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
253
259
  }),
254
- code(),
260
+ decorateMarkdown({ renderLinkButton }),
255
261
  formatting(),
256
- heading(),
257
- hr(),
258
262
  image(),
259
- link({ onRender: onRenderLink, onHover: onHoverLinkTooltip }),
263
+ linkTooltip(renderLinkTooltip),
260
264
  table(),
261
- tasklist(),
262
265
  ];
263
266
 
264
267
  export const Default = {
@@ -275,6 +278,11 @@ export const NoExtensions = {
275
278
 
276
279
  const large = faker.helpers.multiple(() => faker.lorem.paragraph({ min: 8, max: 16 }), { count: 20 }).join('\n\n');
277
280
 
281
+ const largeWithImages = faker.helpers
282
+ .multiple(() => [faker.lorem.paragraph({ min: 12, max: 16 }), img], { count: 20 })
283
+ .flatMap((x) => x)
284
+ .join('\n\n');
285
+
278
286
  export const Empty = {
279
287
  render: () => <Story />,
280
288
  };
@@ -283,26 +291,25 @@ export const Scrolling = {
283
291
  render: () => <Story text={str('# Large Document', '', large)} extensions={[]} />,
284
292
  };
285
293
 
286
- export const Links = {
287
- render: () => (
288
- <Story
289
- text={str(text.links, text.footer)}
290
- extensions={[link({ onHover: onHoverLinkTooltip, onRender: onRenderLink })]}
291
- />
292
- ),
294
+ export const ScrollingWithImages = {
295
+ render: () => <Story text={str('# Large Document', '', largeWithImages)} extensions={[image()]} />,
293
296
  };
294
297
 
295
- export const Code = {
296
- render: () => <Story text={str(text.code, text.footer)} extensions={[code()]} />,
298
+ export const Links = {
299
+ render: () => <Story text={str(text.links, text.footer)} extensions={[linkTooltip(renderLinkTooltip)]} />,
297
300
  };
298
301
 
299
302
  export const Image = {
300
303
  render: () => <Story text={str(text.image, text.footer)} extensions={[image()]} />,
301
304
  };
302
305
 
306
+ export const Code = {
307
+ render: () => <Story text={str(text.code, text.footer)} extensions={[decorateMarkdown()]} />,
308
+ };
309
+
303
310
  export const Lists = {
304
311
  render: () => (
305
- <Story text={str(text.tasks, '', text.list, '', text.numbered, text.footer)} extensions={[tasklist()]} />
312
+ <Story text={str(text.tasks, '', text.list, '', text.numbered, text.footer)} extensions={[decorateMarkdown()]} />
306
313
  ),
307
314
  };
308
315
 
@@ -324,7 +331,7 @@ export const Autocomplete = {
324
331
  <Story
325
332
  text={str('# Autocomplete', '', 'Press Ctrl-Space...', text.footer)}
326
333
  extensions={[
327
- link({ onRender: onRenderLink }),
334
+ decorateMarkdown({ renderLinkButton }),
328
335
  autocomplete({
329
336
  onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
330
337
  }),
@@ -382,15 +389,6 @@ export const Comments = {
382
389
  },
383
390
  };
384
391
 
385
- export const HorizontalRule = {
386
- render: () => (
387
- <Story
388
- text={str('# Horizontal Rule', '', text.paragraphs, '---', text.paragraphs, '---', text.paragraphs)}
389
- extensions={[hr()]}
390
- />
391
- ),
392
- };
393
-
394
392
  export const Vim = {
395
393
  render: () => (
396
394
  <Story
@@ -15,6 +15,7 @@ import React, {
15
15
  useImperativeHandle,
16
16
  useRef,
17
17
  useState,
18
+ useMemo,
18
19
  } from 'react';
19
20
 
20
21
  import { log } from '@dxos/log';
@@ -172,7 +173,8 @@ export const BaseTextEditor = forwardRef<EditorView | null, TextEditorProps>(
172
173
  newView?.destroy();
173
174
  setView(null);
174
175
  };
175
- }, [rootRef, model, readonly, themeMode]);
176
+ // TODO(wittjosiah): Does `rootRef` ever change? Only `.current` changes?
177
+ }, [rootRef, extensions, model, readonly, themeMode]);
176
178
 
177
179
  // Focus editor on Enter (e.g., when tabbing to this component).
178
180
  const handleKeyUp = useCallback<KeyboardEventHandler<HTMLDivElement>>(
@@ -203,14 +205,21 @@ export const BaseTextEditor = forwardRef<EditorView | null, TextEditorProps>(
203
205
  );
204
206
 
205
207
  export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
206
- ({ readonly, placeholder, lineWrapping, theme = textTheme, slots, extensions = [], ...props }, forwardedRef) => {
208
+ (
209
+ { readonly, placeholder, lineWrapping, theme = textTheme, slots, extensions: _extensions, ...props },
210
+ forwardedRef,
211
+ ) => {
207
212
  const { themeMode } = useThemeContext();
208
213
  const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
214
+ const extensions = useMemo(
215
+ () => [createBasicBundle({ themeMode, placeholder, lineWrapping }), ...(_extensions ?? [])],
216
+ [themeMode, placeholder, lineWrapping, _extensions],
217
+ );
209
218
  return (
210
219
  <BaseTextEditor
211
220
  ref={forwardedRef}
212
221
  readonly={readonly}
213
- extensions={[createBasicBundle({ themeMode, placeholder, lineWrapping }), ...extensions]}
222
+ extensions={extensions}
214
223
  theme={theme}
215
224
  slots={updatedSlots}
216
225
  {...props}
@@ -220,14 +229,18 @@ export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
220
229
  );
221
230
 
222
231
  export const MarkdownEditor = forwardRef<EditorView | null, TextEditorProps>(
223
- ({ readonly, placeholder, theme = markdownTheme, slots, extensions = [], ...props }, forwardedRef) => {
232
+ ({ readonly, placeholder, theme = markdownTheme, slots, extensions: _extensions, ...props }, forwardedRef) => {
224
233
  const { themeMode } = useThemeContext();
225
234
  const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
235
+ const extensions = useMemo(
236
+ () => [createMarkdownExtensions({ themeMode, placeholder }), ...(_extensions ?? [])],
237
+ [themeMode, placeholder, _extensions],
238
+ );
226
239
  return (
227
240
  <BaseTextEditor
228
241
  ref={forwardedRef}
229
242
  readonly={readonly}
230
- extensions={[createMarkdownExtensions({ themeMode, placeholder }), ...extensions]}
243
+ extensions={extensions}
231
244
  theme={theme}
232
245
  slots={updatedSlots}
233
246
  {...props}
@@ -8,7 +8,7 @@ import { BroadcastChannelNetworkAdapter } from '@automerge/automerge-repo-networ
8
8
  import '@preact/signals-react';
9
9
  import { EditorView } from '@codemirror/view'; // Register react integration.
10
10
  import get from 'lodash.get';
11
- import React, { useEffect, useState } from 'react';
11
+ import React, { useEffect, useMemo, useState } from 'react';
12
12
 
13
13
  import { type Prop } from '@dxos/automerge/automerge';
14
14
  import { Repo, type DocHandle } from '@dxos/automerge/automerge-repo';
@@ -37,17 +37,20 @@ type EditorProps = {
37
37
  };
38
38
 
39
39
  const Editor = ({ handle, path = ['text'] }: EditorProps) => {
40
- const { parentRef } = useTextEditor({
41
- autoFocus: true,
42
- doc: get(handle.docSync()!, path),
43
- extensions: [
44
- //
40
+ const extensions = useMemo(
41
+ () => [
45
42
  EditorView.baseTheme(defaultTheme),
46
43
  EditorView.editorAttributes.of({ class: 'p-2 bg-white' }),
47
44
  createBasicBundle({ placeholder: 'Type here...' }),
48
45
  automerge({ handle, path }),
49
46
  awareness(),
50
47
  ],
48
+ [handle, path],
49
+ );
50
+ const { parentRef } = useTextEditor({
51
+ autoFocus: true,
52
+ doc: get(handle.docSync()!, path),
53
+ extensions,
51
54
  });
52
55
 
53
56
  return <div ref={parentRef} />;
@@ -4,27 +4,25 @@
4
4
 
5
5
  import '@dxosTheme';
6
6
 
7
- import React from 'react';
7
+ import React, { useMemo, useState } from 'react';
8
8
 
9
- import { useThemeContext } from '@dxos/react-ui';
9
+ import { Toolbar as NaturalToolbar, Select, useThemeContext } from '@dxos/react-ui';
10
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';
14
14
  import {
15
- code,
15
+ type EditorMode,
16
+ EditorModes,
17
+ decorateMarkdown,
16
18
  createMarkdownExtensions,
17
19
  formatting,
18
- heading,
19
- hr,
20
20
  image,
21
- link,
22
21
  table,
23
- tasklist,
24
22
  useFormattingState,
25
23
  } from '../../extensions';
26
24
  import { createDataExtensions, createThemeExtensions, useActionHandler, useTextEditor } from '../../hooks';
27
- import { editorFillLayoutEditor, editorFillLayoutRoot, editorWithToolbarLayout } from '../../styles';
25
+ import { editorFillLayoutEditor, editorWithToolbarLayout } from '../../styles';
28
26
  import { markdownTheme } from '../../themes';
29
27
  import translations from '../../translations';
30
28
  import { Toolbar } from '../Toolbar';
@@ -45,54 +43,78 @@ type StoryProps = {
45
43
  const Story = ({ autoFocus, placeholder, doc, readonly }: StoryProps) => {
46
44
  const { themeMode } = useThemeContext();
47
45
  const [formattingState, trackFormatting] = useFormattingState();
48
- const { parentRef, view } = useTextEditor({
49
- autoFocus,
50
- doc,
51
- extensions: [
52
- //
46
+ const [editorMode, setEditorMode] = useState<EditorMode>('default');
47
+ const extensions = useMemo(
48
+ () => [
49
+ editorMode ? EditorModes[editorMode] : [],
53
50
  createDataExtensions({ readonly }),
54
51
  createThemeExtensions({
55
52
  themeMode,
56
53
  theme: markdownTheme,
57
54
  slots: {
58
- editor: { className: editorFillLayoutEditor },
55
+ content: { className: '!p-4' },
59
56
  },
60
57
  }),
61
58
  // TODO(burdon): Move lineWrapping.
62
59
  createMarkdownExtensions({ placeholder, lineWrapping: true }),
63
60
  // TODO(burdon): Move into markdown bundle (with React callbacks).
64
- code(),
61
+ decorateMarkdown(),
65
62
  formatting(),
66
- heading(),
67
- hr(),
68
63
  image(),
69
- link(),
70
64
  table(),
71
- tasklist(),
72
65
  trackFormatting,
73
66
  ],
74
- });
67
+ [editorMode, themeMode, placeholder, trackFormatting, readonly],
68
+ );
69
+ const { parentRef, view } = useTextEditor({ autoFocus, doc, extensions });
75
70
 
76
71
  const handleAction = useActionHandler(view);
77
72
 
78
- // FIXME This doesn't update the state on view changes. Also not
79
- // sure if view is even guaranteed to exist at this point.
73
+ // TODO(marijn) This doesn't update the state on view changes.
74
+ // Also not sure if view is even guaranteed to exist at this point.
80
75
  return (
81
76
  <div role='none' className={mx('fixed inset-0', editorWithToolbarLayout)}>
82
77
  <Toolbar.Root onAction={handleAction} state={formattingState} classNames={textBlockWidth}>
83
78
  <Toolbar.Markdown />
79
+ <EditorModeToolbar editorMode={editorMode} setEditorMode={setEditorMode} />
84
80
  </Toolbar.Root>
85
81
  <div role='none' className='overflow-y-auto'>
86
- <div
87
- role='textbox'
88
- className={mx(textBlockWidth, attentionSurface, editorFillLayoutRoot, 'p-4')}
89
- ref={parentRef}
90
- />
82
+ <div role='textbox' className={mx(textBlockWidth, attentionSurface, editorFillLayoutEditor)} ref={parentRef} />
91
83
  </div>
92
84
  </div>
93
85
  );
94
86
  };
95
87
 
88
+ const EditorModeToolbar = ({
89
+ editorMode,
90
+ setEditorMode,
91
+ }: {
92
+ editorMode: EditorMode;
93
+ setEditorMode: (mode: EditorMode) => void;
94
+ }) => {
95
+ return (
96
+ <Select.Root value={editorMode} onValueChange={(value) => setEditorMode(value as EditorMode)}>
97
+ <NaturalToolbar.Button asChild>
98
+ <Select.TriggerButton variant='ghost'>{editorMode}</Select.TriggerButton>
99
+ </NaturalToolbar.Button>
100
+ <Select.Portal>
101
+ <Select.Content>
102
+ <Select.ScrollUpButton />
103
+ <Select.Viewport>
104
+ {['default', 'vim'].map((mode) => (
105
+ <Select.Option key={mode} value={mode}>
106
+ {mode}
107
+ </Select.Option>
108
+ ))}
109
+ </Select.Viewport>
110
+ <Select.ScrollDownButton />
111
+ <Select.Arrow />
112
+ </Select.Content>
113
+ </Select.Portal>
114
+ </Select.Root>
115
+ );
116
+ };
117
+
96
118
  export default {
97
119
  title: 'react-ui-editor/useTextEditor',
98
120
  component: TextEditor,
@@ -14,13 +14,11 @@ import { withTheme } from '@dxos/storybook-utils';
14
14
 
15
15
  import { Toolbar } from './Toolbar';
16
16
  import {
17
- code,
17
+ decorateMarkdown,
18
18
  comments,
19
19
  formatting,
20
- heading,
21
20
  image,
22
21
  table,
23
- tasklist,
24
22
  useComments,
25
23
  useFormattingState,
26
24
  } from '../../extensions';
@@ -39,7 +37,7 @@ const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) =
39
37
  const [formattingState, formattingObserver] = useFormattingState();
40
38
  const extensions = useMemo(
41
39
  () => [
42
- code(),
40
+ decorateMarkdown(),
43
41
  comments({
44
42
  onCreate: ({ cursor }) => {
45
43
  const id = PublicKey.random().toHex();
@@ -48,10 +46,8 @@ const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) =
48
46
  },
49
47
  }),
50
48
  formatting(),
51
- heading(),
52
49
  image(),
53
50
  table(),
54
- tasklist(),
55
51
  formattingObserver,
56
52
  ],
57
53
  [],
@@ -76,8 +72,9 @@ const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) =
76
72
  model={model}
77
73
  extensions={extensions}
78
74
  slots={{
79
- root: { className: mx(textBlockWidth, editorFillLayoutRoot, 'pli-2') },
75
+ root: { className: mx(textBlockWidth, editorFillLayoutRoot) },
80
76
  editor: { className: editorFillLayoutEditor },
77
+ content: { className: '!pli-2' },
81
78
  }}
82
79
  />
83
80
  </div>
@@ -233,7 +233,6 @@ const markdownBlocks: ButtonProps[] = [
233
233
  type: 'codeblock',
234
234
  Icon: CodeBlock,
235
235
  getState: (state) => state.blockType === 'codeblock',
236
- disabled: (state) => !state.blankLine,
237
236
  },
238
237
  {
239
238
  type: 'table',
@@ -0,0 +1,92 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ // Based upon @acheronfail’s implementation, fetched 16 Feb 2024
6
+ // https://discuss.codemirror.net/t/cursorscrollmargin-for-v6/7448/5
7
+
8
+ import { type EditorState, type Extension, Facet, Transaction } from '@codemirror/state';
9
+ import { EditorView } from '@codemirror/view';
10
+
11
+ /**
12
+ * Number of lines above/below the cursor to keep visible in the viewport.
13
+ * Defaults to 3.
14
+ */
15
+ export const cursorLineMarginFacet = Facet.define<number, number>({
16
+ combine: (input) => input[0] ?? 3,
17
+ });
18
+
19
+ const annotation = 'cursorLineMargin';
20
+ const lineAtPos = (s: EditorState, pos: number) => s.doc.lineAt(pos).number;
21
+ // seems to the best approximation of CM5's `cursorScrollMargin`
22
+ // https://discuss.codemirror.net/t/cursorscrollmargin-for-v6/7448
23
+ export const cursorLineMargin: Extension = EditorView.updateListener.of(
24
+ ({ transactions, state, selectionSet, startState, view }) => {
25
+ // make sure we don't trigger an infinite loop and ignore our own changes
26
+ if (transactions.length < 1 || transactions.some((tr) => tr.isUserEvent(annotation))) {
27
+ return;
28
+ }
29
+
30
+ const s = state;
31
+ const { main } = s.selection;
32
+ const cursorLineMargin = s.facet(cursorLineMarginFacet);
33
+
34
+ // editor rect
35
+ const rect = view.dom.getBoundingClientRect();
36
+ // top and bottom pixel positions of the visible region of the editor
37
+ const viewportTop = rect.top - view.documentTop;
38
+ const viewportBottom = rect.bottom - view.documentTop;
39
+
40
+ // line with main selection
41
+ const mainLine = lineAtPos(s, main.head);
42
+ // top most visible line
43
+ const _visTopLine = lineAtPos(s, view.lineBlockAtHeight(viewportTop).from);
44
+
45
+ // bottom most visible line
46
+ // NOTE: calculating this is slightly more complex - if the editor is
47
+ // larger than all the lines in it, and the `scrollPastEnd()` extension is
48
+ // enabled, then we need to calculate where the bottom most visible line
49
+ // should be if it extended all the way to the bottom of the editor
50
+ const botLineBlk = view.lineBlockAtHeight(viewportBottom);
51
+ const visBotLineDoc = lineAtPos(s, Math.min(botLineBlk.to, s.doc.length));
52
+ const visBotLine =
53
+ botLineBlk.bottom >= viewportBottom
54
+ ? visBotLineDoc
55
+ : visBotLineDoc + Math.floor((viewportBottom - botLineBlk.bottom) / view.defaultLineHeight);
56
+
57
+ // const needsScrollTop = mainLine <= visTopLine + cursorLineMargin;
58
+ const needsScrollTop = false;
59
+ const needsScrollBot = mainLine >= visBotLine - cursorLineMargin;
60
+
61
+ // the scroll margins are overlapping
62
+ if (needsScrollTop && needsScrollBot) {
63
+ console.warn('is cursorScrollMargin too large for the editor size?');
64
+ return;
65
+ }
66
+
67
+ // no need to scroll, we're in between scroll regions
68
+ if (!needsScrollTop && !needsScrollBot) {
69
+ return;
70
+ }
71
+
72
+ // if we're within the margin, but moving the other way, then don't scroll
73
+ if (selectionSet) {
74
+ const { head } = startState.selection.main;
75
+ const oldMainLine = lineAtPos(startState, head);
76
+ if (needsScrollTop && oldMainLine < mainLine) {
77
+ return;
78
+ }
79
+ if (needsScrollBot && oldMainLine > mainLine) {
80
+ return;
81
+ }
82
+ }
83
+
84
+ view.dispatch({
85
+ annotations: Transaction.userEvent.of(annotation),
86
+ effects: EditorView.scrollIntoView(s.selection.main.head, {
87
+ y: needsScrollTop ? 'start' : 'end',
88
+ yMargin: view.defaultLineHeight * cursorLineMargin,
89
+ }),
90
+ });
91
+ },
92
+ );