mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
* Fix right-click paste command for images (Issue #8826) * Fix clipboard logic for multiple paste types * fix: remove unused code * refactor & robustness * fix: creating paste event with image files --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
9b401f6ea3
commit
2af3221974
3 changed files with 78 additions and 38 deletions
|
@ -18,6 +18,8 @@ import { deepCopyElement } from "./element/newElement";
|
||||||
import { mutateElement } from "./element/mutateElement";
|
import { mutateElement } from "./element/mutateElement";
|
||||||
import { getContainingFrame } from "./frame";
|
import { getContainingFrame } from "./frame";
|
||||||
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
||||||
|
import { createFile, isSupportedImageFileType } from "./data/blob";
|
||||||
|
import { ExcalidrawError } from "./errors";
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
|
@ -39,7 +41,7 @@ export interface ClipboardData {
|
||||||
|
|
||||||
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
||||||
|
|
||||||
type ParsedClipboardEvent =
|
type ParsedClipboardEventTextData =
|
||||||
| { type: "text"; value: string }
|
| { type: "text"; value: string }
|
||||||
| { type: "mixedContent"; value: PastedMixedContent };
|
| { type: "mixedContent"; value: PastedMixedContent };
|
||||||
|
|
||||||
|
@ -75,7 +77,7 @@ export const createPasteEvent = ({
|
||||||
types,
|
types,
|
||||||
files,
|
files,
|
||||||
}: {
|
}: {
|
||||||
types?: { [key in AllowedPasteMimeTypes]?: string };
|
types?: { [key in AllowedPasteMimeTypes]?: string | File };
|
||||||
files?: File[];
|
files?: File[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!types && !files) {
|
if (!types && !files) {
|
||||||
|
@ -88,6 +90,11 @@ export const createPasteEvent = ({
|
||||||
|
|
||||||
if (types) {
|
if (types) {
|
||||||
for (const [type, value] of Object.entries(types)) {
|
for (const [type, value] of Object.entries(types)) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
files = files || [];
|
||||||
|
files.push(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
event.clipboardData?.setData(type, value);
|
event.clipboardData?.setData(type, value);
|
||||||
if (event.clipboardData?.getData(type) !== value) {
|
if (event.clipboardData?.getData(type) !== value) {
|
||||||
|
@ -217,14 +224,14 @@ function parseHTMLTree(el: ChildNode) {
|
||||||
const maybeParseHTMLPaste = (
|
const maybeParseHTMLPaste = (
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||||
const html = event.clipboardData?.getData("text/html");
|
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
||||||
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
||||||
|
|
||||||
const content = parseHTMLTree(doc.body);
|
const content = parseHTMLTree(doc.body);
|
||||||
|
|
||||||
|
@ -238,12 +245,27 @@ const maybeParseHTMLPaste = (
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads OS clipboard programmatically. May not work on all browsers.
|
||||||
|
* Will prompt user for permission if not granted.
|
||||||
|
*/
|
||||||
export const readSystemClipboard = async () => {
|
export const readSystemClipboard = async () => {
|
||||||
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
|
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
|
||||||
|
|
||||||
|
let clipboardItems: ClipboardItems;
|
||||||
|
|
||||||
|
try {
|
||||||
|
clipboardItems = await navigator.clipboard?.read();
|
||||||
|
} catch (error: any) {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard?.readText) {
|
if (navigator.clipboard?.readText) {
|
||||||
return { "text/plain": await navigator.clipboard?.readText() };
|
console.warn(
|
||||||
|
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
|
||||||
|
);
|
||||||
|
const readText = await navigator.clipboard?.readText();
|
||||||
|
if (readText) {
|
||||||
|
return { [MIME_TYPES.text]: readText };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -252,21 +274,16 @@ export const readSystemClipboard = async () => {
|
||||||
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
|
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let clipboardItems: ClipboardItems;
|
|
||||||
|
|
||||||
try {
|
|
||||||
clipboardItems = await navigator.clipboard?.read();
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.name === "DataError") {
|
if (error.name === "DataError") {
|
||||||
console.warn(
|
console.warn(
|
||||||
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
|
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
|
||||||
);
|
);
|
||||||
return types;
|
return types;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -276,10 +293,20 @@ export const readSystemClipboard = async () => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
|
||||||
types[type] = await (await item.getType(type)).text();
|
types[type] = await (await item.getType(type)).text();
|
||||||
|
} else if (isSupportedImageFileType(type)) {
|
||||||
|
const imageBlob = await item.getType(type);
|
||||||
|
const file = createFile(imageBlob, type, undefined);
|
||||||
|
types[type] = file;
|
||||||
|
} else {
|
||||||
|
throw new ExcalidrawError(`Unsupported clipboard type: ${type}`);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
error instanceof ExcalidrawError
|
||||||
|
? error.message
|
||||||
|
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -296,10 +323,10 @@ export const readSystemClipboard = async () => {
|
||||||
/**
|
/**
|
||||||
* Parses "paste" ClipboardEvent.
|
* Parses "paste" ClipboardEvent.
|
||||||
*/
|
*/
|
||||||
const parseClipboardEvent = async (
|
const parseClipboardEventTextData = async (
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ParsedClipboardEvent> => {
|
): Promise<ParsedClipboardEventTextData> => {
|
||||||
try {
|
try {
|
||||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
||||||
|
|
||||||
|
@ -308,7 +335,7 @@ const parseClipboardEvent = async (
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
value:
|
||||||
event.clipboardData?.getData("text/plain") ||
|
event.clipboardData?.getData(MIME_TYPES.text) ||
|
||||||
mixedContent.value
|
mixedContent.value
|
||||||
.map((item) => item.value)
|
.map((item) => item.value)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
|
@ -319,7 +346,7 @@ const parseClipboardEvent = async (
|
||||||
return mixedContent;
|
return mixedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = event.clipboardData?.getData("text/plain");
|
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
||||||
|
|
||||||
return { type: "text", value: (text || "").trim() };
|
return { type: "text", value: (text || "").trim() };
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -328,13 +355,16 @@ const parseClipboardEvent = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to parse clipboard. Prefers system clipboard.
|
* Attempts to parse clipboard event.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
|
const parsedEventData = await parseClipboardEventTextData(
|
||||||
|
event,
|
||||||
|
isPlainPaste,
|
||||||
|
);
|
||||||
|
|
||||||
if (parsedEventData.type === "mixedContent") {
|
if (parsedEventData.type === "mixedContent") {
|
||||||
return {
|
return {
|
||||||
|
@ -423,8 +453,8 @@ export const copyTextToSystemClipboard = async (
|
||||||
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
|
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
|
||||||
try {
|
try {
|
||||||
if (clipboardEvent) {
|
if (clipboardEvent) {
|
||||||
clipboardEvent.clipboardData?.setData("text/plain", text || "");
|
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
|
||||||
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
|
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
|
||||||
throw new Error("Failed to setData on clipboardEvent");
|
throw new Error("Failed to setData on clipboardEvent");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -214,9 +214,9 @@ export const IMAGE_MIME_TYPES = {
|
||||||
jfif: "image/jfif",
|
jfif: "image/jfif",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
|
|
||||||
|
|
||||||
export const MIME_TYPES = {
|
export const MIME_TYPES = {
|
||||||
|
text: "text/plain",
|
||||||
|
html: "text/html",
|
||||||
json: "application/json",
|
json: "application/json",
|
||||||
// excalidraw data
|
// excalidraw data
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
|
@ -230,6 +230,12 @@ export const MIME_TYPES = {
|
||||||
...IMAGE_MIME_TYPES,
|
...IMAGE_MIME_TYPES,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const ALLOWED_PASTE_MIME_TYPES = [
|
||||||
|
MIME_TYPES.text,
|
||||||
|
MIME_TYPES.html,
|
||||||
|
...Object.values(IMAGE_MIME_TYPES),
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const EXPORT_IMAGE_TYPES = {
|
export const EXPORT_IMAGE_TYPES = {
|
||||||
png: "png",
|
png: "png",
|
||||||
svg: "svg",
|
svg: "svg",
|
||||||
|
|
|
@ -106,11 +106,15 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
|
||||||
return type === "png" || type === "svg";
|
return type === "png" || type === "svg";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isSupportedImageFileType = (type: string | null | undefined) => {
|
||||||
|
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
|
||||||
|
};
|
||||||
|
|
||||||
export const isSupportedImageFile = (
|
export const isSupportedImageFile = (
|
||||||
blob: Blob | null | undefined,
|
blob: Blob | null | undefined,
|
||||||
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
|
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
|
||||||
const { type } = blob || {};
|
const { type } = blob || {};
|
||||||
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
|
return isSupportedImageFileType(type);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadSceneOrLibraryFromBlob = async (
|
export const loadSceneOrLibraryFromBlob = async (
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue