@dxos/react-ui-editor 0.6.10-main.bbdfaa4 → 0.6.10-staging.3cfcc89

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 (72) hide show
  1. package/dist/lib/browser/index.mjs +736 -717
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/InputMode.stories.d.ts +1 -0
  5. package/dist/types/src/InputMode.stories.d.ts.map +1 -1
  6. package/dist/types/src/TextEditor.stories.d.ts +19 -12
  7. package/dist/types/src/TextEditor.stories.d.ts.map +1 -1
  8. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  9. package/dist/types/src/defaults.d.ts +5 -1
  10. package/dist/types/src/defaults.d.ts.map +1 -1
  11. package/dist/types/src/extensions/autocomplete.d.ts +3 -2
  12. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  13. package/dist/types/src/extensions/automerge/automerge.stories.d.ts +1 -0
  14. package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
  15. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  16. package/dist/types/src/extensions/dnd.d.ts.map +1 -1
  17. package/dist/types/src/extensions/factories.d.ts +2 -2
  18. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  19. package/dist/types/src/extensions/folding.d.ts.map +1 -1
  20. package/dist/types/src/extensions/markdown/action.d.ts +1 -1
  21. package/dist/types/src/extensions/markdown/action.d.ts.map +1 -1
  22. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  23. package/dist/types/src/extensions/markdown/changes.d.ts +10 -0
  24. package/dist/types/src/extensions/markdown/changes.d.ts.map +1 -0
  25. package/dist/types/src/extensions/markdown/changes.test.d.ts +2 -0
  26. package/dist/types/src/extensions/markdown/changes.test.d.ts.map +1 -0
  27. package/dist/types/src/extensions/markdown/debug.d.ts +11 -0
  28. package/dist/types/src/extensions/markdown/debug.d.ts.map +1 -0
  29. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  30. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  31. package/dist/types/src/extensions/markdown/index.d.ts +1 -0
  32. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  33. package/dist/types/src/extensions/markdown/styles.d.ts +4 -0
  34. package/dist/types/src/extensions/markdown/styles.d.ts.map +1 -0
  35. package/dist/types/src/index.d.ts +0 -1
  36. package/dist/types/src/index.d.ts.map +1 -1
  37. package/dist/types/src/styles/theme.d.ts +1 -1
  38. package/dist/types/src/styles/theme.d.ts.map +1 -1
  39. package/dist/types/src/styles/tokens.d.ts +2 -4
  40. package/dist/types/src/styles/tokens.d.ts.map +1 -1
  41. package/dist/types/src/translations.d.ts +1 -0
  42. package/dist/types/src/translations.d.ts.map +1 -1
  43. package/package.json +26 -27
  44. package/src/TextEditor.stories.tsx +122 -74
  45. package/src/components/Toolbar/Toolbar.tsx +91 -92
  46. package/src/defaults.ts +16 -11
  47. package/src/extensions/annotations.ts +2 -2
  48. package/src/extensions/autocomplete.ts +4 -1
  49. package/src/extensions/awareness/awareness.ts +1 -1
  50. package/src/extensions/comments.ts +11 -45
  51. package/src/extensions/dnd.ts +3 -5
  52. package/src/extensions/factories.ts +4 -4
  53. package/src/extensions/folding.tsx +3 -4
  54. package/src/extensions/markdown/action.ts +1 -0
  55. package/src/extensions/markdown/bundle.ts +0 -1
  56. package/src/extensions/markdown/{link-paste.test.ts → changes.test.ts} +2 -2
  57. package/src/extensions/markdown/changes.ts +148 -0
  58. package/src/extensions/markdown/debug.ts +44 -0
  59. package/src/extensions/markdown/decorate.ts +14 -93
  60. package/src/extensions/markdown/formatting.ts +1 -2
  61. package/src/extensions/markdown/highlight.ts +2 -2
  62. package/src/extensions/markdown/index.ts +1 -0
  63. package/src/extensions/markdown/styles.ts +103 -0
  64. package/src/index.ts +0 -2
  65. package/src/styles/theme.ts +85 -147
  66. package/src/styles/tokens.ts +4 -2
  67. package/src/translations.ts +1 -0
  68. package/dist/types/src/extensions/markdown/link-paste.d.ts +0 -9
  69. package/dist/types/src/extensions/markdown/link-paste.d.ts.map +0 -1
  70. package/dist/types/src/extensions/markdown/link-paste.test.d.ts +0 -2
  71. package/dist/types/src/extensions/markdown/link-paste.test.d.ts.map +0 -1
  72. package/src/extensions/markdown/link-paste.ts +0 -107
