@dxos/react-ui-editor 0.4.8-main.8cb190e → 0.4.8-main.932d824

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 (42) hide show
  1. package/dist/lib/browser/index.mjs +410 -256
  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 +2 -2
  5. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +2 -1
  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.stories.d.ts +1 -0
  13. package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
  14. package/dist/types/src/extensions/index.d.ts +1 -0
  15. package/dist/types/src/extensions/index.d.ts.map +1 -1
  16. package/dist/types/src/extensions/markdown/formatting.d.ts +4 -1
  17. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  18. package/dist/types/src/extensions/markdown/image.d.ts +6 -0
  19. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  20. package/dist/types/src/extensions/modes.d.ts +1 -0
  21. package/dist/types/src/extensions/modes.d.ts.map +1 -1
  22. package/dist/types/src/extensions/state.d.ts +20 -0
  23. package/dist/types/src/extensions/state.d.ts.map +1 -0
  24. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -1
  25. package/dist/types/src/hooks/useTextEditor.stories.d.ts +1 -0
  26. package/dist/types/src/hooks/useTextEditor.stories.d.ts.map +1 -1
  27. package/dist/types/src/translations.d.ts +1 -0
  28. package/dist/types/src/translations.d.ts.map +1 -1
  29. package/package.json +25 -24
  30. package/src/components/TextEditor/TextEditor.stories.tsx +16 -4
  31. package/src/components/TextEditor/TextEditor.tsx +11 -10
  32. package/src/components/Toolbar/Toolbar.stories.tsx +3 -2
  33. package/src/components/Toolbar/Toolbar.tsx +86 -32
  34. package/src/extensions/index.ts +1 -0
  35. package/src/extensions/markdown/formatting.test.ts +13 -13
  36. package/src/extensions/markdown/formatting.ts +80 -76
  37. package/src/extensions/markdown/image.ts +6 -0
  38. package/src/extensions/modes.ts +3 -1
  39. package/src/extensions/state.ts +90 -0
  40. package/src/hooks/useActionHandler.ts +5 -1
  41. package/src/hooks/useTextEditor.stories.tsx +3 -3
  42. package/src/translations.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-editor",
3
- "version": "0.4.8-main.8cb190e",
3
+ "version": "0.4.8-main.932d824",
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",
@@ -36,21 +36,22 @@
36
36
  "lodash.get": "^4.4.2",
37
37
  "lodash.pick": "^4.4.0",
38
38
  "lodash.sortby": "^4.7.0",
39
+ "react-dropzone": "^14.2.3",
39
40
  "style-mod": "^4.1.0",
40
- "@dxos/async": "0.4.8-main.8cb190e",
41
- "@dxos/automerge": "0.4.8-main.8cb190e",
42
- "@dxos/debug": "0.4.8-main.8cb190e",
43
- "@dxos/context": "0.4.8-main.8cb190e",
44
- "@dxos/display-name": "0.4.8-main.8cb190e",
45
- "@dxos/echo-schema": "0.4.8-main.8cb190e",
46
- "@dxos/log": "0.4.8-main.8cb190e",
47
- "@dxos/invariant": "0.4.8-main.8cb190e",
48
- "@dxos/protocols": "0.4.8-main.8cb190e",
49
- "@dxos/react-async": "0.4.8-main.8cb190e",
50
- "@dxos/react-ui": "0.4.8-main.8cb190e",
51
- "@dxos/react-ui-theme": "0.4.8-main.8cb190e",
52
- "@dxos/util": "0.4.8-main.8cb190e",
53
- "@dxos/text-model": "0.4.8-main.8cb190e"
41
+ "@dxos/async": "0.4.8-main.932d824",
42
+ "@dxos/context": "0.4.8-main.932d824",
43
+ "@dxos/automerge": "0.4.8-main.932d824",
44
+ "@dxos/echo-schema": "0.4.8-main.932d824",
45
+ "@dxos/display-name": "0.4.8-main.932d824",
46
+ "@dxos/invariant": "0.4.8-main.932d824",
47
+ "@dxos/debug": "0.4.8-main.932d824",
48
+ "@dxos/protocols": "0.4.8-main.932d824",
49
+ "@dxos/log": "0.4.8-main.932d824",
50
+ "@dxos/react-async": "0.4.8-main.932d824",
51
+ "@dxos/react-ui": "0.4.8-main.932d824",
52
+ "@dxos/react-ui-theme": "0.4.8-main.932d824",
53
+ "@dxos/text-model": "0.4.8-main.932d824",
54
+ "@dxos/util": "0.4.8-main.932d824"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@automerge/automerge-repo-network-broadcastchannel": "^1.1.1",
@@ -77,20 +78,20 @@
77
78
  "vite-plugin-top-level-await": "^1.4.1",
