@squiz/formatted-text-editor 1.21.1-alpha.2 → 1.21.1-alpha.20
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.
- package/demo/App.tsx +45 -10
- package/demo/index.scss +11 -10
- package/lib/Editor/Editor.js +38 -6
- package/lib/Editor/EditorContext.d.ts +10 -0
- package/lib/Editor/EditorContext.js +15 -0
- package/lib/EditorToolbar/FloatingToolbar.js +11 -5
- package/lib/EditorToolbar/Toolbar.js +3 -1
- package/lib/EditorToolbar/Tools/Bold/BoldButton.js +2 -2
- package/lib/EditorToolbar/Tools/Image/Form/ImageForm.d.ts +17 -0
- package/lib/EditorToolbar/Tools/Image/Form/ImageForm.js +82 -0
- package/lib/EditorToolbar/Tools/Image/ImageButton.d.ts +5 -0
- package/lib/EditorToolbar/Tools/Image/ImageButton.js +76 -0
- package/lib/EditorToolbar/Tools/Image/ImageModal.d.ts +8 -0
- package/lib/EditorToolbar/Tools/Image/ImageModal.js +16 -0
- package/lib/EditorToolbar/Tools/Italic/ItalicButton.js +2 -2
- package/lib/EditorToolbar/Tools/Link/Form/LinkForm.d.ts +14 -5
- package/lib/EditorToolbar/Tools/Link/Form/LinkForm.js +67 -15
- package/lib/EditorToolbar/Tools/Link/LinkButton.js +13 -10
- package/lib/EditorToolbar/Tools/Link/LinkModal.js +12 -5
- package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.js +2 -9
- package/lib/EditorToolbar/Tools/Redo/RedoButton.js +2 -2
- package/lib/EditorToolbar/Tools/TextAlign/CenterAlign/CenterAlignButton.js +2 -2
- package/lib/EditorToolbar/Tools/TextAlign/JustifyAlign/JustifyAlignButton.js +2 -2
- package/lib/EditorToolbar/Tools/TextAlign/LeftAlign/LeftAlignButton.js +2 -2
- package/lib/EditorToolbar/Tools/TextAlign/RightAlign/RightAlignButton.js +2 -2
- package/lib/EditorToolbar/Tools/Underline/UnderlineButton.js +2 -2
- package/lib/EditorToolbar/Tools/Undo/UndoButton.js +2 -2
- package/lib/Extensions/CommandsExtension/CommandsExtension.d.ts +20 -0
- package/lib/Extensions/CommandsExtension/CommandsExtension.js +52 -0
- package/lib/Extensions/Extensions.d.ts +7 -4
- package/lib/Extensions/Extensions.js +32 -19
- package/lib/Extensions/ImageExtension/ImageExtension.d.ts +10 -0
- package/lib/Extensions/ImageExtension/ImageExtension.js +92 -0
- package/lib/Extensions/LinkExtension/AssetLinkExtension.d.ts +26 -0
- package/lib/Extensions/LinkExtension/AssetLinkExtension.js +102 -0
- package/lib/Extensions/LinkExtension/LinkExtension.d.ts +21 -12
- package/lib/Extensions/LinkExtension/LinkExtension.js +63 -65
- package/lib/Extensions/LinkExtension/common.d.ts +7 -0
- package/lib/Extensions/LinkExtension/common.js +14 -0
- package/lib/Extensions/PreformattedExtension/PreformattedExtension.d.ts +1 -1
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.js +1 -0
- package/lib/hooks/useExpandedSelection.d.ts +23 -0
- package/lib/hooks/useExpandedSelection.js +37 -0
- package/lib/index.css +157 -75
- package/lib/index.d.ts +5 -2
- package/lib/index.js +9 -3
- package/lib/types.d.ts +3 -0
- package/lib/types.js +2 -0
- package/lib/ui/Button/Button.d.ts +11 -0
- package/lib/ui/{ToolbarButton/ToolbarButton.js → Button/Button.js} +6 -3
- package/lib/ui/Fields/Input/Input.d.ts +5 -0
- package/lib/ui/{Inputs/Text/TextInput.js → Fields/Input/Input.js} +10 -5
- package/lib/ui/Modal/Modal.js +2 -1
- package/lib/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.d.ts +9 -0
- package/lib/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.js +165 -0
- package/lib/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.d.ts +9 -0
- package/lib/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.js +129 -0
- package/lib/utils/undefinedIfEmpty.d.ts +1 -0
- package/lib/utils/undefinedIfEmpty.js +7 -0
- package/package.json +9 -4
- package/src/Editor/Editor.spec.tsx +78 -18
- package/src/Editor/Editor.tsx +20 -7
- package/src/Editor/EditorContext.spec.tsx +26 -0
- package/src/Editor/EditorContext.ts +19 -0
- package/src/Editor/_editor.scss +16 -53
- package/src/EditorToolbar/FloatingToolbar.spec.tsx +2 -3
- package/src/EditorToolbar/FloatingToolbar.tsx +18 -6
- package/src/EditorToolbar/Toolbar.tsx +2 -0
- package/src/EditorToolbar/Tools/Bold/BoldButton.spec.tsx +1 -1
- package/src/EditorToolbar/Tools/Bold/BoldButton.tsx +2 -2
- package/src/EditorToolbar/Tools/Image/Form/ImageForm.spec.tsx +77 -0
- package/src/EditorToolbar/Tools/Image/Form/ImageForm.tsx +90 -0
- package/src/EditorToolbar/Tools/Image/ImageButton.spec.tsx +135 -0
- package/src/EditorToolbar/Tools/Image/ImageButton.tsx +71 -0
- package/src/EditorToolbar/Tools/Image/ImageModal.spec.tsx +83 -0
- package/src/EditorToolbar/Tools/Image/ImageModal.tsx +24 -0
- package/src/EditorToolbar/Tools/Italic/ItalicButton.spec.tsx +1 -1
- package/src/EditorToolbar/Tools/Italic/ItalicButton.tsx +2 -2
- package/src/EditorToolbar/Tools/Link/Form/LinkForm.spec.tsx +37 -9
- package/src/EditorToolbar/Tools/Link/Form/LinkForm.tsx +97 -27
- package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +104 -26
- package/src/EditorToolbar/Tools/Link/LinkButton.tsx +19 -13
- package/src/EditorToolbar/Tools/Link/LinkModal.tsx +13 -6
- package/src/EditorToolbar/Tools/Link/RemoveLinkButton.spec.tsx +26 -26
- package/src/EditorToolbar/Tools/Link/RemoveLinkButton.tsx +4 -12
- package/src/EditorToolbar/Tools/Redo/RedoButton.tsx +2 -2
- package/src/EditorToolbar/Tools/TextAlign/CenterAlign/CenterAlignButton.tsx +2 -2
- package/src/EditorToolbar/Tools/TextAlign/JustifyAlign/JustifyAlignButton.tsx +2 -2
- package/src/EditorToolbar/Tools/TextAlign/LeftAlign/LeftAlignButton.tsx +2 -2
- package/src/EditorToolbar/Tools/TextAlign/RightAlign/RightAlignButton.tsx +2 -2
- package/src/EditorToolbar/Tools/Underline/Underline.spec.tsx +1 -1
- package/src/EditorToolbar/Tools/Underline/UnderlineButton.tsx +2 -2
- package/src/EditorToolbar/Tools/Undo/UndoButton.spec.tsx +22 -1
- package/src/EditorToolbar/Tools/Undo/UndoButton.tsx +2 -2
- package/src/EditorToolbar/_floating-toolbar.scss +5 -0
- package/src/EditorToolbar/_toolbar.scss +11 -5
- package/src/Extensions/CommandsExtension/CommandsExtension.ts +54 -0
- package/src/Extensions/Extensions.ts +32 -17
- package/src/Extensions/ImageExtension/ImageExtension.ts +112 -0
- package/src/Extensions/LinkExtension/AssetLinkExtension.spec.ts +104 -0
- package/src/Extensions/LinkExtension/AssetLinkExtension.ts +128 -0
- package/src/Extensions/LinkExtension/LinkExtension.spec.ts +68 -0
- package/src/Extensions/LinkExtension/LinkExtension.ts +88 -82
- package/src/Extensions/LinkExtension/common.ts +10 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useExpandedSelection.ts +44 -0
- package/src/index.scss +2 -2
- package/src/index.ts +5 -2
- package/src/types.ts +5 -0
- package/src/ui/Button/Button.spec.tsx +44 -0
- package/src/ui/Button/Button.tsx +29 -0
- package/src/ui/{_buttons.scss → Button/_button.scss} +19 -1
- package/src/ui/{Inputs/Text/TextInput.spec.tsx → Fields/Input/Input.spec.tsx} +8 -8
- package/src/ui/Fields/Input/Input.tsx +34 -0
- package/src/ui/Modal/Modal.tsx +2 -1
- package/src/ui/ToolbarDropdown/_toolbar-dropdown.scss +1 -1
- package/src/ui/_forms.scss +14 -0
- package/src/ui/_typography.scss +46 -0
- package/src/utils/converters/mocks/squizNodeJson.mock.ts +252 -0
- package/src/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.spec.ts +480 -0
- package/src/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.ts +202 -0
- package/src/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.spec.ts +329 -0
- package/src/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.ts +151 -0
- package/src/utils/undefinedIfEmpty.spec.ts +12 -0
- package/src/utils/undefinedIfEmpty.ts +3 -0
- package/tailwind.config.cjs +3 -0
- package/tests/renderWithEditor.tsx +28 -15
- package/lib/FormattedTextEditor.d.ts +0 -2
- package/lib/FormattedTextEditor.js +0 -7
- package/lib/ui/Inputs/Text/TextInput.d.ts +0 -4
- package/lib/ui/ToolbarButton/ToolbarButton.d.ts +0 -10
- package/src/Editor/Editor.mock.tsx +0 -43
- package/src/FormattedTextEditor.spec.tsx +0 -10
- package/src/FormattedTextEditor.tsx +0 -3
- package/src/ui/Inputs/Text/TextInput.tsx +0 -20
- package/src/ui/ToolbarButton/ToolbarButton.tsx +0 -26
- package/src/ui/ToolbarButton/_toolbar-button.scss +0 -17
- /package/lib/ui/{Inputs → Fields}/Select/Select.d.ts +0 -0
- /package/lib/ui/{Inputs → Fields}/Select/Select.js +0 -0
- /package/src/ui/{Inputs → Fields}/Select/Select.spec.tsx +0 -0
- /package/src/ui/{Inputs → Fields}/Select/Select.tsx +0 -0
package/src/Editor/_editor.scss
CHANGED
@@ -1,17 +1,15 @@
|
|
1
1
|
.formatted-text-editor {
|
2
2
|
font-family: 'Open Sans' !important;
|
3
|
-
|
4
|
-
&.editor-wrapper {
|
5
|
-
@apply bg-white rounded border-gray-300 border-2 border-solid;
|
6
|
-
}
|
3
|
+
@apply bg-white rounded border-gray-300 border-2 border-solid;
|
7
4
|
|
8
5
|
.remirror-editor-wrapper {
|
9
|
-
@apply text-gray-
|
6
|
+
@apply text-gray-800 pt-0;
|
10
7
|
}
|
11
8
|
|
12
9
|
.remirror-editor {
|
13
10
|
@apply bg-white shadow-none rounded-b p-3;
|
14
|
-
|
11
|
+
overflow: auto;
|
12
|
+
min-height: 15vh;
|
15
13
|
|
16
14
|
&:active,
|
17
15
|
&:focus {
|
@@ -24,6 +22,15 @@
|
|
24
22
|
}
|
25
23
|
}
|
26
24
|
|
25
|
+
&--is-disabled {
|
26
|
+
.remirror-editor {
|
27
|
+
@apply bg-gray-300 cursor-not-allowed;
|
28
|
+
}
|
29
|
+
.remirror-is-empty:first-of-type::before {
|
30
|
+
display: none;
|
31
|
+
}
|
32
|
+
}
|
33
|
+
|
27
34
|
.remirror-is-empty:first-of-type::before {
|
28
35
|
position: absolute;
|
29
36
|
pointer-events: none;
|
@@ -32,51 +39,7 @@
|
|
32
39
|
content: attr(data-placeholder);
|
33
40
|
@apply text-gray-500;
|
34
41
|
}
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
@apply text-blue-300;
|
39
|
-
text-decoration: underline;
|
40
|
-
}
|
41
|
-
|
42
|
-
.remirror-theme h1 {
|
43
|
-
font-size: 1.625rem;
|
44
|
-
font-weight: 600;
|
45
|
-
letter-spacing: -0.2px;
|
46
|
-
line-height: 2rem;
|
47
|
-
}
|
48
|
-
|
49
|
-
.remirror-theme h2 {
|
50
|
-
font-size: 1.25rem;
|
51
|
-
font-weight: 600;
|
52
|
-
letter-spacing: -0.5px;
|
53
|
-
line-height: 1.5rem;
|
54
|
-
}
|
55
|
-
|
56
|
-
.remirror-theme h3 {
|
57
|
-
font-size: 1.125rem;
|
58
|
-
font-weight: 600;
|
59
|
-
letter-spacing: -0.2px;
|
60
|
-
line-height: 1.375rem;
|
61
|
-
}
|
62
|
-
|
63
|
-
.remirror-theme h4 {
|
64
|
-
font-size: 1rem;
|
65
|
-
font-weight: 700;
|
66
|
-
letter-spacing: -0.2px;
|
67
|
-
line-height: 1.25rem;
|
68
|
-
}
|
69
|
-
|
70
|
-
.remirror-theme h5 {
|
71
|
-
font-size: 1rem;
|
72
|
-
font-weight: 600;
|
73
|
-
letter-spacing: -0.2px;
|
74
|
-
line-height: 1.25rem;
|
75
|
-
}
|
76
|
-
|
77
|
-
.remirror-theme h6 {
|
78
|
-
font-size: 0.875rem;
|
79
|
-
font-weight: 600;
|
80
|
-
letter-spacing: -0.2px;
|
81
|
-
line-height: 1.25rem;
|
42
|
+
.ProseMirror-selectednode {
|
43
|
+
@apply border-blue-300 border-2 border-solid;
|
44
|
+
}
|
82
45
|
}
|
@@ -1,7 +1,5 @@
|
|
1
|
-
import React from 'react';
|
2
1
|
import { act, screen } from '@testing-library/react';
|
3
2
|
import { renderWithEditor } from '../../tests';
|
4
|
-
import { FloatingToolbar } from './FloatingToolbar';
|
5
3
|
|
6
4
|
describe('FloatingToolbar', () => {
|
7
5
|
it.each([
|
@@ -15,8 +13,9 @@ describe('FloatingToolbar', () => {
|
|
15
13
|
])(
|
16
14
|
'Renders formatting buttons when text is selected - %s',
|
17
15
|
async (description: string, from: number, to: number, expectedButtons: string[]) => {
|
18
|
-
const { editor } = await renderWithEditor(
|
16
|
+
const { editor } = await renderWithEditor(null, {
|
19
17
|
content: 'My awesome <a href="https://example.org">example</a> content.',
|
18
|
+
editable: true,
|
20
19
|
});
|
21
20
|
|
22
21
|
await act(() => editor.selectText({ from, to }));
|
@@ -5,26 +5,38 @@ import BoldButton from './Tools/Bold/BoldButton';
|
|
5
5
|
import { useExtensionNames } from '../hooks';
|
6
6
|
import RemoveLinkButton from './Tools/Link/RemoveLinkButton';
|
7
7
|
import LinkButton from './Tools/Link/LinkButton';
|
8
|
-
import { FloatingToolbar as RemirrorFloatingToolbar, usePositioner } from '@remirror/react';
|
8
|
+
import { FloatingToolbar as RemirrorFloatingToolbar, useActive, usePositioner } from '@remirror/react';
|
9
9
|
import { VerticalDivider } from '@remirror/react-components';
|
10
10
|
import { createToolbarPositioner, ToolbarPositionerRange } from '../utils/createToolbarPositioner';
|
11
|
+
import ImageButton from './Tools/Image/ImageButton';
|
12
|
+
import { MarkName } from '../Extensions/Extensions';
|
13
|
+
import { ImageExtension } from '../Extensions/ImageExtension/ImageExtension';
|
11
14
|
|
12
|
-
// The editor main toolbar
|
13
15
|
export const FloatingToolbar = () => {
|
16
|
+
const watchedMarks = [MarkName.Link, MarkName.AssetLink];
|
14
17
|
const extensionNames = useExtensionNames();
|
15
|
-
const positioner = useMemo(() => createToolbarPositioner({ types:
|
16
|
-
const
|
18
|
+
const positioner = useMemo(() => createToolbarPositioner({ types: watchedMarks }), []);
|
19
|
+
const active = useActive<ImageExtension>();
|
20
|
+
const {
|
21
|
+
data: { marks },
|
22
|
+
} = usePositioner<Partial<ToolbarPositionerRange>>(positioner, []);
|
23
|
+
|
17
24
|
let buttons = [
|
18
25
|
extensionNames.bold && <BoldButton key="bold" />,
|
19
26
|
extensionNames.italic && <ItalicButton key="italic" />,
|
20
27
|
extensionNames.underline && <UnderlineButton key="underline" />,
|
21
28
|
];
|
22
29
|
|
23
|
-
if (
|
30
|
+
if (active.image()) {
|
31
|
+
buttons.push(
|
32
|
+
<VerticalDivider key="image-divider" className="editor-divider" />,
|
33
|
+
<ImageButton key="add-image" inPopover={true} />,
|
34
|
+
);
|
35
|
+
} else if (marks?.[MarkName.Link].isExclusivelyActive || marks?.[MarkName.AssetLink].isExclusivelyActive) {
|
24
36
|
// if all of the selected text is a link show the options to update/remove the link instead of the regular
|
25
37
|
// formatting options.
|
26
38
|
buttons = [<LinkButton key="update-link" inPopover={true} />, <RemoveLinkButton key="remove-link" />];
|
27
|
-
} else if (!
|
39
|
+
} else if (!marks?.[MarkName.Link].isActive && !marks?.[MarkName.AssetLink].isActive) {
|
28
40
|
// if none of the selected text is a link show the option to create a link.
|
29
41
|
buttons.push(
|
30
42
|
<VerticalDivider key="link-divider" className="editor-divider" />,
|
@@ -9,6 +9,7 @@ import RedoButton from './Tools/Redo/RedoButton';
|
|
9
9
|
import TextTypeDropdown from './Tools/TextType/TextTypeDropdown';
|
10
10
|
import { useExtensionNames } from '../hooks';
|
11
11
|
import LinkButton from './Tools/Link/LinkButton';
|
12
|
+
import ImageButton from './Tools/Image/ImageButton';
|
12
13
|
|
13
14
|
export const Toolbar = () => {
|
14
15
|
const extensionNames = useExtensionNames();
|
@@ -28,6 +29,7 @@ export const Toolbar = () => {
|
|
28
29
|
{extensionNames.underline && <UnderlineButton />}
|
29
30
|
{extensionNames.nodeFormatting && <TextAlignButtons />}
|
30
31
|
{extensionNames.link && <LinkButton />}
|
32
|
+
{extensionNames.image && <ImageButton />}
|
31
33
|
</RemirrorToolbar>
|
32
34
|
);
|
33
35
|
};
|
@@ -14,6 +14,6 @@ describe('Bold button', () => {
|
|
14
14
|
expect(screen.getByRole('button', { name: 'Bold (cmd+B)' }).classList.contains('squiz-fte-btn')).toBeTruthy();
|
15
15
|
const bold = screen.getByRole('button', { name: 'Bold (cmd+B)' });
|
16
16
|
fireEvent.click(bold);
|
17
|
-
expect(bold.classList.contains('is-active')).toBeTruthy();
|
17
|
+
expect(bold.classList.contains('squiz-fte-btn--is-active')).toBeTruthy();
|
18
18
|
});
|
19
19
|
});
|
@@ -1,7 +1,7 @@
|
|
1
1
|
import React from 'react';
|
2
2
|
import { useCommands, useActive, useChainedCommands } from '@remirror/react';
|
3
3
|
import { BoldExtension } from '@remirror/extension-bold';
|
4
|
-
import
|
4
|
+
import Button from '../../../ui/Button/Button';
|
5
5
|
import FormatBoldRoundedIcon from '@mui/icons-material/FormatBoldRounded';
|
6
6
|
|
7
7
|
const BoldButton = () => {
|
@@ -17,7 +17,7 @@ const BoldButton = () => {
|
|
17
17
|
};
|
18
18
|
|
19
19
|
return (
|
20
|
-
<
|
20
|
+
<Button
|
21
21
|
handleOnClick={handleSelect}
|
22
22
|
isDisabled={!enabled}
|
23
23
|
isActive={active.bold()}
|
@@ -0,0 +1,77 @@
|
|
1
|
+
import '@testing-library/jest-dom';
|
2
|
+
import { render, screen, act, fireEvent } from '@testing-library/react';
|
3
|
+
import React from 'react';
|
4
|
+
import ImageForm from './ImageForm';
|
5
|
+
|
6
|
+
describe('Image Form', () => {
|
7
|
+
const handleSubmit = jest.fn();
|
8
|
+
const data = {
|
9
|
+
src: 'https://httpcats.com/302.jpg',
|
10
|
+
alt: 'Cat with mouse in mouth',
|
11
|
+
width: 1600,
|
12
|
+
height: 1400,
|
13
|
+
};
|
14
|
+
|
15
|
+
it('Renders the form with the relevant fields', () => {
|
16
|
+
render(<ImageForm data={data} onSubmit={handleSubmit} />);
|
17
|
+
|
18
|
+
expect(screen.getByLabelText('Source')).toHaveValue('https://httpcats.com/302.jpg');
|
19
|
+
expect(screen.getByLabelText('Alternative description')).toHaveValue('Cat with mouse in mouth');
|
20
|
+
expect(screen.getByLabelText('Width')).toHaveValue(1600);
|
21
|
+
expect(screen.getByLabelText('Height')).toHaveValue(1400);
|
22
|
+
});
|
23
|
+
|
24
|
+
it('calculates the height when width changes and aspect ratio is locked', () => {
|
25
|
+
render(<ImageForm data={data} onSubmit={handleSubmit} />);
|
26
|
+
const widthInput = screen.getByLabelText('Width');
|
27
|
+
const heightInput = screen.getByLabelText('Height');
|
28
|
+
|
29
|
+
// change the width value
|
30
|
+
act(() => {
|
31
|
+
fireEvent.change(widthInput, { target: { value: 800 } });
|
32
|
+
});
|
33
|
+
|
34
|
+
// check that the height value has been calculated correctly
|
35
|
+
expect(heightInput).toHaveValue(450);
|
36
|
+
});
|
37
|
+
|
38
|
+
it('calculates the width when height changes and aspect ratio is locked', () => {
|
39
|
+
render(<ImageForm data={data} onSubmit={handleSubmit} />);
|
40
|
+
const widthInput = screen.getByLabelText('Width');
|
41
|
+
const heightInput = screen.getByLabelText('Height');
|
42
|
+
|
43
|
+
// change the height value
|
44
|
+
act(() => {
|
45
|
+
fireEvent.change(heightInput, { target: { value: 800 } });
|
46
|
+
});
|
47
|
+
|
48
|
+
// check that the width value has been calculated correctly
|
49
|
+
expect(widthInput).toHaveValue(1422.22);
|
50
|
+
});
|
51
|
+
|
52
|
+
it('does not calculate dimensions when aspect ratio is unlocked', () => {
|
53
|
+
render(<ImageForm data={data} onSubmit={handleSubmit} />);
|
54
|
+
const widthInput = screen.getByLabelText('Width');
|
55
|
+
const heightInput = screen.getByLabelText('Height');
|
56
|
+
const toggleButton = screen.getByRole('button', { name: 'Constrain properties' });
|
57
|
+
|
58
|
+
// unlock the aspect ratio
|
59
|
+
fireEvent.click(toggleButton);
|
60
|
+
|
61
|
+
// change the width value
|
62
|
+
act(() => {
|
63
|
+
fireEvent.change(widthInput, { target: { value: 800 } });
|
64
|
+
});
|
65
|
+
|
66
|
+
// check that the height value has not been calculated
|
67
|
+
expect(heightInput).toHaveValue(1400);
|
68
|
+
|
69
|
+
// change the height value
|
70
|
+
act(() => {
|
71
|
+
fireEvent.change(heightInput, { target: { value: 800 } });
|
72
|
+
});
|
73
|
+
|
74
|
+
// check that the width value has not been calculated
|
75
|
+
expect(widthInput).toHaveValue(800);
|
76
|
+
});
|
77
|
+
});
|
@@ -0,0 +1,90 @@
|
|
1
|
+
import React, { ReactElement, useState } from 'react';
|
2
|
+
import { Input } from '../../../../ui/Fields/Input/Input';
|
3
|
+
import { SubmitHandler, useForm } from 'react-hook-form';
|
4
|
+
import { getImageSize } from 'react-image-size';
|
5
|
+
import { ImageAttributes } from '@remirror/extension-image/dist-types/image-extension';
|
6
|
+
import Button from '../../../../ui/Button/Button';
|
7
|
+
import LinkOffIcon from '@mui/icons-material/LinkOff';
|
8
|
+
import InsertLinkRoundedIcon from '@mui/icons-material/InsertLinkRounded';
|
9
|
+
|
10
|
+
export type UpdateImageOptions = ImageAttributes & {
|
11
|
+
src: string;
|
12
|
+
alt: string;
|
13
|
+
width: number;
|
14
|
+
height: number;
|
15
|
+
};
|
16
|
+
export type ImageFormData = Pick<UpdateImageOptions, 'src' | 'alt' | 'width' | 'height'>;
|
17
|
+
|
18
|
+
export type FormProps = {
|
19
|
+
data: Partial<ImageFormData>;
|
20
|
+
onSubmit: SubmitHandler<ImageFormData>;
|
21
|
+
};
|
22
|
+
export type Dimensions = 'width' | 'height';
|
23
|
+
|
24
|
+
const ImageForm = ({ data, onSubmit }: FormProps): ReactElement => {
|
25
|
+
const { register, handleSubmit, setValue } = useForm<ImageFormData>({
|
26
|
+
defaultValues: data,
|
27
|
+
});
|
28
|
+
const [aspectRatioFromWidth, setAspectRatioFromWidth] = useState(9 / 16);
|
29
|
+
const [aspectRatioFromHeight, setAspectRatioFromHeight] = useState(16 / 9);
|
30
|
+
const [aspectRatioLocked, setAspectRatioLocked] = useState(true);
|
31
|
+
|
32
|
+
const setDimensionsFromURL = (e: { target: { value: string } }) => {
|
33
|
+
getImageSize(e.target.value)
|
34
|
+
.then(({ width, height }) => {
|
35
|
+
setValue('width', width);
|
36
|
+
setValue('height', height);
|
37
|
+
setAspectRatioFromWidth(height / width);
|
38
|
+
setAspectRatioFromHeight(width / height);
|
39
|
+
})
|
40
|
+
.catch(() => {
|
41
|
+
// TODO: we will use this when we add validation in a follow-up ticket
|
42
|
+
});
|
43
|
+
};
|
44
|
+
|
45
|
+
const calculateDimensions = () => {
|
46
|
+
if (aspectRatioLocked) {
|
47
|
+
const currentTarget = event?.target as HTMLInputElement;
|
48
|
+
const type = currentTarget.name as Dimensions;
|
49
|
+
const currentValue = currentTarget.value as string;
|
50
|
+
const otherValue = type === 'width' ? 'height' : 'width';
|
51
|
+
const aspectRatio = type === 'width' ? aspectRatioFromWidth : aspectRatioFromHeight;
|
52
|
+
const newValue = Math.round(aspectRatio * Number(currentValue) * 100) / 100;
|
53
|
+
setValue(otherValue, newValue);
|
54
|
+
}
|
55
|
+
};
|
56
|
+
|
57
|
+
const toggleAspectRatio = () => {
|
58
|
+
setAspectRatioLocked(!aspectRatioLocked);
|
59
|
+
};
|
60
|
+
|
61
|
+
return (
|
62
|
+
<form className="squiz-fte-form" onSubmit={handleSubmit(onSubmit)}>
|
63
|
+
<div className="squiz-fte-form-group mb-2">
|
64
|
+
<Input label="Source" {...register('src', { onChange: setDimensionsFromURL })} />
|
65
|
+
</div>
|
66
|
+
<div className="squiz-fte-form-group mb-2">
|
67
|
+
<Input label="Alternative description" {...register('alt')} />
|
68
|
+
</div>
|
69
|
+
<div className="flex flex-row items-end">
|
70
|
+
<div className="squiz-fte-form-group mb-2">
|
71
|
+
<Input label="Width" {...register('width')} type="number" name="width" onChange={calculateDimensions} />
|
72
|
+
</div>
|
73
|
+
<div className="flex mx-1 mb-2">
|
74
|
+
<Button
|
75
|
+
handleOnClick={toggleAspectRatio}
|
76
|
+
isActive={false}
|
77
|
+
icon={aspectRatioLocked ? <InsertLinkRoundedIcon /> : <LinkOffIcon />}
|
78
|
+
label="Constrain properties"
|
79
|
+
isDisabled={false}
|
80
|
+
/>
|
81
|
+
</div>
|
82
|
+
<div className="squiz-fte-form-group mb-2">
|
83
|
+
<Input label="Height" {...register('height')} type="number" name="height" onChange={calculateDimensions} />
|
84
|
+
</div>
|
85
|
+
</div>
|
86
|
+
</form>
|
87
|
+
);
|
88
|
+
};
|
89
|
+
|
90
|
+
export default ImageForm;
|
@@ -0,0 +1,135 @@
|
|
1
|
+
import '@testing-library/jest-dom';
|
2
|
+
import { screen, fireEvent, waitForElementToBeRemoved, act } from '@testing-library/react';
|
3
|
+
import { NodeSelection } from 'prosemirror-state';
|
4
|
+
import React from 'react';
|
5
|
+
import { renderWithEditor } from '../../../../tests';
|
6
|
+
import ImageButton from './ImageButton';
|
7
|
+
|
8
|
+
describe('ImageButton', () => {
|
9
|
+
const openModal = async () => {
|
10
|
+
fireEvent.click(screen.getByRole('button', { name: 'Image (cmd+L)' }));
|
11
|
+
await screen.findByRole('button', { name: 'Apply' });
|
12
|
+
};
|
13
|
+
|
14
|
+
it('Opens the modal when clicking on the image button', async () => {
|
15
|
+
await renderWithEditor(<ImageButton />);
|
16
|
+
|
17
|
+
// open the modal and assert it is visible.
|
18
|
+
await openModal();
|
19
|
+
const modalHeading = screen.getByRole('heading', { name: 'Image' });
|
20
|
+
expect(modalHeading).toBeInTheDocument();
|
21
|
+
});
|
22
|
+
|
23
|
+
it('Opens the modal when clicking the keyboard shortcut', async () => {
|
24
|
+
const { editor, elements } = await renderWithEditor(<ImageButton />);
|
25
|
+
|
26
|
+
// press the keyboard shortcut.
|
27
|
+
fireEvent.keyDown(elements.editor, { key: 'l', ctrlKey: true });
|
28
|
+
|
29
|
+
// verify the modal opens
|
30
|
+
await act(() => editor.selectText(2));
|
31
|
+
expect(await screen.findByLabelText('Source')).toHaveValue('');
|
32
|
+
});
|
33
|
+
|
34
|
+
it('Adds a new image', async () => {
|
35
|
+
const { getJsonContent } = await renderWithEditor(<ImageButton />, { content: 'Some nonsense content here' });
|
36
|
+
|
37
|
+
// open the modal and add an image.
|
38
|
+
await openModal();
|
39
|
+
fireEvent.change(screen.getByLabelText('Source'), { target: { value: 'https://httpcats.com/529.jpg' } });
|
40
|
+
fireEvent.change(screen.getByLabelText('Alternative description'), { target: { value: 'Many cats' } });
|
41
|
+
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
42
|
+
|
43
|
+
await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
|
44
|
+
|
45
|
+
expect(getJsonContent()).toEqual({
|
46
|
+
type: 'paragraph',
|
47
|
+
attrs: expect.any(Object),
|
48
|
+
content: [
|
49
|
+
{
|
50
|
+
type: 'image',
|
51
|
+
attrs: {
|
52
|
+
alt: 'Many cats',
|
53
|
+
crop: null,
|
54
|
+
height: '',
|
55
|
+
width: '',
|
56
|
+
rotate: null,
|
57
|
+
src: 'https://httpcats.com/529.jpg',
|
58
|
+
title: '',
|
59
|
+
fileName: null,
|
60
|
+
resizable: false,
|
61
|
+
},
|
62
|
+
},
|
63
|
+
{ type: 'text', text: 'Some nonsense content here' },
|
64
|
+
],
|
65
|
+
});
|
66
|
+
});
|
67
|
+
|
68
|
+
it('Updates the attributes of an existing image', async () => {
|
69
|
+
const { editor, getJsonContent } = await renderWithEditor(<ImageButton />, {
|
70
|
+
content: 'Some <img src="https://httpcats.com/529.jpg" alt="hi" /> nonsense',
|
71
|
+
});
|
72
|
+
|
73
|
+
await act(() => editor.selectText(new NodeSelection(editor.state.doc.resolve(6))));
|
74
|
+
|
75
|
+
await openModal();
|
76
|
+
fireEvent.change(screen.getByLabelText('Source'), { target: { value: 'https://httpcats.com/303.jpg' } });
|
77
|
+
fireEvent.change(screen.getByLabelText('Alternative description'), { target: { value: 'Updated cats!' } });
|
78
|
+
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
79
|
+
|
80
|
+
await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
|
81
|
+
|
82
|
+
expect(getJsonContent()).toEqual({
|
83
|
+
type: 'paragraph',
|
84
|
+
attrs: expect.any(Object),
|
85
|
+
content: [
|
86
|
+
{
|
87
|
+
text: 'Some ',
|
88
|
+
type: 'text',
|
89
|
+
},
|
90
|
+
{
|
91
|
+
type: 'image',
|
92
|
+
attrs: {
|
93
|
+
alt: 'Updated cats!',
|
94
|
+
crop: null,
|
95
|
+
height: null,
|
96
|
+
width: null,
|
97
|
+
rotate: null,
|
98
|
+
src: 'https://httpcats.com/303.jpg',
|
99
|
+
title: '',
|
100
|
+
fileName: null,
|
101
|
+
resizable: false,
|
102
|
+
},
|
103
|
+
},
|
104
|
+
{ type: 'text', text: ' nonsense' },
|
105
|
+
],
|
106
|
+
});
|
107
|
+
});
|
108
|
+
|
109
|
+
it('Removes the image when content is cleared (backspaced)', async () => {
|
110
|
+
const { editor, getJsonContent } = await renderWithEditor(<ImageButton />, {
|
111
|
+
content: '<img src="https://media2.giphy.com/media/3o6ozsIxg5legYvggo/giphy.gif"/>',
|
112
|
+
});
|
113
|
+
|
114
|
+
await act(() => editor.selectText(10));
|
115
|
+
await act(() => editor.backspace(1));
|
116
|
+
|
117
|
+
expect(getJsonContent()).toEqual({
|
118
|
+
type: 'paragraph',
|
119
|
+
attrs: expect.any(Object),
|
120
|
+
});
|
121
|
+
});
|
122
|
+
|
123
|
+
it('Closes the modal when clicking on the cancel button', async () => {
|
124
|
+
await renderWithEditor(<ImageButton />);
|
125
|
+
|
126
|
+
// open the modal and assert it is visible.
|
127
|
+
await openModal();
|
128
|
+
const modalHeading = screen.getByRole('heading', { name: 'Image' });
|
129
|
+
expect(modalHeading).toBeInTheDocument();
|
130
|
+
|
131
|
+
// close the modal and assert it has disappeared.
|
132
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
133
|
+
expect(modalHeading).not.toBeInTheDocument();
|
134
|
+
});
|
135
|
+
});
|
@@ -0,0 +1,71 @@
|
|
1
|
+
import React, { useState, useCallback, useMemo } from 'react';
|
2
|
+
import ImageRoundedIcon from '@mui/icons-material/ImageRounded';
|
3
|
+
import ImageModal from './ImageModal';
|
4
|
+
import { ImageFormData } from './Form/ImageForm';
|
5
|
+
import Button from '../../../ui/Button/Button';
|
6
|
+
import { useCommands, useKeymap, useActive, usePositioner } from '@remirror/react';
|
7
|
+
import { ImageExtension } from '../../../Extensions/ImageExtension/ImageExtension';
|
8
|
+
import { createToolbarPositioner, ToolbarPositionerRange } from '../../../utils/createToolbarPositioner';
|
9
|
+
|
10
|
+
type ImageButtonProps = {
|
11
|
+
inPopover?: boolean;
|
12
|
+
};
|
13
|
+
|
14
|
+
const ImageButton = ({ inPopover = false }: ImageButtonProps) => {
|
15
|
+
const [showModal, setShowModal] = useState(false);
|
16
|
+
const { insertImage } = useCommands();
|
17
|
+
const active = useActive<ImageExtension>();
|
18
|
+
const positioner = useMemo(() => createToolbarPositioner({ types: ['link'] }), []);
|
19
|
+
const { data } = usePositioner<Partial<ToolbarPositionerRange>>(positioner, []);
|
20
|
+
// if the active selection is not an image, disable the button as it means it will be text
|
21
|
+
const disabled = data.isSelectionInView && !active.image() ? true : false;
|
22
|
+
|
23
|
+
const handleClick = () => {
|
24
|
+
if (!showModal) {
|
25
|
+
// form element are uncontrolled, let the event loop run to
|
26
|
+
// update the selected text in state before showing the modal.
|
27
|
+
requestAnimationFrame(() => {
|
28
|
+
setShowModal(true);
|
29
|
+
});
|
30
|
+
}
|
31
|
+
};
|
32
|
+
|
33
|
+
const insertImageFromData = (data: ImageFormData) => {
|
34
|
+
const { src, alt, width, height } = data;
|
35
|
+
if (src) {
|
36
|
+
insertImage({ src, alt, width, height });
|
37
|
+
}
|
38
|
+
};
|
39
|
+
|
40
|
+
const handleSubmit = (data: ImageFormData) => {
|
41
|
+
insertImageFromData(data);
|
42
|
+
setShowModal(false);
|
43
|
+
};
|
44
|
+
|
45
|
+
const handleShortcut = useCallback(() => {
|
46
|
+
handleClick();
|
47
|
+
// Prevent other key handlers being run
|
48
|
+
return true;
|
49
|
+
}, []);
|
50
|
+
|
51
|
+
if (!inPopover) {
|
52
|
+
// when Ctrl+l is pressed show the modal, only registered in the toolbar button instance to avoid the key press
|
53
|
+
// being double handled.
|
54
|
+
useKeymap('Mod-l', handleShortcut);
|
55
|
+
}
|
56
|
+
|
57
|
+
return (
|
58
|
+
<>
|
59
|
+
<Button
|
60
|
+
handleOnClick={handleClick}
|
61
|
+
isActive={active.image()}
|
62
|
+
icon={<ImageRoundedIcon />}
|
63
|
+
label="Image (cmd+L)"
|
64
|
+
isDisabled={disabled}
|
65
|
+
/>
|
66
|
+
{showModal && <ImageModal onCancel={() => setShowModal(false)} onSubmit={handleSubmit} />}
|
67
|
+
</>
|
68
|
+
);
|
69
|
+
};
|
70
|
+
|
71
|
+
export default ImageButton;
|
@@ -0,0 +1,83 @@
|
|
1
|
+
import '@testing-library/jest-dom';
|
2
|
+
import { screen, fireEvent } from '@testing-library/react';
|
3
|
+
import React from 'react';
|
4
|
+
import { renderWithEditor } from '../../../../tests';
|
5
|
+
import ImageModal from './ImageModal';
|
6
|
+
|
7
|
+
const mockSubmitFunction = jest.fn();
|
8
|
+
const mockCancelFunction = jest.fn();
|
9
|
+
const setup = () => {
|
10
|
+
const utils = renderWithEditor(<ImageModal onCancel={mockCancelFunction} onSubmit={mockSubmitFunction} />);
|
11
|
+
const sourceInput = screen.getByRole('textbox', { name: /source/i }) as HTMLInputElement;
|
12
|
+
const altInput = screen.getByRole('textbox', { name: /alt/i }) as HTMLInputElement;
|
13
|
+
const widthInput = screen.getByRole('spinbutton', { name: /Width/i }) as HTMLInputElement;
|
14
|
+
const heightInput = screen.getByRole('spinbutton', { name: /Height/i }) as HTMLInputElement;
|
15
|
+
return {
|
16
|
+
sourceInput,
|
17
|
+
altInput,
|
18
|
+
widthInput,
|
19
|
+
heightInput,
|
20
|
+
...utils,
|
21
|
+
};
|
22
|
+
};
|
23
|
+
|
24
|
+
describe('ImageModal', () => {
|
25
|
+
it('Renders the modal with title', async () => {
|
26
|
+
await renderWithEditor(<ImageModal onCancel={mockCancelFunction} onSubmit={mockSubmitFunction} />);
|
27
|
+
const modalHeading = screen.getByRole('heading', { name: 'Image' });
|
28
|
+
expect(modalHeading).toBeInTheDocument();
|
29
|
+
});
|
30
|
+
|
31
|
+
it('Populates the source field when a source is supplied', async () => {
|
32
|
+
const { sourceInput } = setup();
|
33
|
+
fireEvent.change(sourceInput, { target: { value: 'https://httpcats.com/302.jpg' } });
|
34
|
+
expect(sourceInput.value).toBe('https://httpcats.com/302.jpg');
|
35
|
+
});
|
36
|
+
|
37
|
+
it('Renders empty width and height fields if the image source is empty', async () => {
|
38
|
+
const { sourceInput, widthInput, heightInput } = setup();
|
39
|
+
fireEvent.change(sourceInput, { target: { value: '' } });
|
40
|
+
expect(widthInput.value).toBe('');
|
41
|
+
expect(heightInput.value).toBe('');
|
42
|
+
});
|
43
|
+
|
44
|
+
it('Updates the height field with aspect ratio based value from width', async () => {
|
45
|
+
const { widthInput, heightInput } = setup();
|
46
|
+
fireEvent.change(widthInput, { target: { value: '300' } });
|
47
|
+
expect(widthInput.value).toBe('300');
|
48
|
+
expect(heightInput.value).toBe('168.75');
|
49
|
+
});
|
50
|
+
it('Updates the width field with aspect ratio based value from height', async () => {
|
51
|
+
const { widthInput, heightInput } = setup();
|
52
|
+
fireEvent.change(heightInput, { target: { value: '100' } });
|
53
|
+
expect(heightInput.value).toBe('100');
|
54
|
+
expect(widthInput.value).toBe('177.78');
|
55
|
+
});
|
56
|
+
it('Does not change the width when height is changed and aspect ratio link is off', () => {
|
57
|
+
const { widthInput, heightInput } = setup();
|
58
|
+
fireEvent.change(heightInput, { target: { value: '100' } });
|
59
|
+
expect(heightInput.value).toBe('100');
|
60
|
+
expect(widthInput.value).toBe('177.78');
|
61
|
+
fireEvent.click(screen.getByRole('button', { name: /constrain properties/i }));
|
62
|
+
fireEvent.change(heightInput, { target: { value: '200' } });
|
63
|
+
expect(heightInput.value).toBe('200');
|
64
|
+
expect(widthInput.value).toBe('177.78');
|
65
|
+
});
|
66
|
+
it('Does not change the height when width is changed and aspect ratio link is off', () => {
|
67
|
+
const { widthInput, heightInput } = setup();
|
68
|
+
fireEvent.change(widthInput, { target: { value: '450' } });
|
69
|
+
expect(widthInput.value).toBe('450');
|
70
|
+
expect(heightInput.value).toBe('253.13');
|
71
|
+
fireEvent.click(screen.getByRole('button', { name: /constrain properties/i }));
|
72
|
+
fireEvent.change(widthInput, { target: { value: '600' } });
|
73
|
+
expect(widthInput.value).toBe('600');
|
74
|
+
expect(heightInput.value).toBe('253.13');
|
75
|
+
});
|
76
|
+
it('Changes the icon when aspect ratio button is toggled', () => {
|
77
|
+
renderWithEditor(<ImageModal onCancel={mockCancelFunction} onSubmit={mockSubmitFunction} />);
|
78
|
+
expect(screen.getByTestId('InsertLinkRoundedIcon')).toBeInTheDocument();
|
79
|
+
fireEvent.click(screen.getByRole('button', { name: /constrain properties/i }));
|
80
|
+
expect(screen.queryByTestId('InsertLinkRoundedIcon')).not.toBeInTheDocument();
|
81
|
+
expect(screen.getByTestId('LinkOffIcon')).toBeInTheDocument();
|
82
|
+
});
|
83
|
+
});
|