@@ -12,6 +12,7 @@ import {
12
12
  ListBullets,
13
13
  ListChecks,
14
14
  ListNumbers,
15
+ MagnifyingGlass,
15
16
  Paragraph,
16
17
  Quotes,
17
18
  TextStrikethrough,
@@ -140,89 +141,6 @@ const ToolbarButton = ({ Icon, children, ...props }: ToolbarButtonProps) => {
140
141
  );
141
142
  };
142
143
 
143
- //
144
- // View Mode
145
- //
146
-
147
- const ViewModeIcons: Record<EditorViewMode, Icon> = {
148
- preview: PencilSimple,
149
- readonly: PencilSimpleSlash,
150
- source: MarkdownLogo,
151
- };
152
-
153
- const MarkdownView = ({ mode }: { mode: EditorViewMode }) => {
154
- const { t } = useTranslation(translationKey);
155
- const { onAction } = useToolbarContext('ViewMode');
156
- const ModeIcon = ViewModeIcons[mode ?? 'preview'];
157
- const suppressNextTooltip = useRef<boolean>(false);
158
- const [tooltipOpen, setTooltipOpen] = useState<boolean>(false);
159
- const [selectOpen, setSelectOpen] = useState<boolean>(false);
160
- return (
161
- <Tooltip.Root
162
- open={tooltipOpen}
163
- onOpenChange={(nextOpen) => {
164
- if (nextOpen && suppressNextTooltip.current) {
165
- suppressNextTooltip.current = false;
166
- return setTooltipOpen(false);
167
- } else {
168
- return setTooltipOpen(nextOpen);
169
- }
170
- }}
171
- >
172
- {/* TODO(thure): `Select` encounters a ref error if used here (repro: select a heading, then select another
173
- heading). Determine the root cause and fix or report to Radix. */}
174
- <DropdownMenu.Root
175
- open={selectOpen}
176
- onOpenChange={(nextOpen: boolean) => {
177
- if (!nextOpen) {
178
- suppressNextTooltip.current = true;
179
- }
180
- return setSelectOpen(nextOpen);
181
- }}
182
- >
183
- <Tooltip.Trigger asChild>
184
- <NaturalToolbar.Button asChild>
185
- <DropdownMenu.Trigger asChild>
186
- <Button variant='ghost' classNames={buttonStyles}>
187
- <span className='sr-only'>{t('mode label')}</span>
188
- <ModeIcon className={iconStyles} />
189
- <CaretDown />
190
- </Button>
191
- </DropdownMenu.Trigger>
192
- </NaturalToolbar.Button>
193
- </Tooltip.Trigger>
194
- <DropdownMenu.Portal>
195
- <DropdownMenu.Content classNames='is-min md:is-min' onCloseAutoFocus={(e) => e.preventDefault()}>
196
- <DropdownMenu.Viewport>
197
- {EditorViewModes.map((value) => {
198
- const Icon = ViewModeIcons[value];
199
- return (
200
- <DropdownMenu.CheckboxItem
201
- key={value}
202
- checked={value === mode}
203
- onClick={() => onAction?.({ type: 'view-mode', data: value })}
204
- >
205
- <Icon className={iconStyles} />
206
- <span className='whitespace-nowrap grow'>{t(`${value} mode label`)}</span>
207
- <Check className={value === mode ? 'visible' : 'invisible'} />
208
- </DropdownMenu.CheckboxItem>
209
- );
210
- })}
211
- </DropdownMenu.Viewport>
212
- <DropdownMenu.Arrow />
213
- </DropdownMenu.Content>
214
- </DropdownMenu.Portal>
215
- </DropdownMenu.Root>
216
- <Tooltip.Portal>
217
- <Tooltip.Content {...tooltipProps}>
218
- {t('view mode label')}
219
- <Tooltip.Arrow />
220
- </Tooltip.Content>
221
- </Tooltip.Portal>
222
- </Tooltip.Root>
223
- );
224
- };
225
-
226
144
  //
