@squiz/formatted-text-editor 1.21.1-alpha.16 → 1.21.1-alpha.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/Editor/Editor.js +8 -1
- 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 +22 -16
- package/package.json +2 -2
- package/src/Editor/Editor.spec.tsx +62 -0
- package/src/Editor/Editor.tsx +13 -2
- package/src/Editor/_editor.scss +3 -0
- 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/lib/Editor/Editor.js
CHANGED
@@ -19,8 +19,15 @@ const Editor = ({ content, editable = true, onChange }) => {
|
|
19
19
|
setState(parameter.state);
|
20
20
|
onChange?.(parameter);
|
21
21
|
};
|
22
|
+
const preventImagePaste = (event) => {
|
23
|
+
const { clipboardData } = event;
|
24
|
+
const pastedData = clipboardData?.items[0];
|
25
|
+
if (pastedData?.type && pastedData?.type.startsWith('image/')) {
|
26
|
+
event.preventDefault();
|
27
|
+
}
|
28
|
+
};
|
22
29
|
return (react_1.default.createElement("div", { className: "squiz-fte-scope" },
|
23
|
-
react_1.default.createElement("div", { className: (0, clsx_1.default)('remirror-theme formatted-text-editor', !editable && 'formatted-text-editor--is-disabled') },
|
30
|
+
react_1.default.createElement("div", { className: (0, clsx_1.default)('remirror-theme formatted-text-editor', !editable && 'formatted-text-editor--is-disabled'), onPaste: preventImagePaste },
|
24
31
|
react_1.default.createElement(react_2.Remirror, { manager: manager, state: state, editable: editable, onChange: handleChange, placeholder: "Write something", label: "Text editor" },
|
25
32
|
editable && react_1.default.createElement(EditorToolbar_1.Toolbar, null),
|
26
33
|
react_1.default.createElement(react_2.EditorComponent, null),
|
@@ -37,24 +37,31 @@ const LinkButton_1 = __importDefault(require("./Tools/Link/LinkButton"));
|
|
37
37
|
const react_2 = require("@remirror/react");
|
38
38
|
const react_components_1 = require("@remirror/react-components");
|
39
39
|
const createToolbarPositioner_1 = require("../utils/createToolbarPositioner");
|
40
|
+
const ImageButton_1 = __importDefault(require("./Tools/Image/ImageButton"));
|
40
41
|
// The editor main toolbar
|
41
42
|
const FloatingToolbar = () => {
|
42
43
|
const extensionNames = (0, hooks_1.useExtensionNames)();
|
43
44
|
const positioner = (0, react_1.useMemo)(() => (0, createToolbarPositioner_1.createToolbarPositioner)({ types: ['link'] }), []);
|
44
45
|
const { data } = (0, react_2.usePositioner)(positioner, []);
|
46
|
+
const activeImage = (0, react_2.useActive)();
|
45
47
|
let buttons = [
|
46
48
|
extensionNames.bold && react_1.default.createElement(BoldButton_1.default, { key: "bold" }),
|
47
49
|
extensionNames.italic && react_1.default.createElement(ItalicButton_1.default, { key: "italic" }),
|
48
50
|
extensionNames.underline && react_1.default.createElement(UnderlineButton_1.default, { key: "underline" }),
|
49
51
|
];
|
50
|
-
if (
|
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
@@ -719,7 +719,14 @@
|
|
719
719
|
--tw-text-opacity: 1;
|
720
720
|
color: rgb(148 148 148 / var(--tw-text-opacity));
|
721
721
|
}
|
722
|
-
.squiz-fte-scope .editor-
|
722
|
+
.squiz-fte-scope .formatted-text-editor .ProseMirror-selectednode {
|
723
|
+
border-width: 2px;
|
724
|
+
border-style: solid;
|
725
|
+
--tw-border-opacity: 1;
|
726
|
+
border-color: rgb(7 116 210 / var(--tw-border-opacity));
|
727
|
+
}
|
728
|
+
.squiz-fte-scope .editor-toolbar,
|
729
|
+
.squiz-fte-scope__floating-popover {
|
723
730
|
border-bottom-width: 2px;
|
724
731
|
border-style: solid;
|
725
732
|
--tw-border-opacity: 1;
|
@@ -730,10 +737,12 @@
|
|
730
737
|
display: flex;
|
731
738
|
justify-items: center;
|
732
739
|
}
|
733
|
-
.squiz-fte-scope .editor-toolbar > *:not(:first-child, .editor-divider)
|
740
|
+
.squiz-fte-scope .editor-toolbar > *:not(:first-child, .editor-divider),
|
741
|
+
.squiz-fte-scope .squiz-fte-scope__floating-popover > *:not(:first-child, .editor-divider) {
|
734
742
|
margin: 0 0 0 2px;
|
735
743
|
}
|
736
|
-
.squiz-fte-scope .editor-toolbar .editor-divider
|
744
|
+
.squiz-fte-scope .editor-toolbar .editor-divider,
|
745
|
+
.squiz-fte-scope .squiz-fte-scope__floating-popover .editor-divider {
|
737
746
|
margin-top: -0.25rem;
|
738
747
|
margin-bottom: -0.25rem;
|
739
748
|
margin-left: 0.25rem;
|
@@ -742,10 +751,12 @@
|
|
742
751
|
margin-right: 2px;
|
743
752
|
height: auto;
|
744
753
|
}
|
745
|
-
.squiz-fte-scope .editor-toolbar .squiz-fte-btn
|
754
|
+
.squiz-fte-scope .editor-toolbar .squiz-fte-btn,
|
755
|
+
.squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-btn {
|
746
756
|
padding: 0.25rem;
|
747
757
|
}
|
748
|
-
.squiz-fte-scope .editor-toolbar .squiz-fte-btn ~ .squiz-fte-btn
|
758
|
+
.squiz-fte-scope .editor-toolbar .squiz-fte-btn ~ .squiz-fte-btn,
|
759
|
+
.squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-btn ~ .squiz-fte-btn {
|
749
760
|
margin-left: 2px;
|
750
761
|
}
|
751
762
|
.squiz-fte-scope__floating-popover {
|
@@ -764,11 +775,9 @@
|
|
764
775
|
var(--tw-ring-shadow, 0 0 #0000),
|
765
776
|
var(--tw-shadow);
|
766
777
|
}
|
767
|
-
.squiz-fte-scope .squiz-fte-scope__floating-popover .
|
768
|
-
|
769
|
-
|
770
|
-
.squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-btn ~ .squiz-fte-btn {
|
771
|
-
margin-left: 2px;
|
778
|
+
.squiz-fte-scope .squiz-fte-scope__floating-popover .editor-divider {
|
779
|
+
margin-top: 0px;
|
780
|
+
margin-bottom: 0px;
|
772
781
|
}
|
773
782
|
.squiz-fte-scope .squiz-fte-btn {
|
774
783
|
border-radius: 4px;
|
@@ -817,6 +826,7 @@
|
|
817
826
|
.squiz-fte-scope .toolbar-dropdown__button {
|
818
827
|
display: flex;
|
819
828
|
align-items: center;
|
829
|
+
border-radius: 4px;
|
820
830
|
font-family:
|
821
831
|
Open Sans,
|
822
832
|
Arial,
|
@@ -933,15 +943,11 @@
|
|
933
943
|
font-size: 0.875rem;
|
934
944
|
font-weight: 700;
|
935
945
|
}
|
936
|
-
.squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button
|
937
|
-
padding: 0.25rem;
|
938
|
-
}
|
939
|
-
.squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button ~ .squiz-fte-btn {
|
940
|
-
margin-left: 2px;
|
941
|
-
}
|
946
|
+
.squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button,
|
942
947
|
.squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-modal-footer__button {
|
943
948
|
padding: 0.25rem;
|
944
949
|
}
|
950
|
+
.squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button ~ .squiz-fte-btn,
|
945
951
|
.squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-modal-footer__button ~ .squiz-fte-btn {
|
946
952
|
margin-left: 2px;
|
947
953
|
}
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@squiz/formatted-text-editor",
|
3
|
-
"version": "1.21.1-alpha.
|
3
|
+
"version": "1.21.1-alpha.19",
|
4
4
|
"main": "lib/index.js",
|
5
5
|
"types": "lib/index.d.ts",
|
6
6
|
"scripts": {
|
@@ -71,5 +71,5 @@
|
|
71
71
|
"volta": {
|
72
72
|
"node": "18.15.0"
|
73
73
|
},
|
74
|
-
"gitHead": "
|
74
|
+
"gitHead": "401a8492ae51c8cd377d0676ec9ce4fccffca7ff"
|
75
75
|
}
|
@@ -5,6 +5,7 @@ import { MockEditor } from './Editor.mock';
|
|
5
5
|
import Editor from './Editor';
|
6
6
|
import '@testing-library/jest-dom';
|
7
7
|
import { renderWithEditor } from '../../tests/renderWithEditor';
|
8
|
+
import ImageButton from '../EditorToolbar/Tools/Image/ImageButton';
|
8
9
|
|
9
10
|
const setContent: any = jest.fn();
|
10
11
|
|
@@ -253,6 +254,67 @@ describe('Formatted text editor', () => {
|
|
253
254
|
expect(editorNode.textContent).toBe('');
|
254
255
|
});
|
255
256
|
|
257
|
+
it('should allow text to be pasted into the editor', async () => {
|
258
|
+
const { editor, getJsonContent } = await renderWithEditor(<ImageButton />, {
|
259
|
+
content: 'Some nonsense content here',
|
260
|
+
});
|
261
|
+
|
262
|
+
// paste something
|
263
|
+
await act(() => editor.paste('I pasted this! '));
|
264
|
+
|
265
|
+
expect(getJsonContent()).toEqual({
|
266
|
+
type: 'paragraph',
|
267
|
+
attrs: expect.any(Object),
|
268
|
+
content: [
|
269
|
+
{
|
270
|
+
text: 'I pasted this! Some nonsense content here',
|
271
|
+
type: 'text',
|
272
|
+
},
|
273
|
+
],
|
274
|
+
});
|
275
|
+
});
|
276
|
+
|
277
|
+
it('should not allow images to be pasted into the editor', async () => {
|
278
|
+
const { elements, getJsonContent } = await renderWithEditor(<ImageButton />, {
|
279
|
+
content: 'Some nonsense content here',
|
280
|
+
});
|
281
|
+
const imageClipboardData = {
|
282
|
+
dropEffect: 'none',
|
283
|
+
effectAllowed: 'uninitialized',
|
284
|
+
items: {
|
285
|
+
'0': {
|
286
|
+
kind: 'file',
|
287
|
+
type: 'image/png',
|
288
|
+
},
|
289
|
+
},
|
290
|
+
files: {
|
291
|
+
'0': {
|
292
|
+
lastModified: 1678233857792,
|
293
|
+
lastModifiedDate: {},
|
294
|
+
name: 'Screen Shot 2023-03-08 at 10.04.12 am.png',
|
295
|
+
size: 58517,
|
296
|
+
type: 'image/png',
|
297
|
+
webkitRelativePath: '',
|
298
|
+
},
|
299
|
+
},
|
300
|
+
types: [],
|
301
|
+
};
|
302
|
+
|
303
|
+
// paste something
|
304
|
+
await act(() => fireEvent.paste(elements.editor, imageClipboardData));
|
305
|
+
|
306
|
+
expect(getJsonContent()).toEqual({
|
307
|
+
type: 'paragraph',
|
308
|
+
attrs: expect.any(Object),
|
309
|
+
content: [
|
310
|
+
{
|
311
|
+
text: 'Some nonsense content here',
|
312
|
+
type: 'text',
|
313
|
+
},
|
314
|
+
],
|
315
|
+
});
|
316
|
+
});
|
317
|
+
|
256
318
|
it('Should not display the toolbar if is not editable', () => {
|
257
319
|
render(<Editor editable={false} />);
|
258
320
|
expect(screen.queryByRole('button', { name: 'Bold (cmd+B)' })).not.toBeInTheDocument();
|
package/src/Editor/Editor.tsx
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
import React from 'react';
|
1
|
+
import React, { ClipboardEvent } from 'react';
|
2
2
|
import { EditorComponent, Remirror, useRemirror } from '@remirror/react';
|
3
3
|
import { RemirrorContentType, RemirrorEventListener, Extension } from '@remirror/core';
|
4
4
|
import { Toolbar, FloatingToolbar } from '../EditorToolbar';
|
@@ -24,9 +24,20 @@ const Editor = ({ content, editable = true, onChange }: EditorProps) => {
|
|
24
24
|
onChange?.(parameter);
|
25
25
|
};
|
26
26
|
|
27
|
+
const preventImagePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
28
|
+
const { clipboardData } = event;
|
29
|
+
const pastedData = clipboardData?.items[0];
|
30
|
+
if (pastedData?.type && pastedData?.type.startsWith('image/')) {
|
31
|
+
event.preventDefault();
|
32
|
+
}
|
33
|
+
};
|
34
|
+
|
27
35
|
return (
|
28
36
|
<div className="squiz-fte-scope">
|
29
|
-
<div
|
37
|
+
<div
|
38
|
+
className={clsx('remirror-theme formatted-text-editor', !editable && 'formatted-text-editor--is-disabled')}
|
39
|
+
onPaste={preventImagePaste}
|
40
|
+
>
|
30
41
|
<Remirror
|
31
42
|
manager={manager}
|
32
43
|
state={state}
|
package/src/Editor/_editor.scss
CHANGED
@@ -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
|
+
}
|