From 8a1152ce366cae8a8b3fc532b95a665cf0900779 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Mon, 9 Dec 2024 22:57:37 +0200 Subject: [PATCH 1/3] fix: Flush pending DOM updates before .focus() (#8901) --- .../excalidraw/components/ConfirmDialog.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/components/ConfirmDialog.tsx b/packages/excalidraw/components/ConfirmDialog.tsx index 2bda72e2c00e..637f0659a808 100644 --- a/packages/excalidraw/components/ConfirmDialog.tsx +++ b/packages/excalidraw/components/ConfirmDialog.tsx @@ -1,3 +1,4 @@ +import { flushSync } from "react-dom"; import { t } from "../i18n"; import type { DialogProps } from "./Dialog"; import { Dialog } from "./Dialog"; @@ -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(); }} /> @@ -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" From 9b401f6ea3aa99f83c6a18f4a691ea90fa36314c Mon Sep 17 00:00:00 2001 From: Antonio Della Fortuna <50418432+adarkforce@users.noreply.github.com> Date: Tue, 10 Dec 2024 13:41:10 +0100 Subject: [PATCH 2/3] fix: fixed image transparency by adding alpha option to preserve image alpha channel (#8895) added alpha option to preserve image alpha channel --- packages/excalidraw/data/blob.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index 69a62cda5d05..d19107a2258f 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -329,7 +329,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, From 2af32219740b40633af023b7e8a17cb376f531cf Mon Sep 17 00:00:00 2001 From: Shreyansh Jain <42074753+shreyjay0@users.noreply.github.com> Date: Wed, 11 Dec 2024 01:40:34 +0530 Subject: [PATCH 3/3] fix: right-click paste for images in clipboard (Issue #8826) (#8845) * 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> --- packages/excalidraw/clipboard.ts | 100 ++++++++++++++++++++----------- packages/excalidraw/constants.ts | 10 +++- packages/excalidraw/data/blob.ts | 6 +- 3 files changed, 78 insertions(+), 38 deletions(-) diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index b53b59caec85..296a3415abf1 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -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; @@ -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 }; @@ -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) { @@ -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) { @@ -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); @@ -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; } @@ -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}`, ); } } @@ -296,10 +323,10 @@ export const readSystemClipboard = async () => { /** * Parses "paste" ClipboardEvent. */ -const parseClipboardEvent = async ( +const parseClipboardEventTextData = async ( event: ClipboardEvent, isPlainPaste = false, -): Promise => { +): Promise => { try { const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event); @@ -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") @@ -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 { @@ -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 => { - const parsedEventData = await parseClipboardEvent(event, isPlainPaste); + const parsedEventData = await parseClipboardEventTextData( + event, + isPlainPaste, + ); if (parsedEventData.type === "mixedContent") { return { @@ -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; diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index b722028c2358..b8f7e1b83e52 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -214,9 +214,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", @@ -230,6 +230,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", diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index d19107a2258f..435009884d7c 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -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 } => { const { type } = blob || {}; - return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type); + return isSupportedImageFileType(type); }; export const loadSceneOrLibraryFromBlob = async (