227
145
  // Heading
228
146
  //
@@ -474,28 +392,109 @@ const MarkdownCustom = ({ onUpload }: MarkdownCustomOptions = {}) => {
474
392
  );
475
393
  };
476
394
 
395
+ //
396
+ // View Mode
397
+ //
398
+
399
+ const ViewModeIcons: Record<EditorViewMode, Icon> = {
400
+ preview: PencilSimple,
401
+ readonly: PencilSimpleSlash,
402
+ source: MarkdownLogo,
403
+ };
404
+
405
+ const MarkdownView = ({ mode }: { mode: EditorViewMode }) => {
406
+ const { t } = useTranslation(translationKey);
407
+ const { onAction } = useToolbarContext('ViewMode');
408
+ const ModeIcon = ViewModeIcons[mode ?? 'preview'];
409
+ const suppressNextTooltip = useRef<boolean>(false);
410
+ const [tooltipOpen, setTooltipOpen] = useState<boolean>(false);
411
+ const [selectOpen, setSelectOpen] = useState<boolean>(false);
412
+ return (
413
+ <Tooltip.Root
414
+ open={tooltipOpen}
415
+ onOpenChange={(nextOpen) => {
416
+ if (nextOpen && suppressNextTooltip.current) {
417
+ suppressNextTooltip.current = false;
418
+ return setTooltipOpen(false);
419
+ } else {
420
+ return setTooltipOpen(nextOpen);
421
+ }
422
+ }}
423
+ >
424
+ {/* TODO(thure): `Select` encounters a ref error if used here (repro: select a heading, then select another
425
+ heading). Determine the root cause and fix or report to Radix. */}
426
+ <DropdownMenu.Root
427
+ open={selectOpen}
428
+ onOpenChange={(nextOpen: boolean) => {
429
+ if (!nextOpen) {
430
+ suppressNextTooltip.current = true;
431
+ }
432
+ return setSelectOpen(nextOpen);
433
+ }}
434
+ >
435
+ <Tooltip.Trigger asChild>
436
+ <NaturalToolbar.Button asChild>
437
+ <DropdownMenu.Trigger asChild>
438
+ <Button variant='ghost' classNames={buttonStyles}>
439
+ <span className='sr-only'>{t('mode label')}</span>
440
+ <ModeIcon className={iconStyles} />
441
+ <CaretDown />
442
+ </Button>
443
+ </DropdownMenu.Trigger>
444
+ </NaturalToolbar.Button>
445
+ </Tooltip.Trigger>
446
+ <DropdownMenu.Portal>
447
+ <DropdownMenu.Content classNames='is-min md:is-min' onCloseAutoFocus={(e) => e.preventDefault()}>
448
+ <DropdownMenu.Viewport>
449
+ {EditorViewModes.map((value) => {
450
+ const Icon = ViewModeIcons[value];
451
+ return (
452
+ <DropdownMenu.CheckboxItem
453
+ key={value}
454
+ checked={value === mode}
455
+ onClick={() => onAction?.({ type: 'view-mode', data: value })}
456
+ >
457
+ <Icon className={iconStyles} />
458
+ <span className='whitespace-nowrap grow'>{t(`${value} mode label`)}</span>
459
+ <Check className={value === mode ? 'visible' : 'invisible'} />
460
+ </DropdownMenu.CheckboxItem>
461
+ );
462
+ })}
463
+ </DropdownMenu.Viewport>
464
+ <DropdownMenu.Arrow />
465
+ </DropdownMenu.Content>
466
+ </DropdownMenu.Portal>
467
+ </DropdownMenu.Root>
468
+ <Tooltip.Portal>
469
+ <Tooltip.Content {...tooltipProps}>
470
+ {t('view mode label')}
471
+ <Tooltip.Arrow />
472
+ </Tooltip.Content>
473
+ </Tooltip.Portal>
474
+ </Tooltip.Root>
475
+ );
476
+ };
477
+
477
478
  //
