@uxf/wysiwyg 13.0.3 → 14.0.0
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/package.json +7 -7
- package/plugins/image/create-image-plugin.js +6 -0
- package/plugins/image/image-upload-errors.test.d.ts +1 -0
- package/plugins/image/image-upload-errors.test.js +78 -0
- package/plugins/image/with-paste-image-url.js +24 -3
- package/ui/paragraph-element.js +1 -1
- package/ui/paragraph-element.test.d.ts +1 -0
- package/ui/paragraph-element.test.js +15 -0
- package/ui/toolbar/buttons/toolbar-button.js +5 -2
- package/wysiwyg-editor.js +5 -3
- package/wysiwyg-editor.stories.js +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uxf/wysiwyg",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.0.0",
|
|
4
4
|
"description": "UXF Wysiwyg editor",
|
|
5
5
|
"author": "Robin Dvorak <dvorak@uxf.cz>",
|
|
6
6
|
"homepage": "https://gitlab.com/uxf-npm/wysiwyg",
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"@platejs/link": "53.3.5",
|
|
19
19
|
"@platejs/list-classic": "53.0.0",
|
|
20
20
|
"@platejs/media": "53.1.4",
|
|
21
|
-
"@uxf/core": "
|
|
22
|
-
"@uxf/core-react": "
|
|
23
|
-
"@uxf/ui": "
|
|
21
|
+
"@uxf/core": "14.0.0",
|
|
22
|
+
"@uxf/core-react": "14.0.0",
|
|
23
|
+
"@uxf/ui": "14.0.0",
|
|
24
24
|
"platejs": "53.3.15",
|
|
25
25
|
"react": "^19.3.0",
|
|
26
26
|
"react-dom": "^19.3.0"
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"@platejs/media": "53.1.4",
|
|
34
34
|
"@types/react": "19.3.0",
|
|
35
35
|
"@types/react-dom": "19.3.0",
|
|
36
|
-
"@uxf/core": "
|
|
37
|
-
"@uxf/core-react": "
|
|
38
|
-
"@uxf/ui": "
|
|
36
|
+
"@uxf/core": "14.0.0",
|
|
37
|
+
"@uxf/core-react": "14.0.0",
|
|
38
|
+
"@uxf/ui": "14.0.0",
|
|
39
39
|
"platejs": "53.3.15",
|
|
40
40
|
"react": "19.3.0",
|
|
41
41
|
"react-dom": "19.3.0"
|
|
@@ -34,6 +34,12 @@ const baseImagePlugin = (0, platejs_1.createSlatePlugin)({
|
|
|
34
34
|
request.open("GET", url, true);
|
|
35
35
|
request.responseType = "blob";
|
|
36
36
|
request.onload = () => {
|
|
37
|
+
// `onload` fires for any complete response, an error page included.
|
|
38
|
+
if (request.status < 200 || request.status >= 300) {
|
|
39
|
+
// eslint-disable-next-line no-console
|
|
40
|
+
console.error(`Error receiving image (${request.status}).`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
37
43
|
const blob = request.response;
|
|
38
44
|
const file = new File([blob], "image-from-wysiwyg", {
|
|
39
45
|
type: blob.type,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const platejs_1 = require("platejs");
|
|
4
|
+
const create_paragraph_plugin_1 = require("../paragraph/create-paragraph-plugin");
|
|
5
|
+
const create_image_plugin_1 = require("./create-image-plugin");
|
|
6
|
+
const FILE = { extension: ".png", id: 1, name: "a.png", namespace: "wysiwyg", uuid: "u" };
|
|
7
|
+
function createEditor() {
|
|
8
|
+
const uploadImage = jest.fn(async () => FILE);
|
|
9
|
+
const editor = (0, platejs_1.createSlateEditor)({
|
|
10
|
+
plugins: [
|
|
11
|
+
(0, create_paragraph_plugin_1.createParagraphPlugin)(),
|
|
12
|
+
create_image_plugin_1.createImagePlugin.configure({ options: { getImageUrl: (file) => file.name, uploadImage } }),
|
|
13
|
+
],
|
|
14
|
+
value: [{ type: "p", children: [{ text: "" }] }],
|
|
15
|
+
});
|
|
16
|
+
editor.tf.select({ path: [0, 0], offset: 0 });
|
|
17
|
+
return { editor, uploadImage };
|
|
18
|
+
}
|
|
19
|
+
function textData(text) {
|
|
20
|
+
return {
|
|
21
|
+
effectAllowed: "uninitialized",
|
|
22
|
+
files: [],
|
|
23
|
+
getData: (type) => (type === "text/plain" ? text : ""),
|
|
24
|
+
types: ["text/plain"],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
jest.restoreAllMocks();
|
|
29
|
+
// jsdom has no fetch of its own.
|
|
30
|
+
delete global.fetch;
|
|
31
|
+
});
|
|
32
|
+
test("a pasted image URL that answers with an error is not uploaded", async () => {
|
|
33
|
+
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 404, blob: async () => new Blob() });
|
|
34
|
+
const { editor, uploadImage } = createEditor();
|
|
35
|
+
await editor.tf.insertData(textData("https://example.com/missing.png"));
|
|
36
|
+
expect(uploadImage).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
test("a pasted image URL that cannot be fetched at all neither rejects the paste nor uploads", async () => {
|
|
39
|
+
global.fetch = jest.fn().mockRejectedValue(new TypeError("Failed to fetch"));
|
|
40
|
+
const { editor, uploadImage } = createEditor();
|
|
41
|
+
await expect(editor.tf.insertData(textData("https://example.com/cors.png"))).resolves.not.toThrow();
|
|
42
|
+
expect(uploadImage).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
test("an <img> in pasted HTML whose download fails is not uploaded", () => {
|
|
45
|
+
var _a, _b, _c, _d;
|
|
46
|
+
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
47
|
+
const requests = [];
|
|
48
|
+
jest.spyOn(window, "XMLHttpRequest").mockImplementation(function () {
|
|
49
|
+
const request = {
|
|
50
|
+
onload: () => undefined,
|
|
51
|
+
open: jest.fn(),
|
|
52
|
+
response: new Blob(["<html/>"]),
|
|
53
|
+
send: jest.fn(),
|
|
54
|
+
status: 404,
|
|
55
|
+
};
|
|
56
|
+
requests.push(request);
|
|
57
|
+
return request;
|
|
58
|
+
});
|
|
59
|
+
const { editor, uploadImage } = createEditor();
|
|
60
|
+
const element = document.createElement("img");
|
|
61
|
+
element.setAttribute("src", "https://example.com/missing.png");
|
|
62
|
+
(_c = (_b = (_a = editor.plugins.img.parsers.html) === null || _a === void 0 ? void 0 : _a.deserializer) === null || _b === void 0 ? void 0 : _b.parse) === null || _c === void 0 ? void 0 : _c.call(_b, {
|
|
63
|
+
editor,
|
|
64
|
+
element,
|
|
65
|
+
getOptions: () => editor.getOptions({ key: "img" }),
|
|
66
|
+
});
|
|
67
|
+
expect(requests).toHaveLength(1);
|
|
68
|
+
(_d = requests.at(0)) === null || _d === void 0 ? void 0 : _d.onload();
|
|
69
|
+
expect(uploadImage).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
test("a pasted image URL whose upload fails does not reject the paste", async () => {
|
|
72
|
+
global.fetch = jest
|
|
73
|
+
.fn()
|
|
74
|
+
.mockResolvedValue({ ok: true, status: 200, blob: async () => new Blob(["x"], { type: "image/png" }) });
|
|
75
|
+
const { editor, uploadImage } = createEditor();
|
|
76
|
+
uploadImage.mockRejectedValueOnce(new Error("Upload failed"));
|
|
77
|
+
await expect(editor.tf.insertData(textData("https://example.com/a.png"))).resolves.not.toThrow();
|
|
78
|
+
});
|
|
@@ -3,6 +3,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.withPasteImageUrl = void 0;
|
|
4
4
|
const insert_image_1 = require("./transforms/insert-image");
|
|
5
5
|
const is_image_url_1 = require("./utils/is-image-url");
|
|
6
|
+
/** The image behind a pasted URL, or `null` when it cannot be downloaded (an error status, CORS, offline). */
|
|
7
|
+
async function downloadImage(url) {
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetch(url, { mode: "cors" });
|
|
10
|
+
return response.ok ? await response.blob() : null;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
6
16
|
/**
|
|
7
17
|
* If inserted text is image url, upload image and insert it instead. The disable flag is checked
|
|
8
18
|
* inside the handler because `.overrideEditor` is installed when the plugin is defined.
|
|
@@ -16,15 +26,26 @@ const withPasteImageUrl = ({ editor, getOptions, tf: { insertData }, }) => ({
|
|
|
16
26
|
}
|
|
17
27
|
const text = dataTransfer.getData("text/plain");
|
|
18
28
|
if ((0, is_image_url_1.isImageUrl)(text)) {
|
|
19
|
-
const
|
|
20
|
-
|
|
29
|
+
const blob = await downloadImage(text);
|
|
30
|
+
if (!blob) {
|
|
31
|
+
// Pasted as text, as without this plugin. Uploading an error page as the image, or losing the
|
|
32
|
+
// paste to a rejected fetch, was worse.
|
|
33
|
+
return insertData(dataTransfer);
|
|
34
|
+
}
|
|
21
35
|
const file = new File([blob], "image-from-wysiwyg." + (0, is_image_url_1.getImageExtension)(text), { type: blob.type });
|
|
22
36
|
if (!uploadImage) {
|
|
23
37
|
// eslint-disable-next-line no-console
|
|
24
38
|
console.log("Missing uploadImage function in image plugin options!");
|
|
25
39
|
return;
|
|
26
40
|
}
|
|
27
|
-
|
|
41
|
+
let fileResponse;
|
|
42
|
+
try {
|
|
43
|
+
fileResponse = await uploadImage(file);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// A failed upload falls back to pasting the URL as text, like a failed download.
|
|
47
|
+
return insertData(dataTransfer);
|
|
48
|
+
}
|
|
28
49
|
if (!getImageUrl) {
|
|
29
50
|
// eslint-disable-next-line no-console
|
|
30
51
|
console.log("Missing getImageUrl function in image plugin options!");
|
package/ui/paragraph-element.js
CHANGED
|
@@ -8,6 +8,6 @@ const react_1 = __importDefault(require("react"));
|
|
|
8
8
|
const CLASS_NAME = "uxf-wysiwyg__element";
|
|
9
9
|
const ParagraphElement = (props) => {
|
|
10
10
|
var _a;
|
|
11
|
-
return (react_1.default.createElement("p", { ...props.attributes, className: `${CLASS_NAME} ${CLASS_NAME}-paragraph${(_a = props.className) !== null && _a !== void 0 ? _a : ""}` }, props.children));
|
|
11
|
+
return (react_1.default.createElement("p", { ...props.attributes, className: `${CLASS_NAME} ${CLASS_NAME}-paragraph ${(_a = props.className) !== null && _a !== void 0 ? _a : ""}` }, props.children));
|
|
12
12
|
};
|
|
13
13
|
exports.ParagraphElement = ParagraphElement;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const react_1 = require("@testing-library/react");
|
|
7
|
+
const react_2 = __importDefault(require("react"));
|
|
8
|
+
const paragraph_element_1 = require("./paragraph-element");
|
|
9
|
+
test("keeps a passed className separate from its own", () => {
|
|
10
|
+
var _a, _b;
|
|
11
|
+
const props = { attributes: {}, children: "Text", className: "custom" };
|
|
12
|
+
const { container } = (0, react_1.render)(react_2.default.createElement(paragraph_element_1.ParagraphElement, { ...props }));
|
|
13
|
+
expect((_a = container.querySelector("p")) === null || _a === void 0 ? void 0 : _a.classList.contains("uxf-wysiwyg__element-paragraph")).toBe(true);
|
|
14
|
+
expect((_b = container.querySelector("p")) === null || _b === void 0 ? void 0 : _b.classList.contains("custom")).toBe(true);
|
|
15
|
+
});
|
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.ToolbarButton = void 0;
|
|
7
|
+
const use_media_query_1 = require("@uxf/core-react/hooks/use-media-query");
|
|
7
8
|
const button_1 = require("@uxf/ui/button");
|
|
8
9
|
const icon_1 = require("@uxf/ui/icon");
|
|
9
10
|
const tooltip_1 = require("@uxf/ui/tooltip");
|
|
@@ -12,6 +13,9 @@ const utils_1 = require("../../../utils");
|
|
|
12
13
|
const CLASS_NAME = "uxf-wysiwyg__toolbar-button";
|
|
13
14
|
const ToolbarButton = (props) => {
|
|
14
15
|
var _a, _b;
|
|
16
|
+
// Touch input (no hover, so no tooltip). Through useMediaQuery rather than matchMedia during render, so it
|
|
17
|
+
// follows a change of input device.
|
|
18
|
+
const isCoarsePointer = (0, use_media_query_1.useMediaQuery)("(pointer: coarse)");
|
|
15
19
|
const onMouseDown = props.onMouseDown;
|
|
16
20
|
/* `onPointerDown` must be used, because Editor loses focus on `onClick`. */
|
|
17
21
|
const onLeftMouseDown = (e) => {
|
|
@@ -26,8 +30,7 @@ const ToolbarButton = (props) => {
|
|
|
26
30
|
}
|
|
27
31
|
const button = (react_1.default.createElement(button_1.Button, { className: `${CLASS_NAME} ${(_a = props.className) !== null && _a !== void 0 ? _a : ""}`, isIconButton: true, onPointerDown: onLeftMouseDown, variant: props.isActive ? "default" : "secondary" },
|
|
28
32
|
react_1.default.createElement(icon_1.Icon, { className: `${CLASS_NAME}__icon`, name: props.iconName, size: (_b = props.iconSize) !== null && _b !== void 0 ? _b : 20 })));
|
|
29
|
-
|
|
30
|
-
if (window.matchMedia("(pointer: coarse)").matches) {
|
|
33
|
+
if (isCoarsePointer) {
|
|
31
34
|
return button;
|
|
32
35
|
}
|
|
33
36
|
return (react_1.default.createElement(tooltip_1.Tooltip, { className: `${CLASS_NAME}__tooltip`, content: props.tooltipText }, button));
|
package/wysiwyg-editor.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
37
|
exports.WysiwygEditor = void 0;
|
|
38
38
|
const classes_1 = require("@uxf/core/constants/classes");
|
|
39
|
-
const
|
|
39
|
+
const use_is_hydrated_1 = require("@uxf/core-react/hooks/use-is-hydrated");
|
|
40
40
|
const translations_1 = require("@uxf/core-react/translations");
|
|
41
41
|
const loader_1 = require("@uxf/ui/loader");
|
|
42
42
|
const react_1 = require("platejs/react");
|
|
@@ -88,8 +88,10 @@ const Editor = (props) => {
|
|
|
88
88
|
};
|
|
89
89
|
const WysiwygEditor = (props) => {
|
|
90
90
|
var _a, _b;
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// Plate needs the browser. Rendered on the client after hydration (e.g. after a client-side navigation), the
|
|
92
|
+
// editor shows at once instead of flashing the loader for a commit.
|
|
93
|
+
const isHydrated = (0, use_is_hydrated_1.useIsHydrated)();
|
|
94
|
+
if (!isHydrated) {
|
|
93
95
|
return (react_2.default.createElement("div", { className: `${CLASS_NAME} ${CLASS_NAME}--${classes_1.CLASSES.IS_LOADING} ${(_a = props.className) !== null && _a !== void 0 ? _a : ""}` },
|
|
94
96
|
react_2.default.createElement(loader_1.Loader, { className: `${CLASS_NAME}__loader` })));
|
|
95
97
|
}
|
|
@@ -38,7 +38,7 @@ exports.initialValue = void 0;
|
|
|
38
38
|
exports.Playground = Playground;
|
|
39
39
|
exports.SelectedPlugins = SelectedPlugins;
|
|
40
40
|
const show_1 = require("@uxf/core-react/components/show");
|
|
41
|
-
const
|
|
41
|
+
const use_is_hydrated_1 = require("@uxf/core-react/hooks/use-is-hydrated");
|
|
42
42
|
const react_1 = __importStar(require("react"));
|
|
43
43
|
const wysiwyg_1 = require("@uxf/wysiwyg");
|
|
44
44
|
exports.initialValue = [
|
|
@@ -157,7 +157,7 @@ function Playground() {
|
|
|
157
157
|
}),
|
|
158
158
|
(0, wysiwyg_1.createHighlightPluginWithUi)("red"),
|
|
159
159
|
]);
|
|
160
|
-
const
|
|
160
|
+
const isHydrated = (0, use_is_hydrated_1.useIsHydrated)();
|
|
161
161
|
return (react_1.default.createElement(react_1.default.Fragment, null,
|
|
162
162
|
react_1.default.createElement(wysiwyg_1.WysiwygEditor, { id: "editor", initialValue: exports.initialValue, onChange: setValue, plugins: plugins }),
|
|
163
163
|
react_1.default.createElement("div", { className: "relative mt-4" },
|
|
@@ -165,7 +165,7 @@ function Playground() {
|
|
|
165
165
|
react_1.default.createElement("div", { className: "w-full border-t border-gray-300" })),
|
|
166
166
|
react_1.default.createElement("div", { className: "relative flex justify-center" },
|
|
167
167
|
react_1.default.createElement("span", { className: "bg-white px-2 text-sm text-gray-500" }, "Value"))),
|
|
168
|
-
react_1.default.createElement(show_1.Show, { when:
|
|
168
|
+
react_1.default.createElement(show_1.Show, { when: isHydrated },
|
|
169
169
|
react_1.default.createElement("pre", null,
|
|
170
170
|
react_1.default.createElement("code", null, JSON.stringify(value, null, " "))))));
|
|
171
171
|
}
|
|
@@ -189,7 +189,7 @@ function SelectedPlugins() {
|
|
|
189
189
|
}),
|
|
190
190
|
(0, wysiwyg_1.createHighlightPluginWithUi)("red"),
|
|
191
191
|
]);
|
|
192
|
-
const
|
|
192
|
+
const isHydrated = (0, use_is_hydrated_1.useIsHydrated)();
|
|
193
193
|
return (react_1.default.createElement(react_1.default.Fragment, null,
|
|
194
194
|
react_1.default.createElement(wysiwyg_1.WysiwygEditor, { id: "editor", initialValue: _initialValues, onChange: setValue, plugins: plugins }),
|
|
195
195
|
react_1.default.createElement("div", { className: "relative mt-4" },
|
|
@@ -197,7 +197,7 @@ function SelectedPlugins() {
|
|
|
197
197
|
react_1.default.createElement("div", { className: "w-full border-t border-gray-300" })),
|
|
198
198
|
react_1.default.createElement("div", { className: "relative flex justify-center" },
|
|
199
199
|
react_1.default.createElement("span", { className: "bg-white px-2 text-sm text-gray-500" }, "Value"))),
|
|
200
|
-
react_1.default.createElement(show_1.Show, { when:
|
|
200
|
+
react_1.default.createElement(show_1.Show, { when: isHydrated },
|
|
201
201
|
react_1.default.createElement("pre", null,
|
|
202
202
|
react_1.default.createElement("code", null, JSON.stringify(value, null, " "))))));
|
|
203
203
|
}
|