penpot/frontend/src/app/util/clipboard.js

269 lines
6.6 KiB
JavaScript

/**
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC Sucursal en España SL
*/
const maxParseableSize = 16 * 1024 * 1024;
const allowedTypes = [
"image/webp",
"image/png",
"image/jpeg",
"image/svg+xml",
"application/transit+json",
"text/html",
"text/plain",
];
const exclusiveTypes = [
"application/transit+json",
"text/html",
"text/plain"
];
const svgTextPattern =
/^(\s*<\?xml[^?]*\?>\s*)?(\s*<!--[\s\S]*?-->\s*)*<svg[\s>]/i;
function hasSvgItem(items) {
return items.some((item) => item?.type === "image/svg+xml");
}
/**
* @typedef {Object} ClipboardSettings
* @property {Function} [decodeTransit]
* @property {boolean} [allowHTMLPaste]
*/
const looksLikeJSON = (str) => {
if (typeof str !== 'string') return false;
const trimmed = str.trim();
return (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
);
};
/**
*
* @param {string} text
* @param {ClipboardSettings} options
* @param {Blob} [defaultReturn]
* @returns {Blob}
*/
function parseText(text, options) {
options = options || {};
const decodeTransit = options["decodeTransit"];
if (decodeTransit && looksLikeJSON(text)) {
try {
decodeTransit(text);
return new Blob([text], { type: "application/transit+json" });
} catch (_error) {
return new Blob([text], { type: "text/plain" });
}
}
if (svgTextPattern.test(text)) {
return new Blob([text], { type: "image/svg+xml" });
} else {
return new Blob([text], { type: "text/plain" });
}
}
/**
* Filters ClipboardItem types
*
* @param {ClipboardSettings} options
* @returns {Function<AllowedTypesFilterFunction>}
*/
function filterAllowedTypes(options) {
/**
* @param {string} type
* @returns {boolean}
*/
function filter(type) {
if (
(!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) &&
type === "text/html"
) {
return false;
}
return allowedTypes.includes(type);
};
return filter;
}
/**
* Filters DataTransferItems
*
* @param {ClipboardSettings} options
* @returns {Function<AllowedTypesFilterFunction>}
*/
function filterAllowedItems(options) {
/**
* @param {DataTransferItem}
* @returns {boolean}
*/
function filter(item) {
if (
(!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) &&
item.type === "text/html"
) {
return false;
}
return allowedTypes.includes(item.type);
};
return filter;
}
/**
* Sorts ClipboardItem types
*
* @param {string} a
* @param {string} b
* @returns {number}
*/
function sortTypes(a, b) {
return allowedTypes.indexOf(a) - allowedTypes.indexOf(b);
}
/**
* Sorts DataTransferItems
*
* @param {DataTransferItem} a
* @param {DataTransferItem} b
* @returns {number}
*/
function sortItems(a, b) {
return allowedTypes.indexOf(a.type) - allowedTypes.indexOf(b.type);
}
/**
* Read the active system clipboard via the asynchronous Clipboard API.
*
* Throws a descriptive `Error` when `navigator.clipboard` is unavailable
* (e.g. on insecure origins per the W3C Secure Contexts spec, mirroring
* the failure mode that crashed the copy/write path in #4478 / #6514).
* Without this guard, `navigator.clipboard.read()` raises an opaque
* `TypeError: Cannot read properties of undefined (reading 'read')` and
* the workspace surfaces a generic "Something wrong has happened" toast.
*
* @param {ClipboardSettings} [options]
* @returns {Promise<Array<Blob>>}
*/
export async function fromNavigator(options) {
options = options || {};
const clipboard = navigator.clipboard;
if (!clipboard || typeof clipboard.read !== "function") {
throw new Error(
"Clipboard API is unavailable. This usually happens when the page is served over plain HTTP; serve Penpot over HTTPS to enable paste-from-clipboard."
);
}
const items = await clipboard.read();
const result = await Promise.all(
Array.from(items).map(async (item) => {
const itemAllowedTypes = Array.from(item.types)
.filter(filterAllowedTypes(options))
.sort(sortTypes);
if (
itemAllowedTypes.length === 1 &&
itemAllowedTypes.at(0) === "text/plain"
) {
const blob = await item.getType("text/plain");
if (blob.size < maxParseableSize) {
const text = await blob.text();
return parseText(text, options);
} else {
return blob;
}
}
const type = itemAllowedTypes.at(0);
if (type == null) {
return null;
}
return item.getType(type);
}),
);
return result.filter((item) => !!item);
}
/**
*
* @param {DataTransfer} dataTransfer
* @param {ClipboardSettings} [options]
* @returns {Promise<Array<Blob>>}
*/
export async function fromDataTransfer(dataTransfer, options) {
options = options || {};
const items = await Promise.all(
Array.from(dataTransfer.items)
.filter(filterAllowedItems(options))
.sort(sortItems)
.map(async (item) => {
if (item.kind === "file") {
return Promise.resolve(item.getAsFile());
} else if (item.kind === "string") {
return new Promise((resolve) => {
const type = item.type;
item.getAsString((text) => {
if (type === "text/plain") {
return resolve(parseText(text, options));
} else {
return resolve(new Blob([text], { type }));
}
});
});
}
return Promise.resolve(null);
}),
);
return items
.filter((item) => !!item && item.size > 0)
.reduce((filtered, item, _index, all) => {
if (
exclusiveTypes.includes(item.type) &&
filtered.find((filteredItem) => exclusiveTypes.includes(filteredItem.type))
) {
return filtered;
}
if (
item.type !== "image/svg+xml" && item.type.startsWith("image/") &&
hasSvgItem(all)
) {
return filtered;
}
filtered.push(item);
return filtered;
}, []);
}
/**
*
* @param {*} clipboardData
* @param {ClipboardSettings} [options]
* @returns {Promise<Array<Blob>>}
*/
export function fromClipboardData(clipboardData, options) {
return fromDataTransfer(clipboardData, options);
}
/**
*
* @param {*} e
* @param {ClipboardSettings} [options]
* @returns {Promise<Array<Blob>>}
*/
export function fromClipboardEvent(e, options) {
return fromClipboardData(e.clipboardData, options);
}