@squiz/formatted-text-editor 1.21.1-alpha.13 → 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.
Files changed (29) hide show
  1. package/lib/Editor/Editor.js +12 -4
  2. package/lib/EditorToolbar/FloatingToolbar.js +14 -7
  3. package/lib/EditorToolbar/Tools/Image/Form/ImageForm.js +2 -4
  4. package/lib/EditorToolbar/Tools/Image/ImageButton.d.ts +4 -1
  5. package/lib/EditorToolbar/Tools/Image/ImageButton.js +13 -4
  6. package/lib/EditorToolbar/Tools/Image/ImageModal.js +2 -5
  7. package/lib/EditorToolbar/Tools/Link/LinkButton.js +9 -2
  8. package/lib/Extensions/Extensions.js +1 -1
  9. package/lib/Extensions/ImageExtension/ImageExtension.d.ts +7 -0
  10. package/lib/Extensions/ImageExtension/ImageExtension.js +85 -0
  11. package/lib/index.css +31 -18
  12. package/package.json +4 -3
  13. package/src/Editor/Editor.spec.tsx +86 -0
  14. package/src/Editor/Editor.tsx +17 -5
  15. package/src/Editor/_editor.scss +14 -4
  16. package/src/EditorToolbar/FloatingToolbar.spec.tsx +2 -3
  17. package/src/EditorToolbar/FloatingToolbar.tsx +20 -9
  18. package/src/EditorToolbar/Tools/Image/Form/ImageForm.spec.tsx +55 -1
  19. package/src/EditorToolbar/Tools/Image/Form/ImageForm.tsx +2 -4
  20. package/src/EditorToolbar/Tools/Image/ImageButton.spec.tsx +56 -0
  21. package/src/EditorToolbar/Tools/Image/ImageButton.tsx +21 -7
  22. package/src/EditorToolbar/Tools/Image/ImageModal.tsx +5 -10
  23. package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +1 -1
  24. package/src/EditorToolbar/Tools/Link/LinkButton.tsx +13 -4
  25. package/src/EditorToolbar/_floating-toolbar.scss +4 -5
  26. package/src/Extensions/Extensions.ts +1 -1
  27. package/src/Extensions/ImageExtension/ImageExtension.ts +110 -1
  28. package/src/ui/ToolbarDropdown/_toolbar-dropdown.scss +1 -1
  29. package/tests/renderWithEditor.tsx +5 -1
@@ -7,7 +7,8 @@ const react_1 = __importDefault(require("react"));
7
7
  const react_2 = require("@remirror/react");
8
8
  const EditorToolbar_1 = require("../EditorToolbar");
9
9
  const Extensions_1 = require("../Extensions/Extensions");
10
- const Editor = ({ content, editable, onChange }) => {
10
+ const clsx_1 = __importDefault(require("clsx"));
11
+ const Editor = ({ content, editable = true, onChange }) => {
11
12
  const { manager, state, setState } = (0, react_2.useRemirror)({
12
13
  extensions: Extensions_1.Extensions,
13
14
  content,
@@ -18,11 +19,18 @@ const Editor = ({ content, editable, onChange }) => {
18
19
  setState(parameter.state);
19
20
  onChange?.(parameter);
20
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
+ };
21
29
  return (react_1.default.createElement("div", { className: "squiz-fte-scope" },
22
- react_1.default.createElement("div", { className: "remirror-theme formatted-text-editor editor-wrapper" },
30
+ react_1.default.createElement("div", { className: (0, clsx_1.default)('remirror-theme formatted-text-editor', !editable && 'formatted-text-editor--is-disabled'), onPaste: preventImagePaste },
23
31
  react_1.default.createElement(react_2.Remirror, { manager: manager, state: state, editable: editable, onChange: handleChange, placeholder: "Write something", label: "Text editor" },
24
- react_1.default.createElement(EditorToolbar_1.Toolbar, null),
32
+ editable && react_1.default.createElement(EditorToolbar_1.Toolbar, null),
25
33
  react_1.default.createElement(react_2.EditorComponent, null),
26
- react_1.default.createElement(EditorToolbar_1.FloatingToolbar, null)))));
34
+ editable && react_1.default.createElement(EditorToolbar_1.FloatingToolbar, null)))));
27
35
  };
