@squiz/formatted-text-editor 1.22.1-alpha.0 → 1.22.1-alpha.10
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/lib/EditorToolbar/FloatingToolbar.js +4 -1
- package/lib/EditorToolbar/Toolbar.js +4 -1
- package/lib/EditorToolbar/Tools/Image/Form/ImageForm.js +10 -3
- package/lib/EditorToolbar/Tools/Link/Form/LinkForm.js +29 -6
- package/lib/EditorToolbar/Tools/Link/LinkButton.d.ts +1 -1
- package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.d.ts +2 -1
- package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.js +42 -3
- package/lib/ui/Modal/Modal.js +2 -2
- package/lib/utils/validation.d.ts +2 -0
- package/lib/utils/validation.js +10 -0
- package/package.json +2 -2
- package/src/EditorToolbar/FloatingToolbar.spec.tsx +1 -1
- package/src/EditorToolbar/FloatingToolbar.tsx +4 -1
- package/src/EditorToolbar/Toolbar.tsx +7 -1
- package/src/EditorToolbar/Tools/Image/Form/ImageForm.tsx +9 -4
- package/src/EditorToolbar/Tools/Image/ImageButton.spec.tsx +11 -0
- package/src/EditorToolbar/Tools/Link/Form/LinkForm.tsx +50 -5
- package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +39 -59
- package/src/EditorToolbar/Tools/Link/LinkButton.tsx +1 -1
- package/src/EditorToolbar/Tools/Link/RemoveLinkButton.spec.tsx +54 -1
- package/src/EditorToolbar/Tools/Link/RemoveLinkButton.tsx +26 -5
- package/src/ui/Modal/Modal.spec.tsx +2 -2
- package/src/ui/Modal/Modal.tsx +2 -2
- package/src/utils/validation.ts +8 -0
@@ -56,7 +56,10 @@ const FloatingToolbar = () => {
|
|
56
56
|
else if (marks?.[Extensions_1.MarkName.Link].isExclusivelyActive || marks?.[Extensions_1.MarkName.AssetLink].isExclusivelyActive) {
|
57
57
|
// if all of the selected text is a link show the options to update/remove the link instead of the regular
|
58
58
|
// formatting options.
|
59
|
-
buttons = [
|
59
|
+
buttons = [
|
60
|
+
react_1.default.createElement(LinkButton_1.default, { key: "update-link", inPopover: true }),
|
61
|
+
react_1.default.createElement(RemoveLinkButton_1.default, { key: "remove-link", inPopover: true }),
|
62
|
+
];
|
60
63
|
}
|
61
64
|
else if (!marks?.[Extensions_1.MarkName.Link].isActive && !marks?.[Extensions_1.MarkName.AssetLink].isActive) {
|
62
65
|
// if none of the selected text is a link show the option to create a link.
|
@@ -16,6 +16,7 @@ const TextTypeDropdown_1 = __importDefault(require("./Tools/TextType/TextTypeDro
|
|
16
16
|
const hooks_1 = require("../hooks");
|
17
17
|
const LinkButton_1 = __importDefault(require("./Tools/Link/LinkButton"));
|
18
18
|
const ImageButton_1 = __importDefault(require("./Tools/Image/ImageButton"));
|
19
|
+
const RemoveLinkButton_1 = __importDefault(require("./Tools/Link/RemoveLinkButton"));
|
19
20
|
const Toolbar = () => {
|
20
21
|
const extensionNames = (0, hooks_1.useExtensionNames)();
|
21
22
|
return (react_1.default.createElement(react_components_1.Toolbar, { className: "remirror-toolbar editor-toolbar" },
|
@@ -28,7 +29,9 @@ const Toolbar = () => {
|
|
28
29
|
extensionNames.italic && react_1.default.createElement(ItalicButton_1.default, null),
|
29
30
|
extensionNames.underline && react_1.default.createElement(UnderlineButton_1.default, null),
|
30
31
|
extensionNames.nodeFormatting && react_1.default.createElement(TextAlignButtons_1.default, null),
|
31
|
-
extensionNames.link && react_1.default.createElement(
|
32
|
+
extensionNames.link && (react_1.default.createElement(react_1.default.Fragment, null,
|
33
|
+
react_1.default.createElement(LinkButton_1.default, null),
|
34
|
+
react_1.default.createElement(RemoveLinkButton_1.default, null))),
|
32
35
|
extensionNames.image && react_1.default.createElement(ImageButton_1.default, null)));
|
33
36
|
};
|
34
37
|
exports.Toolbar = Toolbar;
|
@@ -37,11 +37,11 @@ const clsx_1 = __importDefault(require("clsx"));
|
|
37
37
|
const Extensions_1 = require("../../../../Extensions/Extensions");
|
38
38
|
const Select_1 = require("../../../../ui/Fields/Select/Select");
|
39
39
|
const EditorContext_1 = require("../../../../Editor/EditorContext");
|
40
|
+
const validation_1 = require("../../../../utils/validation");
|
40
41
|
const imageTypeOptions = {
|
41
42
|
[Extensions_1.NodeName.Image]: { label: 'External image' },
|
42
43
|
[Extensions_1.NodeName.AssetImage]: { label: 'Asset image' },
|
43
44
|
};
|
44
|
-
const regexDataURI = /^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)?)?(;base64)?,([a-z0-9!$&',()*+;=\-._~:@/?%\s]*)$/i;
|
45
45
|
const ImageForm = ({ data, onSubmit }) => {
|
46
46
|
const { register, handleSubmit, setValue, watch, formState: { errors }, } = (0, react_hook_form_1.useForm)({
|
47
47
|
defaultValues: data,
|
@@ -97,17 +97,23 @@ const ImageForm = ({ data, onSubmit }) => {
|
|
97
97
|
required: 'Source is required',
|
98
98
|
validate: {
|
99
99
|
isValidImage: async (value) => {
|
100
|
-
if (value && regexDataURI.test(value)) {
|
100
|
+
if (value && validation_1.regexDataURI.test(value)) {
|
101
101
|
return 'Must not be a data URI';
|
102
102
|
}
|
103
103
|
if (value && (await validateIsNotImage(value))) {
|
104
104
|
return 'Must be a valid image URL';
|
105
105
|
}
|
106
106
|
},
|
107
|
+
noEmptySpaces: validation_1.noEmptySpacesValidation,
|
107
108
|
},
|
108
109
|
}) })),
|
109
110
|
react_1.default.createElement("div", { className: "squiz-fte-form-group mb-2" },
|
110
|
-
react_1.default.createElement(Input_1.Input, { label: "Alternative description", required: true, error: errors?.image?.alt?.message, ...register('image.alt', {
|
111
|
+
react_1.default.createElement(Input_1.Input, { label: "Alternative description", required: true, error: errors?.image?.alt?.message, ...register('image.alt', {
|
112
|
+
required: 'Alternative description is required',
|
113
|
+
validate: {
|
114
|
+
noEmptySpaces: validation_1.noEmptySpacesValidation,
|
115
|
+
},
|
116
|
+
}) })),
|
111
117
|
react_1.default.createElement("div", { className: "flex flex-row" },
|
112
118
|
react_1.default.createElement("div", { className: "squiz-fte-form-group mb-2" },
|
113
119
|
react_1.default.createElement(Input_1.Input, { label: "Width", type: "number", required: true, error: errors?.image?.width?.message, ...register('image.width', {
|
@@ -146,6 +152,7 @@ const ImageForm = ({ data, onSubmit }) => {
|
|
146
152
|
return 'Asset ID is invalid or not an image';
|
147
153
|
}
|
148
154
|
},
|
155
|
+
noEmptySpaces: validation_1.noEmptySpacesValidation,
|
149
156
|
},
|
150
157
|
}) }))))));
|
151
158
|
};
|
@@ -35,6 +35,7 @@ const Select_1 = require("../../../../ui/Fields/Select/Select");
|
|
35
35
|
const common_1 = require("../../../../Extensions/LinkExtension/common");
|
36
36
|
const EditorContext_1 = require("../../../../Editor/EditorContext");
|
37
37
|
const Extensions_1 = require("../../../../Extensions/Extensions");
|
38
|
+
const validation_1 = require("../../../../utils/validation");
|
38
39
|
const linkTypeOptions = {
|
39
40
|
[Extensions_1.MarkName.Link]: { label: 'Link to URL' },
|
40
41
|
[Extensions_1.MarkName.AssetLink]: { label: 'Link to asset' },
|
@@ -54,27 +55,49 @@ const LinkForm = ({ data, onSubmit }) => {
|
|
54
55
|
react_1.default.createElement(Select_1.Select, { name: "linkType", label: "Type", value: linkType, options: linkTypeOptions, onChange: (value) => setValue('linkType', value) })),
|
55
56
|
linkType === Extensions_1.MarkName.Link && (react_1.default.createElement(react_1.default.Fragment, null,
|
56
57
|
react_1.default.createElement("div", { className: (0, clsx_1.default)('squiz-fte-form-group mb-2') },
|
57
|
-
react_1.default.createElement(Input_1.Input, { label: "URL", ...register('link.href'
|
58
|
+
react_1.default.createElement(Input_1.Input, { label: "URL", required: true, error: errors?.link?.href?.message, ...register('link.href', {
|
59
|
+
required: 'URL is required',
|
60
|
+
validate: {
|
61
|
+
noEmptySpaces: validation_1.noEmptySpacesValidation,
|
62
|
+
},
|
63
|
+
}) })),
|
58
64
|
react_1.default.createElement("div", { className: (0, clsx_1.default)('squiz-fte-form-group mb-2') },
|
59
|
-
react_1.default.createElement(Input_1.Input, { label: "Text", ...register('text'
|
65
|
+
react_1.default.createElement(Input_1.Input, { label: "Text", required: true, error: errors?.text?.message, ...register('text', {
|
66
|
+
required: 'Text is required',
|
67
|
+
validate: {
|
68
|
+
noEmptySpaces: validation_1.noEmptySpacesValidation,
|
69
|
+
},
|
70
|
+
}) })),
|
60
71
|
react_1.default.createElement("div", { className: (0, clsx_1.default)('squiz-fte-form-group mb-2') },
|
61
|
-
react_1.default.createElement(Input_1.Input, { label: "Title", ...register('link.title'
|
72
|
+
react_1.default.createElement(Input_1.Input, { label: "Title", required: true, error: errors?.link?.title?.message, ...register('link.title', {
|
73
|
+
required: 'Title is required',
|
74
|
+
validate: {
|
75
|
+
noEmptySpaces: validation_1.noEmptySpacesValidation,
|
76
|
+
},
|
77
|
+
}) })),
|
62
78
|
react_1.default.createElement("div", { className: (0, clsx_1.default)('squiz-fte-form-group mb-0') },
|
63
79
|
react_1.default.createElement(Select_1.Select, { name: "link.target", label: "Target", value: data?.link?.target || '_self', options: targetOptions, onChange: (value) => setValue('link.target', value) })))),
|
64
80
|
linkType === Extensions_1.MarkName.AssetLink && (react_1.default.createElement(react_1.default.Fragment, null,
|
65
81
|
react_1.default.createElement("div", { className: (0, clsx_1.default)('squiz-fte-form-group mb-2') },
|
66
|
-
react_1.default.createElement(Input_1.Input, { label: "Asset ID", error: errors?.assetLink?.matrixAssetId?.message, ...register('assetLink.matrixAssetId', {
|
82
|
+
react_1.default.createElement(Input_1.Input, { label: "Asset ID", required: true, error: errors?.assetLink?.matrixAssetId?.message, ...register('assetLink.matrixAssetId', {
|
83
|
+
required: 'Asset ID is required',
|
67
84
|
validate: {
|
68
85
|
isValidAsset: async (assetId) => {
|
69
86
|
if (assetId && !(await context.matrix.resolveMatrixAsset(assetId))) {
|
70
87
|
return 'Invalid asset ID';
|
71
88
|
}
|
72
89
|
},
|
90
|
+
noEmptySpaces: validation_1.noEmptySpacesValidation,
|
73
91
|
},
|
74
92
|
}) })),
|
75
93
|
react_1.default.createElement("div", { className: (0, clsx_1.default)('squiz-fte-form-group mb-2') },
|
76
|
-
react_1.default.createElement(Input_1.Input, { label: "Text", ...register('text'
|
94
|
+
react_1.default.createElement(Input_1.Input, { label: "Text", required: true, error: errors?.text?.message, ...register('text', {
|
95
|
+
required: 'Text is required',
|
96
|
+
validate: {
|
97
|
+
noEmptySpaces: validation_1.noEmptySpacesValidation,
|
98
|
+
},
|
99
|
+
}) })),
|
77
100
|
react_1.default.createElement("div", { className: (0, clsx_1.default)('squiz-fte-form-group mb-0') },
|
78
|
-
react_1.default.createElement(Select_1.Select, { name: "assetLink.target", label: "Target", value: data?.
|
101
|
+
react_1.default.createElement(Select_1.Select, { name: "assetLink.target", label: "Target", value: data?.assetLink?.target || '_self', options: targetOptions, onChange: (value) => setValue('assetLink.target', value) }))))));
|
79
102
|
};
|
80
103
|
exports.LinkForm = LinkForm;
|
@@ -1,14 +1,53 @@
|
|
1
1
|
"use strict";
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
3
|
+
if (k2 === undefined) k2 = k;
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
7
|
+
}
|
8
|
+
Object.defineProperty(o, k2, desc);
|
9
|
+
}) : (function(o, m, k, k2) {
|
10
|
+
if (k2 === undefined) k2 = k;
|
11
|
+
o[k2] = m[k];
|
12
|
+
}));
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
15
|
+
}) : function(o, v) {
|
16
|
+
o["default"] = v;
|
17
|
+
});
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
19
|
+
if (mod && mod.__esModule) return mod;
|
20
|
+
var result = {};
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
22
|
+
__setModuleDefault(result, mod);
|
23
|
+
return result;
|
24
|
+
};
|
2
25
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
3
26
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
4
27
|
};
|
5
28
|
Object.defineProperty(exports, "__esModule", { value: true });
|
6
|
-
const react_1 =
|
29
|
+
const react_1 = __importStar(require("react"));
|
7
30
|
const react_2 = require("@remirror/react");
|
8
31
|
const Button_1 = __importDefault(require("../../../ui/Button/Button"));
|
9
32
|
const LinkOff_1 = __importDefault(require("@mui/icons-material/LinkOff"));
|
10
|
-
const RemoveLinkButton = () => {
|
33
|
+
const RemoveLinkButton = ({ inPopover = false }) => {
|
11
34
|
const chain = (0, react_2.useChainedCommands)();
|
12
|
-
|
35
|
+
const active = (0, react_2.useActive)();
|
36
|
+
const disabled = !active.link();
|
37
|
+
const handleClick = () => {
|
38
|
+
chain.removeLink().removeAssetLink().focus().run();
|
39
|
+
};
|
40
|
+
const handleShortcut = (0, react_1.useCallback)(() => {
|
41
|
+
handleClick();
|
42
|
+
// Prevent other key handlers being run
|
43
|
+
return true;
|
44
|
+
}, []);
|
45
|
+
// when Shift+Ctrl+k is pressed show the modal, only registered in the toolbar button instance to avoid the key press
|
46
|
+
// being double handled.
|
47
|
+
if (!inPopover) {
|
48
|
+
// disable the shortcut if the button is disabled
|
49
|
+
(0, react_2.useKeymap)('Shift-Mod-k', disabled ? () => true : handleShortcut);
|
50
|
+
}
|
51
|
+
return (react_1.default.createElement(Button_1.default, { handleOnClick: handleClick, isActive: false, isDisabled: disabled, icon: react_1.default.createElement(LinkOff_1.default, null), label: "Remove link (shift+cmd+K)" }));
|
13
52
|
};
|
14
53
|
exports.default = RemoveLinkButton;
|
package/lib/ui/Modal/Modal.js
CHANGED
@@ -48,8 +48,8 @@ const Modal = ({ children, title, onCancel, onSubmit, className }, ref) => {
|
|
48
48
|
};
|
49
49
|
// register key listeners for Enter/Escape on key up so the editor doesn't handle the event as well
|
50
50
|
(0, react_1.useEffect)(() => {
|
51
|
-
window.addEventListener('
|
52
|
-
return () => window.removeEventListener('
|
51
|
+
window.addEventListener('keydown', keydown);
|
52
|
+
return () => window.removeEventListener('keydown', keydown);
|
53
53
|
}, []);
|
54
54
|
// add/remove the modal container from the DOM and focus on the first input
|
55
55
|
(0, react_1.useEffect)(() => {
|
@@ -0,0 +1,10 @@
|
|
1
|
+
"use strict";
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
3
|
+
exports.regexDataURI = exports.noEmptySpacesValidation = void 0;
|
4
|
+
const noEmptySpacesValidation = async (value) => {
|
5
|
+
if (value && !(value.trim().length > 0)) {
|
6
|
+
return 'Empty space is not allowed';
|
7
|
+
}
|
8
|
+
};
|
9
|
+
exports.noEmptySpacesValidation = noEmptySpacesValidation;
|
10
|
+
exports.regexDataURI = /^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)?)?(;base64)?,([a-z0-9!$&',()*+;=\-._~:@/?%\s]*)$/i;
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@squiz/formatted-text-editor",
|
3
|
-
"version": "1.22.1-alpha.
|
3
|
+
"version": "1.22.1-alpha.10",
|
4
4
|
"main": "lib/index.js",
|
5
5
|
"types": "lib/index.d.ts",
|
6
6
|
"scripts": {
|
@@ -74,5 +74,5 @@
|
|
74
74
|
"volta": {
|
75
75
|
"node": "18.15.0"
|
76
76
|
},
|
77
|
-
"gitHead": "
|
77
|
+
"gitHead": "cfc71ed7aab20c298e93ca0f89502d0d80e8674c"
|
78
78
|
}
|
@@ -13,7 +13,7 @@ describe('FloatingToolbar', () => {
|
|
13
13
|
['Regular text + link selected', 3, 17, ['Bold (cmd+B)', 'Italic (cmd+I)', 'Underline (cmd+U)']],
|
14
14
|
['Nothing selected, positioned directly on the left of a link', 12, 12, []],
|
15
15
|
['Nothing selected, positioned directly on the right of a link', 19, 19, []],
|
16
|
-
['Nothing selected, positioned within a link', 13, 13, ['Link (cmd+K)', 'Remove link']],
|
16
|
+
['Nothing selected, positioned within a link', 13, 13, ['Link (cmd+K)', 'Remove link (shift+cmd+K)']],
|
17
17
|
])(
|
18
18
|
'Renders formatting buttons when text is selected - %s',
|
19
19
|
async (description: string, from: number, to: number, expectedButtons: string[]) => {
|
@@ -32,7 +32,10 @@ export const FloatingToolbar = () => {
|
|
32
32
|
} else if (marks?.[MarkName.Link].isExclusivelyActive || marks?.[MarkName.AssetLink].isExclusivelyActive) {
|
33
33
|
// if all of the selected text is a link show the options to update/remove the link instead of the regular
|
34
34
|
// formatting options.
|
35
|
-
buttons = [
|
35
|
+
buttons = [
|
36
|
+
<LinkButton key="update-link" inPopover={true} />,
|
37
|
+
<RemoveLinkButton key="remove-link" inPopover={true} />,
|
38
|
+
];
|
36
39
|
} else if (!marks?.[MarkName.Link].isActive && !marks?.[MarkName.AssetLink].isActive) {
|
37
40
|
// if none of the selected text is a link show the option to create a link.
|
38
41
|
buttons.push(
|
@@ -10,6 +10,7 @@ import TextTypeDropdown from './Tools/TextType/TextTypeDropdown';
|
|
10
10
|
import { useExtensionNames } from '../hooks';
|
11
11
|
import LinkButton from './Tools/Link/LinkButton';
|
12
12
|
import ImageButton from './Tools/Image/ImageButton';
|
13
|
+
import RemoveLinkButton from './Tools/Link/RemoveLinkButton';
|
13
14
|
|
14
15
|
export const Toolbar = () => {
|
15
16
|
const extensionNames = useExtensionNames();
|
@@ -28,7 +29,12 @@ export const Toolbar = () => {
|
|
28
29
|
{extensionNames.italic && <ItalicButton />}
|
29
30
|
{extensionNames.underline && <UnderlineButton />}
|
30
31
|
{extensionNames.nodeFormatting && <TextAlignButtons />}
|
31
|
-
{extensionNames.link &&
|
32
|
+
{extensionNames.link && (
|
33
|
+
<>
|
34
|
+
<LinkButton />
|
35
|
+
<RemoveLinkButton />
|
36
|
+
</>
|
37
|
+
)}
|
32
38
|
{extensionNames.image && <ImageButton />}
|
33
39
|
</RemirrorToolbar>
|
34
40
|
);
|
@@ -12,6 +12,7 @@ import { AssetImageAttributes } from '../../../../Extensions/ImageExtension/Asse
|
|
12
12
|
import { DeepPartial } from '../../../../types';
|
13
13
|
import { Select, SelectOptions } from '../../../../ui/Fields/Select/Select';
|
14
14
|
import { EditorContext } from '../../../../Editor/EditorContext';
|
15
|
+
import { noEmptySpacesValidation, regexDataURI } from '../../../../utils/validation';
|
15
16
|
|
16
17
|
export type ImageFormData = {
|
17
18
|
imageType: NodeName;
|
@@ -30,9 +31,6 @@ export type FormProps = {
|
|
30
31
|
};
|
31
32
|
export type Dimensions = 'image.width' | 'image.height';
|
32
33
|
|
33
|
-
const regexDataURI =
|
34
|
-
/^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)?)?(;base64)?,([a-z0-9!$&',()*+;=\-._~:@/?%\s]*)$/i;
|
35
|
-
|
36
34
|
const ImageForm = ({ data, onSubmit }: FormProps): ReactElement => {
|
37
35
|
const {
|
38
36
|
register,
|
@@ -118,6 +116,7 @@ const ImageForm = ({ data, onSubmit }: FormProps): ReactElement => {
|
|
118
116
|
return 'Must be a valid image URL';
|
119
117
|
}
|
120
118
|
},
|
119
|
+
noEmptySpaces: noEmptySpacesValidation,
|
121
120
|
},
|
122
121
|
})}
|
123
122
|
/>
|
@@ -127,7 +126,12 @@ const ImageForm = ({ data, onSubmit }: FormProps): ReactElement => {
|
|
127
126
|
label="Alternative description"
|
128
127
|
required
|
129
128
|
error={errors?.image?.alt?.message}
|
130
|
-
{...register('image.alt', {
|
129
|
+
{...register('image.alt', {
|
130
|
+
required: 'Alternative description is required',
|
131
|
+
validate: {
|
132
|
+
noEmptySpaces: noEmptySpacesValidation,
|
133
|
+
},
|
134
|
+
})}
|
131
135
|
/>
|
132
136
|
</div>
|
133
137
|
<div className="flex flex-row">
|
@@ -199,6 +203,7 @@ const ImageForm = ({ data, onSubmit }: FormProps): ReactElement => {
|
|
199
203
|
return 'Asset ID is invalid or not an image';
|
200
204
|
}
|
201
205
|
},
|
206
|
+
noEmptySpaces: noEmptySpacesValidation,
|
202
207
|
},
|
203
208
|
})}
|
204
209
|
/>
|
@@ -324,4 +324,15 @@ describe('ImageButton', () => {
|
|
324
324
|
expect(screen.getByText(expectedError)).toBeInTheDocument();
|
325
325
|
},
|
326
326
|
);
|
327
|
+
|
328
|
+
it('Shows an error if the field value is just an empty space', async () => {
|
329
|
+
await renderWithEditor(<ImageButton />);
|
330
|
+
|
331
|
+
await openModal();
|
332
|
+
fireEvent.change(screen.getByLabelText('Source'), { target: { value: ' ' } });
|
333
|
+
fireEvent.change(screen.getByLabelText('Alternative description'), { target: { value: ' ' } });
|
334
|
+
await act(() => fireEvent.click(screen.getByRole('button', { name: 'Apply' })));
|
335
|
+
|
336
|
+
expect(screen.getAllByText('Empty space is not allowed')).toHaveLength(2);
|
337
|
+
});
|
327
338
|
});
|
@@ -10,6 +10,7 @@ import { LinkTarget } from '../../../../Extensions/LinkExtension/common';
|
|
10
10
|
import { EditorContext } from '../../../../Editor/EditorContext';
|
11
11
|
import { MarkName } from '../../../../Extensions/Extensions';
|
12
12
|
import { DeepPartial } from '../../../../types';
|
13
|
+
import { noEmptySpacesValidation } from '../../../../utils/validation';
|
13
14
|
|
14
15
|
export type LinkFormData = {
|
15
16
|
linkType: MarkName;
|
@@ -62,13 +63,44 @@ export const LinkForm = ({ data, onSubmit }: FormProps): ReactElement => {
|
|
62
63
|
{linkType === MarkName.Link && (
|
63
64
|
<>
|
64
65
|
<div className={clsx('squiz-fte-form-group mb-2')}>
|
65
|
-
<Input
|
66
|
+
<Input
|
67
|
+
label="URL"
|
68
|
+
required
|
69
|
+
error={errors?.link?.href?.message}
|
70
|
+
{...register('link.href', {
|
71
|
+
required: 'URL is required',
|
72
|
+
validate: {
|
73
|
+
noEmptySpaces: noEmptySpacesValidation,
|
74
|
+
},
|
75
|
+
})}
|
76
|
+
/>
|
66
77
|
</div>
|
78
|
+
|
67
79
|
<div className={clsx('squiz-fte-form-group mb-2')}>
|
68
|
-
<Input
|
80
|
+
<Input
|
81
|
+
label="Text"
|
82
|
+
required
|
83
|
+
error={errors?.text?.message}
|
84
|
+
{...register('text', {
|
85
|
+
required: 'Text is required',
|
86
|
+
validate: {
|
87
|
+
noEmptySpaces: noEmptySpacesValidation,
|
88
|
+
},
|
89
|
+
})}
|
90
|
+
/>
|
69
91
|
</div>
|
70
92
|
<div className={clsx('squiz-fte-form-group mb-2')}>
|
71
|
-
<Input
|
93
|
+
<Input
|
94
|
+
label="Title"
|
95
|
+
required
|
96
|
+
error={errors?.link?.title?.message}
|
97
|
+
{...register('link.title', {
|
98
|
+
required: 'Title is required',
|
99
|
+
validate: {
|
100
|
+
noEmptySpaces: noEmptySpacesValidation,
|
101
|
+
},
|
102
|
+
})}
|
103
|
+
/>
|
72
104
|
</div>
|
73
105
|
<div className={clsx('squiz-fte-form-group mb-0')}>
|
74
106
|
<Select
|
@@ -87,26 +119,39 @@ export const LinkForm = ({ data, onSubmit }: FormProps): ReactElement => {
|
|
87
119
|
<div className={clsx('squiz-fte-form-group mb-2')}>
|
88
120
|
<Input
|
89
121
|
label="Asset ID"
|
122
|
+
required
|
90
123
|
error={errors?.assetLink?.matrixAssetId?.message}
|
91
124
|
{...register('assetLink.matrixAssetId', {
|
125
|
+
required: 'Asset ID is required',
|
92
126
|
validate: {
|
93
127
|
isValidAsset: async (assetId: string | undefined) => {
|
94
128
|
if (assetId && !(await context.matrix.resolveMatrixAsset(assetId))) {
|
95
129
|
return 'Invalid asset ID';
|
96
130
|
}
|
97
131
|
},
|
132
|
+
noEmptySpaces: noEmptySpacesValidation,
|
98
133
|
},
|
99
134
|
})}
|
100
135
|
/>
|
101
136
|
</div>
|
102
137
|
<div className={clsx('squiz-fte-form-group mb-2')}>
|
103
|
-
<Input
|
138
|
+
<Input
|
139
|
+
label="Text"
|
140
|
+
required
|
141
|
+
error={errors?.text?.message}
|
142
|
+
{...register('text', {
|
143
|
+
required: 'Text is required',
|
144
|
+
validate: {
|
145
|
+
noEmptySpaces: noEmptySpacesValidation,
|
146
|
+
},
|
147
|
+
})}
|
148
|
+
/>
|
104
149
|
</div>
|
105
150
|
<div className={clsx('squiz-fte-form-group mb-0')}>
|
106
151
|
<Select
|
107
152
|
name="assetLink.target"
|
108
153
|
label="Target"
|
109
|
-
value={data?.
|
154
|
+
value={data?.assetLink?.target || '_self'}
|
110
155
|
options={targetOptions}
|
111
156
|
onChange={(value) => setValue('assetLink.target', value as LinkTarget)}
|
112
157
|
/>
|
@@ -48,7 +48,8 @@ describe('LinkButton', () => {
|
|
48
48
|
|
49
49
|
it('Updates the attributes of an existing link', async () => {
|
50
50
|
const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
|
51
|
-
content:
|
51
|
+
content:
|
52
|
+
'<a href="https://www.example.org/my-link" title="Sample title">Sample link</a> with some other content.',
|
52
53
|
});
|
53
54
|
|
54
55
|
// jump to the middle of the link.
|
@@ -58,6 +59,7 @@ describe('LinkButton', () => {
|
|
58
59
|
await openModal();
|
59
60
|
fireEvent.change(screen.getByLabelText('URL'), { target: { value: 'https://www.example.org/updated-link' } });
|
60
61
|
fireEvent.change(screen.getByLabelText('Text'), { target: { value: 'Updated sample link' } });
|
62
|
+
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Updated sample title' } });
|
61
63
|
|
62
64
|
// verify the content matches what was initially set prior to applying.
|
63
65
|
expect(getJsonContent()).toEqual({
|
@@ -70,7 +72,7 @@ describe('LinkButton', () => {
|
|
70
72
|
marks: [
|
71
73
|
{
|
72
74
|
type: 'link',
|
73
|
-
attrs: { href: 'https://www.example.org/my-link', target: '_self', title:
|
75
|
+
attrs: { href: 'https://www.example.org/my-link', target: '_self', title: 'Sample title' },
|
74
76
|
},
|
75
77
|
],
|
76
78
|
},
|
@@ -96,7 +98,7 @@ describe('LinkButton', () => {
|
|
96
98
|
marks: [
|
97
99
|
{
|
98
100
|
type: 'link',
|
99
|
-
attrs: { href: 'https://www.example.org/updated-link', target: '_self', title:
|
101
|
+
attrs: { href: 'https://www.example.org/updated-link', target: '_self', title: 'Updated sample title' },
|
100
102
|
},
|
101
103
|
],
|
102
104
|
},
|
@@ -105,59 +107,10 @@ describe('LinkButton', () => {
|
|
105
107
|
});
|
106
108
|
});
|
107
109
|
|
108
|
-
it('Removes the link when the URL is cleared', async () => {
|
109
|
-
const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
|
110
|
-
content: '<a href="https://www.example.org/my-link">Sample link</a> with some other content.',
|
111
|
-
});
|
112
|
-
|
113
|
-
// jump to the middle of the link.
|
114
|
-
await act(() => editor.selectText(3));
|
115
|
-
|
116
|
-
// open the modal and clear the link.
|
117
|
-
await openModal();
|
118
|
-
fireEvent.change(screen.getByLabelText('URL'), { target: { value: '' } });
|
119
|
-
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
120
|
-
|
121
|
-
await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
|
122
|
-
|
123
|
-
// cursor should be positioned after the link and the link should be removed.
|
124
|
-
expect(editor.from).toBe(12);
|
125
|
-
expect(editor.to).toBe(12);
|
126
|
-
expect(getJsonContent()).toEqual({
|
127
|
-
type: 'paragraph',
|
128
|
-
attrs: expect.any(Object),
|
129
|
-
content: [{ type: 'text', text: 'Sample link with some other content.' }],
|
130
|
-
});
|
131
|
-
});
|
132
|
-
|
133
|
-
it('Removes the content when the text is cleared', async () => {
|
134
|
-
const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
|
135
|
-
content: '<a href="https://www.example.org/my-link">Sample link</a> with some other content.',
|
136
|
-
});
|
137
|
-
|
138
|
-
// jump to the middle of the link.
|
139
|
-
await act(() => editor.selectText(3));
|
140
|
-
|
141
|
-
// open the modal and clear the text.
|
142
|
-
await openModal();
|
143
|
-
fireEvent.change(screen.getByLabelText('Text'), { target: { value: '' } });
|
144
|
-
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
145
|
-
|
146
|
-
await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
|
147
|
-
|
148
|
-
// cursor should be positioned where the link was and the link+text should be removed.
|
149
|
-
expect(editor.from).toBe(1);
|
150
|
-
expect(editor.to).toBe(1);
|
151
|
-
expect(getJsonContent()).toEqual({
|
152
|
-
type: 'paragraph',
|
153
|
-
attrs: expect.any(Object),
|
154
|
-
content: [{ type: 'text', text: ' with some other content.' }],
|
155
|
-
});
|
156
|
-
});
|
157
|
-
|
158
110
|
it('Updates unselected part of link when link is partially selected', async () => {
|
159
111
|
const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
|
160
|
-
content:
|
112
|
+
content:
|
113
|
+
'<a href="https://www.example.org/my-link" title="Sample title">Sample link</a> <strong>with</strong> some other content.',
|
161
114
|
});
|
162
115
|
|
163
116
|
// jump to the middle of the link and select some of the text after it.
|
@@ -182,7 +135,7 @@ describe('LinkButton', () => {
|
|
182
135
|
marks: [
|
183
136
|
{
|
184
137
|
type: 'link',
|
185
|
-
attrs: { href: 'https://www.example.org/my-link', target: '_self', title:
|
138
|
+
attrs: { href: 'https://www.example.org/my-link', target: '_self', title: 'Sample title' },
|
186
139
|
},
|
187
140
|
],
|
188
141
|
},
|
@@ -192,7 +145,7 @@ describe('LinkButton', () => {
|
|
192
145
|
marks: [
|
193
146
|
{
|
194
147
|
type: 'link',
|
195
|
-
attrs: { href: 'https://www.example.org/my-link', target: '_self', title:
|
148
|
+
attrs: { href: 'https://www.example.org/my-link', target: '_self', title: 'Sample title' },
|
196
149
|
},
|
197
150
|
{ type: 'bold' },
|
198
151
|
],
|
@@ -203,7 +156,7 @@ describe('LinkButton', () => {
|
|
203
156
|
marks: [
|
204
157
|
{
|
205
158
|
type: 'link',
|
206
|
-
attrs: { href: 'https://www.example.org/my-link', target: '_self', title:
|
159
|
+
attrs: { href: 'https://www.example.org/my-link', target: '_self', title: 'Sample title' },
|
207
160
|
},
|
208
161
|
],
|
209
162
|
},
|
@@ -214,7 +167,8 @@ describe('LinkButton', () => {
|
|
214
167
|
|
215
168
|
it('Updates text and formatting when selection has a mixture of formatting', async () => {
|
216
169
|
const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
|
217
|
-
content:
|
170
|
+
content:
|
171
|
+
'<a href="https://www.example.org/my-link" title="Sample title">Sample link</a> <strong>with</strong> some other content.',
|
218
172
|
});
|
219
173
|
|
220
174
|
// jump to the middle of the link and select some of the text after it.
|
@@ -240,7 +194,7 @@ describe('LinkButton', () => {
|
|
240
194
|
marks: [
|
241
195
|
{
|
242
196
|
type: 'link',
|
243
|
-
attrs: { href: 'https://www.example.org/my-link', target: '_self', title:
|
197
|
+
attrs: { href: 'https://www.example.org/my-link', target: '_self', title: 'Sample title' },
|
244
198
|
},
|
245
199
|
],
|
246
200
|
},
|
@@ -385,4 +339,30 @@ describe('LinkButton', () => {
|
|
385
339
|
expect(screen.getByText('Invalid asset ID')).toBeInTheDocument();
|
386
340
|
expect(resolveMatrixAsset).toHaveBeenCalledWith('invalid-asset-id');
|
387
341
|
});
|
342
|
+
|
343
|
+
it('Shows an error if a required field is not provided', async () => {
|
344
|
+
await renderWithEditor(<LinkButton />);
|
345
|
+
|
346
|
+
await openModal();
|
347
|
+
fireEvent.change(screen.getByLabelText('URL'), { target: { value: '' } });
|
348
|
+
fireEvent.change(screen.getByLabelText('Text'), { target: { value: '' } });
|
349
|
+
fireEvent.change(screen.getByLabelText('Title'), { target: { value: '' } });
|
350
|
+
await act(() => fireEvent.click(screen.getByRole('button', { name: 'Apply' })));
|
351
|
+
|
352
|
+
expect(screen.getByText('URL is required')).toBeInTheDocument();
|
353
|
+
expect(screen.getByText('Text is required')).toBeInTheDocument();
|
354
|
+
expect(screen.getByText('Title is required')).toBeInTheDocument();
|
355
|
+
});
|
356
|
+
|
357
|
+
it('Shows an error if the field value is just an empty space', async () => {
|
358
|
+
await renderWithEditor(<LinkButton />);
|
359
|
+
|
360
|
+
await openModal();
|
361
|
+
fireEvent.change(screen.getByLabelText('URL'), { target: { value: ' ' } });
|
362
|
+
fireEvent.change(screen.getByLabelText('Text'), { target: { value: ' ' } });
|
363
|
+
fireEvent.change(screen.getByLabelText('Title'), { target: { value: ' ' } });
|
364
|
+
await act(() => fireEvent.click(screen.getByRole('button', { name: 'Apply' })));
|
365
|
+
|
366
|
+
expect(screen.getAllByText('Empty space is not allowed')).toHaveLength(3);
|
367
|
+
});
|
388
368
|
});
|
@@ -10,7 +10,7 @@ import { AssetLinkExtension } from '../../../Extensions/LinkExtension/AssetLinkE
|
|
10
10
|
import { MarkName } from '../../../Extensions/Extensions';
|
11
11
|
import { ImageExtension } from '../../../Extensions/ImageExtension/ImageExtension';
|
12
12
|
|
13
|
-
type LinkButtonProps = {
|
13
|
+
export type LinkButtonProps = {
|
14
14
|
inPopover?: boolean;
|
15
15
|
};
|
16
16
|
|
@@ -35,7 +35,7 @@ describe('RemoveLinkButton', () => {
|
|
35
35
|
await act(() => editor.selectText('all'));
|
36
36
|
|
37
37
|
// remove the links.
|
38
|
-
fireEvent.click(screen.getByRole('button', { name: 'Remove link' }));
|
38
|
+
fireEvent.click(screen.getByRole('button', { name: 'Remove link (shift+cmd+K)' }));
|
39
39
|
|
40
40
|
// make sure both types of link have been removed.
|
41
41
|
expect(getJsonContent()).toEqual({
|
@@ -44,4 +44,57 @@ describe('RemoveLinkButton', () => {
|
|
44
44
|
content: [{ type: 'text', text: 'Sample link with another link' }],
|
45
45
|
});
|
46
46
|
});
|
47
|
+
|
48
|
+
it('Removes the link when clicking the keyboard shortcut', async () => {
|
49
|
+
const { elements, editor, getJsonContent } = await renderWithEditor(<RemoveLinkButton />, {
|
50
|
+
context: { matrix: { matrixDomain: 'my-matrix.squiz.net' } },
|
51
|
+
content: {
|
52
|
+
type: 'doc',
|
53
|
+
content: [
|
54
|
+
{
|
55
|
+
type: 'paragraph',
|
56
|
+
content: [
|
57
|
+
{
|
58
|
+
type: 'text',
|
59
|
+
text: 'Sample link',
|
60
|
+
marks: [{ type: 'assetLink', attrs: { matrixAssetId: '123', target: '_blank' } }],
|
61
|
+
},
|
62
|
+
{ type: 'text', text: ' with ' },
|
63
|
+
{
|
64
|
+
type: 'text',
|
65
|
+
text: 'another link',
|
66
|
+
marks: [{ type: 'link', attrs: { href: 'https://www.example.org/another-link', target: '_self' } }],
|
67
|
+
},
|
68
|
+
],
|
69
|
+
},
|
70
|
+
],
|
71
|
+
},
|
72
|
+
});
|
73
|
+
|
74
|
+
// select all of the text.
|
75
|
+
await act(() => editor.selectText('all'));
|
76
|
+
|
77
|
+
// press the keyboard shortcut.
|
78
|
+
fireEvent.keyDown(elements.editor, { key: 'k', ctrlKey: true, shiftKey: true });
|
79
|
+
|
80
|
+
// make sure both types of link have been removed.
|
81
|
+
expect(getJsonContent()).toEqual({
|
82
|
+
type: 'paragraph',
|
83
|
+
attrs: expect.any(Object),
|
84
|
+
content: [{ type: 'text', text: 'Sample link with another link' }],
|
85
|
+
});
|
86
|
+
});
|
87
|
+
|
88
|
+
it('Enables the Remove link button when link text is selected', async () => {
|
89
|
+
const { editor } = await renderWithEditor(<RemoveLinkButton />, {
|
90
|
+
content: '<a href="https://www.example.org/my-link">Sample link</a> with some other content.',
|
91
|
+
});
|
92
|
+
|
93
|
+
// expect remove button to be disabled
|
94
|
+
expect(screen.getByRole('button', { name: 'Remove link (shift+cmd+K)' })).toBeDisabled();
|
95
|
+
// jump to the middle of the link.
|
96
|
+
await act(() => editor.selectText(3));
|
97
|
+
// expect remove button to be enabled
|
98
|
+
expect(screen.getByRole('button', { name: 'Remove link (shift+cmd+K)' })).not.toBeDisabled();
|
99
|
+
});
|
47
100
|
});
|
@@ -1,17 +1,38 @@
|
|
1
|
-
import React from 'react';
|
2
|
-
import { useChainedCommands } from '@remirror/react';
|
1
|
+
import React, { useCallback } from 'react';
|
2
|
+
import { useChainedCommands, useActive, useKeymap } from '@remirror/react';
|
3
3
|
import Button from '../../../ui/Button/Button';
|
4
4
|
import LinkOffIcon from '@mui/icons-material/LinkOff';
|
5
|
+
import { LinkExtension } from '../../../Extensions/LinkExtension/LinkExtension';
|
6
|
+
import { LinkButtonProps } from './LinkButton';
|
5
7
|
|
6
|
-
const RemoveLinkButton = () => {
|
8
|
+
const RemoveLinkButton = ({ inPopover = false }: LinkButtonProps) => {
|
7
9
|
const chain = useChainedCommands();
|
10
|
+
const active = useActive<LinkExtension>();
|
11
|
+
const disabled = !active.link();
|
12
|
+
|
13
|
+
const handleClick = () => {
|
14
|
+
chain.removeLink().removeAssetLink().focus().run();
|
15
|
+
};
|
16
|
+
const handleShortcut = useCallback(() => {
|
17
|
+
handleClick();
|
18
|
+
// Prevent other key handlers being run
|
19
|
+
return true;
|
20
|
+
}, []);
|
21
|
+
|
22
|
+
// when Shift+Ctrl+k is pressed show the modal, only registered in the toolbar button instance to avoid the key press
|
23
|
+
// being double handled.
|
24
|
+
if (!inPopover) {
|
25
|
+
// disable the shortcut if the button is disabled
|
26
|
+
useKeymap('Shift-Mod-k', disabled ? () => true : handleShortcut);
|
27
|
+
}
|
8
28
|
|
9
29
|
return (
|
10
30
|
<Button
|
11
|
-
handleOnClick={
|
31
|
+
handleOnClick={handleClick}
|
12
32
|
isActive={false}
|
33
|
+
isDisabled={disabled}
|
13
34
|
icon={<LinkOffIcon />}
|
14
|
-
label="Remove link"
|
35
|
+
label="Remove link (shift+cmd+K)"
|
15
36
|
/>
|
16
37
|
);
|
17
38
|
};
|
@@ -82,7 +82,7 @@ describe('Modal', () => {
|
|
82
82
|
</Modal>,
|
83
83
|
);
|
84
84
|
|
85
|
-
fireEvent.
|
85
|
+
fireEvent.keyDown(screen.getByText('Modal content'), { key: 'Enter' });
|
86
86
|
|
87
87
|
expect(mockOnSubmit).toHaveBeenCalled();
|
88
88
|
});
|
@@ -94,7 +94,7 @@ describe('Modal', () => {
|
|
94
94
|
</Modal>,
|
95
95
|
);
|
96
96
|
|
97
|
-
fireEvent.
|
97
|
+
fireEvent.keyDown(screen.getByText('Modal content'), { key: 'Escape' });
|
98
98
|
|
99
99
|
expect(mockOnCancel).toHaveBeenCalled();
|
100
100
|
});
|
package/src/ui/Modal/Modal.tsx
CHANGED
@@ -33,8 +33,8 @@ const Modal = (
|
|
33
33
|
|
34
34
|
// register key listeners for Enter/Escape on key up so the editor doesn't handle the event as well
|
35
35
|
useEffect(() => {
|
36
|
-
window.addEventListener('
|
37
|
-
return () => window.removeEventListener('
|
36
|
+
window.addEventListener('keydown', keydown);
|
37
|
+
return () => window.removeEventListener('keydown', keydown);
|
38
38
|
}, []);
|
39
39
|
|
40
40
|
// add/remove the modal container from the DOM and focus on the first input
|
@@ -0,0 +1,8 @@
|
|
1
|
+
export const noEmptySpacesValidation = async (value: string | undefined) => {
|
2
|
+
if (value && !(value.trim().length > 0)) {
|
3
|
+
return 'Empty space is not allowed';
|
4
|
+
}
|
5
|
+
};
|
6
|
+
|
7
|
+
export const regexDataURI =
|
8
|
+
/^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)?)?(;base64)?,([a-z0-9!$&',()*+;=\-._~:@/?%\s]*)$/i;
|