parent
9d5cfbbfb7
commit
d35386755f
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -1590,6 +1590,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elements: data.elements,
|
||||
files: data.files || null,
|
||||
position: "cursor",
|
||||
retainSeed: isPlainPaste,
|
||||
});
|
||||
} else if (data.text) {
|
||||
this.addTextFromPaste(data.text, isPlainPaste);
|
||||
@ -1603,6 +1604,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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);
|
||||
@ -1640,6 +1642,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
y: element.y + gridY - minY,
|
||||
});
|
||||
}),
|
||||
{
|
||||
randomizeSeed: !opts.retainSeed,
|
||||
},
|
||||
);
|
||||
|
||||
const nextElements = [
|
||||
|
@ -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 }),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
@ -20,7 +20,7 @@ import {
|
||||
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 ".";
|
||||
@ -539,8 +539,16 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
* 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);
|
||||
@ -574,6 +582,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)) {
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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<string>),
|
||||
files?: File[],
|
||||
) => {
|
||||
return Object.assign(
|
||||
new Event("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}),
|
||||
{
|
||||
clipboardData: {
|
||||
getData: typeof text === "string" ? () => text : text,
|
||||
files: files || [],
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user