@squiz/formatted-text-editor 1.21.1-alpha.2 → 1.21.1-alpha.21
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 +45 -7
- 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 +77 -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 +22 -14
- 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 +159 -74
- 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 +28 -9
- package/src/Editor/EditorContext.spec.tsx +26 -0
- package/src/Editor/EditorContext.ts +19 -0
- package/src/Editor/_editor.scss +20 -51
- package/src/EditorToolbar/FloatingToolbar.spec.tsx +2 -3
- package/src/EditorToolbar/FloatingToolbar.tsx +15 -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 +72 -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 +30 -21
- 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
@@ -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
|
+
});
|
@@ -0,0 +1,24 @@
|
|
1
|
+
import ImageForm, { ImageFormData } from './Form/ImageForm';
|
2
|
+
import React from 'react';
|
3
|
+
import { useCurrentSelection } from '@remirror/react';
|
4
|
+
import FormModal from '../../../ui/Modal/FormModal';
|
5
|
+
import { SubmitHandler } from 'react-hook-form';
|
6
|
+
import { NodeSelection } from 'prosemirror-state';
|
7
|
+
|
8
|
+
type ImageModalProps = {
|
9
|
+
onCancel: () => void;
|
10
|
+
onSubmit: SubmitHandler<ImageFormData>;
|
11
|
+
};
|
12
|
+
|
13
|
+
const ImageModal = ({ onCancel, onSubmit }: ImageModalProps) => {
|
14
|
+
const selection = useCurrentSelection() as NodeSelection;
|
15
|
+
const currentImage = selection?.node;
|
16
|
+
|
17
|
+
return (
|
18
|
+
<FormModal title="Image" onCancel={onCancel}>
|
19
|
+
<ImageForm data={{ ...currentImage?.attrs, src: currentImage?.attrs.src }} onSubmit={onSubmit} />
|
20
|
+
</FormModal>
|
21
|
+
);
|
22
|
+
};
|
23
|
+
|
24
|
+
export default ImageModal;
|
@@ -14,6 +14,6 @@ describe('Italic button', () => {
|
|
14
14
|
expect(screen.getByRole('button', { name: 'Italic (cmd+I)' }).classList.contains('squiz-fte-btn')).toBeTruthy();
|
15
15
|
const italic = screen.getByRole('button', { name: 'Italic (cmd+I)' });
|
16
16
|
fireEvent.click(italic);
|
17
|
-
expect(italic.classList.contains('is-active')).toBeTruthy();
|
17
|
+
expect(italic.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 { ItalicExtension } from '@remirror/extension-italic';
|
4
|
-
import
|
4
|
+
import Button from '../../../ui/Button/Button';
|
5
5
|
import FormatItalicRoundedIcon from '@mui/icons-material/FormatItalicRounded';
|
6
6
|
|
7
7
|
const ItalicButton = () => {
|
@@ -17,7 +17,7 @@ const ItalicButton = () => {
|
|
17
17
|
};
|
18
18
|
|
19
19
|
return (
|
20
|
-
<
|
20
|
+
<Button
|
21
21
|
handleOnClick={handleSelect}
|
22
22
|
isDisabled={!enabled}
|
23
23
|
isActive={active.italic()}
|
@@ -1,30 +1,58 @@
|
|
1
1
|
import '@testing-library/jest-dom';
|
2
2
|
import { render, screen } from '@testing-library/react';
|
3
3
|
import React from 'react';
|
4
|
-
import LinkForm from './LinkForm';
|
4
|
+
import { LinkForm } from './LinkForm';
|
5
|
+
import { LinkTarget } from '../../../../Extensions/LinkExtension/common';
|
6
|
+
import { MarkName } from '../../../../Extensions/Extensions';
|
5
7
|
|
6
8
|
describe('Link Form', () => {
|
7
9
|
const handleSubmit = jest.fn();
|
8
10
|
const data = {
|
9
|
-
|
10
|
-
target: '_blank',
|
11
|
-
title: 'Link title',
|
11
|
+
linkType: MarkName.Link,
|
12
12
|
text: 'Link text',
|
13
|
+
range: { from: 10, to: 15 },
|
14
|
+
link: {
|
15
|
+
href: 'https://www.squiz.net/link-form',
|
16
|
+
target: LinkTarget.Blank,
|
17
|
+
title: 'Link title',
|
18
|
+
},
|
19
|
+
assetLink: {
|
20
|
+
matrixAssetId: '100',
|
21
|
+
matrixIdentifier: 'matrix-identifier',
|
22
|
+
matrixDomain: 'my-matrix.squiz.net',
|
23
|
+
target: LinkTarget.Blank,
|
24
|
+
},
|
13
25
|
};
|
14
26
|
|
15
|
-
it('Renders the form with
|
27
|
+
it('Renders the form with expected default values when no data is provided', () => {
|
28
|
+
render(<LinkForm onSubmit={handleSubmit} />);
|
29
|
+
|
30
|
+
expect(screen.getByLabelText('Type')).toHaveTextContent('Link to URL');
|
31
|
+
expect(screen.getByLabelText('URL')).toHaveValue('');
|
32
|
+
expect(screen.getByLabelText('Text')).toHaveValue('');
|
33
|
+
expect(screen.getByLabelText('Title')).toHaveValue('');
|
34
|
+
expect(screen.getByLabelText('Target')).toHaveTextContent('Current window');
|
35
|
+
expect(document.querySelectorAll('label')).toHaveLength(5);
|
36
|
+
});
|
37
|
+
|
38
|
+
it('Renders the form with the expected fields for arbitrary links', () => {
|
16
39
|
render(<LinkForm data={data} onSubmit={handleSubmit} />);
|
17
40
|
|
41
|
+
expect(screen.getByLabelText('Type')).toHaveTextContent('Link to URL');
|
18
42
|
expect(screen.getByLabelText('URL')).toHaveValue('https://www.squiz.net/link-form');
|
19
43
|
expect(screen.getByLabelText('Text')).toHaveValue('Link text');
|
20
44
|
expect(screen.getByLabelText('Title')).toHaveValue('Link title');
|
21
45
|
expect(screen.getByLabelText('Target')).toHaveTextContent('New window');
|
46
|
+
expect(document.querySelectorAll('label')).toHaveLength(5);
|
22
47
|
});
|
23
48
|
|
24
|
-
it('Renders the form with the
|
25
|
-
render(<LinkForm data={data} onSubmit={handleSubmit} />);
|
49
|
+
it('Renders the form with the expected fields for asset links', () => {
|
50
|
+
render(<LinkForm data={{ ...data, linkType: MarkName.AssetLink }} onSubmit={handleSubmit} />);
|
26
51
|
|
27
|
-
|
28
|
-
expect(
|
52
|
+
expect(screen.getByLabelText('Type')).toHaveTextContent('Link to asset');
|
53
|
+
expect(screen.getByLabelText('Asset ID')).toHaveValue('100');
|
54
|
+
expect(screen.getByLabelText('Text')).toHaveValue('Link text');
|
55
|
+
expect(screen.getByLabelText('Target')).toHaveTextContent('New window');
|
56
|
+
expect(document.querySelectorAll('label')).toHaveLength(4);
|
29
57
|
});
|
30
58
|
});
|
@@ -1,48 +1,118 @@
|
|
1
|
-
import React, { ReactElement } from 'react';
|
2
|
-
import
|
3
|
-
import { Select, SelectOptions } from '../../../../ui/Inputs/Select/Select';
|
1
|
+
import React, { ReactElement, useContext } from 'react';
|
2
|
+
import clsx from 'clsx';
|
4
3
|
import { SubmitHandler, useForm } from 'react-hook-form';
|
5
|
-
import {
|
4
|
+
import { FromToProps } from 'remirror';
|
5
|
+
import { Input } from '../../../../ui/Fields/Input/Input';
|
6
|
+
import { Select, SelectOptions } from '../../../../ui/Fields/Select/Select';
|
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 =
|
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
|
23
|
+
data?: DeepPartial<LinkFormData>;
|
11
24
|
onSubmit: SubmitHandler<LinkFormData>;
|
12
25
|
};
|
13
26
|
|
14
|
-
const
|
15
|
-
|
16
|
-
|
27
|
+
const linkTypeOptions: SelectOptions = {
|
28
|
+
[MarkName.Link]: { label: 'Link to URL' },
|
29
|
+
[MarkName.AssetLink]: { label: 'Link to asset' },
|
30
|
+
};
|
31
|
+
|
32
|
+
const targetOptions: SelectOptions = {
|
33
|
+
[LinkTarget.Self]: { label: 'Current window' },
|
34
|
+
[LinkTarget.Blank]: { label: 'New window' },
|
17
35
|
};
|
18
36
|
|
19
|
-
const LinkForm = ({ data, onSubmit }: FormProps): ReactElement => {
|
20
|
-
const
|
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
|
-
<TextInput label="URL" {...register('href')} />
|
28
|
-
</div>
|
29
|
-
<div className="squiz-fte-form-group mb-2">
|
30
|
-
<TextInput label="Text" {...register('text')} />
|
31
|
-
</div>
|
32
|
-
<div className="squiz-fte-form-group mb-2">
|
33
|
-
<TextInput label="Title" {...register('title')} />
|
34
|
-
</div>
|
35
|
-
<div className="squiz-fte-form-group mb-0">
|
36
53
|
<Select
|
37
|
-
name="
|
38
|
-
label="
|
39
|
-
value={
|
40
|
-
options={
|
41
|
-
onChange={(value) => setValue('
|
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.isValidMatrixAssetId(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: {
|
40
|
+
attrs: { href: 'https://www.squiz.net/link-button', target: '_blank', title: 'Link title' },
|
41
41
|
},
|
42
42
|
],
|
43
43
|
},
|
@@ -75,7 +75,7 @@ describe('LinkButton', () => {
|
|
75
75
|
marks: [
|
76
76
|
{
|
77
77
|
type: 'link',
|
78
|
-
attrs: {
|
78
|
+
attrs: { href: 'https://www.example.org/updated-link', target: '_self', title: null },
|
79
79
|
},
|
80
80
|
],
|
81
81
|
},
|
@@ -134,25 +134,7 @@ describe('LinkButton', () => {
|
|
134
134
|
});
|
135
135
|
});
|
136
136
|
|
137
|
-
it
|
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 () => {
|
137
|
+
it('Updates unselected part of link when link is partially selected', async () => {
|
156
138
|
const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
|
157
139
|
content: '<a href="https://www.example.org/my-link">Sample link</a> <strong>with</strong> some other content.',
|
158
140
|
});
|
@@ -179,7 +161,7 @@ describe('LinkButton', () => {
|
|
179
161
|
marks: [
|
180
162
|
{
|
181
163
|
type: 'link',
|
182
|
-
attrs: {
|
164
|
+
attrs: { href: 'https://www.example.org/my-link', target: '_self', title: null },
|
183
165
|
},
|
184
166
|
],
|
185
167
|
},
|
@@ -189,7 +171,7 @@ describe('LinkButton', () => {
|
|
189
171
|
marks: [
|
190
172
|
{
|
191
173
|
type: 'link',
|
192
|
-
attrs: {
|
174
|
+
attrs: { href: 'https://www.example.org/my-link', target: '_self', title: null },
|
193
175
|
},
|
194
176
|
{ type: 'bold' },
|
195
177
|
],
|
@@ -200,7 +182,7 @@ describe('LinkButton', () => {
|
|
200
182
|
marks: [
|
201
183
|
{
|
202
184
|
type: 'link',
|
203
|
-
attrs: {
|
185
|
+
attrs: { href: 'https://www.example.org/my-link', target: '_self', title: null },
|
204
186
|
},
|
205
187
|
],
|
206
188
|
},
|
@@ -237,7 +219,7 @@ describe('LinkButton', () => {
|
|
237
219
|
marks: [
|
238
220
|
{
|
239
221
|
type: 'link',
|
240
|
-
attrs: {
|
222
|
+
attrs: { href: 'https://www.example.org/my-link', target: '_self', title: null },
|
241
223
|
},
|
242
224
|
],
|
243
225
|
},
|
@@ -252,7 +234,7 @@ describe('LinkButton', () => {
|
|
252
234
|
});
|
253
235
|
|
254
236
|
// jump to the middle of the link.
|
255
|
-
await act(() => editor.selectText(
|
237
|
+
await act(() => editor.selectText({ from: 1, to: 12 }));
|
256
238
|
|
257
239
|
// press the keyboard shortcut.
|
258
240
|
fireEvent.keyDown(elements.editor, { key: 'k', ctrlKey: true });
|
@@ -274,4 +256,100 @@ describe('LinkButton', () => {
|
|
274
256
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
275
257
|
expect(modalHeading).not.toBeInTheDocument();
|
276
258
|
});
|
259
|
+
|
260
|
+
it('Add a new asset link', async () => {
|
261
|
+
const matrixIdentifier = 'matrix-api-identifier';
|
262
|
+
const matrixDomain = 'https://my-matrix.squiz.net';
|
263
|
+
const { getJsonContent } = await renderWithEditor(<LinkButton />, {
|
264
|
+
context: { matrix: { matrixIdentifier, matrixDomain } },
|
265
|
+
});
|
266
|
+
|
267
|
+
await openModal();
|
268
|
+
select(screen.getByLabelText('Type'), 'Link to asset');
|
269
|
+
fireEvent.change(screen.getByLabelText('Asset ID'), { target: { value: '123' } });
|
270
|
+
fireEvent.change(screen.getByLabelText('Text'), { target: { value: 'Link text' } });
|
271
|
+
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
272
|
+
|
273
|
+
await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
|
274
|
+
|
275
|
+
expect(getJsonContent()).toEqual({
|
276
|
+
type: 'paragraph',
|
277
|
+
attrs: expect.any(Object),
|
278
|
+
content: [
|
279
|
+
{
|
280
|
+
type: 'text',
|
281
|
+
text: 'Link text',
|
282
|
+
marks: [
|
283
|
+
{
|
284
|
+
type: 'assetLink',
|
285
|
+
attrs: { matrixAssetId: '123', target: '_self', matrixDomain, matrixIdentifier },
|
286
|
+
},
|
287
|
+
],
|
288
|
+
},
|
289
|
+
],
|
290
|
+
});
|
291
|
+
});
|
292
|
+
|
293
|
+
it('Updates an existing link to be an asset link', async () => {
|
294
|
+
const matrixIdentifier = 'matrix-api-identifier';
|
295
|
+
const matrixDomain = 'https://my-matrix.squiz.net';
|
296
|
+
const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
|
297
|
+
content:
|
298
|
+
'<a href="https://www.example.org/my-link">Sample link</a> with ' +
|
299
|
+
'<a href="https://www.example.org/another-link">another link</a>',
|
300
|
+
context: { matrix: { matrixIdentifier, matrixDomain } },
|
301
|
+
});
|
302
|
+
|
303
|
+
await act(() => editor.selectText(5));
|
304
|
+
|
305
|
+
await openModal();
|
306
|
+
select(screen.getByLabelText('Type'), 'Link to asset');
|
307
|
+
fireEvent.change(screen.getByLabelText('Asset ID'), { target: { value: '123' } });
|
308
|
+
select(screen.getByLabelText('Target'), 'New window');
|
309
|
+
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
310
|
+
|
311
|
+
await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
|
312
|
+
|
313
|
+
expect(getJsonContent()).toEqual({
|
314
|
+
type: 'paragraph',
|
315
|
+
attrs: expect.any(Object),
|
316
|
+
content: [
|
317
|
+
{
|
318
|
+
type: 'text',
|
319
|
+
text: 'Sample link',
|
320
|
+
marks: [
|
321
|
+
{
|
322
|
+
type: 'assetLink',
|
323
|
+
attrs: { matrixAssetId: '123', target: '_blank', matrixDomain, matrixIdentifier },
|
324
|
+
},
|
325
|
+
],
|
326
|
+
},
|
327
|
+
{ type: 'text', text: ' with ' },
|
328
|
+
{
|
329
|
+
type: 'text',
|
330
|
+
text: 'another link',
|
331
|
+
marks: [
|
332
|
+
{
|
333
|
+
type: 'link',
|
334
|
+
attrs: { href: 'https://www.example.org/another-link', target: '_self', title: null },
|
335
|
+
},
|
336
|
+
],
|
337
|
+
},
|
338
|
+
],
|
339
|
+
});
|
340
|
+
});
|
341
|
+
|
342
|
+
it('Shows an error if an invalid asset ID is provided', async () => {
|
343
|
+
const isValidMatrixAssetId = jest.fn(() => Promise.resolve(false));
|
344
|
+
|
345
|
+
await renderWithEditor(<LinkButton />, { context: { matrix: { isValidMatrixAssetId } } });
|
346
|
+
|
347
|
+
await openModal();
|
348
|
+
select(screen.getByLabelText('Type'), 'Link to asset');
|
349
|
+
fireEvent.change(screen.getByLabelText('Asset ID'), { target: { value: 'invalid-asset-id' } });
|
350
|
+
await act(() => fireEvent.click(screen.getByRole('button', { name: 'Apply' })));
|
351
|
+
|
352
|
+
expect(screen.getByText('Invalid asset ID')).toBeInTheDocument();
|
353
|
+
expect(isValidMatrixAssetId).toHaveBeenCalledWith('invalid-asset-id');
|
354
|
+
});
|
277
355
|
});
|
@@ -2,9 +2,13 @@ import React, { useCallback, useState } from 'react';
|
|
2
2
|
import InsertLinkRoundedIcon from '@mui/icons-material/InsertLinkRounded';
|
3
3
|
import LinkModal from './LinkModal';
|
4
4
|
import { LinkFormData } from './Form/LinkForm';
|
5
|
-
import
|
6
|
-
import { useActive, useCommands,
|
5
|
+
import Button from '../../../ui/Button/Button';
|
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 {
|
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
|
-
|
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
|
-
|
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
|
-
//
|
35
|
-
|
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 {
|
2
|
-
import LinkForm, { LinkFormData } from './Form/LinkForm';
|
1
|
+
import { LinkForm, LinkFormData } from './Form/LinkForm';
|
3
2
|
import React from 'react';
|
4
|
-
import { useRemirrorContext
|
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 =
|
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={
|
31
|
+
<LinkForm data={data} onSubmit={onSubmit} />
|
25
32
|
</FormModal>
|
26
33
|
);
|
27
34
|
};
|