@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.
- package/lib/Editor/Editor.js +12 -4
- package/lib/EditorToolbar/FloatingToolbar.js +14 -7
- package/lib/EditorToolbar/Tools/Image/Form/ImageForm.js +2 -4
- package/lib/EditorToolbar/Tools/Image/ImageButton.d.ts +4 -1
- package/lib/EditorToolbar/Tools/Image/ImageButton.js +13 -4
- package/lib/EditorToolbar/Tools/Image/ImageModal.js +2 -5
- package/lib/EditorToolbar/Tools/Link/LinkButton.js +9 -2
- package/lib/Extensions/Extensions.js +1 -1
- package/lib/Extensions/ImageExtension/ImageExtension.d.ts +7 -0
- package/lib/Extensions/ImageExtension/ImageExtension.js +85 -0
- package/lib/index.css +31 -18
- package/package.json +4 -3
- package/src/Editor/Editor.spec.tsx +86 -0
- package/src/Editor/Editor.tsx +17 -5
- package/src/Editor/_editor.scss +14 -4
- package/src/EditorToolbar/FloatingToolbar.spec.tsx +2 -3
- package/src/EditorToolbar/FloatingToolbar.tsx +20 -9
- package/src/EditorToolbar/Tools/Image/Form/ImageForm.spec.tsx +55 -1
- package/src/EditorToolbar/Tools/Image/Form/ImageForm.tsx +2 -4
- package/src/EditorToolbar/Tools/Image/ImageButton.spec.tsx +56 -0
- package/src/EditorToolbar/Tools/Image/ImageButton.tsx +21 -7
- package/src/EditorToolbar/Tools/Image/ImageModal.tsx +5 -10
- package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +1 -1
- package/src/EditorToolbar/Tools/Link/LinkButton.tsx +13 -4
- package/src/EditorToolbar/_floating-toolbar.scss +4 -5
- package/src/Extensions/Extensions.ts +1 -1
- package/src/Extensions/ImageExtension/ImageExtension.ts +110 -1
- package/src/ui/ToolbarDropdown/_toolbar-dropdown.scss +1 -1
- package/tests/renderWithEditor.tsx +5 -1
package/lib/Editor/Editor.js
CHANGED
@@ -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
|
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:
|
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 (
|
51
|
-
|
52
|
-
|
53
|
-
|
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
|
56
|
-
|
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((
|
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'
|
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" },
|
@@ -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
|
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 =
|
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
|
-
(
|
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:
|
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 =
|
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?.
|
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
|
-
|
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-
|
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 .
|
761
|
-
|
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.
|
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": "
|
72
|
+
"node": "18.15.0"
|
72
73
|
},
|
73
|
-
"gitHead": "
|
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
|
});
|
package/src/Editor/Editor.tsx
CHANGED
@@ -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
|
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>
|
package/src/Editor/_editor.scss
CHANGED
@@ -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(
|
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 (
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
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="
|
31
|
-
<
|
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((
|
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'
|
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
|
-
|
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 =
|
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
|
-
|
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={
|
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 {
|
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
|
-
|
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?.
|
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(
|
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
|
-
|
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
|
-
|
5
|
-
|
6
|
-
|
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
|
-
|
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
|
+
}
|
@@ -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
|
};
|