28
36
  exports.default = Editor;
@@ -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
@@ -667,8 +667,6 @@
667
667
  }
668
668
  .squiz-fte-scope .formatted-text-editor {
669
669
  font-family: "Open Sans" !important;
670
- }
671
- .squiz-fte-scope .formatted-text-editor.editor-wrapper {
672
670
  border-radius: 4px;
673
671
  border-width: 2px;
674
672
  border-style: solid;
@@ -695,6 +693,7 @@
695
693
  var(--tw-ring-shadow, 0 0 #0000),
696
694
  var(--tw-shadow);
697
695
  overflow: auto;
696
+ min-height: 15vh;
698
697
  }
699
698
  .squiz-fte-scope .formatted-text-editor .remirror-editor:active,
700
699
  .squiz-fte-scope .formatted-text-editor .remirror-editor:focus {
@@ -703,6 +702,14 @@
703
702
  .squiz-fte-scope .formatted-text-editor .remirror-editor p {
704
703
  display: block;
705
704
  }
705
+ .squiz-fte-scope .formatted-text-editor--is-disabled .remirror-editor {
706
+ cursor: not-allowed;
707
+ --tw-bg-opacity: 1;
708
+ background-color: rgb(224 224 224 / var(--tw-bg-opacity));
709
+ }
710
+ .squiz-fte-scope .formatted-text-editor--is-disabled .remirror-is-empty:first-of-type::before {
711
+ display: none;
712
+ }
706
713
  .squiz-fte-scope .formatted-text-editor .remirror-is-empty:first-of-type::before {
707
714
  position: absolute;
708
715
  pointer-events: none;
@@ -712,7 +719,14 @@
712
719
  --tw-text-opacity: 1;
713
720
  color: rgb(148 148 148 / var(--tw-text-opacity));
714
721
  }
715
- .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 {
716
730
  border-bottom-width: 2px;
717
731
  border-style: solid;
718
732
  --tw-border-opacity: 1;
@@ -723,10 +737,12 @@
723
737
  display: flex;
724
738
  justify-items: center;
725
739
  }
726
- .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) {
727
742
  margin: 0 0 0 2px;
728
743
  }
729
- .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 {
730
746
  margin-top: -0.25rem;
731
747
  margin-bottom: -0.25rem;
732
748
  margin-left: 0.25rem;
@@ -735,10 +751,12 @@
735
751
  margin-right: 2px;
736
752
  height: auto;
737
753
  }
738
- .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 {
739
756
  padding: 0.25rem;
740
757
  }
741
- .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 {
742
760
  margin-left: 2px;
743
761
  }
744
762
  .squiz-fte-scope__floating-popover {
@@ -757,11 +775,9 @@
757
775
  var(--tw-ring-shadow, 0 0 #0000),
758
776
  var(--tw-shadow);
759
777
  }
760
- .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-btn {
761
- padding: 0.25rem;
762
- }
763
- .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-btn ~ .squiz-fte-btn {
764
- margin-left: 2px;
778
+ .squiz-fte-scope .squiz-fte-scope__floating-popover .editor-divider {
779
+ margin-top: 0px;
780
+ margin-bottom: 0px;
765
781
  }
766
782
  .squiz-fte-scope .squiz-fte-btn {
767
783
  border-radius: 4px;
@@ -810,6 +826,7 @@
810
826
  .squiz-fte-scope .toolbar-dropdown__button {
811
827
  display: flex;
812
828
  align-items: center;
829
+ border-radius: 4px;
813
830
  font-family:
814
831
  Open Sans,
815
832
  Arial,
@@ -926,15 +943,11 @@
926
943
  font-size: 0.875rem;
927
944
  font-weight: 700;
928
945
  }
929
- .squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button {
930
- padding: 0.25rem;
931
- }
932
- .squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button ~ .squiz-fte-btn {
933
- margin-left: 2px;
934
- }
946
+ .squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button,
935
947
  .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-modal-footer__button {
936
948
  padding: 0.25rem;
937
949
  }
950
+ .squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button ~ .squiz-fte-btn,
938
951
  .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-modal-footer__button ~ .squiz-fte-btn {
939
952
  margin-left: 2px;
940
953
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/formatted-text-editor",
3
- "version": "1.21.1-alpha.13",
3
+ "version": "1.21.1-alpha.19",
4
4
  "main": "lib/index.js",
5
5
  "types": "lib/index.d.ts",
6
6
  "scripts": {
@@ -30,6 +30,7 @@
30
30
  "@testing-library/jest-dom": "5.16.5",
31
31
  "@testing-library/react": "14.0.0",
32
32
  "@testing-library/user-event": "14.4.3",
33
+ "@types/node": "18.15.2",
33
34
  "@types/react": "18.0.26",
34
35
  "@types/react-dom": "18.0.9",
35
36
  "@vitejs/plugin-react": "3.0.0",
@@ -68,7 +69,7 @@
68
69
  }
69
70
  },
70
71
  "volta": {
71
- "node": "16.19.0"
72
+ "node": "18.15.0"
72
73
  },
73
- "gitHead": "4d6aa4982ad220602d31fddf081abab79b9128c6"
74
+ "gitHead": "401a8492ae51c8cd377d0676ec9ce4fccffca7ff"
74
75
  }
@@ -4,6 +4,8 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
4
4
  import { MockEditor } from './Editor.mock';
5
5
  import Editor from './Editor';
6
6
  import '@testing-library/jest-dom';
7
+ import { renderWithEditor } from '../../tests/renderWithEditor';
8
+ import ImageButton from '../EditorToolbar/Tools/Image/ImageButton';
7
9
 
8
10
  const setContent: any = jest.fn();
9
11
 
@@ -251,4 +253,88 @@ describe('Formatted text editor', () => {
251
253
 
252
254
  expect(editorNode.textContent).toBe('');
253
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
+
318
+ it('Should not display the toolbar if is not editable', () => {
319
+ render(<Editor editable={false} />);
320
+ expect(screen.queryByRole('button', { name: 'Bold (cmd+B)' })).not.toBeInTheDocument();
321
+ expect(screen.queryByRole('button', { name: 'Italic (cmd+I)' })).not.toBeInTheDocument();
322
+ expect(screen.queryByRole('button', { name: 'Underline (cmd+U)' })).not.toBeInTheDocument();
323
+ });
324
+
325
+ it('Should not display the floating toolbar if is not editable', async () => {
326
+ const from = 3 as number;
327
+ const to = 17 as number;
328
+ const { editor } = await renderWithEditor(null, {
329
+ content: 'My awesome <a href="https://example.org">example</a> content.',
330
+ editable: false,
331
+ });
332
+
333
+ await act(() => editor.selectText({ from, to }));
334
+
335
+ const buttons = screen.queryAllByRole('button');
336
+ const buttonLabels = buttons.map((button) => button.getAttribute('title'));
337
+
338
+ expect(buttonLabels).toEqual([]);
339
+ });
254
340
  });
@@ -1,8 +1,9 @@
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';
5
5
  import { Extensions } from '../Extensions/Extensions';
6
+ import clsx from 'clsx';
6
7
 
7
8
  type EditorProps = {
8
9
  content?: RemirrorContentType;
@@ -10,7 +11,7 @@ type EditorProps = {
10
11
  editable?: boolean;
11
12
  };
12
13
 
13
- const Editor = ({ content, editable, onChange }: EditorProps) => {
14
+ const Editor = ({ content, editable = true, onChange }: EditorProps) => {
14
15
  const { manager, state, setState } = useRemirror({
15
16
  extensions: Extensions,
16
17
  content,
@@ -23,9 +24,20 @@ const Editor = ({ content, editable, onChange }: EditorProps) => {
23
24
  onChange?.(parameter);
24
25
  };
25
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
+
26
35
  return (
27
36
  <div className="squiz-fte-scope">
28
- <div className="remirror-theme formatted-text-editor editor-wrapper">
37
+ <div
38
+ className={clsx('remirror-theme formatted-text-editor', !editable && 'formatted-text-editor--is-disabled')}
39
+ onPaste={preventImagePaste}
40
+ >
29
41
  <Remirror
30
42
  manager={manager}
31
43
  state={state}
@@ -34,9 +46,9 @@ const Editor = ({ content, editable, onChange }: EditorProps) => {
34
46
  placeholder="Write something"
35
47
  label="Text editor"
36
48
  >
37
- <Toolbar />
49
+ {editable && <Toolbar />}
38
50
  <EditorComponent />
39
- <FloatingToolbar />
51
+ {editable && <FloatingToolbar />}
40
52
  </Remirror>
41
53
  </div>
42
54
  </div>
@@ -1,9 +1,6 @@
1
1
  .formatted-text-editor {
2
2
  font-family: 'Open Sans' !important;
3
-
4
- &.editor-wrapper {
5
- @apply bg-white rounded border-gray-300 border-2 border-solid;
6
- }
3
+ @apply bg-white rounded border-gray-300 border-2 border-solid;
7
4
 
8
5
  .remirror-editor-wrapper {
9
6
  @apply text-gray-800 pt-0;
@@ -12,6 +9,7 @@
12
9
  .remirror-editor {
13
10
  @apply bg-white shadow-none rounded-b p-3;
14
11
  overflow: auto;
12
+ min-height: 15vh;
15
13
 
16
14
  &:active,
17
15
  &:focus {
@@ -24,6 +22,15 @@
24
22
  }
25
23
  }
26
24
 
25
+ &--is-disabled {
26
+ .remirror-editor {
27
+ @apply bg-gray-300 cursor-not-allowed;
28
+ }
29
+ .remirror-is-empty:first-of-type::before {
30
+ display: none;
31
+ }
32
+ }
33
+
27
34
  .remirror-is-empty:first-of-type::before {
28
35
  position: absolute;
29
36
  pointer-events: none;
@@ -32,4 +39,7 @@
32
39
  content: attr(data-placeholder);
33
40
  @apply text-gray-500;
34
41
  }
42
+ .ProseMirror-selectednode {
43
+ @apply border-blue-300 border-2 border-solid;
44
+ }
35
45
  }
@@ -1,7 +1,5 @@
1
- import React from 'react';
2
1
  import { act, screen } from '@testing-library/react';
3
2
  import { renderWithEditor } from '../../tests';
4
- import { FloatingToolbar } from './FloatingToolbar';
5
3
 
6
4
  describe('FloatingToolbar', () => {
7
5
  it.each([
@@ -15,8 +13,9 @@ describe('FloatingToolbar', () => {
15
13
  ])(
16
14
  'Renders formatting buttons when text is selected - %s',
17
15
  async (description: string, from: number, to: number, expectedButtons: string[]) => {
18
- const { editor } = await renderWithEditor(<FloatingToolbar />, {
16
+ const { editor } = await renderWithEditor(null, {
19
17
  content: 'My awesome <a href="https://example.org">example</a> content.',
18
+ editable: true,
20
19
  });
21
20
 
22
21
  await act(() => editor.selectText({ from, to }));
@@ -5,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;
@@ -6,9 +6,11 @@ import { BuiltinPreset } from 'remirror';
6
6
  import { EditorComponent, Remirror, useRemirror } from '@remirror/react';
7
7
  import { Extensions } from '../src/Extensions/Extensions';
8
8
  import { RemirrorTestChain } from 'jest-remirror';
9
+ import { FloatingToolbar } from '../src/EditorToolbar/FloatingToolbar';
9
10
 
10
11
  export type EditorRenderOptions = RenderOptions & {
11
12
  content?: RemirrorContentType;
13
+ editable?: boolean;
12
14
  extensions?: Extension[];
13
15
  };
14
16
 
@@ -27,7 +29,7 @@ type EditorRenderResult = {
27
29
  };
28
30
  };
29
31
 
30
- const TestEditor = ({ children, extensions, content, onReady }: TestEditorProps) => {
32
+ const TestEditor = ({ children, extensions, content, onReady, editable }: TestEditorProps) => {
31
33
  const { manager, state, setState } = useRemirror({
32
34
  extensions: () => extensions || Extensions(),
33
35
  content: content,
@@ -43,12 +45,14 @@ const TestEditor = ({ children, extensions, content, onReady }: TestEditorProps)
43
45
  <Remirror
44
46
  manager={manager}
45
47
  state={state}
48
+ editable={editable}
46
49
  onChange={(params) => {
47
50
  setState(params.state);
48
51
  }}
49
52
  >
50
53
  {children}
51
54
  <EditorComponent />
55
+ {editable && <FloatingToolbar />}
52
56
  </Remirror>
53
57
  );
54
58
  };