78
79
  "vite-plugin-wasm": "^3.3.0",
79
80
  "vitest": "^1.3.1",
80
- "@dxos/echo-signals": "0.4.8-main.8cb190e",
81
- "@dxos/config": "0.4.8-main.8cb190e",
82
- "@dxos/echo-typegen": "0.4.8-main.8cb190e",
83
- "@dxos/keyboard": "0.4.8-main.8cb190e",
84
- "@dxos/random": "0.4.8-main.8cb190e",
85
- "@dxos/react-ui": "0.4.8-main.8cb190e",
86
- "@dxos/storybook-utils": "0.4.8-main.8cb190e",
87
- "@dxos/react-client": "0.4.8-main.8cb190e"
81
+ "@dxos/config": "0.4.8-main.932d824",
82
+ "@dxos/echo-signals": "0.4.8-main.932d824",
83
+ "@dxos/echo-typegen": "0.4.8-main.932d824",
84
+ "@dxos/keyboard": "0.4.8-main.932d824",
85
+ "@dxos/random": "0.4.8-main.932d824",
86
+ "@dxos/react-ui": "0.4.8-main.932d824",
87
+ "@dxos/storybook-utils": "0.4.8-main.932d824",
88
+ "@dxos/react-client": "0.4.8-main.932d824"
88
89
  },
89
90
  "peerDependencies": {
90
91
  "@phosphor-icons/react": "^2.0.5",
91
92
  "react": "^18.2.0",
92
93
  "react-dom": "^18.2.0",
93
- "@dxos/react-client": "0.4.8-main.8cb190e"
94
+ "@dxos/react-client": "0.4.8-main.932d824"
94
95
  },
