mirror of
https://github.com/penpot/penpot.git
synced 2026-08-30 08:39:19 +00:00
269 lines
6.6 KiB
JavaScript
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);
|
|
}
|