478
479
  // Actions
479
480
  //
480
481
 
481
- // TODO(burdon): Make extensible.
482
482
  const MarkdownActions = () => {
483
483
  const { onAction, state } = useToolbarContext('MarkdownActions');
484
484
  const { t } = useTranslation(translationKey);
485
485
 
486
- let toolTipKey = 'comment label';
486
+ let commentToolTipKey = 'comment label';
487
487
  if (state?.comment) {
488
- toolTipKey = 'selection overlaps existing comment label';
488
+ commentToolTipKey = 'selection overlaps existing comment label';
489
489
  } else if (state?.selection === false) {
490
- toolTipKey = 'select text to comment label';
490
+ commentToolTipKey = 'select text to comment label';
491
491
  }
492
492
 
493
493
  return (
494
494
  <>
495
- {/* TODO(burdon): Toggle readonly state. */}
496
- {/* <ToolbarButton value='comment' Icon={BookOpenText} onClick={() => onAction?.({ type: 'comment' })}> */}
497
- {/* {t('comment label')} */}
498
- {/* </ToolbarButton> */}
495
+ <ToolbarButton value='search' Icon={MagnifyingGlass} onClick={() => onAction?.({ type: 'search' })}>
496
+ {t('search label')}
497
+ </ToolbarButton>
499
498
  <ToolbarButton
500
499
  value='comment'
501
500
  Icon={ChatText}
@@ -503,7 +502,7 @@ const MarkdownActions = () => {
503
502
  onClick={() => onAction?.({ type: 'comment' })}
504
503
  disabled={!state || state.comment || !state.selection}
505
504
  >
506
- {t(toolTipKey)}
505
+ {t(commentToolTipKey)}
507
506
  </ToolbarButton>
508
507
  </>
509
508
  );
package/src/defaults.ts CHANGED
@@ -4,32 +4,37 @@
4
4
 
5
5
  import { EditorView } from '@codemirror/view';
6
6
 
7
- import { getToken } from './styles';
7
+ import { mx } from '@dxos/react-ui-theme';
8
+
9
+ import { fontMono } from './styles';
10
+
11
+ const margin = '!mt-[16px]';
8
12
 
9
13
  /**
10
14
  * CodeMirror content width.
11
15
  * 40rem = 640px. Corresponds to initial plank width (Google docs, Stashpad, etc.)
12
16
  * 50rem = 800px. Maximum content width for solo mode.
13
17
  */
14
- export const editorContent = '!mt-[16px] !mb-[32px] !mli-auto w-full max-w-[min(50rem,100%-4rem)]';
18
+ export const editorContent = mx(margin, '!mli-auto w-full max-w-[min(50rem,100%-2rem)]');
19
+
20
+ /**
21
+ * Margin for numbers.
22
+ */
23
+ export const editorFullWidth = mx(margin, '!ml-[3rem]');
15
24
 
16
25
  export const editorWithToolbarLayout =
17
26
  'grid grid-cols-1 grid-rows-[min-content_1fr] data-[toolbar=disabled]:grid-rows-[1fr] justify-center content-start overflow-hidden';
18
27
 
19
- export const editorGutter = EditorView.baseTheme({
28
+ // TODO(burdon): Define scrollMargins for fixed gutter.
29
+ export const editorGutter = EditorView.theme({
30
+ // Match margin from content.
20
31
  '.cm-gutters': {
21
- // Match margin from content.
22
32
  marginTop: '16px',
23
- marginBottom: '16px',
24
- // Inside within content margin.
25
- marginRight: '-32px',
26
- width: '32px',
27
- backgroundColor: 'transparent !important',
28
33
  },
29
34
  });
30
35
 
31
- export const editorMonospace = EditorView.baseTheme({
36
+ export const editorMonospace = EditorView.theme({
32
37
  '.cm-content': {
33
- fontFamily: `${getToken('fontFamily.mono')} !important`,
38
+ fontFamily: fontMono,
34
39
  },
35
40
  });
@@ -69,10 +69,10 @@ export const annotations = (options: AnnotationOptions = {}): Extension => {
69
69
  ];
70
70
  };
71
71
 
72
- const styles = EditorView.baseTheme({
72
+ const styles = EditorView.theme({
73
73
  '.cm-annotation': {
74
74
  textDecoration: 'underline',
75
75
  textDecorationStyle: 'wavy',
76
- textDecorationColor: 'red',
76
+ textDecorationColor: 'var(--dx-error)',
77
77
  },
78
78
  });
@@ -9,6 +9,7 @@
9
9
  import {
10
10
  autocompletion,
11
11
  completionKeymap,
12
+ type CompletionSource,
12
13
  type Completion,
13
14
  type CompletionContext,
14
15
  type CompletionResult,
@@ -20,13 +21,14 @@ export type AutocompleteResult = Completion;
20
21
 
21
22
  export type AutocompleteOptions = {
22
23
  activateOnTyping?: boolean;
24
+ override?: CompletionSource[];
23
25
  onSearch?: (text: string) => Completion[];
24
26
  };
25
27
 
26
28
  /**
27
29
  * Autocomplete extension.
28
30
  */
29
- export const autocomplete = ({ activateOnTyping, onSearch }: AutocompleteOptions = {}) => {
31
+ export const autocomplete = ({ activateOnTyping, override, onSearch }: AutocompleteOptions = {}) => {
30
32
  const extentions = [
31
33
  // https://codemirror.net/docs/ref/#view.keymap
32
34
  // https://discuss.codemirror.net/t/how-can-i-replace-the-default-autocompletion-keymap-v6/3322
@@ -37,6 +39,7 @@ export const autocomplete = ({ activateOnTyping, onSearch }: AutocompleteOptions
37
39
  // https://codemirror.net/docs/ref/#autocomplete.autocompletion
38
40
  autocompletion({
39
41
  activateOnTyping,
42
+ override,
40
43
 
41
44
  // closeOnBlur: false,
42
45
  // defaultKeymap: false,
@@ -258,7 +258,7 @@ class RemoteCaretWidget extends WidgetType {
258
258
  }
259
259
  }
260
260
 
261
- const styles = EditorView.baseTheme({
261
+ const styles = EditorView.theme({
262
262
  '.cm-collab-selection': {},
263
263
  '.cm-collab-selectionLine': {
264
264
  padding: 0,
@@ -32,7 +32,6 @@ import { nonNullable } from '@dxos/util';
32
32
  import { Cursor } from './cursor';
33
33
  import { type Comment, type Range } from './types';
34
34
  import { overlap } from './util';
35
- import { getToken } from '../styles';
36
35
  import { callbackWrapper } from '../util';
37
36
 
38
37
  //
@@ -106,53 +105,20 @@ export const commentsState = StateField.define<CommentsState>({
106
105
  },
107
106
  });
108
107
 
109
- //
110
- // UX
111
- //
112
-
113
- const styles = EditorView.baseTheme({
108
+ /**
109
+ * NOTE: Matches search.
110
+ */
111
+ const styles = EditorView.theme({
114
112
  '.cm-comment, .cm-comment-current': {
113
+ margin: '0 -3px',
114
+ padding: '3px',
115
+ borderRadius: '3px',
116
+ backgroundColor: 'var(--dx-cmCommentSurface)',
117
+ color: 'var(--dx-cmComment)',
115
118
  cursor: 'pointer',
116
- borderWidth: '1px',
117
- borderStyle: 'solid',
118
- borderRadius: '2px',
119
- transition: 'background-color 0.1s ease',
120
- },
121
- // Light theme.
122
- '&light .cm-comment': {
123
- backgroundColor: getToken('extend.colors.yellow.50'),
124
- mixBlendMode: 'darken',
125
- borderColor: getToken('extend.colors.yellow.100'),
126
- },
127
- '&light .cm-comment:hover': { backgroundColor: getToken('extend.colors.yellow.100') },
128
- '&light .cm-comment-current': {
129
- backgroundColor: getToken('extend.colors.primary.100'),
130
- borderColor: getToken('extend.colors.primary.200'),
131
- },
132
- '&light .cm-comment-current:hover': {
133
- backgroundColor: getToken('extend.colors.primary.150'),
134
- borderColor: getToken('extend.colors.primary.250'),
135
- },
136
-
137
- // Dark theme.
138
- '&dark .cm-comment': {
139
- color: getToken('extend.colors.yellow.50'),
140
- backgroundColor: getToken('extend.colors.yellow.800'),
141
- borderColor: getToken('extend.colors.yellow.700'),
142
- mixBlendMode: 'plus-lighter',
143
- },
144
- '&dark .cm-comment:hover': {
145
- backgroundColor: getToken('extend.colors.yellow.700'),
146
- borderColor: getToken('extend.colors.yellow.650'),
147
- },
148
- '&dark .cm-comment-current': {
149
- color: getToken('extend.colors.primary.50'),
150
- backgroundColor: getToken('extend.colors.primary.800'),
151
- borderColor: getToken('extend.colors.primary.700'),
152
119
  },
153
- '&dark .cm-comment-current:hover': {
154
- backgroundColor: getToken('extend.colors.primary.700'),
155
- borderColor: getToken('extend.colors.primary.650'),
120
+ '.cm-comment:hover, .cm-comment-current': {
121
+ textDecoration: 'underline',
156
122
  },
157
123
  });
158
124
 
@@ -5,14 +5,12 @@
5
5
  import type { Extension } from '@codemirror/state';
6
6
  import { dropCursor, EditorView } from '@codemirror/view';
7
7
 
8
- import { getToken } from '../styles';
9
-
10
8
  export type DNDOptions = { onDrop?: (view: EditorView, event: { files: FileList }) => void };
11
9
 
12
- const styles = EditorView.baseTheme({
10
+ const styles = EditorView.theme({
13
11
  '.cm-dropCursor': {
14
- borderLeft: `2px solid ${getToken('extend.colors.primary.500')}`,
15
- color: getToken('extend.colors.primary.500'),
12
+ borderLeft: '2px solid var(--dx-accentText)',
13
+ color: 'var(--dx-accentText)',
16
14
  padding: '0 4px',
17
15
  },
18
16
  '.cm-dropCursor:after': {
@@ -19,6 +19,7 @@ import {
19
19
  scrollPastEnd,
20
20
  } from '@codemirror/view';
21
21
  import defaultsDeep from 'lodash.defaultsdeep';
22
+ import merge from 'lodash.merge';
22
23
 
23
24
  import { generateName } from '@dxos/display-name';
24
25
  import { log } from '@dxos/log';
@@ -128,8 +129,8 @@ export const createBasicExtensions = (_props?: BasicExtensionsOptions): Extensio
128
129
  //
129
130
 
130
131
  export type ThemeExtensionsOptions = {
131
- theme?: ThemeStyles;
132
132
  themeMode?: ThemeMode;
133
+ styles?: ThemeStyles;
133
134
  slots?: {
134
135
  editor?: {
135
136
  className?: string;
@@ -148,12 +149,11 @@ const defaultThemeSlots = {
148
149
 
149
150
  // TODO(burdon): Should only have one baseTheme?
150
151
  // https://codemirror.net/examples/styling
151
- export const createThemeExtensions = ({ theme, themeMode, slots: _slots }: ThemeExtensionsOptions = {}): Extension => {
152
+ export const createThemeExtensions = ({ themeMode, styles, slots: _slots }: ThemeExtensionsOptions = {}): Extension => {
152
153
  const slots = defaultsDeep({}, _slots, defaultThemeSlots);
153
154
  return [
154
- EditorView.baseTheme(defaultTheme),
155
155
  EditorView.darkTheme.of(themeMode === 'dark'),
156
- theme && EditorView.theme(theme),
156
+ EditorView.baseTheme(styles ? merge({}, defaultTheme, styles) : defaultTheme),
157
157
  slots.editor?.className && EditorView.editorAttributes.of({ class: slots.editor.className }),
158
158
  slots.content?.className && EditorView.contentAttributes.of({ class: slots.content.className }),
159
159
  ].filter(isNotFalsy);
@@ -6,7 +6,8 @@ import { codeFolding, foldGutter } from '@codemirror/language';
6
6
  import { type Extension } from '@codemirror/state';
7
7
  import React from 'react';
8
8
 
9
- import { getSize, mx } from '@dxos/react-ui-theme';
9
+ import { Icon } from '@dxos/react-ui';
10
+ import { getSize } from '@dxos/react-ui-theme';
10
11
 
11
12
  import { renderRoot } from './util';
12
13
 
@@ -23,9 +24,7 @@ export const folding = (_props: FoldingOptions = {}): Extension => [
23
24
  markerDOM: (open) => {
24
25
  return renderRoot(
25
26
  document.createElement('div'),
26
- <svg className={mx(getSize(3), 'm-3 cursor-pointer', open && 'rotate-90')}>
27
- <use href={'/icons.svg#ph--caret-right--regular'} />
28
- </svg>,
27
+ <Icon icon='ph--caret-right--regular' classNames={[getSize(3), 'm-2 cursor-pointer', open && 'rotate-90']} />,
29
28
  );
30
29
  },
31
30
  }),
@@ -40,6 +40,7 @@ export type ActionType =
40
40
  | 'list-task'
41
41
  | 'mention'
42
42
  | 'prompt'
43
+ | 'search'
43
44
  | 'strikethrough'
44
45
  | 'table';
45
46
 
@@ -61,7 +61,6 @@ export const createMarkdownExtensions = ({ themeMode }: MarkdownBundleOptions =
61
61
  syntaxHighlighting(markdownHighlightStyle()),
62
62
 
63
63
  keymap.of([
64
- // TODO(burdon): Indent by 4 if in task list.
65
64
  // https://codemirror.net/docs/ref/#commands.indentWithTab
66
65
  indentWithTab,
67
66
 
@@ -6,7 +6,7 @@ import { expect } from 'chai';
6
6
 
7
7
  import { describe, test } from '@dxos/test';
8
8
 
9
- import { createLinkLabel } from './link-paste';
9
+ import { createLinkLabel } from './changes';
10
10
 
11
11
  const testCases = [
12
12
  { input: 'https://www.example.com', expected: 'example.com' },
@@ -19,7 +19,7 @@ const testCases = [
19
19
  { input: 'ftp://example.com', expected: 'ftp://example.com' },
20
20
  ];
21
21
 
22
- describe('links', () => {
22
+ describe('changes', () => {
23
23
  test('createLinkLabel', () => {
24
24
  testCases.forEach(({ input, expected }) => {
25
25
  expect(createLinkLabel(new URL(input))).to.eq(expected);
@@ -0,0 +1,148 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { syntaxTree } from '@codemirror/language';
6
+ import { type ChangeSpec, Transaction } from '@codemirror/state';
7
+ import { ViewPlugin, type ViewUpdate, type PluginValue } from '@codemirror/view';
8
+
9
+ /**
10
+ * Monitors and augments changes.
11
+ */
12
+ // TODO(burdon): Tests.
13
+ export const adjustChanges = () => {
14
+ return ViewPlugin.fromClass(
15
+ class implements PluginValue {
16
+ update(update: ViewUpdate) {
17
+ const tree = syntaxTree(update.state);
18
+ const adjustments: ChangeSpec[] = [];
19
+
20
+ for (const tr of update.transactions) {
21
+ const event = tr.annotation(Transaction.userEvent);
22
+ switch (event) {
23
+ //
24
+ // Enter
25
+ //
26
+ case 'input': {
27
+ const changes = tr.changes;
28
+ if (changes.empty) {
29
+ break;
30
+ }
31
+
32
+ changes.iterChanges((fromA) => {
33
+ const node = tree.resolveInner(fromA, 1);
34
+ if (node?.name === 'BulletList') {
35
+ // Add space to previous line if an empty list item (otherwise it is not interpreted as a Task).
36
+ const { text } = update.state.doc.lineAt(fromA);
37
+ if (text.endsWith(']')) {
38
+ adjustments.push({ from: fromA, to: fromA, insert: ' ' });
39
+ }
40
+ }
41
+ });
42
+
43
+ break;
44
+ }
45
+
46
+ //
47
+ // Paste
48
+ //
49
+ case 'input.paste': {
50
+ const changes = tr.changes;
51
+ if (changes.empty) {
52
+ break;
53
+ }
54
+
55
+ changes.iterChanges((fromA, toA, fromB, toB, text) => {
56
+ // Check for URL.
57
+ const url = getValidUrl(update.view.state.sliceDoc(fromB, toB));
58
+ if (url) {
59
+ const node = tree.resolveInner(fromA, -1);
60
+ const invalidPositions = new Set(['Link', 'LinkMark', 'Code', 'CodeText', 'FencedCode', 'URL']);
61
+ if (!invalidPositions.has(node?.name)) {
62
+ const replacedText = tr.startState.sliceDoc(fromA, toA);
63
+ adjustments.push({ from: fromA, to: toB, insert: createLink(url, replacedText) });
64
+ }
65
+ } else {
66
+ const node = tree.resolveInner(fromA, 1);
67
+ switch (node?.name) {
68
+ case 'Task': {
69
+ // Remove task marker if pasting into task list.
70
+ const str = text.toString();
71
+ const match = str.match(/\s*- \[[ xX]\]\s*(.+)/);
72
+ if (match) {
73
+ const [, replacement] = match;
74
+ adjustments.push({ from: fromA, to: toB, insert: replacement });
75
+ }
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ });
81
+
82
+ break;
83
+ }
84
+ }
85
+ }
86
+
87
+ // TODO(burdon): Is this the right way to augment changes?
88
+ if (adjustments.length) {
89
+ setTimeout(() => {
90
+ update.view.dispatch(
91
+ update.view.state.update({
92
+ changes: adjustments,
93
+ }),
94
+ );
95
+ });
96
+ }
97
+ }
98
+ },
99
+ );
100
+ };
101
+
102
+ //
103
+ // Links
104
+ //
105
+
106
+ export const createLink = (url: URL, label: string): string => {
107
+ // Check if image.
108
+ // Example: https://dxos.network/dxos-logotype-blue.png
109
+ const { host, pathname } = url;
110
+ const [, extension] = pathname.split('.');
111
+ const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'];
112
+ if (imageExtensions.includes(extension)) {
113
+ return `![${label || host}](${url})`;
114
+ }
115
+
116
+ if (!label) {
117
+ label = createLinkLabel(url);
118
+ }
119
+
120
+ return `[${label}](${url})`;
121
+ };
122
+
123
+ export const createLinkLabel = (url: URL): string => {
124
+ let { protocol, host, pathname } = url;
125
+ if (protocol === 'http:' || protocol === 'https:') {
126
+ protocol = '';
127
+ }
128
+
129
+ // NOTE(Zan): Consult: https://github.com/dxos/dxos/issues/7331 before changing this.
130
+ // Remove 'www.' if at the beginning of the URL
131
+ host = host.replace(/^www\./, '');
132
+
133
+ return [protocol, host].filter(Boolean).join('//') + (pathname !== '/' ? pathname : '');
134
+ };
135
+
136
+ const getValidUrl = (str: string): URL | undefined => {
137
+ const validProtocols = ['http:', 'https:', 'mailto:', 'tel:'];
138
+ try {
139
+ const url = new URL(str);
140
+ if (!validProtocols.includes(url.protocol)) {
141
+ return undefined;
142
+ }
143
+
144
+ return url;
145
+ } catch (_err) {
146
+ return undefined;
147
+ }
148
+ };