@squiz/formatted-text-editor 1.21.1-alpha.16 → 1.21.1-alpha.19

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.
@@ -19,8 +19,15 @@ const Editor = ({ content, editable = true, onChange }) => {
19
19
  setState(parameter.state);
20
20
  onChange?.(parameter);
21
21
  };
22
+ const preventImagePaste = (event) => {
23
+ const { clipboardData } = event;
24
+ const pastedData = clipboardData?.items[0];
25
+ if (pastedData?.type && pastedData?.type.startsWith('image/')) {
26
+ event.preventDefault();
27
+ }
28
+ };
22
29
  return (react_1.default.createElement("div", { className: "squiz-fte-scope" },
23
- react_1.default.createElement("div", { className: (0, clsx_1.default)('remirror-theme formatted-text-editor', !editable && 'formatted-text-editor--is-disabled') },
30
+ react_1.default.createElement("div", { className: (0, clsx_1.default)('remirror-theme formatted-text-editor', !editable && 'formatted-text-editor--is-disabled'), onPaste: preventImagePaste },
24
31
  react_1.default.createElement(react_2.Remirror, { manager: manager, state: state, editable: editable, onChange: handleChange, placeholder: "Write something", label: "Text editor" },
25
32
  editable && react_1.default.createElement(EditorToolbar_1.Toolbar, null),
26
33
  react_1.default.createElement(react_2.EditorComponent, null),
@@ -37,24 +37,31 @@ const LinkButton_1 = __importDefault(require("./Tools/Link/LinkButton"));
37
37
  const react_2 = require("@remirror/react");
38
38
  const react_components_1 = require("@remirror/react-components");
39
39
  const createToolbarPositioner_1 = require("../utils/createToolbarPositioner");
40
+ const ImageButton_1 = __importDefault(require("./Tools/Image/ImageButton"));
40
41
  // The editor main toolbar
41
42
  const FloatingToolbar = () => {
42
43
  const extensionNames = (0, hooks_1.useExtensionNames)();
43
44
  const positioner = (0, react_1.useMemo)(() => (0, createToolbarPositioner_1.createToolbarPositioner)({ types: ['link'] }), []);
44
45
  const { data } = (0, react_2.usePositioner)(positioner, []);
46
+ const activeImage = (0, react_2.useActive)();
45
47
  let buttons = [
46
48
  extensionNames.bold && react_1.default.createElement(BoldButton_1.default, { key: "bold" }),
47
49
  extensionNames.italic && react_1.default.createElement(ItalicButton_1.default, { key: "italic" }),
48
50
  extensionNames.underline && react_1.default.createElement(UnderlineButton_1.default, { key: "underline" }),
49
51
  ];
50
- if (data.marks?.link.isExclusivelyActive) {
51
- // if all of the selected text is a link show the options to update/remove the link instead of the regular
52
- // formatting options.
53
- buttons = [react_1.default.createElement(LinkButton_1.default, { key: "update-link", inPopover: true }), react_1.default.createElement(RemoveLinkButton_1.default, { key: "remove-link" })];
52
+ if (!activeImage.image()) {
53
+ if (data.marks?.link.isExclusivelyActive) {
54
+ // if all of the selected text is a link show the options to update/remove the link instead of the regular
55
+ // formatting options.
56
+ buttons = [react_1.default.createElement(LinkButton_1.default, { key: "update-link", inPopover: true }), react_1.default.createElement(RemoveLinkButton_1.default, { key: "remove-link" })];
57
+ }
58
+ else if (!data.marks?.link.isActive) {
59
+ // if none of the selected text is a link show the option to create a link.
60
+ buttons.push(react_1.default.createElement(react_components_1.VerticalDivider, { key: "link-divider", className: "editor-divider" }), react_1.default.createElement(LinkButton_1.default, { key: "add-link", inPopover: true }));
61
+ }
54
62
  }
55
- else if (!data.marks?.link.isActive) {
56
- // if none of the selected text is a link show the option to create a link.
57
- buttons.push(react_1.default.createElement(react_components_1.VerticalDivider, { key: "link-divider", className: "editor-divider" }), react_1.default.createElement(LinkButton_1.default, { key: "add-link", inPopover: true }));
63
+ else {
64
+ buttons.push(react_1.default.createElement(react_components_1.VerticalDivider, { key: "image-divider", className: "editor-divider" }), react_1.default.createElement(ImageButton_1.default, { key: "add-image", inPopover: true }));
58
65
  }
59
66
  return (react_1.default.createElement(react_2.FloatingToolbar, { className: "squiz-fte-scope squiz-fte-scope__floating-popover", positioner: positioner }, buttons));
60
67
  };
@@ -41,7 +41,6 @@ const ImageForm = ({ data, onSubmit }) => {
41
41
  const [aspectRatioFromHeight, setAspectRatioFromHeight] = (0, react_1.useState)(16 / 9);
42
42
  const [aspectRatioLocked, setAspectRatioLocked] = (0, react_1.useState)(true);
43
43
  const setDimensionsFromURL = (e) => {
44
- // get the new url, calculate the width and height and set those fields
45
44
  (0, react_image_size_1.getImageSize)(e.target.value)
46
45
  .then(({ width, height }) => {
47
46
  setValue('width', width);
@@ -49,9 +48,8 @@ const ImageForm = ({ data, onSubmit }) => {
49
48
  setAspectRatioFromWidth(height / width);
50
49
  setAspectRatioFromHeight(width / height);
51
50
  })
52
- .catch((errorMessage) => {
51
+ .catch(() => {
53
52
  // TODO: we will use this when we add validation in a follow-up ticket
54
- console.log(errorMessage);
55
53
  });
56
54
  };
57
55
  const calculateDimensions = () => {
@@ -70,7 +68,7 @@ const ImageForm = ({ data, onSubmit }) => {
70
68
  };
71
69
  return (react_1.default.createElement("form", { className: "squiz-fte-form", onSubmit: handleSubmit(onSubmit) },
72
70
  react_1.default.createElement("div", { className: "squiz-fte-form-group mb-2" },
73
- react_1.default.createElement(Input_1.Input, { label: "Source", ...register('src'), onBlur: setDimensionsFromURL })),
71
+ react_1.default.createElement(Input_1.Input, { label: "Source", ...register('src', { onChange: setDimensionsFromURL }) })),
74
72
  react_1.default.createElement("div", { className: "squiz-fte-form-group mb-2" },
75
73
  react_1.default.createElement(Input_1.Input, { label: "Alternative description", ...register('alt') })),
76
74
  react_1.default.createElement("div", { className: "flex flex-row items-end" },
@@ -1,2 +1,5 @@
1
- declare const ImageButton: () => JSX.Element;
1
+ type ImageButtonProps = {
2
+ inPopover?: boolean;
3
+ };
4
+ declare const ImageButton: ({ inPopover }: ImageButtonProps) => JSX.Element;
2
5
  export default ImageButton;
@@ -31,10 +31,15 @@ const ImageRounded_1 = __importDefault(require("@mui/icons-material/ImageRounded
31
31
  const ImageModal_1 = __importDefault(require("./ImageModal"));
32
32
  const Button_1 = __importDefault(require("../../../ui/Button/Button"));
33
33
  const react_2 = require("@remirror/react");
34
- const ImageButton = () => {
34
+ const createToolbarPositioner_1 = require("../../../utils/createToolbarPositioner");
35
+ const ImageButton = ({ inPopover = false }) => {
35
36
  const [showModal, setShowModal] = (0, react_1.useState)(false);
36
37
  const { insertImage } = (0, react_2.useCommands)();
37
- const active = showModal;
38
+ const active = (0, react_2.useActive)();
39
+ const positioner = (0, react_1.useMemo)(() => (0, createToolbarPositioner_1.createToolbarPositioner)({ types: ['link'] }), []);
40
+ const { data } = (0, react_2.usePositioner)(positioner, []);
41
+ // if the active selection is not an image, disable the button as it means it will be text
42
+ const disabled = data.isSelectionInView && !active.image() ? true : false;
38
43
  const handleClick = () => {
39
44
  if (!showModal) {
40
45
  // form element are uncontrolled, let the event loop run to
@@ -59,9 +64,13 @@ const ImageButton = () => {
59
64
  // Prevent other key handlers being run
60
65
  return true;
61
66
  }, []);
62
- (0, react_2.useKeymap)('Mod-l', handleShortcut);
67
+ if (!inPopover) {
68
+ // when Ctrl+l is pressed show the modal, only registered in the toolbar button instance to avoid the key press
69
+ // being double handled.
70
+ (0, react_2.useKeymap)('Mod-l', handleShortcut);
71
+ }
63
72
  return (react_1.default.createElement(react_1.default.Fragment, null,
64
- react_1.default.createElement(Button_1.default, { handleOnClick: handleClick, isActive: active, icon: react_1.default.createElement(ImageRounded_1.default, null), label: "Image (cmd+L)", isDisabled: false }),
73
+ react_1.default.createElement(Button_1.default, { handleOnClick: handleClick, isActive: active.image(), icon: react_1.default.createElement(ImageRounded_1.default, null), label: "Image (cmd+L)", isDisabled: disabled }),
65
74
  showModal && react_1.default.createElement(ImageModal_1.default, { onCancel: () => setShowModal(false), onSubmit: handleSubmit })));
66
75
  };
67
76
  exports.default = ImageButton;
@@ -3,17 +3,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- const remirror_1 = require("remirror");
7
6
  const ImageForm_1 = __importDefault(require("./Form/ImageForm"));
8
7
  const react_1 = __importDefault(require("react"));
9
8
  const react_2 = require("@remirror/react");
10
9
  const FormModal_1 = __importDefault(require("../../../ui/Modal/FormModal"));
11
10
  const ImageModal = ({ onCancel, onSubmit }) => {
12
- const { helpers, view: { state }, } = (0, react_2.useRemirrorContext)();
13
11
  const selection = (0, react_2.useCurrentSelection)();
14
- const currentImage = (0, remirror_1.getMarkRanges)(selection, 'image')[0];
15
- const selectedImage = helpers.getTextBetween(selection.from, selection.to, state.doc);
12
+ const currentImage = selection?.node;
16
13
  return (react_1.default.createElement(FormModal_1.default, { title: "Image", onCancel: onCancel },
17
- react_1.default.createElement(ImageForm_1.default, { data: { ...currentImage?.mark.attrs, src: selectedImage }, onSubmit: onSubmit })));
14
+ react_1.default.createElement(ImageForm_1.default, { data: { ...currentImage?.attrs, src: currentImage?.attrs.src }, onSubmit: onSubmit })));
18
15
  };
19
16
  exports.default = ImageModal;
@@ -32,13 +32,20 @@ const LinkModal_1 = __importDefault(require("./LinkModal"));
32
32
  const Button_1 = __importDefault(require("../../../ui/Button/Button"));
33
33
  const react_2 = require("@remirror/react");
34
34
  const LinkExtension_1 = require("../../../Extensions/LinkExtension/LinkExtension");
35
+ const createToolbarPositioner_1 = require("../../../utils/createToolbarPositioner");
35
36
  const LinkButton = ({ inPopover = false }) => {
36
37
  const [showModal, setShowModal] = (0, react_1.useState)(false);
37
38
  const { selectLink, updateLink } = (0, react_2.useCommands)();
38
39
  const active = (0, react_2.useActive)();
40
+ // If the image tool is active, disable the link tool as they shouldn't work at the same time
41
+ const disabled = (0, react_2.useActive)().image();
42
+ const positioner = (0, react_1.useMemo)(() => (0, createToolbarPositioner_1.createToolbarPositioner)({ types: ['link'] }), []);
43
+ const { data } = (0, react_2.usePositioner)(positioner, []);
39
44
  const handleClick = () => {
40
45
  if (!showModal) {
41
- selectLink();
46
+ if (data.isSelectionInView) {
47
+ selectLink();
48
+ }
42
49
  // form element are uncontrolled, let the event loop run to
43
50
  // update the selected text in state before showing the modal.
44
51
  requestAnimationFrame(() => {
@@ -56,7 +63,7 @@ const LinkButton = ({ inPopover = false }) => {
56
63
  (0, react_2.useExtensionEvent)(LinkExtension_1.LinkExtension, 'onShortcut', (0, react_1.useCallback)(() => handleClick(), []));
57
64
  }
58
65
  return (react_1.default.createElement(react_1.default.Fragment, null,
59
- react_1.default.createElement(Button_1.default, { handleOnClick: handleClick, isActive: active.link(), icon: react_1.default.createElement(InsertLinkRounded_1.default, null), label: "Link (cmd+K)" }),
66
+ react_1.default.createElement(Button_1.default, { handleOnClick: handleClick, isActive: active.link(), icon: react_1.default.createElement(InsertLinkRounded_1.default, null), label: "Link (cmd+K)", isDisabled: disabled }),
60
67
  showModal && react_1.default.createElement(LinkModal_1.default, { onCancel: () => setShowModal(false), onSubmit: handleSubmit })));
61
68
  };
62
69
  exports.default = LinkButton;
@@ -14,7 +14,7 @@ const Extensions = () => [
14
14
  new PreformattedExtension_1.PreformattedExtension(),
15
15
  new extensions_1.UnderlineExtension(),
16
16
  new extensions_1.HistoryExtension(),
17
- new ImageExtension_1.ImageExtension(),
17
+ new ImageExtension_1.ImageExtension({ preferPastedTextContent: false }),
18
18
  new LinkExtension_1.LinkExtension({
19
19
  supportedTargets: [
20
20
  // '_self' is the browser default and will be used when encountering a link with a
@@ -1,3 +1,10 @@
1
1
  import { ImageExtension as RemirrorImageExtension } from 'remirror/extensions';
2
+ import { PasteRule } from 'prosemirror-paste-rules';
3
+ import { ApplySchemaAttributes, NodeSpecOverride, NodeExtensionSpec, PrimitiveSelection } from '@remirror/core';
4
+ import { ImageAttributes } from '@remirror/extension-image/dist-types/image-extension';
5
+ import { CommandFunction } from '@remirror/pm';
2
6
  export declare class ImageExtension extends RemirrorImageExtension {
7
+ createPasteRules(): PasteRule[];
8
+ createNodeSpec(extra: ApplySchemaAttributes, override: NodeSpecOverride): NodeExtensionSpec;
9
+ insertImage(attributes: ImageAttributes, selection?: PrimitiveSelection): CommandFunction;
3
10
  }
@@ -2,6 +2,91 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ImageExtension = void 0;
4
4
  const extensions_1 = require("remirror/extensions");
5
+ const core_1 = require("@remirror/core");
6
+ /**
7
+ * Get the width and the height of the image.
8
+ */
9
+ function getDimensions(element) {
10
+ let { width, height } = element.style;
11
+ width = width || element.getAttribute('width') || '';
12
+ height = height || element.getAttribute('height') || '';
13
+ return { width, height };
14
+ }
15
+ /**
16
+ * Retrieve attributes from the dom for the image extension.
17
+ */
18
+ function getImageAttributes({ element, parse }) {
19
+ const { width, height } = getDimensions(element);
20
+ return {
21
+ ...parse(element),
22
+ alt: element.getAttribute('alt') ?? '',
23
+ height: Number.parseInt(height || '0', 10) || null,
24
+ src: element.getAttribute('src') ?? null,
25
+ title: element.getAttribute('title') ?? '',
26
+ width: Number.parseInt(width || '0', 10) || null,
27
+ fileName: element.getAttribute('data-file-name') ?? null,
28
+ };
29
+ }
5
30
  class ImageExtension extends extensions_1.ImageExtension {
31
+ createPasteRules() {
32
+ return [
33
+ {
34
+ type: 'file',
35
+ regexp: /image/i,
36
+ fileHandler: () => {
37
+ return false;
38
+ },
39
+ },
40
+ ];
41
+ }
42
+ createNodeSpec(extra, override) {
43
+ const { preferPastedTextContent } = this.options;
44
+ return {
45
+ inline: true,
46
+ draggable: true,
47
+ selectable: true,
48
+ ...override,
49
+ attrs: {
50
+ ...extra.defaults(),
51
+ alt: { default: '' },
52
+ crop: { default: null },
53
+ height: { default: null },
54
+ width: { default: null },
55
+ rotate: { default: null },
56
+ src: { default: null },
57
+ title: { default: '' },
58
+ fileName: { default: null },
59
+ resizable: { default: false },
60
+ },
61
+ parseDOM: [
62
+ {
63
+ tag: 'img[src]',
64
+ getAttrs: (element) => {
65
+ if ((0, core_1.isElementDomNode)(element)) {
66
+ const attrs = getImageAttributes({ element, parse: extra.parse });
67
+ if (preferPastedTextContent && attrs.src?.startsWith('file:///')) {
68
+ return false;
69
+ }
70
+ return attrs;
71
+ }
72
+ return {};
73
+ },
74
+ },
75
+ ...(override.parseDOM ?? []),
76
+ ],
77
+ toDOM: (node) => {
78
+ const attrs = (0, core_1.omitExtraAttributes)(node.attrs, extra);
79
+ return ['img', { ...extra.dom(node), ...attrs }];
80
+ },
81
+ };
82
+ }
83
+ insertImage(attributes, selection) {
84
+ return ({ tr, dispatch }) => {
85
+ const { from, to } = (0, core_1.getTextSelection)(selection ?? tr.selection, tr.doc);
86
+ const node = this.type.create(attributes);
87
+ dispatch?.(tr.replaceRangeWith(from, to, node));
88
+ return true;
89
+ };
90
+ }
6
91
  }
7
92
  exports.ImageExtension = ImageExtension;
package/lib/index.css CHANGED
@@ -719,7 +719,14 @@
719
719
  --tw-text-opacity: 1;
720
720
  color: rgb(148 148 148 / var(--tw-text-opacity));
721
721
  }
722
- .squiz-fte-scope .editor-toolbar {
722
+ .squiz-fte-scope .formatted-text-editor .ProseMirror-selectednode {
723
+ border-width: 2px;
724
+ border-style: solid;
725
+ --tw-border-opacity: 1;
726
+ border-color: rgb(7 116 210 / var(--tw-border-opacity));
727
+ }
728
+ .squiz-fte-scope .editor-toolbar,
729
+ .squiz-fte-scope__floating-popover {
723
730
  border-bottom-width: 2px;
724
731
  border-style: solid;
725
732
  --tw-border-opacity: 1;
@@ -730,10 +737,12 @@
730
737
  display: flex;
731
738
  justify-items: center;
732
739
  }
733
- .squiz-fte-scope .editor-toolbar > *:not(:first-child, .editor-divider) {
740
+ .squiz-fte-scope .editor-toolbar > *:not(:first-child, .editor-divider),
741
+ .squiz-fte-scope .squiz-fte-scope__floating-popover > *:not(:first-child, .editor-divider) {
734
742
  margin: 0 0 0 2px;
735
743
  }
736
- .squiz-fte-scope .editor-toolbar .editor-divider {
744
+ .squiz-fte-scope .editor-toolbar .editor-divider,
745
+ .squiz-fte-scope .squiz-fte-scope__floating-popover .editor-divider {
737
746
  margin-top: -0.25rem;
738
747
  margin-bottom: -0.25rem;
739
748
  margin-left: 0.25rem;
@@ -742,10 +751,12 @@
742
751
  margin-right: 2px;
743
752
  height: auto;
744
753
  }
745
- .squiz-fte-scope .editor-toolbar .squiz-fte-btn {
754
+ .squiz-fte-scope .editor-toolbar .squiz-fte-btn,
755
+ .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-btn {
746
756
  padding: 0.25rem;
747
757
  }
748
- .squiz-fte-scope .editor-toolbar .squiz-fte-btn ~ .squiz-fte-btn {
758
+ .squiz-fte-scope .editor-toolbar .squiz-fte-btn ~ .squiz-fte-btn,
759
+ .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-btn ~ .squiz-fte-btn {
749
760
  margin-left: 2px;
750
761
  }
751
762
  .squiz-fte-scope__floating-popover {
@@ -764,11 +775,9 @@
764
775
  var(--tw-ring-shadow, 0 0 #0000),
765
776
  var(--tw-shadow);
766
777
  }
767
- .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-btn {
768
- padding: 0.25rem;
769
- }
770
- .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-btn ~ .squiz-fte-btn {
771
- margin-left: 2px;
778
+ .squiz-fte-scope .squiz-fte-scope__floating-popover .editor-divider {
779
+ margin-top: 0px;
780
+ margin-bottom: 0px;
772
781
  }
773
782
  .squiz-fte-scope .squiz-fte-btn {
774
783
  border-radius: 4px;
@@ -817,6 +826,7 @@
817
826
  .squiz-fte-scope .toolbar-dropdown__button {
818
827
  display: flex;
819
828
  align-items: center;
829
+ border-radius: 4px;
820
830
  font-family:
821
831
  Open Sans,
822
832
  Arial,
@@ -933,15 +943,11 @@
933
943
  font-size: 0.875rem;
934
944
  font-weight: 700;
935
945
  }
936
- .squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button {
937
- padding: 0.25rem;
938
- }
939
- .squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button ~ .squiz-fte-btn {
940
- margin-left: 2px;
941
- }
946
+ .squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button,
942
947
  .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-modal-footer__button {
943
948
  padding: 0.25rem;
944
949
  }
950
+ .squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button ~ .squiz-fte-btn,
945
951
  .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-modal-footer__button ~ .squiz-fte-btn {
946
952
  margin-left: 2px;
947
953
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/formatted-text-editor",
3
- "version": "1.21.1-alpha.16",
3
+ "version": "1.21.1-alpha.19",
4
4
  "main": "lib/index.js",
5
5
  "types": "lib/index.d.ts",
6
6
  "scripts": {
@@ -71,5 +71,5 @@
71
71
  "volta": {
72
72
  "node": "18.15.0"
73
73
  },
74
- "gitHead": "1d008f8debcfd1acaad50d73fa83b51c46253bf4"
74
+ "gitHead": "401a8492ae51c8cd377d0676ec9ce4fccffca7ff"
75
75
  }
@@ -5,6 +5,7 @@ import { MockEditor } from './Editor.mock';
5
5
  import Editor from './Editor';
6
6
  import '@testing-library/jest-dom';
7
7
  import { renderWithEditor } from '../../tests/renderWithEditor';
8
+ import ImageButton from '../EditorToolbar/Tools/Image/ImageButton';
8
9
 
9
10
  const setContent: any = jest.fn();
10
11
 
@@ -253,6 +254,67 @@ describe('Formatted text editor', () => {
253
254
  expect(editorNode.textContent).toBe('');
254
255
  });
255
256
 
257
+ it('should allow text to be pasted into the editor', async () => {
258
+ const { editor, getJsonContent } = await renderWithEditor(<ImageButton />, {
259
+ content: 'Some nonsense content here',
260
+ });
261
+
262
+ // paste something
263
+ await act(() => editor.paste('I pasted this! '));
264
+
265
+ expect(getJsonContent()).toEqual({
266
+ type: 'paragraph',
267
+ attrs: expect.any(Object),
268
+ content: [
269
+ {
270
+ text: 'I pasted this! Some nonsense content here',
271
+ type: 'text',
272
+ },
273
+ ],
274
+ });
275
+ });
276
+
277
+ it('should not allow images to be pasted into the editor', async () => {
278
+ const { elements, getJsonContent } = await renderWithEditor(<ImageButton />, {
279
+ content: 'Some nonsense content here',
280
+ });
281
+ const imageClipboardData = {
282
+ dropEffect: 'none',
283
+ effectAllowed: 'uninitialized',
284
+ items: {
285
+ '0': {
286
+ kind: 'file',
287
+ type: 'image/png',
288
+ },
289
+ },
290
+ files: {
291
+ '0': {
292
+ lastModified: 1678233857792,
293
+ lastModifiedDate: {},
294
+ name: 'Screen Shot 2023-03-08 at 10.04.12 am.png',
295
+ size: 58517,
296
+ type: 'image/png',
297
+ webkitRelativePath: '',
298
+ },
299
+ },
300
+ types: [],
301
+ };
302
+
303
+ // paste something
304
+ await act(() => fireEvent.paste(elements.editor, imageClipboardData));
305
+
306
+ expect(getJsonContent()).toEqual({
307
+ type: 'paragraph',
308
+ attrs: expect.any(Object),
309
+ content: [
310
+ {
311
+ text: 'Some nonsense content here',
312
+ type: 'text',
313
+ },
314
+ ],
315
+ });
316
+ });
317
+
256
318
  it('Should not display the toolbar if is not editable', () => {
257
319
  render(<Editor editable={false} />);
258
320
  expect(screen.queryByRole('button', { name: 'Bold (cmd+B)' })).not.toBeInTheDocument();
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { ClipboardEvent } from 'react';
2
2
  import { EditorComponent, Remirror, useRemirror } from '@remirror/react';
3
3
  import { RemirrorContentType, RemirrorEventListener, Extension } from '@remirror/core';
4
4
  import { Toolbar, FloatingToolbar } from '../EditorToolbar';
@@ -24,9 +24,20 @@ const Editor = ({ content, editable = true, onChange }: EditorProps) => {
24
24
  onChange?.(parameter);
25
25
  };
26
26
 
27
+ const preventImagePaste = (event: ClipboardEvent<HTMLDivElement>) => {
28
+ const { clipboardData } = event;
29
+ const pastedData = clipboardData?.items[0];
30
+ if (pastedData?.type && pastedData?.type.startsWith('image/')) {
31
+ event.preventDefault();
32
+ }
33
+ };
34
+
27
35
  return (
28
36
  <div className="squiz-fte-scope">
29
- <div className={clsx('remirror-theme formatted-text-editor', !editable && 'formatted-text-editor--is-disabled')}>
37
+ <div
38
+ className={clsx('remirror-theme formatted-text-editor', !editable && 'formatted-text-editor--is-disabled')}
39
+ onPaste={preventImagePaste}
40
+ >
30
41
  <Remirror
31
42
  manager={manager}
32
43
  state={state}
@@ -39,4 +39,7 @@
39
39
  content: attr(data-placeholder);
40
40
  @apply text-gray-500;
41
41
  }
42
+ .ProseMirror-selectednode {
43
+ @apply border-blue-300 border-2 border-solid;
44
+ }
42
45
  }
@@ -5,30 +5,41 @@ import BoldButton from './Tools/Bold/BoldButton';
5
5
  import { useExtensionNames } from '../hooks';
6
6
  import RemoveLinkButton from './Tools/Link/RemoveLinkButton';
7
7
  import LinkButton from './Tools/Link/LinkButton';
8
- import { FloatingToolbar as RemirrorFloatingToolbar, usePositioner } from '@remirror/react';
8
+ import { FloatingToolbar as RemirrorFloatingToolbar, usePositioner, useActive } from '@remirror/react';
9
9
  import { VerticalDivider } from '@remirror/react-components';
10
10
  import { createToolbarPositioner, ToolbarPositionerRange } from '../utils/createToolbarPositioner';
11
+ import ImageButton from './Tools/Image/ImageButton';
12
+ import { ImageExtension } from '../Extensions/ImageExtension/ImageExtension';
11
13
 
12
14
  // The editor main toolbar
13
15
  export const FloatingToolbar = () => {
14
16
  const extensionNames = useExtensionNames();
15
17
  const positioner = useMemo(() => createToolbarPositioner({ types: ['link'] }), []);
16
18
  const { data } = usePositioner<Partial<ToolbarPositionerRange>>(positioner, []);
19
+ const activeImage = useActive<ImageExtension>();
20
+
17
21
  let buttons = [
18
22
  extensionNames.bold && <BoldButton key="bold" />,
19
23
  extensionNames.italic && <ItalicButton key="italic" />,
20
24
  extensionNames.underline && <UnderlineButton key="underline" />,
21
25
  ];
22
26
 
23
- if (data.marks?.link.isExclusivelyActive) {
24
- // if all of the selected text is a link show the options to update/remove the link instead of the regular
25
- // formatting options.
26
- buttons = [<LinkButton key="update-link" inPopover={true} />, <RemoveLinkButton key="remove-link" />];
27
- } else if (!data.marks?.link.isActive) {
28
- // if none of the selected text is a link show the option to create a link.
27
+ if (!activeImage.image()) {
28
+ if (data.marks?.link.isExclusivelyActive) {
29
+ // if all of the selected text is a link show the options to update/remove the link instead of the regular
30
+ // formatting options.
31
+ buttons = [<LinkButton key="update-link" inPopover={true} />, <RemoveLinkButton key="remove-link" />];
32
+ } else if (!data.marks?.link.isActive) {
33
+ // if none of the selected text is a link show the option to create a link.
34
+ buttons.push(
35
+ <VerticalDivider key="link-divider" className="editor-divider" />,
36
+ <LinkButton key="add-link" inPopover={true} />,
37
+ );
38
+ }
39
+ } else {
29
40
  buttons.push(
30
- <VerticalDivider key="link-divider" className="editor-divider" />,
31
- <LinkButton key="add-link" inPopover={true} />,
41
+ <VerticalDivider key="image-divider" className="editor-divider" />,
42
+ <ImageButton key="add-image" inPopover={true} />,
32
43
  );
33
44
  }
34
45
 
@@ -1,5 +1,5 @@
1
1
  import '@testing-library/jest-dom';
2
- import { render, screen } from '@testing-library/react';
2
+ import { render, screen, act, fireEvent } from '@testing-library/react';
3
3
  import React from 'react';
4
4
  import ImageForm from './ImageForm';
5
5
 
@@ -20,4 +20,58 @@ describe('Image Form', () => {
20
20
  expect(screen.getByLabelText('Width')).toHaveValue(1600);
21
21
  expect(screen.getByLabelText('Height')).toHaveValue(1400);
22
22
  });
23
+
24
+ it('calculates the height when width changes and aspect ratio is locked', () => {
25
+ render(<ImageForm data={data} onSubmit={handleSubmit} />);
26
+ const widthInput = screen.getByLabelText('Width');
27
+ const heightInput = screen.getByLabelText('Height');
28
+
29
+ // change the width value
30
+ act(() => {
31
+ fireEvent.change(widthInput, { target: { value: 800 } });
32
+ });
33
+
34
+ // check that the height value has been calculated correctly
35
+ expect(heightInput).toHaveValue(450);
36
+ });
37
+
38
+ it('calculates the width when height changes and aspect ratio is locked', () => {
39
+ render(<ImageForm data={data} onSubmit={handleSubmit} />);
40
+ const widthInput = screen.getByLabelText('Width');
41
+ const heightInput = screen.getByLabelText('Height');
42
+
43
+ // change the height value
44
+ act(() => {
45
+ fireEvent.change(heightInput, { target: { value: 800 } });
46
+ });
47
+
48
+ // check that the width value has been calculated correctly
49
+ expect(widthInput).toHaveValue(1422.22);
50
+ });
51
+
52
+ it('does not calculate dimensions when aspect ratio is unlocked', () => {
53
+ render(<ImageForm data={data} onSubmit={handleSubmit} />);
54
+ const widthInput = screen.getByLabelText('Width');
55
+ const heightInput = screen.getByLabelText('Height');
56
+ const toggleButton = screen.getByRole('button', { name: 'Constrain properties' });
57
+
58
+ // unlock the aspect ratio
59
+ fireEvent.click(toggleButton);
60
+
61
+ // change the width value
62
+ act(() => {
63
+ fireEvent.change(widthInput, { target: { value: 800 } });
64
+ });
65
+
66
+ // check that the height value has not been calculated
67
+ expect(heightInput).toHaveValue(1400);
68
+
69
+ // change the height value
70
+ act(() => {
71
+ fireEvent.change(heightInput, { target: { value: 800 } });
72
+ });
73
+
74
+ // check that the width value has not been calculated
75
+ expect(widthInput).toHaveValue(800);
76
+ });
23
77
  });
@@ -30,7 +30,6 @@ const ImageForm = ({ data, onSubmit }: FormProps): ReactElement => {
30
30
  const [aspectRatioLocked, setAspectRatioLocked] = useState(true);
31
31
 
32
32
  const setDimensionsFromURL = (e: { target: { value: string } }) => {
33
- // get the new url, calculate the width and height and set those fields
34
33
  getImageSize(e.target.value)
35
34
  .then(({ width, height }) => {
36
35
  setValue('width', width);
@@ -38,9 +37,8 @@ const ImageForm = ({ data, onSubmit }: FormProps): ReactElement => {
38
37
  setAspectRatioFromWidth(height / width);
39
38
  setAspectRatioFromHeight(width / height);
40
39
  })
41
- .catch((errorMessage) => {
40
+ .catch(() => {
42
41
  // TODO: we will use this when we add validation in a follow-up ticket
43
- console.log(errorMessage);
44
42
  });
45
43
  };
46
44
 
@@ -63,7 +61,7 @@ const ImageForm = ({ data, onSubmit }: FormProps): ReactElement => {
63
61
  return (
64
62
  <form className="squiz-fte-form" onSubmit={handleSubmit(onSubmit)}>
65
63
  <div className="squiz-fte-form-group mb-2">
66
- <Input label="Source" {...register('src')} onBlur={setDimensionsFromURL} />
64
+ <Input label="Source" {...register('src', { onChange: setDimensionsFromURL })} />
67
65
  </div>
68
66
  <div className="squiz-fte-form-group mb-2">
69
67
  <Input label="Alternative description" {...register('alt')} />
@@ -1,5 +1,6 @@
1
1
  import '@testing-library/jest-dom';
2
2
  import { screen, fireEvent, waitForElementToBeRemoved, act } from '@testing-library/react';
3
+ import { NodeSelection } from 'prosemirror-state';
3
4
  import React from 'react';
4
5
  import { renderWithEditor } from '../../../../tests';
5
6
  import ImageButton from './ImageButton';
@@ -64,6 +65,61 @@ describe('ImageButton', () => {
64
65
  });
65
66
  });
66
67
 
68
+ it('Updates the attributes of an existing image', async () => {
69
+ const { editor, getJsonContent } = await renderWithEditor(<ImageButton />, {
70
+ content: 'Some <img src="https://httpcats.com/529.jpg" alt="hi" /> nonsense',
71
+ });
72
+
73
+ await act(() => editor.selectText(new NodeSelection(editor.state.doc.resolve(6))));
74
+
75
+ await openModal();
76
+ fireEvent.change(screen.getByLabelText('Source'), { target: { value: 'https://httpcats.com/303.jpg' } });
77
+ fireEvent.change(screen.getByLabelText('Alternative description'), { target: { value: 'Updated cats!' } });
78
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
79
+
80
+ await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
81
+
82
+ expect(getJsonContent()).toEqual({
83
+ type: 'paragraph',
84
+ attrs: expect.any(Object),
85
+ content: [
86
+ {
87
+ text: 'Some ',
88
+ type: 'text',
89
+ },
90
+ {
91
+ type: 'image',
92
+ attrs: {
93
+ alt: 'Updated cats!',
94
+ crop: null,
95
+ height: null,
96
+ width: null,
97
+ rotate: null,
98
+ src: 'https://httpcats.com/303.jpg',
99
+ title: '',
100
+ fileName: null,
101
+ resizable: false,
102
+ },
103
+ },
104
+ { type: 'text', text: ' nonsense' },
105
+ ],
106
+ });
107
+ });
108
+
109
+ it('Removes the image when content is cleared (backspaced)', async () => {
110
+ const { editor, getJsonContent } = await renderWithEditor(<ImageButton />, {
111
+ content: '<img src="https://media2.giphy.com/media/3o6ozsIxg5legYvggo/giphy.gif"/>',
112
+ });
113
+
114
+ await act(() => editor.selectText(10));
115
+ await act(() => editor.backspace(1));
116
+
117
+ expect(getJsonContent()).toEqual({
118
+ type: 'paragraph',
119
+ attrs: expect.any(Object),
120
+ });
121
+ });
122
+
67
123
  it('Closes the modal when clicking on the cancel button', async () => {
68
124
  await renderWithEditor(<ImageButton />);
69
125
 
@@ -1,14 +1,24 @@
1
- import React, { useState, useCallback } from 'react';
1
+ import React, { useState, useCallback, useMemo } from 'react';
2
2
  import ImageRoundedIcon from '@mui/icons-material/ImageRounded';
3
3
  import ImageModal from './ImageModal';
4
4
  import { ImageFormData } from './Form/ImageForm';
5
5
  import Button from '../../../ui/Button/Button';
6
- import { useCommands, useKeymap } from '@remirror/react';
6
+ import { useCommands, useKeymap, useActive, usePositioner } from '@remirror/react';
7
+ import { ImageExtension } from '../../../Extensions/ImageExtension/ImageExtension';
8
+ import { createToolbarPositioner, ToolbarPositionerRange } from '../../../utils/createToolbarPositioner';
7
9
 
8
- const ImageButton = () => {
10
+ type ImageButtonProps = {
11
+ inPopover?: boolean;
12
+ };
13
+
14
+ const ImageButton = ({ inPopover = false }: ImageButtonProps) => {
9
15
  const [showModal, setShowModal] = useState(false);
10
16
  const { insertImage } = useCommands();
11
- const active = showModal;
17
+ const active = useActive<ImageExtension>();
18
+ const positioner = useMemo(() => createToolbarPositioner({ types: ['link'] }), []);
19
+ const { data } = usePositioner<Partial<ToolbarPositionerRange>>(positioner, []);
20
+ // if the active selection is not an image, disable the button as it means it will be text
21
+ const disabled = data.isSelectionInView && !active.image() ? true : false;
12
22
 
13
23
  const handleClick = () => {
14
24
  if (!showModal) {
@@ -38,16 +48,20 @@ const ImageButton = () => {
38
48
  return true;
39
49
  }, []);
40
50
 
41
- useKeymap('Mod-l', handleShortcut);
51
+ if (!inPopover) {
52
+ // when Ctrl+l is pressed show the modal, only registered in the toolbar button instance to avoid the key press
53
+ // being double handled.
54
+ useKeymap('Mod-l', handleShortcut);
55
+ }
42
56
 
43
57
  return (
44
58
  <>
45
59
  <Button
46
60
  handleOnClick={handleClick}
47
- isActive={active}
61
+ isActive={active.image()}
48
62
  icon={<ImageRoundedIcon />}
49
63
  label="Image (cmd+L)"
50
- isDisabled={false}
64
+ isDisabled={disabled}
51
65
  />
52
66
  {showModal && <ImageModal onCancel={() => setShowModal(false)} onSubmit={handleSubmit} />}
53
67
  </>
@@ -1,9 +1,9 @@
1
- import { getMarkRanges } from 'remirror';
2
1
  import ImageForm, { ImageFormData } from './Form/ImageForm';
3
2
  import React from 'react';
4
- import { useRemirrorContext, useCurrentSelection } from '@remirror/react';
3
+ import { useCurrentSelection } from '@remirror/react';
5
4
  import FormModal from '../../../ui/Modal/FormModal';
6
5
  import { SubmitHandler } from 'react-hook-form';
6
+ import { NodeSelection } from 'prosemirror-state';
7
7
 
8
8
  type ImageModalProps = {
9
9
  onCancel: () => void;
@@ -11,17 +11,12 @@ type ImageModalProps = {
11
11
  };
12
12
 
13
13
  const ImageModal = ({ onCancel, onSubmit }: ImageModalProps) => {
14
- const {
15
- helpers,
16
- view: { state },
17
- } = useRemirrorContext();
18
- const selection = useCurrentSelection();
19
- const currentImage = getMarkRanges(selection, 'image')[0];
20
- const selectedImage = helpers.getTextBetween(selection.from, selection.to, state.doc);
14
+ const selection = useCurrentSelection() as NodeSelection;
15
+ const currentImage = selection?.node;
21
16
 
22
17
  return (
23
18
  <FormModal title="Image" onCancel={onCancel}>
24
- <ImageForm data={{ ...currentImage?.mark.attrs, src: selectedImage }} onSubmit={onSubmit} />
19
+ <ImageForm data={{ ...currentImage?.attrs, src: currentImage?.attrs.src }} onSubmit={onSubmit} />
25
20
  </FormModal>
26
21
  );
27
22
  };
@@ -252,7 +252,7 @@ describe('LinkButton', () => {
252
252
  });
253
253
 
254
254
  // jump to the middle of the link.
255
- await act(() => editor.selectText(5));
255
+ await act(() => editor.selectText({ from: 1, to: 12 }));
256
256
 
257
257
  // press the keyboard shortcut.
258
258
  fireEvent.keyDown(elements.editor, { key: 'k', ctrlKey: true });
@@ -1,10 +1,12 @@
1
- import React, { useCallback, useState } from 'react';
1
+ import React, { useCallback, useState, useMemo } 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
5
  import Button from '../../../ui/Button/Button';
6
- import { useActive, useCommands, useExtensionEvent } from '@remirror/react';
6
+ import { useActive, useCommands, useExtensionEvent, usePositioner } from '@remirror/react';
7
7
  import { LinkExtension } from '../../../Extensions/LinkExtension/LinkExtension';
8
+ import { createToolbarPositioner, ToolbarPositionerRange } from '../../../utils/createToolbarPositioner';
9
+ import { ImageExtension } from '../../../Extensions/ImageExtension/ImageExtension';
8
10
 
9
11
  type LinkButtonProps = {
10
12
  inPopover?: boolean;
@@ -14,10 +16,16 @@ const LinkButton = ({ inPopover = false }: LinkButtonProps) => {
14
16
  const [showModal, setShowModal] = useState(false);
15
17
  const { selectLink, updateLink } = useCommands<LinkExtension>();
16
18
  const active = useActive<LinkExtension>();
19
+ // If the image tool is active, disable the link tool as they shouldn't work at the same time
20
+ const disabled = useActive<ImageExtension>().image();
21
+ const positioner = useMemo(() => createToolbarPositioner({ types: ['link'] }), []);
22
+ const { data } = usePositioner<Partial<ToolbarPositionerRange>>(positioner, []);
23
+
17
24
  const handleClick = () => {
18
25
  if (!showModal) {
19
- selectLink();
20
-
26
+ if (data.isSelectionInView) {
27
+ selectLink();
28
+ }
21
29
  // form element are uncontrolled, let the event loop run to
22
30
  // update the selected text in state before showing the modal.
23
31
  requestAnimationFrame(() => {
@@ -47,6 +55,7 @@ const LinkButton = ({ inPopover = false }: LinkButtonProps) => {
47
55
  isActive={active.link()}
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,10 +1,9 @@
1
1
  /// This class is excluded from the scope of squiz-fte-scope as it is outside of the scoped element
2
2
  .squiz-fte-scope__floating-popover {
3
+ @extend .editor-toolbar;
3
4
  @apply bg-white border-gray-200 p-1 shadow rounded-md border flex;
4
- .squiz-fte-btn {
5
- @apply p-1;
6
- ~ .squiz-fte-btn {
7
- margin-left: 2px;
8
- }
5
+
6
+ .editor-divider {
7
+ @apply my-0;
9
8
  }
10
9
  }
@@ -21,7 +21,7 @@ export const Extensions = (): Extension[] => [
21
21
  new PreformattedExtension(),
22
22
  new UnderlineExtension(),
23
23
  new HistoryExtension(),
24
- new ImageExtension(),
24
+ new ImageExtension({ preferPastedTextContent: false }),
25
25
  new LinkExtension({
26
26
  supportedTargets: [
27
27
  // '_self' is the browser default and will be used when encountering a link with a
@@ -1,3 +1,112 @@
1
1
  import { ImageExtension as RemirrorImageExtension } from 'remirror/extensions';
2
+ import { PasteRule } from 'prosemirror-paste-rules';
3
+ import {
4
+ isElementDomNode,
5
+ omitExtraAttributes,
6
+ ApplySchemaAttributes,
7
+ NodeSpecOverride,
8
+ NodeExtensionSpec,
9
+ getTextSelection,
10
+ PrimitiveSelection,
11
+ } from '@remirror/core';
12
+ import { ImageAttributes } from '@remirror/extension-image/dist-types/image-extension';
13
+ import { CommandFunction } from '@remirror/pm';
2
14
 
3
- export class ImageExtension extends RemirrorImageExtension {}
15
+ /**
16
+ * Get the width and the height of the image.
17
+ */
18
+ function getDimensions(element: HTMLElement) {
19
+ let { width, height } = element.style;
20
+ width = width || element.getAttribute('width') || '';
21
+ height = height || element.getAttribute('height') || '';
22
+
23
+ return { width, height };
24
+ }
25
+
26
+ /**
27
+ * Retrieve attributes from the dom for the image extension.
28
+ */
29
+ function getImageAttributes({ element, parse }: { element: HTMLElement; parse: ApplySchemaAttributes['parse'] }) {
30
+ const { width, height } = getDimensions(element);
31
+
32
+ return {
33
+ ...parse(element),
34
+ alt: element.getAttribute('alt') ?? '',
35
+ height: Number.parseInt(height || '0', 10) || null,
36
+ src: element.getAttribute('src') ?? null,
37
+ title: element.getAttribute('title') ?? '',
38
+ width: Number.parseInt(width || '0', 10) || null,
39
+ fileName: element.getAttribute('data-file-name') ?? null,
40
+ };
41
+ }
42
+
43
+ export class ImageExtension extends RemirrorImageExtension {
44
+ createPasteRules(): PasteRule[] {
45
+ return [
46
+ {
47
+ type: 'file',
48
+ regexp: /image/i,
49
+ fileHandler: (): boolean => {
50
+ return false;
51
+ },
52
+ },
53
+ ];
54
+ }
55
+
56
+ createNodeSpec(extra: ApplySchemaAttributes, override: NodeSpecOverride): NodeExtensionSpec {
57
+ const { preferPastedTextContent } = this.options;
58
+ return {
59
+ inline: true,
60
+ draggable: true,
61
+ selectable: true,
62
+ ...override,
63
+ attrs: {
64
+ ...extra.defaults(),
65
+ alt: { default: '' },
66
+ crop: { default: null },
67
+ height: { default: null },
68
+ width: { default: null },
69
+ rotate: { default: null },
70
+ src: { default: null },
71
+ title: { default: '' },
72
+ fileName: { default: null },
73
+
74
+ resizable: { default: false },
75
+ },
76
+ parseDOM: [
77
+ {
78
+ tag: 'img[src]',
79
+ getAttrs: (element) => {
80
+ if (isElementDomNode(element)) {
81
+ const attrs = getImageAttributes({ element, parse: extra.parse });
82
+
83
+ if (preferPastedTextContent && attrs.src?.startsWith('file:///')) {
84
+ return false;
85
+ }
86
+
87
+ return attrs;
88
+ }
89
+
90
+ return {};
91
+ },
92
+ },
93
+ ...(override.parseDOM ?? []),
94
+ ],
95
+ toDOM: (node) => {
96
+ const attrs = omitExtraAttributes(node.attrs, extra);
97
+ return ['img', { ...extra.dom(node), ...attrs }];
98
+ },
99
+ };
100
+ }
101
+
102
+ insertImage(attributes: ImageAttributes, selection?: PrimitiveSelection): CommandFunction {
103
+ return ({ tr, dispatch }) => {
104
+ const { from, to } = getTextSelection(selection ?? tr.selection, tr.doc);
105
+ const node = this.type.create(attributes);
106
+
107
+ dispatch?.(tr.replaceRangeWith(from, to, node));
108
+
109
+ return true;
110
+ };
111
+ }
112
+ }
@@ -1,6 +1,6 @@
1
1
  .toolbar-dropdown {
2
2
  &__button {
3
- @apply flex items-center font-base text-md font-semibold text-gray-600;
3
+ @apply flex items-center font-base text-md font-semibold text-gray-600 rounded;
4
4
  align-self: center;
5
5
 
6
6
  height: 2rem;