Skip to content

Commit

Permalink
Merge pull request #272 from excalidraw/master
Browse files Browse the repository at this point in the history
merge upstream
  • Loading branch information
zsviczian authored Dec 14, 2024
2 parents da76030 + 2af3221 commit 7bcf829
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 41 deletions.
100 changes: 65 additions & 35 deletions packages/excalidraw/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
import { createFile, isSupportedImageFileType } from "./data/blob";
import { ExcalidrawError } from "./errors";

type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
Expand All @@ -39,7 +41,7 @@ export interface ClipboardData {

type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];

type ParsedClipboardEvent =
type ParsedClipboardEventTextData =
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent };

Expand Down Expand Up @@ -75,7 +77,7 @@ export const createPasteEvent = ({
types,
files,
}: {
types?: { [key in AllowedPasteMimeTypes]?: string };
types?: { [key in AllowedPasteMimeTypes]?: string | File };
files?: File[];
}) => {
if (!types && !files) {
Expand All @@ -88,6 +90,11 @@ export const createPasteEvent = ({

if (types) {
for (const [type, value] of Object.entries(types)) {
if (typeof value !== "string") {
files = files || [];
files.push(value);
continue;
}
try {
event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) {
Expand Down Expand Up @@ -217,14 +224,14 @@ function parseHTMLTree(el: ChildNode) {
const maybeParseHTMLPaste = (
event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData("text/html");
const html = event.clipboardData?.getData(MIME_TYPES.html);

if (!html) {
return null;
}

try {
const doc = new DOMParser().parseFromString(html, "text/html");
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);

const content = parseHTMLTree(doc.body);

Expand All @@ -238,34 +245,44 @@ const maybeParseHTMLPaste = (
return null;
};

/**
* Reads OS clipboard programmatically. May not work on all browsers.
* Will prompt user for permission if not granted.
*/
export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string } = {};

try {
if (navigator.clipboard?.readText) {
return { "text/plain": await navigator.clipboard?.readText() };
}
} catch (error: any) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
throw error;
}
}
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};

let clipboardItems: ClipboardItems;

try {
clipboardItems = await navigator.clipboard?.read();
} catch (error: any) {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
try {
if (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) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
}

throw error;
}
}
throw error;
}
Expand All @@ -276,10 +293,20 @@ export const readSystemClipboard = async () => {
continue;
}
try {
types[type] = await (await item.getType(type)).text();
if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
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) {
console.warn(
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
error instanceof ExcalidrawError
? error.message
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
);
}
}
Expand All @@ -296,10 +323,10 @@ export const readSystemClipboard = async () => {
/**
* Parses "paste" ClipboardEvent.
*/
const parseClipboardEvent = async (
const parseClipboardEventTextData = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ParsedClipboardEvent> => {
): Promise<ParsedClipboardEventTextData> => {
try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);

Expand All @@ -308,7 +335,7 @@ const parseClipboardEvent = async (
return {
type: "text",
value:
event.clipboardData?.getData("text/plain") ||
event.clipboardData?.getData(MIME_TYPES.text) ||
mixedContent.value
.map((item) => item.value)
.join("\n")
Expand All @@ -319,7 +346,7 @@ const parseClipboardEvent = async (
return mixedContent;
}

const text = event.clipboardData?.getData("text/plain");
const text = event.clipboardData?.getData(MIME_TYPES.text);

return { type: "text", value: (text || "").trim() };
} catch {
Expand All @@ -328,13 +355,16 @@ const parseClipboardEvent = async (
};

/**
* Attempts to parse clipboard. Prefers system clipboard.
* Attempts to parse clipboard event.
*/
export const parseClipboard = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
const parsedEventData = await parseClipboardEventTextData(
event,
isPlainPaste,
);

if (parsedEventData.type === "mixedContent") {
return {
Expand Down Expand Up @@ -423,8 +453,8 @@ export const copyTextToSystemClipboard = async (
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
try {
if (clipboardEvent) {
clipboardEvent.clipboardData?.setData("text/plain", text || "");
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
throw new Error("Failed to setData on clipboardEvent");
}
return;
Expand Down
19 changes: 17 additions & 2 deletions packages/excalidraw/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { flushSync } from "react-dom";
import { t } from "../i18n";
import type { DialogProps } from "./Dialog";
import { Dialog } from "./Dialog";
Expand Down Expand Up @@ -43,7 +44,14 @@ const ConfirmDialog = (props: Props) => {
onClick={() => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
onCancel();
// flush any pending updates synchronously,
// otherwise it could lead to crash in some chromium versions (131.0.6778.86),
// when `.focus` is invoked with container in some intermediate state
// (container seems mounted in DOM, but focus still causes a crash)
flushSync(() => {
onCancel();
});

container?.focus();
}}
/>
Expand All @@ -52,7 +60,14 @@ const ConfirmDialog = (props: Props) => {
onClick={() => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
onConfirm();
// flush any pending updates synchronously,
// otherwise it leads to crash in some chromium versions (131.0.6778.86),
// when `.focus` is invoked with container in some intermediate state
// (container seems mounted in DOM, but focus still causes a crash)
flushSync(() => {
onConfirm();
});

container?.focus();
}}
actionType="danger"
Expand Down
10 changes: 8 additions & 2 deletions packages/excalidraw/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,9 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif",
} as const;

export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;

export const MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
Expand All @@ -233,6 +233,12 @@ export const MIME_TYPES = {
...IMAGE_MIME_TYPES,
} 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 = {
png: "png",
svg: "svg",
Expand Down
8 changes: 6 additions & 2 deletions packages/excalidraw/data/blob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,15 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
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 = (
blob: Blob | null | undefined,
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {};
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
return isSupportedImageFileType(type);
};

export const loadSceneOrLibraryFromBlob = async (
Expand Down Expand Up @@ -329,7 +333,7 @@ export const resizeImageFile = async (
}

return new File(
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })],
file.name,
{
type: opts.outputType || file.type,
Expand Down

0 comments on commit 7bcf829

Please sign in to comment.