@squiz/formatted-text-editor 1.21.1-alpha.9 → 1.22.0

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 (123) hide show
  1. package/demo/App.tsx +38 -10
  2. package/demo/index.scss +2 -7
  3. package/jest.config.ts +0 -2
  4. package/lib/Editor/Editor.js +45 -7
  5. package/lib/Editor/EditorContext.d.ts +15 -0
  6. package/lib/Editor/EditorContext.js +15 -0
  7. package/lib/EditorToolbar/FloatingToolbar.js +11 -5
  8. package/lib/EditorToolbar/Tools/Image/Form/ImageForm.d.ts +9 -8
  9. package/lib/EditorToolbar/Tools/Image/Form/ImageForm.js +91 -23
  10. package/lib/EditorToolbar/Tools/Image/ImageButton.d.ts +4 -1
  11. package/lib/EditorToolbar/Tools/Image/ImageButton.js +22 -14
  12. package/lib/EditorToolbar/Tools/Image/ImageModal.js +9 -5
  13. package/lib/EditorToolbar/Tools/Link/Form/LinkForm.d.ts +14 -5
  14. package/lib/EditorToolbar/Tools/Link/Form/LinkForm.js +66 -14
  15. package/lib/EditorToolbar/Tools/Link/LinkButton.js +21 -13
  16. package/lib/EditorToolbar/Tools/Link/LinkModal.js +12 -5
  17. package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.js +1 -8
  18. package/lib/Extensions/CommandsExtension/CommandsExtension.d.ts +20 -0
  19. package/lib/Extensions/CommandsExtension/CommandsExtension.js +52 -0
  20. package/lib/Extensions/Extensions.d.ts +11 -1
  21. package/lib/Extensions/Extensions.js +42 -20
  22. package/lib/Extensions/ImageExtension/AssetImageExtension.d.ts +17 -0
  23. package/lib/Extensions/ImageExtension/AssetImageExtension.js +92 -0
  24. package/lib/Extensions/ImageExtension/ImageExtension.d.ts +4 -0
  25. package/lib/Extensions/ImageExtension/ImageExtension.js +11 -0
  26. package/lib/Extensions/LinkExtension/AssetLinkExtension.d.ts +26 -0
  27. package/lib/Extensions/LinkExtension/AssetLinkExtension.js +102 -0
  28. package/lib/Extensions/LinkExtension/LinkExtension.d.ts +19 -12
  29. package/lib/Extensions/LinkExtension/LinkExtension.js +56 -66
  30. package/lib/Extensions/LinkExtension/common.d.ts +7 -0
  31. package/lib/Extensions/LinkExtension/common.js +14 -0
  32. package/lib/Extensions/PreformattedExtension/PreformattedExtension.js +6 -2
  33. package/lib/hooks/index.d.ts +1 -0
  34. package/lib/hooks/index.js +1 -0
  35. package/lib/hooks/useExpandedSelection.d.ts +23 -0
  36. package/lib/hooks/useExpandedSelection.js +37 -0
  37. package/lib/index.css +58 -26
  38. package/lib/index.d.ts +3 -2
  39. package/lib/index.js +5 -3
  40. package/lib/types.d.ts +3 -0
  41. package/lib/types.js +2 -0
  42. package/lib/ui/Button/Button.d.ts +2 -1
  43. package/lib/ui/Button/Button.js +4 -5
  44. package/lib/ui/Fields/Input/Input.d.ts +1 -0
  45. package/lib/ui/Fields/Input/Input.js +9 -3
  46. package/lib/ui/Modal/Modal.js +5 -3
  47. package/lib/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.d.ts +1 -2
  48. package/lib/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.js +118 -104
  49. package/lib/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.js +102 -69
  50. package/lib/utils/resolveMatrixAssetUrl.d.ts +1 -0
  51. package/lib/utils/resolveMatrixAssetUrl.js +10 -0
  52. package/lib/utils/undefinedIfEmpty.d.ts +1 -0
  53. package/lib/utils/undefinedIfEmpty.js +7 -0
  54. package/package.json +8 -4
  55. package/src/Editor/Editor.spec.tsx +78 -18
  56. package/src/Editor/Editor.tsx +28 -9
  57. package/src/Editor/EditorContext.spec.tsx +26 -0
  58. package/src/Editor/EditorContext.ts +26 -0
  59. package/src/Editor/_editor.scss +20 -4
  60. package/src/EditorToolbar/FloatingToolbar.spec.tsx +26 -7
  61. package/src/EditorToolbar/FloatingToolbar.tsx +15 -6
  62. package/src/EditorToolbar/Tools/Image/Form/ImageForm.spec.tsx +81 -6
  63. package/src/EditorToolbar/Tools/Image/Form/ImageForm.tsx +167 -47
  64. package/src/EditorToolbar/Tools/Image/ImageButton.spec.tsx +250 -2
  65. package/src/EditorToolbar/Tools/Image/ImageButton.tsx +29 -16
  66. package/src/EditorToolbar/Tools/Image/ImageModal.spec.tsx +59 -20
  67. package/src/EditorToolbar/Tools/Image/ImageModal.tsx +12 -10
  68. package/src/EditorToolbar/Tools/Link/Form/LinkForm.spec.tsx +37 -9
  69. package/src/EditorToolbar/Tools/Link/Form/LinkForm.tsx +96 -26
  70. package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +137 -26
  71. package/src/EditorToolbar/Tools/Link/LinkButton.tsx +28 -19
  72. package/src/EditorToolbar/Tools/Link/LinkModal.tsx +13 -6
  73. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.spec.tsx +27 -26
  74. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.tsx +2 -10
  75. package/src/EditorToolbar/Tools/Undo/UndoButton.spec.tsx +22 -1
  76. package/src/EditorToolbar/_floating-toolbar.scss +4 -5
  77. package/src/EditorToolbar/_toolbar.scss +1 -1
  78. package/src/Extensions/CommandsExtension/CommandsExtension.ts +54 -0
  79. package/src/Extensions/Extensions.ts +42 -19
  80. package/src/Extensions/ImageExtension/AssetImageExtension.spec.ts +76 -0
  81. package/src/Extensions/ImageExtension/AssetImageExtension.ts +111 -0
  82. package/src/Extensions/ImageExtension/ImageExtension.ts +17 -1
  83. package/src/Extensions/LinkExtension/AssetLinkExtension.spec.ts +104 -0
  84. package/src/Extensions/LinkExtension/AssetLinkExtension.ts +128 -0
  85. package/src/Extensions/LinkExtension/LinkExtension.spec.ts +68 -0
  86. package/src/Extensions/LinkExtension/LinkExtension.ts +71 -85
  87. package/src/Extensions/LinkExtension/common.ts +10 -0
  88. package/src/Extensions/PreformattedExtension/PreformattedExtension.spec.ts +41 -0
  89. package/src/Extensions/PreformattedExtension/PreformattedExtension.ts +6 -2
  90. package/src/hooks/index.ts +1 -0
  91. package/src/hooks/useExpandedSelection.ts +44 -0
  92. package/src/index.ts +3 -2
  93. package/src/types.ts +5 -0
  94. package/src/ui/Button/Button.tsx +10 -6
  95. package/src/ui/Button/_button.scss +1 -1
  96. package/src/ui/Fields/Input/Input.spec.tsx +7 -1
  97. package/src/ui/Fields/Input/Input.tsx +23 -4
  98. package/src/ui/Modal/Modal.spec.tsx +15 -0
  99. package/src/ui/Modal/Modal.tsx +8 -4
  100. package/src/ui/ToolbarDropdown/_toolbar-dropdown.scss +1 -1
  101. package/src/ui/_forms.scss +14 -0
  102. package/src/utils/converters/mocks/squizNodeJson.mock.ts +196 -0
  103. package/src/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.spec.ts +41 -6
  104. package/src/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.ts +132 -111
  105. package/src/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.spec.ts +68 -34
  106. package/src/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.ts +115 -79
  107. package/src/utils/resolveMatrixAssetUrl.spec.ts +26 -0
  108. package/src/utils/resolveMatrixAssetUrl.ts +7 -0
  109. package/src/utils/undefinedIfEmpty.spec.ts +12 -0
  110. package/src/utils/undefinedIfEmpty.ts +3 -0
  111. package/tailwind.config.cjs +3 -0
  112. package/tests/renderWithEditor.tsx +26 -13
  113. package/tsconfig.json +1 -1
  114. package/lib/FormattedTextEditor.d.ts +0 -2
  115. package/lib/FormattedTextEditor.js +0 -7
  116. package/lib/utils/converters/validNodeTypes.d.ts +0 -2
  117. package/lib/utils/converters/validNodeTypes.js +0 -21
  118. package/src/Editor/Editor.mock.tsx +0 -43
  119. package/src/FormattedTextEditor.spec.tsx +0 -10
  120. package/src/FormattedTextEditor.tsx +0 -3
  121. package/src/utils/converters/validNodeTypes.spec.ts +0 -33
  122. package/src/utils/converters/validNodeTypes.ts +0 -21
  123. /package/tests/{select.tsx → select.ts} +0 -0
