diff --git a/src/actions/actionClipboard.tsx b/src/actions/actionClipboard.tsx index 661f65f38..18fefafd2 100644 --- a/src/actions/actionClipboard.tsx +++ b/src/actions/actionClipboard.tsx @@ -18,7 +18,7 @@ export const actionCopy = register({ perform: (elements, appState, _, app) => { const selectedElements = getSelectedElements(elements, appState, true); - copyToClipboard(selectedElements, appState, app.files); + copyToClipboard(selectedElements, app.files); return { commitToHistory: false, diff --git a/src/clipboard.ts b/src/clipboard.ts index ddd2c1db4..19fb7fffd 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -2,12 +2,12 @@ import { ExcalidrawElement, NonDeletedExcalidrawElement, } from "./element/types"; -import { AppState, BinaryFiles } from "./types"; +import { BinaryFiles } from "./types"; import { SVG_EXPORT_TAG } from "./scene/export"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { isInitializedImageElement } from "./element/typeChecks"; -import { isPromiseLike } from "./utils"; +import { isPromiseLike, isTestEnv } from "./utils"; type ElementsClipboard = { type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; @@ -55,24 +55,40 @@ const clipboardContainsElements = ( export const copyToClipboard = async ( elements: readonly NonDeletedExcalidrawElement[], - appState: AppState, files: BinaryFiles | null, ) => { + let foundFile = false; + + const _files = elements.reduce((acc, element) => { + if (isInitializedImageElement(element)) { + foundFile = true; + if (files && files[element.fileId]) { + acc[element.fileId] = files[element.fileId]; + } + } + return acc; + }, {} as BinaryFiles); + + if (foundFile && !files) { + console.warn( + "copyToClipboard: attempting to file element(s) without providing associated `files` object.", + ); + } + // select binded text elements when copying const contents: ElementsClipboard = { type: EXPORT_DATA_TYPES.excalidrawClipboard, elements, - files: files - ? elements.reduce((acc, element) => { - if (isInitializedImageElement(element) && files[element.fileId]) { - acc[element.fileId] = files[element.fileId]; - } - return acc; - }, {} as BinaryFiles) - : undefined, + files: files ? _files : undefined, }; const json = JSON.stringify(contents); + + if (isTestEnv()) { + return json; + } + CLIPBOARD = json; + try { PREFER_APP_CLIPBOARD = false; await copyTextToSystemClipboard(json); diff --git a/src/components/App.tsx b/src/components/App.tsx index 6b9f41ca2..3564d0699 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -60,6 +60,7 @@ import { ENV, EVENT, GRID_SIZE, + IMAGE_MIME_TYPES, IMAGE_RENDER_TIMEOUT, isAndroid, isBrave, @@ -1650,6 +1651,7 @@ class App extends React.Component { elements: data.elements, files: data.files || null, position: "cursor", + retainSeed: isPlainPaste, }); } else if (data.text) { this.addTextFromPaste(data.text, isPlainPaste); @@ -1663,6 +1665,7 @@ class App extends React.Component { elements: readonly ExcalidrawElement[]; files: BinaryFiles | null; position: { clientX: number; clientY: number } | "cursor" | "center"; + retainSeed?: boolean; }) => { const elements = restoreElements(opts.elements, null); const [minX, minY, maxX, maxY] = getCommonBounds(elements); @@ -1700,6 +1703,9 @@ class App extends React.Component { y: element.y + gridY - minY, }); }), + { + randomizeSeed: !opts.retainSeed, + }, ); const nextElements = [ @@ -4786,7 +4792,12 @@ class App extends React.Component { pointerDownState.drag.hasOccurred = true; // prevent dragging even if we're no longer holding cmd/ctrl otherwise // it would have weird results (stuff jumping all over the screen) - if (selectedElements.length > 0 && !pointerDownState.withCmdOrCtrl) { + // Checking for editingElement to avoid jump while editing on mobile #6503 + if ( + selectedElements.length > 0 && + !pointerDownState.withCmdOrCtrl && + !this.state.editingElement + ) { const [dragX, dragY] = getGridPoint( pointerCoords.x - pointerDownState.drag.offset.x, pointerCoords.y - pointerDownState.drag.offset.y, @@ -5809,7 +5820,9 @@ class App extends React.Component { const imageFile = await fileOpen({ description: "Image", - extensions: ["jpg", "png", "svg", "gif"], + extensions: Object.keys( + IMAGE_MIME_TYPES, + ) as (keyof typeof IMAGE_MIME_TYPES)[], }); const imageElement = this.createImageElement({ diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index 7ae6517a8..19bb33308 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/src/components/LibraryMenuItems.tsx @@ -102,7 +102,7 @@ const LibraryMenuItems = ({ ...item, // duplicate each library item before inserting on canvas to confine // ids and bindings to each library item. See #6465 - elements: duplicateElements(item.elements), + elements: duplicateElements(item.elements, { randomizeSeed: true }), }; }); }; diff --git a/src/constants.ts b/src/constants.ts index 23fefa6e5..19b41b688 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -105,20 +105,30 @@ export const CANVAS_ONLY_ACTIONS = ["selectAll"]; export const GRID_SIZE = 20; // TODO make it configurable? -export const MIME_TYPES = { - excalidraw: "application/vnd.excalidraw+json", - excalidrawlib: "application/vnd.excalidrawlib+json", - json: "application/json", +export const IMAGE_MIME_TYPES = { svg: "image/svg+xml", - "excalidraw.svg": "image/svg+xml", png: "image/png", - "excalidraw.png": "image/png", jpg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp", ico: "image/x-icon", + avif: "image/avif", + jfif: "image/jfif", +} as const; + +export const MIME_TYPES = { + json: "application/json", + // excalidraw data + excalidraw: "application/vnd.excalidraw+json", + excalidrawlib: "application/vnd.excalidrawlib+json", + // image-encoded excalidraw data + "excalidraw.svg": "image/svg+xml", + "excalidraw.png": "image/png", + // binary binary: "application/octet-stream", + // image + ...IMAGE_MIME_TYPES, } as const; export const EXPORT_DATA_TYPES = { @@ -189,16 +199,6 @@ export const DEFAULT_EXPORT_PADDING = 10; // px export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; -export const ALLOWED_IMAGE_MIME_TYPES = [ - MIME_TYPES.png, - MIME_TYPES.jpg, - MIME_TYPES.svg, - MIME_TYPES.gif, - MIME_TYPES.webp, - MIME_TYPES.bmp, - MIME_TYPES.ico, -] as const; - export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024; export const SVG_NS = "http://www.w3.org/2000/svg"; diff --git a/src/data/blob.ts b/src/data/blob.ts index 47cff293f..4565b5cb5 100644 --- a/src/data/blob.ts +++ b/src/data/blob.ts @@ -1,6 +1,6 @@ import { nanoid } from "nanoid"; import { cleanAppStateForExport } from "../appState"; -import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; +import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import { clearElementsForExport } from "../element"; import { ExcalidrawElement, FileId } from "../element/types"; import { CanvasError } from "../errors"; @@ -117,11 +117,9 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => { export const isSupportedImageFile = ( blob: Blob | null | undefined, -): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => { +): blob is Blob & { type: ValueOf } => { const { type } = blob || {}; - return ( - !!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type) - ); + return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type); }; export const loadSceneOrLibraryFromBlob = async ( diff --git a/src/data/filesystem.ts b/src/data/filesystem.ts index ffe088faf..fa29604f4 100644 --- a/src/data/filesystem.ts +++ b/src/data/filesystem.ts @@ -8,16 +8,7 @@ import { EVENT, MIME_TYPES } from "../constants"; import { AbortError } from "../errors"; import { debounce } from "../utils"; -type FILE_EXTENSION = - | "gif" - | "jpg" - | "png" - | "excalidraw.png" - | "svg" - | "excalidraw.svg" - | "json" - | "excalidraw" - | "excalidrawlib"; +type FILE_EXTENSION = Exclude; const INPUT_CHANGE_INTERVAL_MS = 500; diff --git a/src/element/newElement.ts b/src/element/newElement.ts index bddeff5fe..7fc89d608 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -15,7 +15,7 @@ import { } from "../element/types"; import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils"; import { randomInteger, randomId } from "../random"; -import { mutateElement, newElementWith } from "./mutateElement"; +import { bumpVersion, mutateElement, newElementWith } from "./mutateElement"; import { getNewGroupIdsForDuplication } from "../groups"; import { AppState } from "../types"; import { getElementAbsoluteCoords } from "."; @@ -569,8 +569,16 @@ export const duplicateElement = ( * it's advised to supply the whole elements array, or sets of elements that * are encapsulated (such as library items), if the purpose is to retain * bindings to the cloned elements intact. + * + * NOTE by default does not randomize or regenerate anything except the id. */ -export const duplicateElements = (elements: readonly ExcalidrawElement[]) => { +export const duplicateElements = ( + elements: readonly ExcalidrawElement[], + opts?: { + /** NOTE also updates version flags and `updated` */ + randomizeSeed: boolean; + }, +) => { const clonedElements: ExcalidrawElement[] = []; const origElementsMap = arrayToMap(elements); @@ -604,6 +612,11 @@ export const duplicateElements = (elements: readonly ExcalidrawElement[]) => { clonedElement.id = maybeGetNewId(element.id)!; + if (opts?.randomizeSeed) { + clonedElement.seed = randomInteger(); + bumpVersion(clonedElement); + } + if (clonedElement.groupIds) { clonedElement.groupIds = clonedElement.groupIds.map((groupId) => { if (!groupNewIdsMap.has(groupId)) { diff --git a/src/packages/utils.ts b/src/packages/utils.ts index 560fa13ca..d9365895e 100644 --- a/src/packages/utils.ts +++ b/src/packages/utils.ts @@ -220,15 +220,7 @@ export const exportToClipboard = async ( } else if (opts.type === "png") { await copyBlobToClipboardAsPng(exportToBlob(opts)); } else if (opts.type === "json") { - const appState = { - offsetTop: 0, - offsetLeft: 0, - width: 0, - height: 0, - ...getDefaultAppState(), - ...opts.appState, - }; - await copyToClipboard(opts.elements, appState, opts.files); + await copyToClipboard(opts.elements, opts.files); } else { throw new Error("Invalid export type"); } diff --git a/src/tests/clipboard.test.tsx b/src/tests/clipboard.test.tsx index 1fdc0f452..bbaa4d179 100644 --- a/src/tests/clipboard.test.tsx +++ b/src/tests/clipboard.test.tsx @@ -1,5 +1,10 @@ import ReactDOM from "react-dom"; -import { render, waitFor, GlobalTestState } from "./test-utils"; +import { + render, + waitFor, + GlobalTestState, + createPasteEvent, +} from "./test-utils"; import { Pointer, Keyboard } from "./helpers/ui"; import ExcalidrawApp from "../excalidraw-app"; import { KEYS } from "../keys"; @@ -9,6 +14,8 @@ import { } from "../element/textElement"; import { getElementBounds } from "../element"; import { NormalizedZoomValue } from "../types"; +import { API } from "./helpers/api"; +import { copyToClipboard } from "../clipboard"; const { h } = window; @@ -35,38 +42,28 @@ const setClipboardText = (text: string) => { }); }; -const sendPasteEvent = () => { - const clipboardEvent = new Event("paste", { - bubbles: true, - cancelable: true, - composed: true, - }); - - // set `clipboardData` properties. - // @ts-ignore - clipboardEvent.clipboardData = { - getData: () => window.navigator.clipboard.readText(), - files: [], - }; - +const sendPasteEvent = (text?: string) => { + const clipboardEvent = createPasteEvent( + text || (() => window.navigator.clipboard.readText()), + ); document.dispatchEvent(clipboardEvent); }; -const pasteWithCtrlCmdShiftV = () => { +const pasteWithCtrlCmdShiftV = (text?: string) => { Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { //triggering keydown with an empty clipboard Keyboard.keyPress(KEYS.V); //triggering paste event with faked clipboard - sendPasteEvent(); + sendPasteEvent(text); }); }; -const pasteWithCtrlCmdV = () => { +const pasteWithCtrlCmdV = (text?: string) => { Keyboard.withModifierKeys({ ctrl: true }, () => { //triggering keydown with an empty clipboard Keyboard.keyPress(KEYS.V); //triggering paste event with faked clipboard - sendPasteEvent(); + sendPasteEvent(text); }); }; @@ -89,6 +86,32 @@ beforeEach(async () => { }); }); +describe("general paste behavior", () => { + it("should randomize seed on paste", async () => { + const rectangle = API.createElement({ type: "rectangle" }); + const clipboardJSON = (await copyToClipboard([rectangle], null))!; + + pasteWithCtrlCmdV(clipboardJSON); + + await waitFor(() => { + expect(h.elements.length).toBe(1); + expect(h.elements[0].seed).not.toBe(rectangle.seed); + }); + }); + + it("should retain seed on shift-paste", async () => { + const rectangle = API.createElement({ type: "rectangle" }); + const clipboardJSON = (await copyToClipboard([rectangle], null))!; + + // assert we don't randomize seed on shift-paste + pasteWithCtrlCmdShiftV(clipboardJSON); + await waitFor(() => { + expect(h.elements.length).toBe(1); + expect(h.elements[0].seed).toBe(rectangle.seed); + }); + }); +}); + describe("paste text as single lines", () => { it("should create an element for each line when copying with Ctrl/Cmd+V", async () => { const text = "sajgfakfn\naaksfnknas\nakefnkasf"; diff --git a/src/tests/flip.test.tsx b/src/tests/flip.test.tsx index 45a5e1477..c1469bc83 100644 --- a/src/tests/flip.test.tsx +++ b/src/tests/flip.test.tsx @@ -1,5 +1,10 @@ import ReactDOM from "react-dom"; -import { GlobalTestState, render, waitFor } from "./test-utils"; +import { + createPasteEvent, + GlobalTestState, + render, + waitFor, +} from "./test-utils"; import { UI, Pointer } from "./helpers/ui"; import { API } from "./helpers/api"; import { actionFlipHorizontal, actionFlipVertical } from "../actions"; @@ -680,19 +685,7 @@ describe("freedraw", () => { describe("image", () => { const createImage = async () => { const sendPasteEvent = (file?: File) => { - const clipboardEvent = new Event("paste", { - bubbles: true, - cancelable: true, - composed: true, - }); - - // set `clipboardData` properties. - // @ts-ignore - clipboardEvent.clipboardData = { - getData: () => window.navigator.clipboard.readText(), - files: [file], - }; - + const clipboardEvent = createPasteEvent("", file ? [file] : []); document.dispatchEvent(clipboardEvent); }; diff --git a/src/tests/test-utils.ts b/src/tests/test-utils.ts index c33e80c7d..9560f681f 100644 --- a/src/tests/test-utils.ts +++ b/src/tests/test-utils.ts @@ -190,3 +190,24 @@ export const toggleMenu = (container: HTMLElement) => { // open menu fireEvent.click(container.querySelector(".dropdown-menu-button")!); }; + +export const createPasteEvent = ( + text: + | string + | /* getData function */ ((type: string) => string | Promise), + files?: File[], +) => { + return Object.assign( + new Event("paste", { + bubbles: true, + cancelable: true, + composed: true, + }), + { + clipboardData: { + getData: typeof text === "string" ? () => text : text, + files: files || [], + }, + }, + ); +}; diff --git a/src/types.ts b/src/types.ts index 449e28900..c668ef1fb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,9 +36,9 @@ import { SubtypeRecord, } from "./subtypes"; import type { FileSystemHandle } from "./data/filesystem"; -import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants"; +import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants"; import { ContextMenuItems } from "./components/ContextMenu"; -import { Merge, ForwardRef } from "./utility-types"; +import { Merge, ForwardRef, ValueOf } from "./utility-types"; import React from "react"; export type Point = Readonly; @@ -67,7 +67,7 @@ export type DataURL = string & { _brand: "DataURL" }; export type BinaryFileData = { mimeType: - | typeof ALLOWED_IMAGE_MIME_TYPES[number] + | ValueOf // future user or unknown file type | typeof MIME_TYPES.binary; id: FileId; @@ -430,7 +430,7 @@ export type AppClassProperties = { FileId, { image: HTMLImageElement | Promise; - mimeType: typeof ALLOWED_IMAGE_MIME_TYPES[number]; + mimeType: ValueOf; } >; files: BinaryFiles;