@squiz/formatted-text-editor 1.12.0-alpha.8 → 1.12.1-alpha.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 (163) hide show
  1. package/.eslintrc.json +34 -0
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +2 -3
  4. package/build.js +21 -0
  5. package/cypress/e2e/bold.spec.cy.ts +18 -0
  6. package/cypress/global.d.ts +9 -0
  7. package/cypress/support/commands.ts +130 -0
  8. package/cypress/support/e2e.ts +20 -0
  9. package/cypress/tsconfig.json +8 -0
  10. package/cypress.config.ts +7 -0
  11. package/demo/App.tsx +39 -0
  12. package/demo/index.html +13 -0
  13. package/demo/index.scss +40 -0
  14. package/demo/main.tsx +10 -0
  15. package/demo/public/favicon-dxp.svg +3 -0
  16. package/demo/vite-env.d.ts +1 -0
  17. package/file-transformer.js +1 -0
  18. package/jest.bootstrap.ts +3 -0
  19. package/jest.config.ts +30 -0
  20. package/lib/Editor/Editor.d.ts +4 -2
  21. package/lib/Editor/Editor.js +11 -14
  22. package/lib/EditorToolbar/FloatingToolbar.d.ts +1 -0
  23. package/lib/EditorToolbar/FloatingToolbar.js +31 -0
  24. package/lib/EditorToolbar/Toolbar.d.ts +1 -0
  25. package/lib/EditorToolbar/Toolbar.js +25 -0
  26. package/lib/EditorToolbar/Tools/Link/Form/LinkForm.d.ts +10 -0
  27. package/lib/EditorToolbar/Tools/Link/Form/LinkForm.js +23 -0
  28. package/lib/EditorToolbar/Tools/Link/LinkButton.d.ts +5 -0
  29. package/lib/EditorToolbar/Tools/Link/LinkButton.js +34 -0
  30. package/lib/EditorToolbar/Tools/Link/LinkModal.d.ts +8 -0
  31. package/lib/EditorToolbar/Tools/Link/LinkModal.js +14 -0
  32. package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.d.ts +2 -0
  33. package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.js +16 -0
  34. package/lib/EditorToolbar/Tools/Redo/RedoButton.d.ts +2 -0
  35. package/lib/EditorToolbar/Tools/Redo/RedoButton.js +16 -0
  36. package/lib/EditorToolbar/Tools/TextAlign/TextAlignButtons.js +4 -1
  37. package/lib/EditorToolbar/Tools/TextType/Heading/HeadingButton.d.ts +5 -0
  38. package/lib/EditorToolbar/Tools/TextType/Heading/HeadingButton.js +32 -0
  39. package/lib/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.d.ts +2 -0
  40. package/lib/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.js +16 -0
  41. package/lib/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.d.ts +2 -0
  42. package/lib/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.js +16 -0
  43. package/lib/EditorToolbar/Tools/TextType/TextTypeDropdown.d.ts +2 -0
  44. package/lib/EditorToolbar/Tools/TextType/TextTypeDropdown.js +35 -0
  45. package/lib/EditorToolbar/Tools/Undo/UndoButton.d.ts +2 -0
  46. package/lib/EditorToolbar/Tools/Undo/UndoButton.js +16 -0
  47. package/lib/EditorToolbar/index.d.ts +2 -0
  48. package/lib/EditorToolbar/index.js +2 -0
  49. package/lib/Extensions/Extensions.d.ts +4 -0
  50. package/lib/Extensions/Extensions.js +20 -0
  51. package/lib/Extensions/LinkExtension/LinkExtension.d.ts +16 -0
  52. package/lib/Extensions/LinkExtension/LinkExtension.js +91 -0
  53. package/lib/Extensions/PreformattedExtension/PreformattedExtension.d.ts +10 -0
  54. package/lib/Extensions/PreformattedExtension/PreformattedExtension.js +46 -0
  55. package/lib/FormattedTextEditor.d.ts +2 -2
  56. package/lib/FormattedTextEditor.js +1 -6
  57. package/lib/hooks/index.d.ts +1 -0
  58. package/lib/hooks/index.js +1 -0
  59. package/lib/hooks/useExtensionNames.d.ts +1 -0
  60. package/lib/hooks/useExtensionNames.js +12 -0
  61. package/lib/index.css +787 -3686
  62. package/lib/ui/Inputs/Select/Select.d.ts +12 -0
  63. package/lib/ui/Inputs/Select/Select.js +23 -0
  64. package/lib/ui/Inputs/Text/TextInput.d.ts +4 -0
  65. package/lib/ui/Inputs/Text/TextInput.js +7 -0
  66. package/lib/ui/Modal/FormModal.d.ts +5 -0
  67. package/lib/ui/Modal/FormModal.js +11 -0
  68. package/lib/ui/Modal/Modal.d.ts +10 -0
  69. package/lib/ui/Modal/Modal.js +48 -0
  70. package/lib/ui/ToolbarButton/ToolbarButton.d.ts +1 -1
  71. package/lib/ui/ToolbarButton/ToolbarButton.js +1 -1
  72. package/lib/ui/ToolbarDropdown/ToolbarDropdown.d.ts +6 -0
  73. package/lib/ui/ToolbarDropdown/ToolbarDropdown.js +20 -0
  74. package/lib/ui/ToolbarDropdownButton/ToolbarDropdownButton.d.ts +9 -0
  75. package/lib/ui/ToolbarDropdownButton/ToolbarDropdownButton.js +8 -0
  76. package/lib/utils/createToolbarPositioner.d.ts +18 -0
  77. package/lib/utils/createToolbarPositioner.js +81 -0
  78. package/lib/utils/getCursorRect.d.ts +2 -0
  79. package/lib/utils/getCursorRect.js +3 -0
  80. package/package.json +22 -13
  81. package/postcss.config.js +12 -0
  82. package/src/Editor/Editor.mock.tsx +43 -0
  83. package/src/Editor/Editor.spec.tsx +254 -0
  84. package/src/Editor/Editor.tsx +46 -0
  85. package/src/Editor/_editor.scss +82 -0
  86. package/src/EditorToolbar/FloatingToolbar.spec.tsx +30 -0
  87. package/src/EditorToolbar/FloatingToolbar.tsx +40 -0
  88. package/src/EditorToolbar/Toolbar.tsx +33 -0
  89. package/src/EditorToolbar/Tools/Bold/BoldButton.spec.tsx +19 -0
  90. package/src/EditorToolbar/Tools/Bold/BoldButton.tsx +30 -0
  91. package/src/EditorToolbar/Tools/Italic/ItalicButton.spec.tsx +19 -0
  92. package/src/EditorToolbar/Tools/Italic/ItalicButton.tsx +30 -0
  93. package/src/EditorToolbar/Tools/Link/Form/LinkForm.spec.tsx +30 -0
  94. package/src/EditorToolbar/Tools/Link/Form/LinkForm.tsx +48 -0
  95. package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +277 -0
  96. package/src/EditorToolbar/Tools/Link/LinkButton.tsx +56 -0
  97. package/src/EditorToolbar/Tools/Link/LinkModal.tsx +29 -0
  98. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.spec.tsx +46 -0
  99. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.tsx +27 -0
  100. package/src/EditorToolbar/Tools/Redo/RedoButton.spec.tsx +59 -0
  101. package/src/EditorToolbar/Tools/Redo/RedoButton.tsx +30 -0
  102. package/src/EditorToolbar/Tools/TextAlign/CenterAlign/CenterAlignButton.spec.tsx +39 -0
  103. package/src/EditorToolbar/Tools/TextAlign/CenterAlign/CenterAlignButton.tsx +31 -0
  104. package/src/EditorToolbar/Tools/TextAlign/JustifyAlign/JustifyAlignButton.spec.tsx +39 -0
  105. package/src/EditorToolbar/Tools/TextAlign/JustifyAlign/JustifyAlignButton.tsx +31 -0
  106. package/src/EditorToolbar/Tools/TextAlign/LeftAlign/LeftAlignButton.spec.tsx +39 -0
  107. package/src/EditorToolbar/Tools/TextAlign/LeftAlign/LeftAlignButton.tsx +31 -0
  108. package/src/EditorToolbar/Tools/TextAlign/RightAlign/RightAlignButton.spec.tsx +39 -0
  109. package/src/EditorToolbar/Tools/TextAlign/RightAlign/RightAlignButton.tsx +31 -0
  110. package/src/EditorToolbar/Tools/TextAlign/TextAlignButtons.tsx +21 -0
  111. package/src/EditorToolbar/Tools/TextType/Heading/HeadingButton.spec.tsx +56 -0
  112. package/src/EditorToolbar/Tools/TextType/Heading/HeadingButton.tsx +52 -0
  113. package/src/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.spec.tsx +30 -0
  114. package/src/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.tsx +25 -0
  115. package/src/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.spec.tsx +47 -0
  116. package/src/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.tsx +30 -0
  117. package/src/EditorToolbar/Tools/TextType/TextTypeDropdown.spec.tsx +51 -0
  118. package/src/EditorToolbar/Tools/TextType/TextTypeDropdown.tsx +44 -0
  119. package/src/EditorToolbar/Tools/Underline/Underline.spec.tsx +19 -0
  120. package/src/EditorToolbar/Tools/Underline/UnderlineButton.tsx +30 -0
  121. package/src/EditorToolbar/Tools/Undo/UndoButton.spec.tsx +49 -0
  122. package/src/EditorToolbar/Tools/Undo/UndoButton.tsx +30 -0
  123. package/src/EditorToolbar/_floating-toolbar.scss +4 -0
  124. package/src/EditorToolbar/_toolbar.scss +16 -0
  125. package/src/EditorToolbar/index.ts +2 -0
  126. package/src/Extensions/Extensions.ts +29 -0
  127. package/src/Extensions/LinkExtension/LinkExtension.ts +116 -0
  128. package/src/Extensions/PreformattedExtension/PreformattedExtension.ts +50 -0
  129. package/src/FormattedTextEditor.spec.tsx +10 -0
  130. package/src/FormattedTextEditor.tsx +3 -0
  131. package/src/hooks/index.ts +1 -0
  132. package/src/hooks/useExtensionNames.ts +15 -0
  133. package/src/index.scss +19 -0
  134. package/src/index.ts +3 -0
  135. package/src/ui/Inputs/Select/Select.spec.tsx +30 -0
  136. package/src/ui/Inputs/Select/Select.tsx +66 -0
  137. package/src/ui/Inputs/Text/TextInput.spec.tsx +43 -0
  138. package/src/ui/Inputs/Text/TextInput.tsx +20 -0
  139. package/src/ui/Modal/FormModal.spec.tsx +20 -0
  140. package/src/ui/Modal/FormModal.tsx +17 -0
  141. package/src/ui/Modal/Modal.spec.tsx +113 -0
  142. package/src/ui/Modal/Modal.tsx +97 -0
  143. package/src/ui/Modal/_modal.scss +24 -0
  144. package/src/ui/ToolbarButton/ToolbarButton.tsx +26 -0
  145. package/src/ui/ToolbarButton/_toolbar-button.scss +17 -0
  146. package/src/ui/ToolbarDropdown/ToolbarDropdown.spec.tsx +78 -0
  147. package/src/ui/ToolbarDropdown/ToolbarDropdown.tsx +42 -0
  148. package/src/ui/ToolbarDropdown/_toolbar-dropdown.scss +32 -0
  149. package/src/ui/ToolbarDropdownButton/ToolbarDropdownButton.spec.tsx +48 -0
  150. package/src/ui/ToolbarDropdownButton/ToolbarDropdownButton.tsx +29 -0
  151. package/src/ui/ToolbarDropdownButton/_toolbar-dropdown-button.scss +14 -0
  152. package/src/ui/_buttons.scss +19 -0
  153. package/src/ui/_forms.scss +16 -0
  154. package/src/utils/createToolbarPositioner.ts +115 -0
  155. package/src/utils/getCursorRect.ts +5 -0
  156. package/tailwind.config.cjs +83 -0
  157. package/tests/index.ts +2 -0
  158. package/tests/renderWithEditor.tsx +110 -0
  159. package/tests/select.tsx +16 -0
  160. package/tsconfig.json +22 -0
  161. package/vite.config.ts +19 -0
  162. package/lib/EditorToolbar/EditorToolbar.d.ts +0 -7
  163. package/lib/EditorToolbar/EditorToolbar.js +0 -22