@@ -1,48 +1,118 @@
1
- import React, { ReactElement } from 'react';
1
+ import React, { ReactElement, useContext } from 'react';
2
+ import clsx from 'clsx';
3
+ import { SubmitHandler, useForm } from 'react-hook-form';
4
+ import { FromToProps } from 'remirror';
2
5
  import { Input } from '../../../../ui/Fields/Input/Input';
3
6
  import { Select, SelectOptions } from '../../../../ui/Fields/Select/Select';
4
- import { SubmitHandler, useForm } from 'react-hook-form';
5
- import { UpdateLinkOptions } from '../../../../Extensions/LinkExtension/LinkExtension';
7
+ import { UpdateLinkProps } from '../../../../Extensions/LinkExtension/LinkExtension';
8
+ import { UpdateAssetLinkProps } from '../../../../Extensions/LinkExtension/AssetLinkExtension';
9
+ import { LinkTarget } from '../../../../Extensions/LinkExtension/common';
10
+ import { EditorContext } from '../../../../Editor/EditorContext';
11
+ import { MarkName } from '../../../../Extensions/Extensions';
12
+ import { DeepPartial } from '../../../../types';
6
13
 
7
- export type LinkFormData = Pick<UpdateLinkOptions, 'href' | 'target' | 'title' | 'text'>;
14
+ export type LinkFormData = {
15
+ linkType: MarkName;
16
+ text: string;
17
+ link: UpdateLinkProps['attrs'];
18
+ assetLink: UpdateAssetLinkProps['attrs'];
19
+ range: FromToProps;
20
+ };
8
21
 
