@dxos/react-ui-editor 0.4.8-main.ac78619 → 0.4.8-main.beadd4b

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 (127) hide show
  1. package/dist/lib/browser/index.mjs +2703 -2714
  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/TextEditor.d.ts +18 -34
  5. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +80 -5
  7. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  8. package/dist/types/src/components/Toolbar/Toolbar.d.ts +10 -4
  9. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  10. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +3 -1
  11. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  12. package/dist/types/src/extensions/automerge/automerge.d.ts +1 -1
  13. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  14. package/dist/types/src/extensions/automerge/automerge.stories.d.ts +2 -1
  15. package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
  16. package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
  17. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  18. package/dist/types/src/extensions/automerge/defs.d.ts +1 -15
  19. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  20. package/dist/types/src/extensions/automerge/index.d.ts +0 -1
  21. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
  22. package/dist/types/src/extensions/automerge/sync.d.ts +2 -1
  23. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  24. package/dist/types/src/extensions/automerge/update-automerge.d.ts +2 -1
  25. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  26. package/dist/types/src/{hooks → extensions/awareness}/awareness-provider.d.ts +1 -1
  27. package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -0
  28. package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -0
  29. package/dist/types/src/extensions/awareness/index.d.ts +3 -0
  30. package/dist/types/src/extensions/awareness/index.d.ts.map +1 -0
  31. package/dist/types/src/extensions/comments.d.ts +2 -2
  32. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  33. package/dist/types/src/extensions/cursor-line-margin.d.ts +6 -7
  34. package/dist/types/src/extensions/cursor-line-margin.d.ts.map +1 -1
  35. package/dist/types/src/extensions/cursor.d.ts +1 -1
  36. package/dist/types/src/extensions/cursor.d.ts.map +1 -1
  37. package/dist/types/src/extensions/doc.d.ts +3 -0
  38. package/dist/types/src/extensions/doc.d.ts.map +1 -0
  39. package/dist/types/src/extensions/factories.d.ts +49 -0
  40. package/dist/types/src/extensions/factories.d.ts.map +1 -0
  41. package/dist/types/src/extensions/index.d.ts +4 -1
  42. package/dist/types/src/extensions/index.d.ts.map +1 -1
  43. package/dist/types/src/extensions/markdown/bundle.d.ts +5 -4
  44. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  45. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  46. package/dist/types/src/extensions/markdown/formatting.d.ts +5 -2
  47. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  48. package/dist/types/src/extensions/markdown/image.d.ts +6 -0
  49. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  50. package/dist/types/src/extensions/markdown/table.d.ts +2 -2
  51. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -1
  52. package/dist/types/src/extensions/modes.d.ts +1 -0
  53. package/dist/types/src/extensions/modes.d.ts.map +1 -1
  54. package/dist/types/src/extensions/state.d.ts +20 -0
  55. package/dist/types/src/extensions/state.d.ts.map +1 -0
  56. package/dist/types/src/extensions/types.d.ts +9 -0
  57. package/dist/types/src/extensions/types.d.ts.map +1 -0
  58. package/dist/types/src/hooks/index.d.ts +1 -4
  59. package/dist/types/src/hooks/index.d.ts.map +1 -1
  60. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -1
  61. package/dist/types/src/hooks/useDocAccessor.d.ts +7 -0
  62. package/dist/types/src/hooks/useDocAccessor.d.ts.map +1 -0
  63. package/dist/types/src/hooks/useTextEditor.d.ts +3 -44
  64. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  65. package/dist/types/src/hooks/useTextEditor.stories.d.ts +7 -0
  66. package/dist/types/src/hooks/useTextEditor.stories.d.ts.map +1 -1
  67. package/dist/types/src/themes/default.d.ts +0 -3
  68. package/dist/types/src/themes/default.d.ts.map +1 -1
  69. package/dist/types/src/translations.d.ts +1 -0
  70. package/dist/types/src/translations.d.ts.map +1 -1
  71. package/package.json +25 -27
  72. package/src/components/TextEditor/TextEditor.stories.tsx +520 -23
  73. package/src/components/TextEditor/TextEditor.tsx +44 -155
  74. package/src/components/Toolbar/Toolbar.stories.tsx +44 -43
  75. package/src/components/Toolbar/Toolbar.tsx +86 -32
  76. package/src/extensions/automerge/automerge.stories.tsx +16 -17
  77. package/src/extensions/automerge/automerge.ts +2 -1
  78. package/src/extensions/automerge/cursor.ts +1 -1
  79. package/src/extensions/automerge/defs.ts +1 -22
  80. package/src/extensions/automerge/index.ts +0 -1
  81. package/src/extensions/automerge/sync.ts +2 -9
  82. package/src/extensions/automerge/update-automerge.ts +2 -1
  83. package/src/{hooks → extensions/awareness}/awareness-provider.ts +1 -1
  84. package/src/extensions/{awareness.ts → awareness/awareness.ts} +1 -1
  85. package/src/extensions/awareness/index.ts +6 -0
  86. package/src/extensions/command/state.ts +1 -1
  87. package/src/extensions/comments.ts +9 -5
  88. package/src/extensions/cursor-line-margin.ts +17 -18
  89. package/src/extensions/cursor.ts +1 -1
  90. package/src/extensions/doc.ts +14 -0
  91. package/src/extensions/factories.ts +167 -0
  92. package/src/extensions/index.ts +4 -1
  93. package/src/extensions/markdown/bundle.ts +11 -28
  94. package/src/extensions/markdown/decorate.ts +2 -4
  95. package/src/extensions/markdown/formatting.test.ts +13 -13
  96. package/src/extensions/markdown/formatting.ts +95 -86
  97. package/src/extensions/markdown/image.ts +7 -0
  98. package/src/extensions/markdown/table.ts +11 -3
  99. package/src/extensions/modes.ts +3 -1
  100. package/src/extensions/state.ts +90 -0
  101. package/src/extensions/types.ts +16 -0
  102. package/src/hooks/index.ts +1 -5
  103. package/src/hooks/useActionHandler.ts +5 -1
  104. package/src/hooks/useDocAccessor.ts +21 -0
  105. package/src/hooks/useTextEditor.stories.tsx +40 -35
  106. package/src/hooks/useTextEditor.ts +11 -120
  107. package/src/index.ts +1 -1
  108. package/src/themes/default.ts +1 -28
  109. package/src/translations.ts +1 -0
  110. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +0 -103
  111. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +0 -1
  112. package/dist/types/src/extensions/awareness.d.ts.map +0 -1
  113. package/dist/types/src/extensions/outliner.d.ts +0 -4
  114. package/dist/types/src/extensions/outliner.d.ts.map +0 -1
  115. package/dist/types/src/hooks/awareness-provider.d.ts.map +0 -1
  116. package/dist/types/src/hooks/defs.d.ts +0 -20
  117. package/dist/types/src/hooks/defs.d.ts.map +0 -1
  118. package/dist/types/src/hooks/useEditorView.d.ts +0 -20
  119. package/dist/types/src/hooks/useEditorView.d.ts.map +0 -1
  120. package/dist/types/src/hooks/useTextModel.d.ts +0 -33
  121. package/dist/types/src/hooks/useTextModel.d.ts.map +0 -1
  122. package/src/components/TextEditor/MarkdownEditor.stories.tsx +0 -524
  123. package/src/extensions/outliner.ts +0 -12
  124. package/src/hooks/defs.ts +0 -30
  125. package/src/hooks/useEditorView.ts +0 -32
  126. package/src/hooks/useTextModel.ts +0 -121
  127. /package/dist/types/src/extensions/{awareness.d.ts → awareness/awareness.d.ts} +0 -0
