@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.
@@ -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 = [react_1.default.createElement(LinkButton_1.default, { key: "update-link", inPopover: true }), react_1.default.createElement(RemoveLinkButton_1.default, { key: "remove-link" })];
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(LinkButton_1.default, null),
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', { required: 'Alternative description is required' }) })),
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?.link?.target || '_self', options: targetOptions, onChange: (value) => setValue('assetLink.target', value) }))))));
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,4 +1,4 @@
1
- type LinkButtonProps = {
1
+ export type LinkButtonProps = {
2
2
  inPopover?: boolean;
3
3
  };
4
4
  declare const LinkButton: ({ inPopover }: LinkButtonProps) => JSX.Element;
@@ -1,2 +1,3 @@
1
- declare const RemoveLinkButton: () => JSX.Element;
1
+ import { LinkButtonProps } from './LinkButton';
2
+ declare const RemoveLinkButton: ({ inPopover }: LinkButtonProps) => JSX.Element;
2
3
  export default RemoveLinkButton;
@@ -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 = __importDefault(require("react"));
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
- return (react_1.default.createElement(Button_1.default, { handleOnClick: () => chain.removeLink().removeAssetLink().focus().run(), isActive: false, icon: react_1.default.createElement(LinkOff_1.default, null), label: "Remove link" }));
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;
@@ -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('keyup', keydown);
52
- return () => window.removeEventListener('keyup', keydown);
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,2 @@
1
+ export declare const noEmptySpacesValidation: (value: string | undefined) => Promise<"Empty space is not allowed" | undefined>;
2
+ export declare const regexDataURI: RegExp;
@@ -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.0",
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": "3956b5c40acb9eaac651518b03c4c30ca1dee273"
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 = [<LinkButton key="update-link" inPopover={true} />, <RemoveLinkButton key="remove-link" />];
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 && <LinkButton />}
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', { required: 'Alternative description is required' })}
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 label="URL" {...register('link.href')} />
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 label="Text" {...register('text')} />
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 label="Title" {...register('link.title')} />
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 label="Text" {...register('text')} />
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?.link?.target || '_self'}
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: '<a href="https://www.example.org/my-link">Sample link</a> with some other 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: null },
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: null },
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: '<a href="https://www.example.org/my-link">Sample link</a> <strong>with</strong> some other 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: null },
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: null },
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: null },
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: '<a href="https://www.example.org/my-link">Sample link</a> <strong>with</strong> some other 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: null },
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={() => chain.removeLink().removeAssetLink().focus().run()}
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.keyUp(screen.getByText('Modal content'), { key: 'Enter' });
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.keyUp(screen.getByText('Modal content'), { key: 'Escape' });
97
+ fireEvent.keyDown(screen.getByText('Modal content'), { key: 'Escape' });
98
98
 
99
99
  expect(mockOnCancel).toHaveBeenCalled();
100
100
  });
@@ -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('keyup', keydown);
37
- return () => window.removeEventListener('keyup', keydown);
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;