Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax

This commit is contained in:
Daniel J. Geiger 2023-04-24 13:08:44 -05:00
commit 7dc728a459
13 changed files with 154 additions and 94 deletions

View File

@ -18,7 +18,7 @@ export const actionCopy = register({
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState, true); const selectedElements = getSelectedElements(elements, appState, true);
copyToClipboard(selectedElements, appState, app.files); copyToClipboard(selectedElements, app.files);
return { return {
commitToHistory: false, commitToHistory: false,

View File

@ -2,12 +2,12 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { AppState, BinaryFiles } from "./types"; import { BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export"; import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import { isInitializedImageElement } from "./element/typeChecks";
import { isPromiseLike } from "./utils"; import { isPromiseLike, isTestEnv } from "./utils";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@ -55,24 +55,40 @@ const clipboardContainsElements = (
export const copyToClipboard = async ( export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles | null, 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 // select binded text elements when copying
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements, elements,
files: files files: files ? _files : undefined,
? elements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles)
: undefined,
}; };
const json = JSON.stringify(contents); const json = JSON.stringify(contents);
if (isTestEnv()) {
return json;
}
CLIPBOARD = json; CLIPBOARD = json;
try { try {
PREFER_APP_CLIPBOARD = false; PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json); await copyTextToSystemClipboard(json);

View File

@ -60,6 +60,7 @@ import {
ENV, ENV,
EVENT, EVENT,
GRID_SIZE, GRID_SIZE,
IMAGE_MIME_TYPES,
IMAGE_RENDER_TIMEOUT, IMAGE_RENDER_TIMEOUT,
isAndroid, isAndroid,
isBrave, isBrave,
@ -1650,6 +1651,7 @@ class App extends React.Component<AppProps, AppState> {
elements: data.elements, elements: data.elements,
files: data.files || null, files: data.files || null,
position: "cursor", position: "cursor",
retainSeed: isPlainPaste,
}); });
} else if (data.text) { } else if (data.text) {
this.addTextFromPaste(data.text, isPlainPaste); this.addTextFromPaste(data.text, isPlainPaste);
@ -1663,6 +1665,7 @@ class App extends React.Component<AppProps, AppState> {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
files: BinaryFiles | null; files: BinaryFiles | null;
position: { clientX: number; clientY: number } | "cursor" | "center"; position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean;
}) => { }) => {
const elements = restoreElements(opts.elements, null); const elements = restoreElements(opts.elements, null);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@ -1700,6 +1703,9 @@ class App extends React.Component<AppProps, AppState> {
y: element.y + gridY - minY, y: element.y + gridY - minY,
}); });
}), }),
{
randomizeSeed: !opts.retainSeed,
},
); );
const nextElements = [ const nextElements = [
@ -4786,7 +4792,12 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.drag.hasOccurred = true; pointerDownState.drag.hasOccurred = true;
// prevent dragging even if we're no longer holding cmd/ctrl otherwise // prevent dragging even if we're no longer holding cmd/ctrl otherwise
// it would have weird results (stuff jumping all over the screen) // 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( const [dragX, dragY] = getGridPoint(
pointerCoords.x - pointerDownState.drag.offset.x, pointerCoords.x - pointerDownState.drag.offset.x,
pointerCoords.y - pointerDownState.drag.offset.y, pointerCoords.y - pointerDownState.drag.offset.y,
@ -5809,7 +5820,9 @@ class App extends React.Component<AppProps, AppState> {
const imageFile = await fileOpen({ const imageFile = await fileOpen({
description: "Image", description: "Image",
extensions: ["jpg", "png", "svg", "gif"], extensions: Object.keys(
IMAGE_MIME_TYPES,
) as (keyof typeof IMAGE_MIME_TYPES)[],
}); });
const imageElement = this.createImageElement({ const imageElement = this.createImageElement({

View File

@ -102,7 +102,7 @@ const LibraryMenuItems = ({
...item, ...item,
// duplicate each library item before inserting on canvas to confine // duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465 // ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements), elements: duplicateElements(item.elements, { randomizeSeed: true }),
}; };
}); });
}; };

View File

@ -105,20 +105,30 @@ export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20; // TODO make it configurable? export const GRID_SIZE = 20; // TODO make it configurable?
export const MIME_TYPES = { export const IMAGE_MIME_TYPES = {
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
json: "application/json",
svg: "image/svg+xml", svg: "image/svg+xml",
"excalidraw.svg": "image/svg+xml",
png: "image/png", png: "image/png",
"excalidraw.png": "image/png",
jpg: "image/jpeg", jpg: "image/jpeg",
gif: "image/gif", gif: "image/gif",
webp: "image/webp", webp: "image/webp",
bmp: "image/bmp", bmp: "image/bmp",
ico: "image/x-icon", 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", binary: "application/octet-stream",
// image
...IMAGE_MIME_TYPES,
} as const; } as const;
export const EXPORT_DATA_TYPES = { 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 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 MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS = "http://www.w3.org/2000/svg";

View File

@ -1,6 +1,6 @@
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState"; 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 { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types"; import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
@ -117,11 +117,9 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
export const isSupportedImageFile = ( export const isSupportedImageFile = (
blob: Blob | null | undefined, blob: Blob | null | undefined,
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => { ): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {}; const { type } = blob || {};
return ( return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
);
}; };
export const loadSceneOrLibraryFromBlob = async ( export const loadSceneOrLibraryFromBlob = async (

View File

@ -8,16 +8,7 @@ import { EVENT, MIME_TYPES } from "../constants";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
import { debounce } from "../utils"; import { debounce } from "../utils";
type FILE_EXTENSION = type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
| "gif"
| "jpg"
| "png"
| "excalidraw.png"
| "svg"
| "excalidraw.svg"
| "json"
| "excalidraw"
| "excalidrawlib";
const INPUT_CHANGE_INTERVAL_MS = 500; const INPUT_CHANGE_INTERVAL_MS = 500;

View File

@ -15,7 +15,7 @@ import {
} from "../element/types"; } from "../element/types";
import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils"; import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { mutateElement, newElementWith } from "./mutateElement"; import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups"; import { getNewGroupIdsForDuplication } from "../groups";
import { AppState } from "../types"; import { AppState } from "../types";
import { getElementAbsoluteCoords } from "."; import { getElementAbsoluteCoords } from ".";
@ -569,8 +569,16 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
* it's advised to supply the whole elements array, or sets of elements that * 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 * are encapsulated (such as library items), if the purpose is to retain
* bindings to the cloned elements intact. * 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 clonedElements: ExcalidrawElement[] = [];
const origElementsMap = arrayToMap(elements); const origElementsMap = arrayToMap(elements);
@ -604,6 +612,11 @@ export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
clonedElement.id = maybeGetNewId(element.id)!; clonedElement.id = maybeGetNewId(element.id)!;
if (opts?.randomizeSeed) {
clonedElement.seed = randomInteger();
bumpVersion(clonedElement);
}
if (clonedElement.groupIds) { if (clonedElement.groupIds) {
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => { clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
if (!groupNewIdsMap.has(groupId)) { if (!groupNewIdsMap.has(groupId)) {

View File

@ -220,15 +220,7 @@ export const exportToClipboard = async (
} else if (opts.type === "png") { } else if (opts.type === "png") {
await copyBlobToClipboardAsPng(exportToBlob(opts)); await copyBlobToClipboardAsPng(exportToBlob(opts));
} else if (opts.type === "json") { } else if (opts.type === "json") {
const appState = { await copyToClipboard(opts.elements, opts.files);
offsetTop: 0,
offsetLeft: 0,
width: 0,
height: 0,
...getDefaultAppState(),
...opts.appState,
};
await copyToClipboard(opts.elements, appState, opts.files);
} else { } else {
throw new Error("Invalid export type"); throw new Error("Invalid export type");
} }

View File

@ -1,5 +1,10 @@
import ReactDOM from "react-dom"; 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 { Pointer, Keyboard } from "./helpers/ui";
import ExcalidrawApp from "../excalidraw-app"; import ExcalidrawApp from "../excalidraw-app";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@ -9,6 +14,8 @@ import {
} from "../element/textElement"; } from "../element/textElement";
import { getElementBounds } from "../element"; import { getElementBounds } from "../element";
import { NormalizedZoomValue } from "../types"; import { NormalizedZoomValue } from "../types";
import { API } from "./helpers/api";
import { copyToClipboard } from "../clipboard";
const { h } = window; const { h } = window;
@ -35,38 +42,28 @@ const setClipboardText = (text: string) => {
}); });
}; };
const sendPasteEvent = () => { const sendPasteEvent = (text?: string) => {
const clipboardEvent = new Event("paste", { const clipboardEvent = createPasteEvent(
bubbles: true, text || (() => window.navigator.clipboard.readText()),
cancelable: true, );
composed: true,
});
// set `clipboardData` properties.
// @ts-ignore
clipboardEvent.clipboardData = {
getData: () => window.navigator.clipboard.readText(),
files: [],
};
document.dispatchEvent(clipboardEvent); document.dispatchEvent(clipboardEvent);
}; };
const pasteWithCtrlCmdShiftV = () => { const pasteWithCtrlCmdShiftV = (text?: string) => {
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
//triggering keydown with an empty clipboard //triggering keydown with an empty clipboard
Keyboard.keyPress(KEYS.V); Keyboard.keyPress(KEYS.V);
//triggering paste event with faked clipboard //triggering paste event with faked clipboard
sendPasteEvent(); sendPasteEvent(text);
}); });
}; };
const pasteWithCtrlCmdV = () => { const pasteWithCtrlCmdV = (text?: string) => {
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
//triggering keydown with an empty clipboard //triggering keydown with an empty clipboard
Keyboard.keyPress(KEYS.V); Keyboard.keyPress(KEYS.V);
//triggering paste event with faked clipboard //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", () => { describe("paste text as single lines", () => {
it("should create an element for each line when copying with Ctrl/Cmd+V", async () => { it("should create an element for each line when copying with Ctrl/Cmd+V", async () => {
const text = "sajgfakfn\naaksfnknas\nakefnkasf"; const text = "sajgfakfn\naaksfnknas\nakefnkasf";

View File

@ -1,5 +1,10 @@
import ReactDOM from "react-dom"; 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 { UI, Pointer } from "./helpers/ui";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { actionFlipHorizontal, actionFlipVertical } from "../actions"; import { actionFlipHorizontal, actionFlipVertical } from "../actions";
@ -680,19 +685,7 @@ describe("freedraw", () => {
describe("image", () => { describe("image", () => {
const createImage = async () => { const createImage = async () => {
const sendPasteEvent = (file?: File) => { const sendPasteEvent = (file?: File) => {
const clipboardEvent = new Event("paste", { const clipboardEvent = createPasteEvent("", file ? [file] : []);
bubbles: true,
cancelable: true,
composed: true,
});
// set `clipboardData` properties.
// @ts-ignore
clipboardEvent.clipboardData = {
getData: () => window.navigator.clipboard.readText(),
files: [file],
};
document.dispatchEvent(clipboardEvent); document.dispatchEvent(clipboardEvent);
}; };

View File

@ -190,3 +190,24 @@ export const toggleMenu = (container: HTMLElement) => {
// open menu // open menu
fireEvent.click(container.querySelector(".dropdown-menu-button")!); 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 || [],
},
},
);
};

View File

@ -36,9 +36,9 @@ import {
SubtypeRecord, SubtypeRecord,
} from "./subtypes"; } from "./subtypes";
import type { FileSystemHandle } from "./data/filesystem"; 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 { ContextMenuItems } from "./components/ContextMenu";
import { Merge, ForwardRef } from "./utility-types"; import { Merge, ForwardRef, ValueOf } from "./utility-types";
import React from "react"; import React from "react";
export type Point = Readonly<RoughPoint>; export type Point = Readonly<RoughPoint>;
@ -67,7 +67,7 @@ export type DataURL = string & { _brand: "DataURL" };
export type BinaryFileData = { export type BinaryFileData = {
mimeType: mimeType:
| typeof ALLOWED_IMAGE_MIME_TYPES[number] | ValueOf<typeof IMAGE_MIME_TYPES>
// future user or unknown file type // future user or unknown file type
| typeof MIME_TYPES.binary; | typeof MIME_TYPES.binary;
id: FileId; id: FileId;
@ -430,7 +430,7 @@ export type AppClassProperties = {
FileId, FileId,
{ {
image: HTMLImageElement | Promise<HTMLImageElement>; image: HTMLImageElement | Promise<HTMLImageElement>;
mimeType: typeof ALLOWED_IMAGE_MIME_TYPES[number]; mimeType: ValueOf<typeof IMAGE_MIME_TYPES>;
} }
>; >;
files: BinaryFiles; files: BinaryFiles;