@@ -2,12 +2,10 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { EditorState, type Extension, type StateEffect } from '@codemirror/state';
6
- import { EditorView, scrollPastEnd } from '@codemirror/view';
5
+ import { EditorState, type EditorStateConfig, type StateEffect } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
7
  import { useFocusableGroup } from '@fluentui/react-tabster';
8
- import defaultsDeep from 'lodash.defaultsdeep';
9
8
  import React, {
10
- type ComponentProps,
11
9
  type KeyboardEventHandler,
12
10
  forwardRef,
13
11
  useCallback,
@@ -15,19 +13,12 @@ import React, {
15
13
  useImperativeHandle,
16
14
  useRef,
17
15
  useState,
18
- useMemo,
19
16
  } from 'react';
20
17
 
21
- import { DocAccessor } from '@dxos/echo-schema';
22
18
  import { log } from '@dxos/log';
23
- import { useThemeContext } from '@dxos/react-ui';
24
- import { focusRing } from '@dxos/react-ui-theme';
25
19
  import { isNotFalsy } from '@dxos/util';
26
20
 
27
- import { createMarkdownExtensions, editorMode } from '../../extensions';
28
- import { createBasicExtensions, createThemeExtensions, type EditorModel } from '../../hooks';
29
- import { type ThemeStyles } from '../../styles';
30
- import { defaultTheme, markdownTheme, textTheme } from '../../themes';
21
+ import { documentId, editorMode, focusEvent } from '../../extensions';
31
22
  import { logChanges } from '../../util';
32
23
 
33
24
  export type CursorInfo = {
@@ -39,61 +30,49 @@ export type CursorInfo = {
39
30
  after?: string;
40
31
  };
41
32
 
42
- export type TextEditorSlots = {
43
- root?: Omit<ComponentProps<'div'>, 'ref'>;
44
- editor?: {
45
- className?: string;
46
- };
47
- content?: {
48
- className?: string;
49
- };
50
- };
51
-
52
- export type TextEditorProps = {
53
- model: EditorModel; // TODO(burdon): Optional (e.g., just provide content if readonly).
54
- readonly?: boolean; // TODO(burdon): Move into model.
33
+ export type TextEditorProps = Pick<EditorStateConfig, 'doc' | 'selection' | 'extensions'> & {
34
+ id?: string;
35
+ className?: string;
55
36
  autoFocus?: boolean;
56
- scrollPastEnd?: boolean;
37
+ scrollTo?: StateEffect<unknown>;
57
38
  moveToEndOfLine?: boolean;
58
- lineWrapping?: boolean;
59
- scrollTo?: StateEffect<any>; // TODO(burdon): Restore scroll position: scrollTo EditorView.scrollSnapshot().
60
- selection?: { anchor: number; head?: number };
61
- placeholder?: string;
62
- theme?: ThemeStyles;
63
- slots?: TextEditorSlots;
64
- extensions?: Extension[];
65
39
  debug?: boolean;
40
+ dataTestId?: string;
66
41
  };
67
42
 
43
+ let instanceCount = 0;
44
+
68
45
  /**
69
- * Base text editor.
46
+ * Thin wrapper for text editor.
47
+ * Handles tabster and focus management.
70
48
  */
71
- // TODO(burdon): Replace with useTextEditor.
72
- export const BaseTextEditor = forwardRef<EditorView | null, TextEditorProps>(
49
+ // TODO(burdon): Use useTextEditor internally.
50
+ export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
73
51
  (
74
52
  {
75
- model,
76
- readonly,
53
+ id,
54
+ doc,
55
+ selection,
56
+ extensions,
57
+ className,
77
58
  autoFocus,
78
- scrollTo = EditorView.scrollIntoView(0),
59
+ scrollTo = EditorView.scrollIntoView(0, { yMargin: 0 }),
79
60
  moveToEndOfLine,
80
- selection,
81
- theme,
82
- slots = defaultSlots,
83
- extensions = [],
84
- scrollPastEnd: _scrollPastEnd,
85
61
  debug,
62
+ dataTestId,
86
63
  },
87
64
  forwardedRef,
88
65
  ) => {
89
- const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
90
- const { themeMode } = useThemeContext();
66
+ // NOTE: Increments by 2 in strict mode.
67
+ const [instanceId] = useState(() => `text-editor-${++instanceCount}`);
91
68
 
69
+ // TODO(burdon): Make tabster optional.
70
+ const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
92
71
  const rootRef = useRef<HTMLDivElement>(null);
93
72
  const [view, setView] = useState<EditorView | null>(null);
94
73
 
95
74
  // 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.
75
+ // NOTE: Ref updates do not cause the parent to re-render; also the ref is not available immediately.
97
76
  useImperativeHandle<EditorView | null, EditorView | null>(forwardedRef, () => view, [view]);
98
77
 
99
78
  // Set focus.
@@ -106,53 +85,32 @@ export const BaseTextEditor = forwardRef<EditorView | null, TextEditorProps>(
106
85
  // Create editor state and view.
107
86
  // The view is recreated if the model or extensions are changed.
108
87
  useEffect(() => {
109
- if (!model || !rootRef.current) {
110
- return;
111
- }
88
+ log('updating', { id, instanceId });
112
89
 
113
90
  //
114
91
  // EditorState
115
92
  // https://codemirror.net/docs/ref/#state.EditorStateConfig
116
93
  //
117
94
  const state = EditorState.create({
118
- doc: typeof model.content === 'string' ? model.content : DocAccessor.getValue(model.content),
95
+ doc,
119
96
  selection,
120
97
  extensions: [
121
- // TODO(burdon): Doesn't catch errors in keymap functions.
98
+ id && documentId.of(id),
99
+ // TODO(burdon): NOTE: Doesn't catch errors in keymap functions.
122
100
  EditorView.exceptionSink.of((err) => {
123
101
  log.catch(err);
124
102
  }),
125
103
 
126
- // Theme.
127
- // TODO(burdon): Make base theme configurable.
128
- EditorView.baseTheme(defaultTheme),
129
- theme && EditorView.theme(theme),
130
- EditorView.darkTheme.of(themeMode === 'dark'),
131
- slots.editor?.className && EditorView.editorAttributes.of({ class: slots.editor.className }),
132
- slots.content?.className && EditorView.contentAttributes.of({ class: slots.content.className }),
133
-
134
- // NOTE: Assumes default line height.
135
- _scrollPastEnd && scrollPastEnd(),
136
-
137
104
  // Focus.
138
105
  EditorView.updateListener.of((update) => {
139
106
  update.transactions.forEach((transaction) => {
140
- if (transaction.isUserEvent('focus.container')) {
107
+ if (transaction.isUserEvent(focusEvent)) {
141
108
  rootRef.current?.focus();
142
109
  }
143
110
  });
144
111
  }),
145
112
 
146
- // State.
147
- EditorView.editable.of(!readonly),
148
- EditorState.readOnly.of(!!readonly),
149
-
150
- // Storage and replication.
151
- // NOTE: This must come before user extensions.
152
- model.extension,
153
-
154
- // Custom.
155
- ...extensions,
113
+ extensions,
156
114
  ].filter(isNotFalsy),
157
115
  });
158
116
 
@@ -162,8 +120,9 @@ export const BaseTextEditor = forwardRef<EditorView | null, TextEditorProps>(
162
120
  //
163
121
  const view = new EditorView({
164
122
  state,
165
- parent: rootRef.current,
123
+ parent: rootRef.current!,
166
124
  scrollTo,
125
+
167
126
  // NOTE: Uncomment to debug/monitor all transactions.
168
127
  // https://codemirror.net/docs/ref/#view.EditorView.dispatch
169
128
  dispatchTransactions: (trs, view) => {
@@ -174,24 +133,24 @@ export const BaseTextEditor = forwardRef<EditorView | null, TextEditorProps>(
174
133
  },
175
134
  });
176
135
 
177
- setView(view);
178
-
179
- if (moveToEndOfLine) {
136
+ // Position cursor at end of first line.
137
+ if (moveToEndOfLine && !(scrollTo || selection)) {
180
138
  const { to } = view.state.doc.lineAt(0);
181
- view?.dispatch({ selection: { anchor: to } });
139
+ view.dispatch({ selection: { anchor: to } });
182
140
  }
183
141
 
184
142
  // Remove tabster attribute (rely on custom keymap).
185
143
  if (state.facet(editorMode).noTabster) {
186
- rootRef.current.removeAttribute('data-tabster');
144
+ rootRef.current?.removeAttribute('data-tabster');
187
145
  }
188
146
 
147
+ setView(view);
148
+
189
149
  return () => {
190
150
  view?.destroy();
191
151
  setView(null);
192
152
  };
193
- // TODO(wittjosiah): Does `rootRef` ever change? Only `.current` changes?
194
- }, [rootRef, extensions, model, readonly, themeMode]);
153
+ }, [doc, selection, extensions]);
195
154
 
196
155
  // Focus editor on Enter (e.g., when tabbing to this component).
197
156
  const handleKeyUp = useCallback<KeyboardEventHandler<HTMLDivElement>>(
@@ -211,82 +170,12 @@ export const BaseTextEditor = forwardRef<EditorView | null, TextEditorProps>(
211
170
  <div
212
171
  role='none'
213
172
  ref={rootRef}
214
- key={model.id} // TODO(burdon): Remove?
215
173
  tabIndex={0}
216
- onKeyUp={handleKeyUp}
217
- {...slots.root}
174
+ className={className}
175
+ data-testid={dataTestId}
218
176
  {...tabsterDOMAttribute}
177
+ onKeyUp={handleKeyUp}
219
178
  />
220
179
  );
221
180
  },
222
181
  );
223
-
224
- // TODO(burdon): Replace with hooks.
225
- export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
226
- (
227
- { readonly, placeholder, lineWrapping = true, theme = textTheme, slots, extensions: _extensions, ...props },
228
- forwardedRef,
229
- ) => {
230
- const { themeMode } = useThemeContext();
231
- const updatedSlots = defaultsDeep({}, slots, defaultTextSlots);
232
- const extensions = useMemo(
233
- () => [
234
- createBasicExtensions({ lineWrapping, placeholder }),
235
- createThemeExtensions({ themeMode }),
236
- ...(_extensions ?? []),
237
- ],
238
- [themeMode, placeholder, lineWrapping, _extensions],
239
- );
240
-
241
- return (
242
- <BaseTextEditor
243
- ref={forwardedRef}
244
- readonly={readonly}
245
- extensions={extensions}
246
- theme={theme}
247
- slots={updatedSlots}
248
- {...props}
249
- />
250
- );
251
- },
252
- );
253
-
254
- // TODO(burdon): Replace with hooks.
255
- export const MarkdownEditor = forwardRef<EditorView | null, TextEditorProps>(
256
- ({ readonly, placeholder, theme = markdownTheme, slots, extensions: _extensions, ...props }, forwardedRef) => {
257
- const { themeMode } = useThemeContext();
258
- const updatedSlots = defaultsDeep({}, slots, defaultMarkdownSlots);
259
- const extensions = useMemo(
260
- () => [createMarkdownExtensions({ themeMode, placeholder }), ...(_extensions ?? [])],
261
- [themeMode, placeholder, _extensions],
262
- );
263
-
264
- return (
265
- <BaseTextEditor
266
- ref={forwardedRef}
267
- readonly={readonly}
268
- extensions={extensions}
269
- theme={theme}
270
- slots={updatedSlots}
271
- {...props}
272
- />
273
- );
274
- },
275
- );
276
-
277
- export const defaultSlots: TextEditorSlots = {
278
- root: {
279
- className: focusRing,
280
- },
281
- editor: {
282
- className: 'bs-full',
283
- },
284
- };
285
-
286
- export const defaultTextSlots: TextEditorSlots = {
287
- ...defaultSlots,
288
- };
289
-
290
- export const defaultMarkdownSlots: TextEditorSlots = {
291
- ...defaultSlots,
292
- };
@@ -4,79 +4,80 @@
4
4
 
5
5
  import '@dxosTheme';
6
6
 
7
- import React, { type FC, useMemo, useState } from 'react';
7
+ import React, { type FC, useState } from 'react';
8
8
 
9
9
  import { TextObject } from '@dxos/echo-schema';
10
10
  import { PublicKey } from '@dxos/keys';
11
11
  import { faker } from '@dxos/random';
12
- import { Tooltip } from '@dxos/react-ui';
13
- import { mx, textBlockWidth } from '@dxos/react-ui-theme';
12
+ import { Tooltip, useThemeContext } from '@dxos/react-ui';
13
+ import { textBlockWidth } from '@dxos/react-ui-theme';
14
14
  import { withTheme } from '@dxos/storybook-utils';
15
15
 
16
16
  import { Toolbar } from './Toolbar';
17
17
  import {
18
+ type Comment,
18
19
  decorateMarkdown,
19
20
  comments,
20
- formatting,
21
- image,
21
+ createBasicExtensions,
22
+ createDataExtensions,
23
+ createThemeExtensions,
24
+ formattingKeymap,
22
25
  table,
23
26
  useComments,
24
27
  useFormattingState,
28
+ createMarkdownExtensions,
29
+ image,
25
30
  } from '../../extensions';
26
- import { type Comment, useActionHandler, useEditorView, useTextModel } from '../../hooks';
31
+ import { useActionHandler, useDocAccessor, useTextEditor } from '../../hooks';
27
32
  import translations from '../../translations';
28
- import { MarkdownEditor } from '../TextEditor';
29
33
 
30
34
  faker.seed(101);
31
35
 
32
- const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) => {
36
+ const Story: FC<{ content: string }> = ({ content }) => {
37
+ const { themeMode } = useThemeContext();
33
38
  const [item] = useState({ text: new TextObject(content) });
34
- const [_comments, setComments] = useState<Comment[]>([]);
35
- const [editorRef, viewInvalidated] = useEditorView(id);
36
- const model = useTextModel({ text: item.text });
39
+ const { id, doc, accessor } = useDocAccessor(item.text);
37
40
  const [formattingState, formattingObserver] = useFormattingState();
38
- const extensions = useMemo(
39
- () => [
40
- decorateMarkdown(),
41
- comments({
42
- onCreate: ({ cursor }) => {
43
- const id = PublicKey.random().toHex();
44
- setComments((comments) => [...comments, { id, cursor }]);
45
- return id;
46
- },
47
- }),
48
- formatting(),
49
- image(),
50
- table(),
51
- formattingObserver,
52
- ],
53
- [],
41
+ const { parentRef, view } = useTextEditor(
42
+ () => ({
43
+ id,
44
+ doc,
45
+ extensions: [
46
+ createBasicExtensions(),
47
+ createMarkdownExtensions({ themeMode }),
48
+ createThemeExtensions({ themeMode, slots: { editor: { className: 'p-2' } } }),
49
+ createDataExtensions({ id, text: accessor }),
50
+ comments({
51
+ onCreate: ({ cursor }) => {
52
+ const id = PublicKey.random().toHex();
53
+ setComments((comments) => [...comments, { id, cursor }]);
54
+ return id;
55
+ },
56
+ }),
57
+ decorateMarkdown(),
58
+ formattingKeymap(),
59
+ image(),
60
+ table(),
61
+ formattingObserver,
62
+ ],
63
+ }),
64
+ [id, accessor, themeMode],
54
65
  );
55
66
 
56
- useComments(viewInvalidated ? null : editorRef.current, id, _comments);
57
- const handleAction = useActionHandler(editorRef.current);
67
+ const [_comments, setComments] = useState<Comment[]>([]);
68
+ useComments(view, id, _comments);
58
69
 
59
- if (!model) {
60
- return null;
61
- }
70
+ const handleAction = useActionHandler(view);
62
71
 
63
72
  return (
64
73
  <div role='none' className='fixed inset-0 flex flex-col'>
65
74
  <Toolbar.Root onAction={handleAction} state={formattingState} classNames={textBlockWidth}>
66
75
  <Toolbar.Markdown />
76
+ <Toolbar.Custom onUpload={async (file) => ({ url: file.name })} />
67
77
  <Toolbar.Separator />
68
- <Toolbar.Extended />
78
+ <Toolbar.Actions />
69
79
  </Toolbar.Root>
70
-
71
- <MarkdownEditor
72
- ref={editorRef}
73
- model={model}
74
- extensions={extensions}
75
- slots={{
76
- root: { className: mx(textBlockWidth) },
77
- editor: { className: 'p-2' },
78
- }}
79
- />
80
+ <div ref={parentRef} className={textBlockWidth} />
80
81
  </div>
81
82
  );
82
83
  };
@@ -7,6 +7,7 @@ import {
7
7
  ChatText,
8
8
  Code,
9
9
  CodeBlock,
10
+ Image,
10
11
  Link,
11
12
  ListBullets,
12
13
  ListChecks,
@@ -25,7 +26,8 @@ import {
25
26
  Quotes,
26
27
  } from '@phosphor-icons/react';
27
28
  import { createContext } from '@radix-ui/react-context';
28
- import React, { type PropsWithChildren, useRef, useState } from 'react';
29
+ import React, { type PropsWithChildren, useEffect, useRef, useState } from 'react';
30
+ import { useDropzone } from 'react-dropzone';
29
31
 
30
32
  import {
31
33
  DensityProvider,
@@ -34,7 +36,8 @@ import {
34
36
  type ThemedClassName,
35
37
  Toolbar as NaturalToolbar,
36
38
  Tooltip,
37
- type ToolbarToggleGroupItemProps,
39
+ type ToolbarToggleGroupItemProps as NaturalToolbarToggleGroupItemProps,
40
+ type ToolbarButtonProps as NaturalToolbarButtonProps,
38
41
  useTranslation,
39
42
  } from '@dxos/react-ui';
40
43
  import { getSize } from '@dxos/react-ui-theme';
@@ -111,9 +114,9 @@ type ButtonProps = {
111
114
  disabled?: (state: Formatting) => boolean;
112
115
  };
113
116
 
114
- type ToolbarButtonProps = ToolbarToggleGroupItemProps & { Icon: Icon };
117
+ type ToolbarToggleButtonProps = NaturalToolbarToggleGroupItemProps & { Icon: Icon };
115
118
 
116
- const ToolbarButton = ({ Icon, children, ...props }: ToolbarButtonProps) => {
119
+ const ToolbarToggleButton = ({ Icon, children, ...props }: ToolbarToggleButtonProps) => {
117
120
  return (
118
121
  <Tooltip.Root>
119
122
  <Tooltip.Trigger asChild>
@@ -132,6 +135,27 @@ const ToolbarButton = ({ Icon, children, ...props }: ToolbarButtonProps) => {
132
135
  );
133
136
  };
134
137
 
138
+ type ToolbarButtonProps = NaturalToolbarButtonProps & { Icon: Icon };
139
+
140
+ const ToolbarButton = ({ Icon, children, ...props }: ToolbarButtonProps) => {
141
+ return (
142
+ <Tooltip.Root>
143
+ <Tooltip.Trigger asChild>
144
+ <NaturalToolbar.Button variant='ghost' {...props} classNames={buttonStyles}>
145
+ <Icon className={iconStyles} />
146
+ <span className='sr-only'>{children}</span>
147
+ </NaturalToolbar.Button>
148
+ </Tooltip.Trigger>
149
+ <Tooltip.Portal>
150
+ <Tooltip.Content {...tooltipProps}>
151
+ {children}
152
+ <Tooltip.Arrow />
153
+ </Tooltip.Content>
154
+ </Tooltip.Portal>
155
+ </Tooltip.Root>
156
+ );
157
+ };
158
+
135
159
  const ToolbarSeparator = () => <div role='separator' className='grow' />;
136
160
 
137
161
  const MarkdownHeading = () => {
@@ -227,7 +251,7 @@ const MarkdownStyles = () => {
227
251
  value={markdownStyles.filter(({ getState }) => state && getState(state)).map(({ type }) => type)}
228
252
  >
229
253
  {markdownStyles.map(({ type, getState, Icon }) => (
230
- <ToolbarButton
254
+ <ToolbarToggleButton
231
255
  key={type}
232
256
  value={type}
233
257
  Icon={Icon}
@@ -235,7 +259,7 @@ const MarkdownStyles = () => {
235
259
  onClick={state ? () => onAction?.({ type, data: !getState(state) }) : undefined}
236
260
  >
237
261
  {t(`${type} label`)}
238
- </ToolbarButton>
262
+ </ToolbarToggleButton>
239
263
  ))}
240
264
  </NaturalToolbar.ToggleGroup>
241
265
  );
@@ -253,14 +277,14 @@ const MarkdownLists = () => {
253
277
  return (
254
278
  <NaturalToolbar.ToggleGroup type='single' value={state?.listStyle ? `list-${state.listStyle}` : ''}>
255
279
  {markdownLists.map(({ type, getState, Icon }) => (
256
- <ToolbarButton
280
+ <ToolbarToggleButton
257
281
  key={type}
258
282
  value={type}
259
283
  Icon={Icon}
260
284
  onClick={state ? () => onAction?.({ type, data: !getState(state) }) : undefined}
261
285
  >
262
286
  {t(`${type} label`)}
263
- </ToolbarButton>
287
+ </ToolbarToggleButton>
264
288
  ))}
265
289
  </NaturalToolbar.ToggleGroup>
266
290
  );
@@ -292,7 +316,7 @@ const MarkdownBlocks = () => {
292
316
  return (
293
317
  <NaturalToolbar.ToggleGroup type='single' value={value?.type ?? ''}>
294
318
  {markdownBlocks.map(({ type, disabled, getState, Icon }) => (
295
- <ToolbarButton
319
+ <ToolbarToggleButton
296
320
  key={type}
297
321
  value={type}
298
322
  Icon={Icon}
@@ -300,7 +324,7 @@ const MarkdownBlocks = () => {
300
324
  onClick={state ? () => onAction?.({ type, data: !getState(state) }) : undefined}
301
325
  >
302
326
  {t(`${type} label`)}
303
- </ToolbarButton>
327
+ </ToolbarToggleButton>
304
328
  ))}
305
329
  </NaturalToolbar.ToggleGroup>
306
330
  );
@@ -315,38 +339,68 @@ const MarkdownStandard = () => (
315
339
  </>
316
340
  );
317
341
 
318
- const MarkdownExtended = () => {
342
+ // TODO(burdon): Make extensible.
343
+ export type MarkdownCustomOptions = {
344
+ onUpload?: (file: File) => Promise<{ url?: string }>;
345
+ };
346
+
347
+ const MarkdownCustom = ({ onUpload }: MarkdownCustomOptions = {}) => {
319
348
  const { onAction } = useToolbarContext('MarkdownStyles');
320
349
  const { t } = useTranslation(translationKey);
350
+ // https://react-dropzone.js.org/#src
351
+ const { acceptedFiles, getInputProps, open } = useDropzone({
352
+ multiple: false,
353
+ noDrag: true,
354
+ accept: {
355
+ 'image/*': ['.jpg', '.jpeg', '.png', '.gif'],
356
+ },
357
+ });
358
+ useEffect(() => {
359
+ if (onUpload && acceptedFiles.length) {
360
+ setTimeout(async () => {
361
+ // NOTE: Clone file since react-dropzone patches in a non-standard `path` property, which confuses IPFS.
362
+ const f = acceptedFiles[0];
363
+ const file = new File([f], f.name, {
364
+ type: f.type,
365
+ lastModified: f.lastModified,
366
+ });
367
+
368
+ const { url } = await onUpload(file);
369
+ if (url) {
370
+ onAction?.({ type: 'image', data: url });
371
+ }
372
+ });
373
+ }
374
+ }, [acceptedFiles]);
375
+
321
376
  return (
322
- <Tooltip.Root>
323
- <Tooltip.Trigger asChild>
324
- <NaturalToolbar.Button
325
- variant='ghost'
326
- data-testid='editor.toolbar.comment'
327
- onClick={() => onAction?.({ type: 'comment' })}
328
- classNames={buttonStyles}
329
- >
330
- <ChatText className={iconStyles} />
331
- <span className='sr-only'>{t('comment label')}</span>
332
- </NaturalToolbar.Button>
333
- </Tooltip.Trigger>
334
- <Tooltip.Portal>
335
- <Tooltip.Content {...tooltipProps}>
336
- {t('comment label')}
337
- <Tooltip.Arrow />
338
- </Tooltip.Content>
339
- </Tooltip.Portal>
340
- </Tooltip.Root>
377
+ <>
378
+ <input {...getInputProps()} />
379
+ <ToolbarButton value='image' Icon={Image} onClick={() => open()}>
380
+ {t('image label')}
381
+ </ToolbarButton>
382
+ </>
383
+ );
384
+ };
385
+
386
+ // TODO(burdon): Make extensible.
387
+ const MarkdownActions = () => {
388
+ const { onAction } = useToolbarContext('MarkdownStyles');
389
+ const { t } = useTranslation(translationKey);
390
+ return (
391
+ <ToolbarButton value='comment' Icon={ChatText} onClick={() => onAction?.({ type: 'comment' })}>
392
+ {t('comment label')}
393
+ </ToolbarButton>
341
394
  );
342
395
  };
343
396
 
344
397
  export const Toolbar = {
345
398
  Root: ToolbarRoot,
346
- Button: ToolbarButton,
399
+ Button: ToolbarToggleButton,
347
400
  Separator: ToolbarSeparator,
348
401
  Markdown: MarkdownStandard,
349
- Extended: MarkdownExtended,
402
+ Custom: MarkdownCustom,
403
+ Actions: MarkdownActions,
350
404
  };
351
405
 
352
406
  export { useToolbarContext };