@@ -0,0 +1,277 @@
1
+ import '@testing-library/jest-dom';
2
+ import { act, screen, fireEvent, waitForElementToBeRemoved } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { renderWithEditor, select } from '../../../../tests';
5
+ import LinkButton from './LinkButton';
6
+
7
+ describe('LinkButton', () => {
8
+ const openModal = async () => {
9
+ fireEvent.click(screen.getByRole('button', { name: 'Link (cmd+K)' }));
10
+ await screen.findByRole('button', { name: 'Apply' });
11
+ };
12
+
13
+ it('Adds a new link', async () => {
14
+ const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, { content: '[start][end]' });
15
+
16
+ // move the cursor to between the start and end tags.
17
+ await act(() => editor.selectText(8));
18
+
19
+ // open the modal and add a link.
20
+ await openModal();
21
+ fireEvent.change(screen.getByLabelText('URL'), { target: { value: 'https://www.squiz.net/link-button' } });
22
+ fireEvent.change(screen.getByLabelText('Text'), { target: { value: 'Link text' } });
23
+ fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Link title' } });
24
+ select(screen.getByLabelText('Target'), 'New window');
25
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
26
+
27
+ await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
28
+
29
+ expect(getJsonContent()).toEqual({
30
+ type: 'paragraph',
31
+ attrs: expect.any(Object),
32
+ content: [
33
+ { type: 'text', text: '[start]' },
34
+ {
35
+ type: 'text',
36
+ text: 'Link text',
37
+ marks: [
38
+ {
39
+ type: 'link',
40
+ attrs: { auto: false, href: 'https://www.squiz.net/link-button', target: '_blank', title: 'Link title' },
41
+ },
42
+ ],
43
+ },
44
+ { type: 'text', text: '[end]' },
45
+ ],
46
+ });
47
+ });
48
+
49
+ it('Updates the attributes of an existing link', async () => {
50
+ const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
51
+ content: '<a href="https://www.example.org/my-link">Sample link</a> with some other content.',
52
+ });
53
+
54
+ // jump to the middle of the link.
55
+ await act(() => editor.selectText(3));
56
+
57
+ // open the modal and update the link.
58
+ await openModal();
59
+ fireEvent.change(screen.getByLabelText('URL'), { target: { value: 'https://www.example.org/updated-link' } });
60
+ fireEvent.change(screen.getByLabelText('Text'), { target: { value: 'Updated sample link' } });
61
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
62
+
63
+ await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
64
+
65
+ // cursor should be positioned after the link and the text should be updated.
66
+ expect(editor.from).toBe(20);
67
+ expect(editor.to).toBe(20);
68
+ expect(getJsonContent()).toEqual({
69
+ type: 'paragraph',
70
+ attrs: expect.any(Object),
71
+ content: [
72
+ {
73
+ type: 'text',
74
+ text: 'Updated sample link',
75
+ marks: [
76
+ {
77
+ type: 'link',
78
+ attrs: { auto: false, href: 'https://www.example.org/updated-link', target: null, title: null },
79
+ },
80
+ ],
81
+ },
82
+ { type: 'text', text: ' with some other content.' },
83
+ ],
84
+ });
85
+ });
86
+
87
+ it('Removes the link when the URL is cleared', async () => {
88
+ const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
89
+ content: '<a href="https://www.example.org/my-link">Sample link</a> with some other content.',
90
+ });
91
+
92
+ // jump to the middle of the link.
93
+ await act(() => editor.selectText(3));
94
+
95
+ // open the modal and clear the link.
96
+ await openModal();
97
+ fireEvent.change(screen.getByLabelText('URL'), { target: { value: '' } });
98
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
99
+
100
+ await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
101
+
102
+ // cursor should be positioned after the link and the link should be removed.
103
+ expect(editor.from).toBe(12);
104
+ expect(editor.to).toBe(12);
105
+ expect(getJsonContent()).toEqual({
106
+ type: 'paragraph',
107
+ attrs: expect.any(Object),
108
+ content: [{ type: 'text', text: 'Sample link with some other content.' }],
109
+ });
110
+ });
111
+
112
+ it('Removes the content when the text is cleared', async () => {
113
+ const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
114
+ content: '<a href="https://www.example.org/my-link">Sample link</a> with some other content.',
115
+ });
116
+
117
+ // jump to the middle of the link.
118
+ await act(() => editor.selectText(3));
119
+
120
+ // open the modal and clear the text.
121
+ await openModal();
122
+ fireEvent.change(screen.getByLabelText('Text'), { target: { value: '' } });
123
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
124
+
125
+ await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
126
+
127
+ // cursor should be positioned where the link was and the link+text should be removed.
128
+ expect(editor.from).toBe(1);
129
+ expect(editor.to).toBe(1);
130
+ expect(getJsonContent()).toEqual({
131
+ type: 'paragraph',
132
+ attrs: expect.any(Object),
133
+ content: [{ type: 'text', text: ' with some other content.' }],
134
+ });
135
+ });
136
+
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 () => {
156
+ const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
157
+ content: '<a href="https://www.example.org/my-link">Sample link</a> <strong>with</strong> some other content.',
158
+ });
159
+
160
+ // jump to the middle of the link and select some of the text after it.
161
+ await act(() => editor.selectText({ from: 3, to: 22 }));
162
+
163
+ // open the modal and apply the link to the selection.
164
+ await openModal();
165
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
166
+
167
+ await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
168
+
169
+ // link should be applied to the additional selected text ("with some"), formatting should be preserved.
170
+ expect(editor.from).toBe(22);
171
+ expect(editor.to).toBe(22);
172
+ expect(getJsonContent()).toEqual({
173
+ type: 'paragraph',
174
+ attrs: expect.any(Object),
175
+ content: [
176
+ {
177
+ type: 'text',
178
+ text: 'Sample link ',
179
+ marks: [
180
+ {
181
+ type: 'link',
182
+ attrs: { auto: false, href: 'https://www.example.org/my-link', target: null, title: null },
183
+ },
184
+ ],
185
+ },
186
+ {
187
+ type: 'text',
188
+ text: 'with',
189
+ marks: [
190
+ {
191
+ type: 'link',
192
+ attrs: { auto: false, href: 'https://www.example.org/my-link', target: null, title: null },
193
+ },
194
+ { type: 'bold' },
195
+ ],
196
+ },
197
+ {
198
+ type: 'text',
199
+ text: ' some',
200
+ marks: [
201
+ {
202
+ type: 'link',
203
+ attrs: { auto: false, href: 'https://www.example.org/my-link', target: null, title: null },
204
+ },
205
+ ],
206
+ },
207
+ { type: 'text', text: ' other content.' },
208
+ ],
209
+ });
210
+ });
211
+
212
+ it('Updates text and formatting when selection has a mixture of formatting', async () => {
213
+ const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
214
+ content: '<a href="https://www.example.org/my-link">Sample link</a> <strong>with</strong> some other content.',
215
+ });
216
+
217
+ // jump to the middle of the link and select some of the text after it.
218
+ await act(() => editor.selectText({ from: 3, to: 22 }));
219
+
220
+ // open the modal and apply the link to the selection.
221
+ await openModal();
222
+ fireEvent.change(screen.getByLabelText('Text'), { target: { value: 'Updated sample link and' } });
223
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
224
+
225
+ await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
226
+
227
+ // link should be applied and text should be updated.
228
+ expect(editor.from).toBe(24);
229
+ expect(editor.to).toBe(24);
230
+ expect(getJsonContent()).toEqual({
231
+ type: 'paragraph',
232
+ attrs: expect.any(Object),
233
+ content: [
234
+ {
235
+ type: 'text',
236
+ text: 'Updated sample link and',
237
+ marks: [
238
+ {
239
+ type: 'link',
240
+ attrs: { auto: false, href: 'https://www.example.org/my-link', target: null, title: null },
241
+ },
242
+ ],
243
+ },
244
+ { type: 'text', text: ' other content.' },
245
+ ],
246
+ });
247
+ });
248
+
249
+ it('Opens the modal when clicking the keyboard shortcut', async () => {
250
+ const { editor, elements } = await renderWithEditor(<LinkButton />, {
251
+ content: '<a href="https://www.example.org/my-link">Sample link</a> with some other content.',
252
+ });
253
+
254
+ // jump to the middle of the link.
255
+ await act(() => editor.selectText(5));
256
+
257
+ // press the keyboard shortcut.
258
+ fireEvent.keyDown(elements.editor, { key: 'k', ctrlKey: true });
259
+
260
+ // verify the modal opens and has populated with where the link details where our cursor is positioned.
261
+ expect(await screen.findByLabelText('URL')).toHaveValue('https://www.example.org/my-link');
262
+ expect(await screen.findByLabelText('Text')).toHaveValue('Sample link');
263
+ });
264
+
265
+ it('Closes the modal when clicking on the cancel button', async () => {
266
+ await renderWithEditor(<LinkButton />);
267
+
268
+ // open the modal and assert it is visible.
269
+ await openModal();
270
+ const modalHeading = screen.getByRole('heading', { name: 'Link' });
271
+ expect(modalHeading).toBeInTheDocument();
272
+
273
+ // close the modal and assert it has disappeared.
274
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
275
+ expect(modalHeading).not.toBeInTheDocument();
276
+ });
277
+ });
@@ -0,0 +1,56 @@
1
+ import React, { useCallback, useState } from 'react';
2
+ import InsertLinkRoundedIcon from '@mui/icons-material/InsertLinkRounded';
3
+ import LinkModal from './LinkModal';
4
+ import { LinkFormData } from './Form/LinkForm';
5
+ import ToolbarButton from '../../../ui/ToolbarButton/ToolbarButton';
6
+ import { useActive, useCommands, useExtensionEvent } from '@remirror/react';
7
+ import { LinkExtension } from '../../../Extensions/LinkExtension/LinkExtension';
8
+
9
+ type LinkButtonProps = {
10
+ inPopover?: boolean;
11
+ };
12
+
13
+ const LinkButton = ({ inPopover = false }: LinkButtonProps) => {
14
+ const [showModal, setShowModal] = useState(false);
15
+ const { selectLink, updateLink } = useCommands<LinkExtension>();
16
+ const active = useActive<LinkExtension>();
17
+ const handleClick = () => {
18
+ 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
+ });
26
+ }
27
+ };
28
+ const handleSubmit = (data: LinkFormData) => {
29
+ updateLink(data);
30
+ setShowModal(false);
31
+ };
32
+
33
+ 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
+ );
41
+ }
42
+
43
+ return (
44
+ <>
45
+ <ToolbarButton
46
+ handleOnClick={handleClick}
47
+ isActive={active.link()}
48
+ icon={<InsertLinkRoundedIcon />}
49
+ label="Link (cmd+K)"
50
+ />
51
+ {showModal && <LinkModal onCancel={() => setShowModal(false)} onSubmit={handleSubmit} />}
52
+ </>
53
+ );
54
+ };
55
+
56
+ export default LinkButton;
@@ -0,0 +1,29 @@
1
+ import { getMarkRanges } from 'remirror';
2
+ import LinkForm, { LinkFormData } from './Form/LinkForm';
3
+ import React from 'react';
4
+ import { useRemirrorContext, useCurrentSelection } from '@remirror/react';
5
+ import FormModal from '../../../ui/Modal/FormModal';
6
+ import { SubmitHandler } from 'react-hook-form';
7
+
8
+ type LinkModalProps = {
9
+ onCancel: () => void;
10
+ onSubmit: SubmitHandler<LinkFormData>;
11
+ };
12
+
13
+ const LinkModal = ({ onCancel, onSubmit }: LinkModalProps) => {
14
+ const {
15
+ helpers,
16
+ view: { state },
17
+ } = useRemirrorContext();
18
+ const selection = useCurrentSelection();
19
+ const currentLink = getMarkRanges(selection, 'link')[0];
20
+ const selectedText = helpers.getTextBetween(selection.from, selection.to, state.doc);
21
+
22
+ return (
23
+ <FormModal title="Link" onCancel={onCancel}>
24
+ <LinkForm data={{ ...currentLink?.mark.attrs, text: selectedText }} onSubmit={onSubmit} />
25
+ </FormModal>
26
+ );
27
+ };
28
+
29
+ export default LinkModal;
@@ -0,0 +1,46 @@
1
+ import '@testing-library/jest-dom';
2
+ import { act, screen, fireEvent } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { renderWithEditor } from '../../../../tests';
5
+ import RemoveLinkButton from './RemoveLinkButton';
6
+
7
+ describe('RemoveLinkButton', () => {
8
+ it('Removes a link', async () => {
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>.',
13
+ });
14
+
15
+ // move the cursor to inside of the link.
16
+ await act(() => editor.selectText(5));
17
+
18
+ // remove the link.
19
+ fireEvent.click(screen.getByRole('button', { name: 'Remove link' }));
20
+
21
+ // make sure the link has been removed.
22
+ expect(getJsonContent()).toEqual({
23
+ type: 'paragraph',
24
+ 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
+ });
45
+ });
46
+ });
@@ -0,0 +1,27 @@
1
+ import React from 'react';
2
+ import { useRemirrorContext, useChainedCommands } from '@remirror/react';
3
+ import ToolbarButton from '../../../ui/ToolbarButton/ToolbarButton';
4
+ import LinkOffIcon from '@mui/icons-material/LinkOff';
5
+
6
+ const RemoveLinkButton = () => {
7
+ const { commands } = useRemirrorContext({ autoUpdate: true });
8
+ const chain = useChainedCommands();
9
+ const enabled = commands.removeLink.enabled();
10
+ const handleClick = () => {
11
+ if (enabled) {
12
+ chain.removeLink().focus().run();
13
+ }
14
+ };
15
+
16
+ return (
17
+ <ToolbarButton
18
+ handleOnClick={handleClick}
19
+ isActive={false}
20
+ isDisabled={!enabled}
21
+ icon={<LinkOffIcon />}
22
+ label="Remove link"
23
+ />
24
+ );
25
+ };
26
+
27
+ export default RemoveLinkButton;
@@ -0,0 +1,59 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import Editor from '../../../Editor/Editor';
4
+ import React from 'react';
5
+
6
+ describe('Redo button', () => {
7
+ it('Renders the redo button', () => {
8
+ render(<Editor />);
9
+ expect(screen.getByRole('button', { name: 'Redo (shift+cmd+Z)' })).toBeInTheDocument();
10
+ });
11
+
12
+ it('Renders a disabled button if you have not made any changes yet', () => {
13
+ render(<Editor />);
14
+ const redo = screen.getByRole('button', { name: 'Redo (shift+cmd+Z)' });
15
+ expect(redo).toBeDisabled();
16
+ });
17
+
18
+ it('Enables the button when you perform an action and then revert it', () => {
19
+ const { baseElement } = render(<Editor />);
20
+
21
+ // perform some action
22
+ const leftAlignButton = baseElement.querySelector('button[title="Align left"]') as HTMLButtonElement;
23
+ expect(leftAlignButton).toBeTruthy();
24
+ fireEvent.click(leftAlignButton);
25
+ expect(baseElement.querySelector('p[data-node-text-align="left"]')).toBeTruthy();
26
+
27
+ // Revert this action
28
+ const undo = screen.getByRole('button', { name: 'Undo (cmd+Z)' });
29
+ fireEvent.click(undo);
30
+ expect(baseElement.querySelector('p[data-node-text-align="left"]')).toBeFalsy();
31
+
32
+ // Check that this enables the redo button
33
+ const redo = screen.getByRole('button', { name: 'Redo (shift+cmd+Z)' });
34
+ expect(redo).not.toBeDisabled();
35
+ });
36
+
37
+ it('Reverts this action when clicked', () => {
38
+ const { baseElement } = render(<Editor />);
39
+
40
+ // perform some action
41
+ const leftAlignButton = baseElement.querySelector('button[title="Align left"]') as HTMLButtonElement;
42
+ expect(leftAlignButton).toBeTruthy();
43
+ fireEvent.click(leftAlignButton);
44
+ expect(baseElement.querySelector('p[data-node-text-align="left"]')).toBeTruthy();
45
+
46
+ // Revert this action
47
+ const undo = screen.getByRole('button', { name: 'Undo (cmd+Z)' });
48
+ fireEvent.click(undo);
49
+ expect(baseElement.querySelector('p[data-node-text-align="left"]')).toBeFalsy();
50
+
51
+ // Check that this enables the redo button
52
+ const redo = screen.getByRole('button', { name: 'Redo (shift+cmd+Z)' });
53
+ expect(redo).not.toBeDisabled();
54
+
55
+ // Click the redo button and check that this has reverted the previous action
56
+ fireEvent.click(redo);
57
+ expect(baseElement.querySelector('p[data-node-text-align="left"]')).toBeTruthy();
58
+ });
59
+ });
@@ -0,0 +1,30 @@
1
+ import React from 'react';
2
+ import { useCommands, useHelpers } from '@remirror/react';
3
+ import { HistoryExtension } from 'remirror/extensions';
4
+ import ToolbarButton from '../../../ui/ToolbarButton/ToolbarButton';
5
+ import RedoRoundedIcon from '@mui/icons-material/RedoRounded';
6
+
7
+ const RedoButton = () => {
8
+ const { redo } = useCommands<HistoryExtension>();
9
+ const { redoDepth } = useHelpers<HistoryExtension>(true);
10
+
11
+ const handleSelect = () => {
12
+ if (redo.enabled()) {
13
+ redo();
14
+ }
15
+ };
16
+
17
+ const enabled = redoDepth() > 0;
18
+
19
+ return (
20
+ <ToolbarButton
21
+ handleOnClick={handleSelect}
22
+ isDisabled={!enabled}
23
+ isActive={false}
24
+ icon={<RedoRoundedIcon />}
25
+ label="Redo (shift+cmd+Z)"
26
+ />
27
+ );
28
+ };
29
+
30
+ export default RedoButton;
@@ -0,0 +1,39 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, fireEvent } from '@testing-library/react';
3
+ import Editor from '../../../../Editor/Editor';
4
+ import React from 'react';
5
+
6
+ describe('Center align button', () => {
7
+ it('Renders the center align button', () => {
8
+ const { baseElement, getByRole } = render(<Editor />);
9
+ expect(baseElement).toBeTruthy();
10
+ expect(getByRole('button', { name: 'Align center' })).toBeTruthy();
11
+ });
12
+
13
+ it('Applies active status after selecting center align button', () => {
14
+ const { baseElement, getByRole } = render(<Editor />);
15
+ expect(baseElement).toBeTruthy();
16
+
17
+ const centerAlignButton = getByRole('button', { name: 'Align center' });
18
+ expect(centerAlignButton).toBeTruthy();
19
+ expect(centerAlignButton.classList.contains('is-active')).toBeFalsy();
20
+
21
+ fireEvent.click(centerAlignButton);
22
+
23
+ setTimeout(() => {
24
+ expect(centerAlignButton.classList.contains('is-active')).toBeTruthy();
25
+ }, 50);
26
+ });
27
+
28
+ it('Should apply center alignment to editor after clicking button', () => {
29
+ const { baseElement, getByRole } = render(<Editor />);
30
+ expect(baseElement).toBeTruthy();
31
+ expect(baseElement.querySelector('p[data-node-text-align="center"]')).toBeFalsy();
32
+
33
+ const centerAlignButton = getByRole('button', { name: 'Align center' });
34
+ expect(centerAlignButton).toBeTruthy();
35
+ fireEvent.click(centerAlignButton);
36
+
37
+ expect(baseElement.querySelector('p[data-node-text-align="center"]')).toBeTruthy();
38
+ });
39
+ });
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { useCommands, useChainedCommands } from '@remirror/react';
3
+ import { NodeFormattingExtension } from '@remirror/extension-node-formatting';
4
+ import ToolbarButton from '../../../../ui/ToolbarButton/ToolbarButton';
5
+ import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
6
+
7
+ const CenterAlignButton = () => {
8
+ const { centerAlign } = useCommands<NodeFormattingExtension>();
9
+ const chain = useChainedCommands();
10
+
11
+ const handleSelect = () => {
12
+ if (centerAlign.enabled()) {
13
+ chain.centerAlign().focus().run();
14
+ }
15
+ };
16
+
17
+ const active = centerAlign.active?.() || false;
18
+ const enabled = centerAlign.enabled();
19
+
20
+ return (
21
+ <ToolbarButton
22
+ handleOnClick={handleSelect}
23
+ isDisabled={!enabled}
24
+ isActive={active}
25
+ icon={<FormatAlignCenterIcon />}
26
+ label="Align center"
27
+ />
28
+ );
29
+ };
30
+
31
+ export default CenterAlignButton;
@@ -0,0 +1,39 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, fireEvent } from '@testing-library/react';
3
+ import Editor from '../../../../Editor/Editor';
4
+ import React from 'react';
5
+
6
+ describe('Justify align button', () => {
7
+ it('Renders the justify align button', () => {
8
+ const { baseElement, getByRole } = render(<Editor />);
9
+ expect(baseElement).toBeTruthy();
10
+ expect(getByRole('button', { name: 'Justify' })).toBeTruthy();
11
+ });
12
+
13
+ it('Applies active status after selecting justify align button', () => {
14
+ const { baseElement, getByRole } = render(<Editor />);
15
+ expect(baseElement).toBeTruthy();
16
+
17
+ const justifyAlignButton = getByRole('button', { name: 'Justify' });
18
+ expect(justifyAlignButton).toBeTruthy();
19
+ expect(justifyAlignButton.classList.contains('is-active')).toBeFalsy();
20
+
21
+ fireEvent.click(justifyAlignButton);
22
+
23
+ setTimeout(() => {
24
+ expect(justifyAlignButton.classList.contains('is-active')).toBeTruthy();
25
+ }, 50);
26
+ });
27
+
28
+ it('Should apply justify alignment to editor after clicking button', () => {
29
+ const { baseElement, getByRole } = render(<Editor />);
30
+ expect(baseElement).toBeTruthy();
31
+ expect(baseElement.querySelector('p[data-node-text-align="justify"]')).toBeFalsy();
32
+
33
+ const justifyAlignButton = getByRole('button', { name: 'Justify' });
34
+ expect(justifyAlignButton).toBeTruthy();
35
+ fireEvent.click(justifyAlignButton);
36
+
37
+ expect(baseElement.querySelector('p[data-node-text-align="justify"]')).toBeTruthy();
38
+ });
39
+ });
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { useCommands, useChainedCommands } from '@remirror/react';
3
+ import { NodeFormattingExtension } from '@remirror/extension-node-formatting';
4
+ import ToolbarButton from '../../../../ui/ToolbarButton/ToolbarButton';
5
+ import FormatAlignJustifyIcon from '@mui/icons-material/FormatAlignJustify';
6
+
7
+ const JustifyAlignButton = () => {
8
+ const { justifyAlign } = useCommands<NodeFormattingExtension>();
9
+ const chain = useChainedCommands();
10
+
11
+ const handleSelect = () => {
12
+ if (justifyAlign.enabled()) {
13
+ chain.justifyAlign().focus().run();
14
+ }
15
+ };
16
+
17
+ const active = justifyAlign.active?.() || false;
18
+ const enabled = justifyAlign.enabled();
19
+
20
+ return (
21
+ <ToolbarButton
22
+ handleOnClick={handleSelect}
23
+ isDisabled={!enabled}
24
+ isActive={active}
25
+ icon={<FormatAlignJustifyIcon />}
26
+ label="Justify"
27
+ />
28
+ );
29
+ };
30
+
31
+ export default JustifyAlignButton;