9
22
  export type FormProps = {
10
- data: Partial<LinkFormData>;
23
+ data?: DeepPartial<LinkFormData>;
11
24
  onSubmit: SubmitHandler<LinkFormData>;
12
25
  };
13
26
 
14
- const selectOptions: SelectOptions = {
15
- _self: { label: 'Current window' },
16
- _blank: { label: 'New window' },
27
+ const linkTypeOptions: SelectOptions = {
28
+ [MarkName.Link]: { label: 'Link to URL' },
29
+ [MarkName.AssetLink]: { label: 'Link to asset' },
17
30
  };
18
31
 
19
- const LinkForm = ({ data, onSubmit }: FormProps): ReactElement => {
20
- const { register, handleSubmit, setValue } = useForm<LinkFormData>({
32
+ const targetOptions: SelectOptions = {
33
+ [LinkTarget.Self]: { label: 'Current window' },
34
+ [LinkTarget.Blank]: { label: 'New window' },
35
+ };
36
+
37
+ export const LinkForm = ({ data, onSubmit }: FormProps): ReactElement => {
38
+ const context = useContext(EditorContext);
39
+ const {
40
+ register,
41
+ handleSubmit,
42
+ setValue,
43
+ watch,
44
+ formState: { errors },
45
+ } = useForm<LinkFormData>({
21
46
  defaultValues: data,
22
47
  });
48
+ const linkType = watch('linkType') || MarkName.Link;
23
49
 
24
50
  return (
25
51
  <form className="squiz-fte-form" onSubmit={handleSubmit(onSubmit)}>
26
52
  <div className="squiz-fte-form-group mb-2">
27
- <Input label="URL" {...register('href')} />
28
- </div>
29
- <div className="squiz-fte-form-group mb-2">
30
- <Input label="Text" {...register('text')} />
31
- </div>
32
- <div className="squiz-fte-form-group mb-2">
33
- <Input label="Title" {...register('title')} />
34
- </div>
35
- <div className="squiz-fte-form-group mb-0">
36
53
  <Select
37
- name="target"
38
- label="Target"
39
- value={data.target || '_self'}
40
- options={selectOptions}
41
- onChange={(value) => setValue('target', value)}
54
+ name="linkType"
55
+ label="Type"
56
+ value={linkType}
57
+ options={linkTypeOptions}
58
+ onChange={(value) => setValue('linkType', value as MarkName)}
42
59
  />
43
60
  </div>
61
+ {/* Arbitrary link form fields */}
62
+ {linkType === MarkName.Link && (
63
+ <>
64
+ <div className={clsx('squiz-fte-form-group mb-2')}>
65
+ <Input label="URL" {...register('link.href')} />
66
+ </div>
67
+ <div className={clsx('squiz-fte-form-group mb-2')}>
68
+ <Input label="Text" {...register('text')} />
69
+ </div>
70
+ <div className={clsx('squiz-fte-form-group mb-2')}>
71
+ <Input label="Title" {...register('link.title')} />
72
+ </div>
73
+ <div className={clsx('squiz-fte-form-group mb-0')}>
74
+ <Select
75
+ name="link.target"
76
+ label="Target"
77
+ value={data?.link?.target || '_self'}
78
+ options={targetOptions}
79
+ onChange={(value) => setValue('link.target', value as LinkTarget)}
80
+ />
81
+ </div>
82
+ </>
83
+ )}
84
+ {/* Asset link form fields */}
85
+ {linkType === MarkName.AssetLink && (
86
+ <>
87
+ <div className={clsx('squiz-fte-form-group mb-2')}>
88
+ <Input
89
+ label="Asset ID"
90
+ error={errors?.assetLink?.matrixAssetId?.message}
91
+ {...register('assetLink.matrixAssetId', {
92
+ validate: {
93
+ isValidAsset: async (assetId: string | undefined) => {
94
+ if (assetId && !(await context.matrix.resolveMatrixAsset(assetId))) {
95
+ return 'Invalid asset ID';
96
+ }
97
+ },
98
+ },
99
+ })}
100
+ />
101
+ </div>
102
+ <div className={clsx('squiz-fte-form-group mb-2')}>
103
+ <Input label="Text" {...register('text')} />
104
+ </div>
105
+ <div className={clsx('squiz-fte-form-group mb-0')}>
106
+ <Select
107
+ name="assetLink.target"
108
+ label="Target"
109
+ value={data?.link?.target || '_self'}
110
+ options={targetOptions}
111
+ onChange={(value) => setValue('assetLink.target', value as LinkTarget)}
112
+ />
113
+ </div>
114
+ </>
115
+ )}
44
116
  </form>
45
117
  );
46
118
  };
47
-
48
- export default LinkForm;
@@ -37,7 +37,7 @@ describe('LinkButton', () => {
37
37
  marks: [
38
38
  {
39
39
  type: 'link',
40
- attrs: { auto: false, href: 'https://www.squiz.net/link-button', target: '_blank', title: 'Link title' },
40
+ attrs: { href: 'https://www.squiz.net/link-button', target: '_blank', title: 'Link title' },
41
41
  },
42
42
  ],
43
43
  },
@@ -58,6 +58,27 @@ describe('LinkButton', () => {
58
58
  await openModal();
59
59
  fireEvent.change(screen.getByLabelText('URL'), { target: { value: 'https://www.example.org/updated-link' } });
60
60
  fireEvent.change(screen.getByLabelText('Text'), { target: { value: 'Updated sample link' } });
61
+
62
+ // verify the content matches what was initially set prior to applying.
63
+ expect(getJsonContent()).toEqual({
64
+ type: 'paragraph',
65
+ attrs: expect.any(Object),
66
+ content: [
67
+ {
68
+ type: 'text',
69
+ text: 'Sample link',
70
+ marks: [
71
+ {
72
+ type: 'link',
73
+ attrs: { href: 'https://www.example.org/my-link', target: '_self', title: null },
74
+ },
75
+ ],
76
+ },
77
+ { type: 'text', text: ' with some other content.' },
78
+ ],
79
+ });
80
+
81
+ // apply the changes.
61
82
  fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
62
83
 
63
84
  await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
@@ -75,7 +96,7 @@ describe('LinkButton', () => {
75
96
  marks: [
76
97
  {
77
98
  type: 'link',
78
- attrs: { auto: false, href: 'https://www.example.org/updated-link', target: null, title: null },
99
+ attrs: { href: 'https://www.example.org/updated-link', target: '_self', title: null },
79
100
  },
80
101
  ],
81
102
  },
@@ -134,25 +155,7 @@ describe('LinkButton', () => {
134
155
  });
135
156
  });
136
157
 
137
- it.each([
138
- ['Link fully selected', 1, 12, 'Sample link'],
139
- ['Link partially selected', 2, 4, 'Sample link'],
140
- ['Link partially selected along with other content', 8, 15, 'Sample link wi'],
141
- ])(
142
- 'Expands selection when a link is partially selected - %s',
143
- async (description: string, from: number, to: number, expectedSelection: string) => {
144
- const { editor, getSelectedText } = await renderWithEditor(<LinkButton />, {
145
- content: '<a href="https://www.example.org/my-link">Sample link</a> <strong>with</strong> some other content.',
146
- });
147
-
148
- await act(() => editor.selectText({ from, to }));
149
- await openModal();
150
-
151
- expect(getSelectedText()).toBe(expectedSelection);
152
- },
153
- );
154
-
155
- it('Updates full selection when it is expanded from what was initially selected', async () => {
158
+ it('Updates unselected part of link when link is partially selected', async () => {
156
159
  const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
157
160
  content: '<a href="https://www.example.org/my-link">Sample link</a> <strong>with</strong> some other content.',
158
161
  });
@@ -179,7 +182,7 @@ describe('LinkButton', () => {
179
182
  marks: [
180
183
  {
181
184
  type: 'link',
182
- attrs: { auto: false, href: 'https://www.example.org/my-link', target: null, title: null },
185
+ attrs: { href: 'https://www.example.org/my-link', target: '_self', title: null },
183
186
  },
184
187
  ],
185
188
  },
@@ -189,7 +192,7 @@ describe('LinkButton', () => {
189
192
  marks: [
190
193
  {
191
194
  type: 'link',
192
- attrs: { auto: false, href: 'https://www.example.org/my-link', target: null, title: null },
195
+ attrs: { href: 'https://www.example.org/my-link', target: '_self', title: null },
193
196
  },
194
197
  { type: 'bold' },
195
198
  ],
@@ -200,7 +203,7 @@ describe('LinkButton', () => {
200
203
  marks: [
201
204
  {
202
205
  type: 'link',
203
- attrs: { auto: false, href: 'https://www.example.org/my-link', target: null, title: null },
206
+ attrs: { href: 'https://www.example.org/my-link', target: '_self', title: null },
204
207
  },
205
208
  ],
206
209
  },
@@ -237,7 +240,7 @@ describe('LinkButton', () => {
237
240
  marks: [
238
241
  {
239
242
  type: 'link',
240
- attrs: { auto: false, href: 'https://www.example.org/my-link', target: null, title: null },
243
+ attrs: { href: 'https://www.example.org/my-link', target: '_self', title: null },
241
244
  },
242
245
  ],
243
246
  },
@@ -252,7 +255,7 @@ describe('LinkButton', () => {
252
255
  });
253
256
 
254
257
  // jump to the middle of the link.
255
- await act(() => editor.selectText(5));
258
+ await act(() => editor.selectText({ from: 1, to: 12 }));
256
259
 
257
260
  // press the keyboard shortcut.
258
261
  fireEvent.keyDown(elements.editor, { key: 'k', ctrlKey: true });
@@ -274,4 +277,112 @@ describe('LinkButton', () => {
274
277
  fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
275
278
  expect(modalHeading).not.toBeInTheDocument();
276
279
  });
280
+
281
+ it('Add a new asset link', async () => {
282
+ const matrixIdentifier = 'matrix-api-identifier';
283
+ const matrixDomain = 'https://my-matrix.squiz.net';
284
+ const { getJsonContent } = await renderWithEditor(<LinkButton />, {
285
+ context: {
286
+ matrix: {
287
+ matrixIdentifier,
288
+ matrixDomain,
289
+ resolveMatrixAsset: () => Promise.resolve({ id: '100', type: 'physical_file' }),
290
+ },
291
+ },
292
+ });
293
+
294
+ await openModal();
295
+ select(screen.getByLabelText('Type'), 'Link to asset');
296
+ fireEvent.change(screen.getByLabelText('Asset ID'), { target: { value: '123' } });
297
+ fireEvent.change(screen.getByLabelText('Text'), { target: { value: 'Link text' } });
298
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
299
+
300
+ await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
301
+
302
+ expect(getJsonContent()).toEqual({
303
+ type: 'paragraph',
304
+ attrs: expect.any(Object),
305
+ content: [
306
+ {
307
+ type: 'text',
308
+ text: 'Link text',
309
+ marks: [
310
+ {
311
+ type: 'assetLink',
312
+ attrs: { matrixAssetId: '123', target: '_self', matrixDomain, matrixIdentifier },
313
+ },
314
+ ],
315
+ },
316
+ ],
317
+ });
318
+ });
319
+
320
+ it('Updates an existing link to be an asset link', async () => {
321
+ const matrixIdentifier = 'matrix-api-identifier';
322
+ const matrixDomain = 'https://my-matrix.squiz.net';
323
+ const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
324
+ content:
325
+ '<a href="https://www.example.org/my-link">Sample link</a> with ' +
326
+ '<a href="https://www.example.org/another-link">another link</a>',
327
+ context: {
328
+ matrix: {
329
+ matrixIdentifier,
330
+ matrixDomain,
331
+ resolveMatrixAsset: () => Promise.resolve({ id: '100', type: 'physical_file' }),
332
+ },
333
+ },
334
+ });
335
+
336
+ await act(() => editor.selectText(5));
337
+
338
+ await openModal();
339
+ select(screen.getByLabelText('Type'), 'Link to asset');
340
+ fireEvent.change(screen.getByLabelText('Asset ID'), { target: { value: '123' } });
341
+ select(screen.getByLabelText('Target'), 'New window');
342
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
343
+
344
+ await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
345
+
346
+ expect(getJsonContent()).toEqual({
347
+ type: 'paragraph',
348
+ attrs: expect.any(Object),
349
+ content: [
350
+ {
351
+ type: 'text',
352
+ text: 'Sample link',
353
+ marks: [
354
+ {
355
+ type: 'assetLink',
356
+ attrs: { matrixAssetId: '123', target: '_blank', matrixDomain, matrixIdentifier },
357
+ },
358
+ ],
359
+ },
360
+ { type: 'text', text: ' with ' },
361
+ {
362
+ type: 'text',
363
+ text: 'another link',
364
+ marks: [
365
+ {
366
+ type: 'link',
367
+ attrs: { href: 'https://www.example.org/another-link', target: '_self', title: null },
368
+ },
369
+ ],
370
+ },
371
+ ],
372
+ });
373
+ });
374
+
375
+ it('Shows an error if an invalid asset ID is provided', async () => {
376
+ const resolveMatrixAsset = jest.fn(() => Promise.resolve(null));
377
+
378
+ await renderWithEditor(<LinkButton />, { context: { matrix: { resolveMatrixAsset } } });
379
+
380
+ await openModal();
381
+ select(screen.getByLabelText('Type'), 'Link to asset');
382
+ fireEvent.change(screen.getByLabelText('Asset ID'), { target: { value: 'invalid-asset-id' } });
383
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Apply' })));
384
+
385
+ expect(screen.getByText('Invalid asset ID')).toBeInTheDocument();
386
+ expect(resolveMatrixAsset).toHaveBeenCalledWith('invalid-asset-id');
387
+ });
277
388
  });
@@ -3,8 +3,12 @@ import InsertLinkRoundedIcon from '@mui/icons-material/InsertLinkRounded';
3
3
  import LinkModal from './LinkModal';
4
4
  import { LinkFormData } from './Form/LinkForm';
5
5
  import Button from '../../../ui/Button/Button';
6
- import { useActive, useCommands, useExtensionEvent } from '@remirror/react';
6
+ import { useActive, useCommands, useKeymap } from '@remirror/react';
7
7
  import { LinkExtension } from '../../../Extensions/LinkExtension/LinkExtension';
8
+ import { CommandsExtension } from '../../../Extensions/CommandsExtension/CommandsExtension';
9
+ import { AssetLinkExtension } from '../../../Extensions/LinkExtension/AssetLinkExtension';
10
+ import { MarkName } from '../../../Extensions/Extensions';
11
+ import { ImageExtension } from '../../../Extensions/ImageExtension/ImageExtension';
8
12
 
9
13
  type LinkButtonProps = {
10
14
  inPopover?: boolean;
@@ -12,41 +16,46 @@ type LinkButtonProps = {
12
16
 
13
17
  const LinkButton = ({ inPopover = false }: LinkButtonProps) => {
14
18
  const [showModal, setShowModal] = useState(false);
15
- const { selectLink, updateLink } = useCommands<LinkExtension>();
16
- const active = useActive<LinkExtension>();
19
+ const { updateLink, updateAssetLink } = useCommands<AssetLinkExtension | LinkExtension | CommandsExtension>();
20
+ const active = useActive<LinkExtension | AssetLinkExtension | ImageExtension>();
21
+ // If the image tool is active, disable the link tool as they shouldn't work at the same time
22
+ const disabled = active.image();
17
23
  const handleClick = () => {
18
24
  if (!showModal) {
19
- selectLink();
20
-
21
- // form element are uncontrolled, let the event loop run to
22
- // update the selected text in state before showing the modal.
23
- requestAnimationFrame(() => {
24
- setShowModal(true);
25
- });
25
+ setShowModal(true);
26
26
  }
27
27
  };
28
+ const handleShortcut = useCallback(() => {
29
+ handleClick();
30
+ // Prevent other key handlers being run
31
+ return true;
32
+ }, []);
33
+
28
34
  const handleSubmit = (data: LinkFormData) => {
29
- updateLink(data);
35
+ if (data.linkType === MarkName.AssetLink) {
36
+ updateAssetLink({ text: data.text, attrs: data.assetLink, range: data.range });
37
+ } else {
38
+ updateLink({ text: data.text, attrs: data.link, range: data.range });
39
+ }
40
+
30
41
  setShowModal(false);
31
42
  };
32
43
 
44
+ // when Ctrl+K is pressed show the modal, only registered in the toolbar button instance to avoid the key press
45
+ // being double handled.
33
46
  if (!inPopover) {
34
- // when Ctrl+K is pressed show the modal, only registered in the toolbar button instance to avoid the key press
35
- // being double handled.
36
- useExtensionEvent(
37
- LinkExtension,
38
- 'onShortcut',
39
- useCallback(() => handleClick(), []),
40
- );
47
+ // disable the shortcut if the button is disabled
48
+ useKeymap('Mod-k', disabled ? () => true : handleShortcut);
41
49
  }
42
50
 
43
51
  return (
44
52
  <>
45
53
  <Button
46
54
  handleOnClick={handleClick}
47
- isActive={active.link()}
55
+ isActive={active.link() || active.assetLink()}
48
56
  icon={<InsertLinkRoundedIcon />}
49
57
  label="Link (cmd+K)"
58
+ isDisabled={disabled}
50
59
  />
51
60
  {showModal && <LinkModal onCancel={() => setShowModal(false)} onSubmit={handleSubmit} />}
52
61
  </>
@@ -1,9 +1,10 @@
1
- import { getMarkRanges } from 'remirror';
2
- import LinkForm, { LinkFormData } from './Form/LinkForm';
1
+ import { LinkForm, LinkFormData } from './Form/LinkForm';
3
2
  import React from 'react';
4
- import { useRemirrorContext, useCurrentSelection } from '@remirror/react';
3
+ import { useRemirrorContext } from '@remirror/react';
5
4
  import FormModal from '../../../ui/Modal/FormModal';
6
5
  import { SubmitHandler } from 'react-hook-form';
6
+ import { useExpandedSelection } from '../../../hooks';
7
+ import { MarkName } from '../../../Extensions/Extensions';
7
8
 
8
9
  type LinkModalProps = {
9
10
  onCancel: () => void;
@@ -15,13 +16,19 @@ const LinkModal = ({ onCancel, onSubmit }: LinkModalProps) => {
15
16
  helpers,
16
17
  view: { state },
17
18
  } = useRemirrorContext();
18
- const selection = useCurrentSelection();
19
- const currentLink = getMarkRanges(selection, 'link')[0];
19
+ const { selection, marks } = useExpandedSelection([MarkName.Link, MarkName.AssetLink]);
20
20
  const selectedText = helpers.getTextBetween(selection.from, selection.to, state.doc);
21
+ const data = {
22
+ linkType: marks[0]?.type?.name === MarkName.AssetLink ? MarkName.AssetLink : MarkName.Link,
23
+ text: selectedText,
24
+ link: { ...marks.find((mark) => mark.type.name === 'link')?.attrs },
25
+ assetLink: { ...marks.find((mark) => mark.type.name === MarkName.AssetLink)?.attrs },
26
+ range: { from: selection.from, to: selection.to },
27
+ };
21
28
 
22
29
  return (
23
30
  <FormModal title="Link" onCancel={onCancel}>
24
- <LinkForm data={{ ...currentLink?.mark.attrs, text: selectedText }} onSubmit={onSubmit} />
31
+ <LinkForm data={data} onSubmit={onSubmit} />
25
32
  </FormModal>
26
33
  );
27
34
  };
@@ -7,40 +7,41 @@ import RemoveLinkButton from './RemoveLinkButton';
7
7
  describe('RemoveLinkButton', () => {
8
8
  it('Removes a link', async () => {
9
9
  const { editor, getJsonContent } = await renderWithEditor(<RemoveLinkButton />, {
10
- content:
11
- '<a href="https://www.example.org/my-link">Sample link</a> with some other content and ' +
12
- '<a href="https://www.example.org/another-link">another link</a>.',
10
+ context: { matrix: { matrixDomain: 'my-matrix.squiz.net' } },
11
+ content: {
12
+ type: 'doc',
13
+ content: [
14
+ {
15
+ type: 'paragraph',
16
+ content: [
17
+ {
18
+ type: 'text',
19
+ text: 'Sample link',
20
+ marks: [{ type: 'assetLink', attrs: { matrixAssetId: '123', target: '_blank' } }],
21
+ },
22
+ { type: 'text', text: ' with ' },
23
+ {
24
+ type: 'text',
25
+ text: 'another link',
26
+ marks: [{ type: 'link', attrs: { href: 'https://www.example.org/another-link', target: '_self' } }],
27
+ },
28
+ ],
29
+ },
30
+ ],
31
+ },
13
32
  });
14
33
 
15
- // move the cursor to inside of the link.
16
- await act(() => editor.selectText(5));
34
+ // select all of the text.
35
+ await act(() => editor.selectText('all'));
17
36
 
18
- // remove the link.
37
+ // remove the links.
19
38
  fireEvent.click(screen.getByRole('button', { name: 'Remove link' }));
20
39
 
21
- // make sure the link has been removed.
40
+ // make sure both types of link have been removed.
22
41
  expect(getJsonContent()).toEqual({
23
42
  type: 'paragraph',
24
43
  attrs: expect.any(Object),
25
- content: [
26
- { type: 'text', text: 'Sample link with some other content and ' },
27
- {
28
- type: 'text',
29
- text: 'another link',
30
- marks: [
31
- {
32
- type: 'link',
33
- attrs: {
34
- auto: false,
35
- href: 'https://www.example.org/another-link',
36
- target: null,
37
- title: null,
38
- },
39
- },
40
- ],
41
- },
42
- { type: 'text', text: '.' },
43
- ],
44
+ content: [{ type: 'text', text: 'Sample link with another link' }],
44
45
  });
45
46
  });
46
47
  });
@@ -1,23 +1,15 @@
1
1
  import React from 'react';
2
- import { useRemirrorContext, useChainedCommands } from '@remirror/react';
2
+ import { useChainedCommands } from '@remirror/react';
3
3
  import Button from '../../../ui/Button/Button';
4
4
  import LinkOffIcon from '@mui/icons-material/LinkOff';
5
5
 
6
6
  const RemoveLinkButton = () => {
7
- const { commands } = useRemirrorContext({ autoUpdate: true });
8
7
  const chain = useChainedCommands();
9
- const enabled = commands.removeLink.enabled();
10
- const handleClick = () => {
11
- if (enabled) {
12
- chain.removeLink().focus().run();
13
- }
14
- };
15
8
 
16
9
  return (
17
10
  <Button
18
- handleOnClick={handleClick}
11
+ handleOnClick={() => chain.removeLink().removeAssetLink().focus().run()}
19
12
  isActive={false}
20
- isDisabled={!enabled}
21
13
  icon={<LinkOffIcon />}
22
14
  label="Remove link"
23
15
  />
@@ -1,7 +1,9 @@
1
1
  import '@testing-library/jest-dom';
2
- import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
3
  import Editor from '../../../Editor/Editor';
4
4
  import React from 'react';
5
+ import { renderWithEditor } from '../../../../tests';
6
+ import UndoButton from './UndoButton';
5
7
 
6
8
  describe('Undo button', () => {
7
9
  it('Renders the undo button', () => {
@@ -46,4 +48,23 @@ describe('Undo button', () => {
46
48
  fireEvent.click(undo);
47
49
  expect(baseElement.querySelector('p[data-node-text-align="left"]')).toBeFalsy();
48
50
  });
51
+
52
+ it('Reverts text content changes', async () => {
53
+ const { editor, getJsonContent } = await renderWithEditor(<UndoButton />, { content: 'Initial content...' });
54
+
55
+ await act(() => editor.jumpTo('end'));
56
+ await act(() => editor.paste(' with some updated content.'));
57
+ expect(getJsonContent()).toEqual({
58
+ type: 'paragraph',
59
+ attrs: expect.any(Object),
60
+ content: [{ type: 'text', text: 'Initial content... with some updated content.' }],
61
+ });
62
+
63
+ fireEvent.click(screen.getByRole('button', { name: 'Undo (cmd+Z)' }));
64
+ expect(getJsonContent()).toEqual({
65
+ type: 'paragraph',
66
+ attrs: expect.any(Object),
67
+ content: [{ type: 'text', text: 'Initial content...' }],
68
+ });
69
+ });
49
70
  });
@@ -1,10 +1,9 @@
1
1
  /// This class is excluded from the scope of squiz-fte-scope as it is outside of the scoped element
2
2
  .squiz-fte-scope__floating-popover {
3
+ @extend .editor-toolbar;
3
4
  @apply bg-white border-gray-200 p-1 shadow rounded-md border flex;
4
- .squiz-fte-btn {
5
- @apply p-1;
6
- ~ .squiz-fte-btn {
7
- margin-left: 2px;
8
- }
5
+
6
+ .editor-divider {
7
+ @apply my-0;
9
8
  }
10
9
  }
@@ -14,7 +14,7 @@
14
14
  height: auto;
15
15
  }
16
16
  .squiz-fte-btn {
17
- @apply p-1;
17
+ @apply p-1 font-bold;
18
18
  ~ .squiz-fte-btn {
19
19
  margin-left: 2px;
20
20
  }