95
96
  "publishConfig": {
96
97
  "access": "public"
@@ -20,6 +20,7 @@ import { withTheme } from '@dxos/storybook-utils';
20
20
 
21
21
  import { TextEditor, type TextEditorProps } from './TextEditor';
22
22
  import {
23
+ EditorModes,
23
24
  annotations,
24
25
  autocomplete,
25
26
  blast,
@@ -37,14 +38,15 @@ import {
37
38
  linkTooltip,
38
39
  listener,
39
40
  mention,
40
- useComments,
41
+ state,
41
42
  table,
42
43
  typewriter,
43
- EditorModes,
44
+ useComments,
44
45
  type CommandAction,
45
46
  type CommandOptions,
46
47
  type Comment,
47
48
  type CommentsOptions,
49
+ type SelectionState,
48
50
  } from '../../extensions';
49
51
  import { useDocAccessor } from '../../hooks';
50
52
  import translations from '../../translations';
@@ -241,7 +243,7 @@ type StoryProps = {
241
243
  } & Pick<TextEditorProps, 'extensions'>;
242
244
 
243
245
  const Story = ({
244
- id = 'test',
246
+ id = 'editor-' + PublicKey.random().toHex().slice(0, 8),
245
247
  text,
246
248
  comments,
247
249
  extensions: _extensions = [],
@@ -325,8 +327,18 @@ export const Empty = {
325
327
  render: () => <Story />,
326
328
  };
327
329
 
330
+ const global = new Map<string, SelectionState>();
331
+
328
332
  export const Scrolling = {
329
- render: () => <Story text={str('# Large Document', '', large)} />,
333
+ render: () => (
334
+ <Story
335
+ text={str('# Large Document', '', large)}
336
+ extensions={state({
337
+ setState: (id, state) => global.set(id, state),
338
+ getState: (id) => global.get(id),
339
+ })}
340
+ />
341
+ ),
330
342
  };
331
343
 
332
344
  export const ScrollingWithImages = {
@@ -18,7 +18,7 @@ import React, {
18
18
  import { log } from '@dxos/log';
19
19
  import { isNotFalsy } from '@dxos/util';
20
20
 
21
- import { documentId, editorMode } from '../../extensions';
21
+ import { documentId, editorMode, focusEvent } from '../../extensions';
22
22
  import { logChanges } from '../../util';
23
23
 
24
24
  export type CursorInfo = {
@@ -34,7 +34,7 @@ export type TextEditorProps = Pick<EditorStateConfig, 'doc' | 'selection' | 'ext
34
34
  id?: string;
35
35
  className?: string;
36
36
  autoFocus?: boolean;
37
- scrollTo?: StateEffect<any>; // TODO(burdon): Restore scroll position: scrollTo EditorView.scrollSnapshot().
37
+ scrollTo?: StateEffect<unknown>;
38
38
  moveToEndOfLine?: boolean;
39
39
  debug?: boolean;
40
40
  dataTestId?: string;
@@ -56,14 +56,14 @@ export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
56
56
  extensions,
57
57
  className,
58
58
  autoFocus,
59
- scrollTo = EditorView.scrollIntoView(0),
59
+ scrollTo = EditorView.scrollIntoView(0, { yMargin: 0 }),
60
60
  moveToEndOfLine,
61
61
  debug,
62
62
  dataTestId,
63
63
  },
64
64
  forwardedRef,
65
65
  ) => {
66
- // TODO(burdon): Increments by 2!
66
+ // NOTE: Increments by 2 in strict mode.
67
67
  const [instanceId] = useState(() => `text-editor-${++instanceCount}`);
68
68
 
69
69
  // TODO(burdon): Make tabster optional.
@@ -104,7 +104,7 @@ export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
104
104
  // Focus.
105
105
  EditorView.updateListener.of((update) => {
106
106
  update.transactions.forEach((transaction) => {
107
- if (transaction.isUserEvent('focus.container')) {
107
+ if (transaction.isUserEvent(focusEvent)) {
108
108
  rootRef.current?.focus();
109
109
  }
110
110
  });
@@ -122,6 +122,7 @@ export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
122
122
  state,
123
123
  parent: rootRef.current!,
124
124
  scrollTo,
125
+
125
126
  // NOTE: Uncomment to debug/monitor all transactions.
126
127
  // https://codemirror.net/docs/ref/#view.EditorView.dispatch
127
128
  dispatchTransactions: (trs, view) => {
@@ -132,12 +133,10 @@ export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
132
133
  },
133
134
  });
134
135
 
135
- setView(view);
136
-
137
- // Position cursor at end of line.
138
- if (moveToEndOfLine) {
136
+ // Position cursor at end of first line.
137
+ if (moveToEndOfLine && !(scrollTo || selection)) {
139
138
  const { to } = view.state.doc.lineAt(0);
140
- view?.dispatch({ selection: { anchor: to } });
139
+ view.dispatch({ selection: { anchor: to } });
141
140
  }
142
141
 
143
142
  // Remove tabster attribute (rely on custom keymap).
@@ -145,6 +144,8 @@ export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
145
144
  rootRef.current?.removeAttribute('data-tabster');
146
145
  }
147
146
 
147
+ setView(view);
148
+
148
149
  return () => {
149
150
  view?.destroy();
150
151
  setView(null);
@@ -73,10 +73,11 @@ const Story: FC<{ content: string }> = ({ content }) => {
73
73
  <div role='none' className='fixed inset-0 flex flex-col'>
74
74
  <Toolbar.Root onAction={handleAction} state={formattingState} classNames={textBlockWidth}>
75
75
  <Toolbar.Markdown />
76
+ <Toolbar.Custom onUpload={async (file) => ({ url: file.name })} />
76
77
  <Toolbar.Separator />
77
- <Toolbar.Extended />
78
+ <Toolbar.Actions />
78
79
  </Toolbar.Root>
79
- <div role='textbox' ref={parentRef} className={textBlockWidth} />;
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 };
@@ -18,5 +18,6 @@ export * from './listener';
18
18
  export * from './markdown';
19
19
  export * from './mention';
20
20
  export * from './modes';
21
+ export * from './state';
21
22
  export * from './types';
22
23
  export * from './typewriter';
@@ -9,20 +9,20 @@ import { expect } from 'chai';
9
9
  import { describe, test } from '@dxos/test';
10
10
 
11
11
  import {
12
- setHeading,
12
+ addBlockquote,
13
+ addCodeblock,
14
+ addLink,
15
+ addList,
13
16
  addStyle,
17
+ getFormatting,
14
18
  removeStyle,
15
- addLink,
16
19
  removeLink,
17
- addList,
18
20
  removeList,
19
- addBlockquote,
20
21
  removeBlockquote,
21
- addCodeblock,
22
22
  removeCodeblock,
23
+ setHeading,
23
24
  Inline,
24
25
  List,
25
- getFormatting,
26
26
  type Formatting,
27
27
  } from './formatting';
28
28
 
@@ -204,22 +204,22 @@ describe('removeStyle', () => {
204
204
  });
205
205
 
206
206
  describe('addLink', () => {
207
- testCommand('adds a link', '{}', addLink, '[]({})');
207
+ testCommand('adds a link', '{}', addLink(), '[]({})');
208
208
 
209
- testCommand('adds a link around text', 'hello {world}', addLink, 'hello [world]({})');
209
+ testCommand('adds a link around text', 'hello {world}', addLink(), 'hello [world]({})');
210
210
 
211
- testCommand('clears existing links', '[hello {world}](foo)', addLink, 'hello [world]({})');
211
+ testCommand('clears existing links', '[hello {world}](foo)', addLink(), 'hello [world]({})');
212
212
 
213
- testCommand('does nothing across blocks', '{one\n\ntwo}', addLink, null);
213
+ testCommand('does nothing across blocks', '{one\n\ntwo}', addLink(), null);
214
214
 
215
- testCommand('does nothing in code blocks', '```\n{one}\n```', addLink, null);
215
+ testCommand('does nothing in code blocks', '```\n{one}\n```', addLink(), null);
216
216
 
217
- testCommand('patches up overlapping styles before', '*foo {bar* baz}', addLink, '*foo* [*bar* baz]({})');
217
+ testCommand('patches up overlapping styles before', '*foo {bar* baz}', addLink(), '*foo* [*bar* baz]({})');
218
218
 
219
219
  testCommand(
220
220
  'patches up overlapping styles after',
221
221
  'one {two ~~three} four~~',
222
- addLink,
222
+ addLink(),
223
223
  'one [two ~~three~~]({}) ~~four~~',
224
224
  );
225
225
  });
@@ -470,90 +470,94 @@ export const removeLink: StateCommand = ({ state, dispatch }) => {
470
470
  };
471
471
 
472
472
  // Add link markup around the selection
473
- export const addLink: StateCommand = ({ state, dispatch }) => {
474
- const changes = state.changeByRange((range) => {
475
- let { from, to } = range;
476
- const cutStyles: SyntaxNode[] = [];
477
- let okay: boolean | null = null;
478
- // Check whether this range is in a position where a link makes sense
479
- syntaxTree(state).iterate({
480
- from,
481
- to,
482
- enter: (node) => {
483
- if (Object.hasOwn(Textblocks, node.name)) {
484
- // If the selection spans multiple textblocks or is in a
485
- // code block, abort
486
- okay =
487
- Textblocks[node.name] !== 'codeblock' &&
488
- from >= blockContentStart(node) &&
489
- to <= blockContentEnd(node, state.doc);
490
- } else if (Object.hasOwn(InlineMarker, node.name)) {
491
- // Look for inline styles that partially overlap the range.
492
- // Expand the range over them if they start directly
493
- // outside, otherwise mark them for later
494
- const sNode = node.node;
495
- if (node.from < from && node.to <= to) {
496
- if (sNode.firstChild!.to === from) {
497
- from = node.from;
498
- } else {
499
- cutStyles.push(sNode);
500
- }
501
- } else if (node.from >= from && node.to > to) {
502
- if (sNode.lastChild!.from === to) {
503
- to = node.to;
504
- } else {
505
- cutStyles.push(sNode);
473
+ export const addLink =
474
+ ({ url, image }: { url?: string; image?: boolean } = {}): StateCommand =>
475
+ ({ state, dispatch }) => {
476
+ const changes = state.changeByRange((range) => {
477
+ let { from, to } = range;
478
+ const cutStyles: SyntaxNode[] = [];
479
+ let okay: boolean | null = null;
480
+ // Check whether this range is in a position where a link makes sense.
481
+ syntaxTree(state).iterate({
482
+ from,
483
+ to,
484
+ enter: (node) => {
485
+ if (Object.hasOwn(Textblocks, node.name)) {
486
+ // If the selection spans multiple textblocks or is in a code block, abort.
487
+ okay =
488
+ Textblocks[node.name] !== 'codeblock' &&
489
+ from >= blockContentStart(node) &&
490
+ to <= blockContentEnd(node, state.doc);
491
+ } else if (Object.hasOwn(InlineMarker, node.name)) {
492
+ // Look for inline styles that partially overlap the range.
493
+ // Expand the range over them if they start directly outside, otherwise mark them for later.
494
+ const sNode = node.node;
495
+ if (node.from < from && node.to <= to) {
496
+ if (sNode.firstChild!.to === from) {
497
+ from = node.from;
498
+ } else {
499
+ cutStyles.push(sNode);
500
+ }
501
+ } else if (node.from >= from && node.to > to) {
502
+ if (sNode.lastChild!.from === to) {
503
+ to = node.to;
504
+ } else {
505
+ cutStyles.push(sNode);
506
+ }
506
507
  }
507
508
  }
508
- }
509
- },
510
- });
509
+ },
510
+ });
511
511
 
512
- if (okay === null) {
513
- // No textblock found around selection. Check if the rest of the line is empty.
514
- const line = state.doc.lineAt(from);
515
- okay = to <= line.to && !/\S/.test(line.text.slice(from - line.from));
516
- }
517
- if (!okay) {
518
- return { range };
519
- }
512
+ if (okay === null) {
513
+ // No textblock found around selection. Check if the rest of the line is empty.
514
+ const line = state.doc.lineAt(from);
515
+ okay = to <= line.to && !/\S/.test(line.text.slice(from - line.from));
516
+ }
517
+ if (!okay) {
518
+ return { range };
519
+ }
520
520
 
521
- const changes: ChangeSpec[] = [];
522
- // Some changes must be moved to end of change array so that they are applied in the right order
523
- const changesAfter: ChangeSpec[] = [];
524
- // Clear existing links.
525
- removeLinkInner(from, to, changesAfter, state);
526
- let cursorOffset = 1;
527
- // Close and reopen inline styles that partially overlap the range.
528
- for (const style of cutStyles) {
529
- const type = InlineMarker[style.name];
530
- const mark = inlineMarkerText(type);
531
- if (style.from < from) {
532
- // Extends before.
533
- changes.push({ from: skipSpaces(from, state.doc, -1), insert: mark });
534
- changesAfter.push({ from: skipSpaces(from, state.doc, 1, to), insert: mark });
535
- } else {
536
- changes.push({ from: skipSpaces(to, state.doc, -1, from), insert: mark });
537
- const after = skipSpaces(to, state.doc, 1);
538
- if (after === to) {
539
- cursorOffset += mark.length;
521
+ const changes: ChangeSpec[] = [];
522
+ // Some changes must be moved to end of change array so that they are applied in the right order
523
+ const changesAfter: ChangeSpec[] = [];
524
+ // Clear existing links.
525
+ removeLinkInner(from, to, changesAfter, state);
526
+ let cursorOffset = 1;
527
+ // Close and reopen inline styles that partially overlap the range.
528
+ for (const style of cutStyles) {
529
+ const type = InlineMarker[style.name];
530
+ const mark = inlineMarkerText(type);
531
+ if (style.from < from) {
532
+ // Extends before.
533
+ changes.push({ from: skipSpaces(from, state.doc, -1), insert: mark });
534
+ changesAfter.push({ from: skipSpaces(from, state.doc, 1, to), insert: mark });
535
+ } else {
536
+ changes.push({ from: skipSpaces(to, state.doc, -1, from), insert: mark });
537
+ const after = skipSpaces(to, state.doc, 1);
538
+ if (after === to) {
539
+ cursorOffset += mark.length;
540
+ }
541
+ changesAfter.push({ from: after, insert: mark });
540
542
  }
541
- changesAfter.push({ from: after, insert: mark });
542
543
  }
544
+
545
+ // Add the link markup.
546
+ changes.push({ from, insert: image ? '![' : '[' }, { from: to, insert: `](${url ?? ''})` });
547
+ const changeSet = state.changes(changes.concat(changesAfter));
548
+ // Put the cursor between the title or parenthesis.
549
+ return {
550
+ changes: changeSet,
551
+ range: EditorSelection.cursor(changeSet.mapPos(to, 1) - cursorOffset - (url ? url.length + 2 : 0)),
552
+ };
553
+ });
554
+ if (changes.changes.empty) {
555
+ return false;
543
556
  }
544
- // Add the link markup.
545
- changes.push({ from, insert: '[' }, { from: to, insert: ']()' });
546
- const changeSet = state.changes(changes.concat(changesAfter));
547
- // Put the cursor between the parenthesis.
548
- return { changes: changeSet, range: EditorSelection.cursor(changeSet.mapPos(to, 1) - cursorOffset) };
549
- });
550
- if (changes.changes.empty) {
551
- return false;
552
- }
553
557
 
554
- dispatch(state.update(changes, { userEvent: 'format.link.add', scrollIntoView: true }));
555
- return true;
556
- };
558
+ dispatch(state.update(changes, { userEvent: 'format.link.add', scrollIntoView: true }));
559
+ return true;
560
+ };
557
561
 
558
562
  //
559
563
  // Lists