refactor(editor): move the clipboard out of App into AppClipboard (app.clipboard) (#12197)

* refactor(editor): move the clipboard out of App into AppClipboard (app.clipboard)

A pure move of the clipboard handling out of App.tsx (13,478 → 12,942
lines) into App.clipboard.ts: onCut and onCopy (the document's cut/copy
events, through their actions), pasteFromClipboard (the paste event,
parsed, and handed to a host's onPaste first), insertClipboardContent
(inserting the pasted data as whatever it is: images or SVG code,
elements, a Mermaid definition, embeddable URLs, a spreadsheet as a
chart, mixed content, or text), addElementsFromMixedContentPaste and
addTextFromPaste. The plain-paste flags move along as module-level state;
Ctrl/Cmd+V's marking of a plain paste, and its reset, are App's former
lines behind app.clipboard.onPasteShortcut and clearPlainPaste.

Staying in App: addElementsFromPasteOrLibrary and insertImages, shared
with drop, the library and text-to-diagram (insertImages becomes public
for the clipboard), and onIframeSrcCopy, which belongs to the magic frame.

Callers go through `app.clipboard`: no delegate is left on App, and
AppClassProperties exposes `clipboard` (as it does `textTool`) instead of
`pasteFromClipboard`. Beyond the move, `this.` becomes `this.app.` for
what stays on App, onCut and onCopy become public, and the container ref
comes in as a dependency. The moved code is identical to the removed,
modulo those rewrites, whitespace and trailing commas (checked
mechanically).

Claude Opus 5.5 (1M), xhigh effort

* test(editor): cover copy/cut events and more paste routes

Only 80% of the clipboard code ran under the suite, and nothing went
through the document's copy and cut events, which is how Ctrl/Cmd+C/X
reach the editor in a browser. Now a copy event copies the selection and
a cut event cuts it; a host's onPaste returning false keeps the paste
from being inserted; a pasted spreadsheet opens the charts dialog; and
with the image tool off, a paste of mixed content inserts its text.

Claude Opus 5.5 (1M), xhigh effort
This commit is contained in:
David Luzar
2026-09-28 23:51:36 +02:00
committed by GitHub
parent 3947883063
commit e283f724c8
5 changed files with 716 additions and 548 deletions
@@ -89,7 +89,7 @@ export const actionPaste = register({
}
try {
app.pasteFromClipboard(createPasteEvent({ types }));
app.clipboard.pasteFromClipboard(createPasteEvent({ types }));
} catch (error: any) {
console.error(error);
return {
@@ -0,0 +1,612 @@
import { flushSync } from "react-dom";
import {
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
getFontString,
getLineHeight,
isWritableElement,
normalizeEOL,
normalizeLink,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import {
convertToExcalidrawElements,
embeddableURLValidator,
getCommonBounds,
getEmbedLink,
getLineHeightInPx,
makeNextSelectedElementIds,
maybeParseEmbedSrc,
measureText,
newElementWith,
newTextElement,
normalizeText,
wrapText,
} from "@excalidraw/element";
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawEmbeddableElement,
ExcalidrawTextElement,
NonDeleted,
} from "@excalidraw/element/types";
import { actionCopy, actionCut } from "../actions";
import { tryParseSpreadsheet } from "../charts";
import { parseClipboard, parseDataTransferEvent } from "../clipboard";
import { ImageURLToFile, SVGStringToFile } from "../data/blob";
import { t } from "../i18n";
import { isMaybeMermaidDefinition } from "../mermaid";
import { withBatchedUpdates } from "../reactUtils";
import { getShortcutKey } from "../shortcut";
import { scrollBoundsIntoView } from "../viewport";
import type React from "react";
import type {
ClipboardData,
ParsedDataTransferFile,
PastedMixedContent,
} from "../clipboard";
import type App from "./App";
import type { Offsets } from "../types";
let IS_PLAIN_PASTE = false;
let IS_PLAIN_PASTE_TIMER = 0;
let PLAIN_PASTE_TOAST_SHOWN = false;
type AppClipboardDependencies = {
getContainer: () => HTMLDivElement | null;
};
/**
* The clipboard: cut and copy (through their actions), and paste — the
* pasted data parsed (`pasteFromClipboard`) and inserted as whatever it is
* (`insertClipboardContent`): images or SVG code, elements, a Mermaid
* definition, embeddable URLs, a spreadsheet (as a chart), mixed content,
* or text. Pasted elements go in through `app.addElementsFromPasteOrLibrary`,
* as a library item or a drop does.
*/
export class AppClipboard {
constructor(
private app: App,
private dependencies: AppClipboardDependencies,
) {}
// Copy/paste
public onCut = withBatchedUpdates((event: ClipboardEvent) => {
if (!this.app.isInteractionEnabled()) {
return;
}
const isExcalidrawActive = this.dependencies
.getContainer()
?.contains(this.app.ownerDocument.activeElement);
if (!isExcalidrawActive || isWritableElement(event.target)) {
return;
}
this.app.actionManager.executeAction(actionCut, "keyboard", event);
event.preventDefault();
event.stopPropagation();
});
public onCopy = withBatchedUpdates((event: ClipboardEvent) => {
if (!this.app.isInteractionEnabled()) {
return;
}
const isExcalidrawActive = this.dependencies
.getContainer()
?.contains(this.app.ownerDocument.activeElement);
if (!isExcalidrawActive || isWritableElement(event.target)) {
return;
}
this.app.actionManager.executeAction(actionCopy, "keyboard", event);
event.preventDefault();
event.stopPropagation();
});
// TODO: Cover with tests
private async insertClipboardContent(
data: ClipboardData,
dataTransferFiles: ParsedDataTransferFile[],
isPlainPaste: boolean,
) {
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{
clientX: this.app.viewport.lastPosition.x,
clientY: this.app.viewport.lastPosition.y,
},
this.app.state,
);
// ------------------- Error -------------------
if (data.errorMessage) {
this.app.setState({ errorMessage: data.errorMessage });
return;
}
// ------------------- Mixed content with no files -------------------
if (dataTransferFiles.length === 0 && !isPlainPaste && data.mixedContent) {
await this.addElementsFromMixedContentPaste(data.mixedContent, {
isPlainPaste,
sceneX,
sceneY,
});
return;
}
// ------------------- Spreadsheet -------------------
if (!isPlainPaste && data.text) {
const result = tryParseSpreadsheet(data.text);
if (result.ok) {
this.app.setState({
openDialog: {
name: "charts",
data: result.data,
rawText: data.text,
},
});
return;
}
}
// ------------------- Images or SVG code -------------------
const imageFiles = dataTransferFiles.map((data) => data.file);
if (imageFiles.length === 0 && data.text && !isPlainPaste) {
const trimmedText = data.text.trim();
if (trimmedText.startsWith("<svg") && trimmedText.endsWith("</svg>")) {
// ignore SVG validation/normalization which will be done during image
// initialization
imageFiles.push(SVGStringToFile(trimmedText));
}
}
if (imageFiles.length > 0) {
if (this.app.isToolSupported("image")) {
await this.app.insertImages(imageFiles, sceneX, sceneY);
} else {
this.app.setState({ errorMessage: t("errors.imageToolNotSupported") });
}
return;
}
// ------------------- Elements -------------------
if (data.elements) {
const elements = (
data.programmaticAPI
? convertToExcalidrawElements(
data.elements as ExcalidrawElementSkeleton[],
)
: data.elements
) as readonly ExcalidrawElement[];
// TODO: remove formatting from elements if isPlainPaste
this.app.addElementsFromPasteOrLibrary({
elements,
files: data.files || null,
position:
this.app.editorInterface.formFactor === "desktop"
? "cursor"
: "center",
retainSeed: isPlainPaste,
preserveFrameChildrenOrder: true,
});
return;
}
// ------------------- Only textual stuff remaining -------------------
if (!data.text) {
return;
}
// ------------------- Successful Mermaid -------------------
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw");
try {
const { elements: skeletonElements, files = {} } =
await api.parseMermaidToExcalidraw(data.text);
const elements = convertToExcalidrawElements(skeletonElements, {
regenerateIds: true,
});
this.app.addElementsFromPasteOrLibrary({
elements,
files,
position:
this.app.editorInterface.formFactor === "desktop"
? "cursor"
: "center",
});
return;
} catch (err: any) {
console.warn(
`parsing pasted text as mermaid definition failed: ${err.message}`,
);
}
}
// ------------------- Pure embeddable URLs -------------------
const nonEmptyLines = normalizeEOL(data.text)
.split(/\n+/)
.map((s) => s.trim())
.filter(Boolean);
const embbeddableUrls = nonEmptyLines
.map((str) => maybeParseEmbedSrc(str))
.filter(
(string) =>
embeddableURLValidator(string, this.app.props.validateEmbeddable) &&
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
getEmbedLink(string)?.type === "video"),
);
if (
!isPlainPaste &&
embbeddableUrls.length > 0 &&
embbeddableUrls.length === nonEmptyLines.length
) {
const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
for (const url of embbeddableUrls) {
const prevEmbeddable: ExcalidrawEmbeddableElement | undefined =
embeddables[embeddables.length - 1];
const embeddable = this.app.insertEmbeddableElement({
sceneX: prevEmbeddable
? prevEmbeddable.x + prevEmbeddable.width + 20
: sceneX,
sceneY,
link: normalizeLink(url),
});
if (embeddable) {
embeddables.push(embeddable);
}
}
if (embeddables.length) {
this.app.store.scheduleCapture();
this.app.setState({
selectedElementIds: Object.fromEntries(
embeddables.map((embeddable) => [embeddable.id, true]),
),
});
}
return;
}
// ------------------- Text -------------------
this.addTextFromPaste(data.text, isPlainPaste);
}
/**
* Ctrl/Cmd+V: whether the paste event that follows is a plain one (with
* Shift)
*/
public onPasteShortcut = (event: React.KeyboardEvent | KeyboardEvent) => {
IS_PLAIN_PASTE = event.shiftKey;
clearTimeout(IS_PLAIN_PASTE_TIMER);
// reset (100ms to be safe that we it runs after the ensuing
// paste event). Though, technically unnecessary to reset since we
// (re)set the flag before each paste event.
IS_PLAIN_PASTE_TIMER = this.app.ownerWindow.setTimeout(() => {
IS_PLAIN_PASTE = false;
}, 100);
};
public clearPlainPaste = () => {
clearTimeout(IS_PLAIN_PASTE_TIMER);
IS_PLAIN_PASTE_TIMER = 0;
IS_PLAIN_PASTE = false;
};
public pasteFromClipboard = withBatchedUpdates(
async (event: ClipboardEvent) => {
if (!this.app.isInteractionEnabled()) {
return;
}
const isPlainPaste = !!IS_PLAIN_PASTE;
// #686
const target = this.app.ownerDocument.activeElement;
const isExcalidrawActive = this.dependencies
.getContainer()
?.contains(target);
if (event && !isExcalidrawActive) {
return;
}
const elementUnderCursor = this.app.ownerDocument.elementFromPoint(
this.app.viewport.lastPosition.x,
this.app.viewport.lastPosition.y,
);
if (
event &&
(!(
elementUnderCursor instanceof this.app.ownerWindow.HTMLCanvasElement
) ||
isWritableElement(target))
) {
return;
}
// must be called in the same frame (thus before any awaits) as the paste
// event else some browsers (FF...) will clear the clipboardData
// (something something security)
const dataTransferList = await parseDataTransferEvent(event);
const filesList = dataTransferList.getFiles();
const data = await parseClipboard(dataTransferList, isPlainPaste);
if (this.app.props.onPaste) {
try {
if ((await this.app.props.onPaste(data, event)) === false) {
return;
}
} catch (error: any) {
console.error(error);
}
}
await this.insertClipboardContent(data, filesList, isPlainPaste);
this.app.setActiveTool(
{ type: this.app.state.preferredSelectionTool.type },
{ keepSelection: true },
);
event?.preventDefault();
},
);
// TODO rewrite this to paste both text & images at the same time if
// pasted data contains both
private async addElementsFromMixedContentPaste(
mixedContent: PastedMixedContent,
{
isPlainPaste,
sceneX,
sceneY,
}: { isPlainPaste: boolean; sceneX: number; sceneY: number },
) {
if (
!isPlainPaste &&
mixedContent.some((node) => node.type === "imageUrl") &&
this.app.isToolSupported("image")
) {
const imageURLs = mixedContent
.filter((node) => node.type === "imageUrl")
.map((node) => node.value);
const responses = await Promise.all(
imageURLs.map(async (url) => {
try {
return { file: await ImageURLToFile(url) };
} catch (error: any) {
let errorMessage = error.message;
if (error.cause === "FETCH_ERROR") {
errorMessage = t("errors.failedToFetchImage");
} else if (error.cause === "UNSUPPORTED") {
errorMessage = t("errors.unsupportedFileType");
}
return { errorMessage };
}
}),
);
const imageFiles = responses
.filter((response): response is { file: File } => !!response.file)
.map((response) => response.file);
await this.app.insertImages(imageFiles, sceneX, sceneY);
const error = responses.find((response) => !!response.errorMessage);
if (error && error.errorMessage) {
this.app.setState({ errorMessage: error.errorMessage });
}
} else {
const textNodes = mixedContent.filter((node) => node.type === "text");
if (textNodes.length) {
this.addTextFromPaste(
textNodes.map((node) => node.value).join("\n\n"),
isPlainPaste,
);
}
}
}
private addTextFromPaste(text: string, isPlainPaste = false) {
const { x, y } = viewportCoordsToSceneCoords(
{
clientX: this.app.viewport.lastPosition.x,
clientY: this.app.viewport.lastPosition.y,
},
this.app.state,
);
const textElementProps = {
x,
y,
strokeColor: this.app.state.currentItemStrokeColor,
backgroundColor: this.app.state.currentItemBackgroundColor,
fillStyle: this.app.state.currentItemFillStyle,
strokeWidth: this.app.getCurrentItemStrokeWidth("text"),
strokeStyle: this.app.state.currentItemStrokeStyle,
roundness: null,
roughness: this.app.state.currentItemRoughness,
opacity: this.app.state.currentItemOpacity,
text,
fontSize: this.app.state.currentItemFontSize,
fontFamily: this.app.state.currentItemFontFamily,
textAlign: DEFAULT_TEXT_ALIGN,
verticalAlign: DEFAULT_VERTICAL_ALIGN,
locked: false,
};
const fontString = getFontString({
fontSize: textElementProps.fontSize,
fontFamily: textElementProps.fontFamily,
});
const lineHeight = getLineHeight(textElementProps.fontFamily);
const LINE_GAP = 10;
const lines = isPlainPaste ? [text] : text.split("\n");
const createTextElements = (maxTextWidth: number) => {
let currentY = y;
return lines.reduce((acc: ExcalidrawTextElement[], line, idx) => {
const originalText = normalizeText(line).trim();
if (originalText.length) {
const topLayerFrame = this.app.getTopLayerFrameAtSceneCoords({
x,
y: currentY,
});
let metrics = measureText(originalText, fontString, lineHeight);
const isTextUnwrapped = metrics.width > maxTextWidth;
const text = isTextUnwrapped
? wrapText(originalText, fontString, maxTextWidth)
: originalText;
metrics = isTextUnwrapped
? measureText(text, fontString, lineHeight)
: metrics;
const startX = x - metrics.width / 2;
const startY = currentY - metrics.height / 2;
const element = newTextElement({
...textElementProps,
x: startX,
y: startY,
text,
originalText,
lineHeight,
autoResize: !isTextUnwrapped,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
acc.push(element);
currentY += element.height + LINE_GAP;
} else {
const prevLine = lines[idx - 1]?.trim();
// add paragraph only if previous line was not empty, IOW don't add
// more than one empty line
if (prevLine) {
currentY +=
getLineHeightInPx(textElementProps.fontSize, lineHeight) +
LINE_GAP;
}
}
return acc;
}, []);
};
// long texts wrap as a typed one does; wrapped to fit the view, all of
// it is brought in view, too — by moving it there (as far as the view
// would have to scroll), not the view
const pasteWithin = (offsets: Required<Offsets>) => {
const textElements = createTextElements(
this.app.text.getMaxTextWidth(offsets),
);
const scroll =
textElements.length && textElements.some((el) => !el.autoResize)
? scrollBoundsIntoView({
bounds: getCommonBounds(textElements),
appState: this.app.state,
offsets,
})
: null;
if (!scroll) {
return textElements;
}
const dx = scroll.scrollX - this.app.state.scrollX;
const dy = scroll.scrollY - this.app.state.scrollY;
return textElements.map((element) => {
const nextX = element.x + dx;
const nextY = element.y + dy;
// at the point it's pasted at: its center
const topLayerFrame = this.app.getTopLayerFrameAtSceneCoords({
x: nextX + element.width / 2,
y: nextY + element.height / 2,
});
return newElementWith(element, {
x: nextX,
y: nextY,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
});
};
// laid out as if the stats panel weren't there, to see where it lands:
// it counts only beside it
const offsets = this.app.text.getTextViewportOffsets();
let textElements = pasteWithin(offsets);
if (textElements.length === 0) {
return;
}
const getSelectedElementIds = () =>
makeNextSelectedElementIds(
Object.fromEntries(textElements.map((el) => [el.id, true])),
this.app.state,
);
if (this.app.viewport.getSideUIRect("stats")) {
// selected, it's in the stats panel, which grows to show its
// properties: rendered so (not captured yet), the panels are measured
// as they'll be — the styles panel, too, shown then, not reserved
flushSync(() => {
this.app.insertNewElements(textElements);
this.app.setState({ selectedElementIds: getSelectedElementIds() });
});
const besideOffsets = this.app.text.getTextViewportOffsets(
getCommonBounds(textElements),
);
if (
besideOffsets.left !== offsets.left ||
besideOffsets.right !== offsets.right
) {
// beside the stats panel, or the styles panel's not as reserved:
// laid out again, as the same elements (for anyone who's seen them
// rendered already)
const relaid = pasteWithin(besideOffsets);
const pastedIds = new Set(textElements.map((element) => element.id));
textElements = textElements.map((element, index) => {
const { x, y, width, height, text, autoResize, frameId } =
relaid[index];
return newElementWith(element, {
x,
y,
width,
height,
text,
autoResize,
frameId,
});
});
this.app.scene.replaceAllElements(
this.app.scene
.getElementsIncludingDeleted()
.filter((element) => !pastedIds.has(element.id)),
);
this.app.insertNewElements(textElements);
}
} else {
this.app.insertNewElements(textElements);
}
this.app.store.scheduleCapture();
this.app.setState({ selectedElementIds: getSelectedElementIds() });
if (
!isPlainPaste &&
textElements.length > 1 &&
PLAIN_PASTE_TOAST_SHOWN === false &&
this.app.editorInterface.formFactor !== "phone"
) {
this.app.setToast({
message: t("toast.pasteAsSingleElement", {
shortcut: getShortcutKey("CtrlOrCmd+Shift+V"),
}),
duration: 5000,
});
PLAIN_PASTE_TOAST_SHOWN = true;
}
}
}
+10 -546
View File
@@ -30,7 +30,6 @@ import {
DEFAULT_STROKE_STREAMLINE,
DEFAULT_STROKE_STREAMLINE_PRECISE,
DEFAULT_TRANSFORM_HANDLE_SPACING,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
ELEMENT_TRANSLATE_AMOUNT,
@@ -51,7 +50,6 @@ import {
POINTER_EVENTS,
TOOL_TYPE,
DEFAULT_COLLISION_THRESHOLD,
DEFAULT_TEXT_ALIGN,
ARROW_TYPE,
DEFAULT_REDUCED_GLOBAL_ALPHA,
DEFAULT_STICKY_NOTE_SIZE,
@@ -60,7 +58,6 @@ import {
normalizeLink,
toValidURL,
getGridPoint,
getLineHeight,
debounce,
distance,
getFontString,
@@ -80,7 +77,6 @@ import {
isDevEnv,
updateStable,
addEventListener,
normalizeEOL,
getDateTime,
isShallowEqual,
arrayToMap,
@@ -129,7 +125,6 @@ import {
newElement,
newImageElement,
newLinearElement,
newTextElement,
deepCopyElement,
duplicateElements,
hasBoundTextElement,
@@ -160,7 +155,6 @@ import {
isPathALoop,
createSrcDoc,
embeddableURLValidator,
maybeParseEmbedSrc,
getEmbedLink,
getInitializedImageElements,
normalizeSVG,
@@ -192,12 +186,8 @@ import {
hitElementBoundingBoxOnly,
hitElementItself,
cropElement,
wrapText,
isElementLink,
isMeasureTextSupported,
normalizeText,
measureText,
getLineHeightInPx,
getMinTextElementWidth,
ShapeCache,
resolveElementRenderState,
@@ -238,8 +228,6 @@ import {
getElementBounds,
doBoundsIntersect,
isPointInElement,
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
getSnapOutlineMidPoint,
handleFocusPointDrag,
handleFocusPointHover,
@@ -259,7 +247,6 @@ import type {
ExcalidrawFreeDrawElement,
ExcalidrawGenericElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
NonDeleted,
InitializedExcalidrawImageElement,
ExcalidrawImageElement,
@@ -338,9 +325,7 @@ import { trackEvent } from "../analytics";
import { getDefaultAppState, isEraserActive } from "../appState";
import {
copyTextToSystemClipboard,
parseClipboard,
parseDataTransferEvent,
type ParsedDataTransferFile,
} from "../clipboard";
import { exportCanvas, loadFromBlob } from "../data";
@@ -367,7 +352,6 @@ import {
generateIdFromFile,
getDataURL,
getDataURL_sync,
ImageURLToFile,
isImageFileHandle,
isSupportedImageFile,
loadSceneOrLibraryFromBlob,
@@ -403,17 +387,14 @@ import { Renderer } from "../scene/Renderer";
import {
type SetViewportOptions,
getViewportForZoomWithScrollConstraints,
scrollBoundsIntoView,
} from "../viewport";
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
import { LaserTrails } from "../laserTrails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { isOverScrollBars } from "../scene/scrollbars";
import { isMaybeMermaidDefinition } from "../mermaid";
import { LassoTrail } from "../lasso";
import { EraserTrail } from "../eraser";
import { getShortcutKey } from "../shortcut";
import { tryParseSpreadsheet } from "../charts";
import {
getColorTargetAppStateUpdates,
@@ -428,6 +409,7 @@ import ConvertElementTypePopup, {
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { AppArrowText } from "./App.arrowText";
import { AppClipboard } from "./App.clipboard";
import { AppText } from "./App.text";
import { AppTextTool } from "./App.textTool";
import { AppBucketFill } from "./App.bucketFill";
@@ -469,7 +451,6 @@ import type {
ScrollBars,
} from "../scene/types";
import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu";
@@ -501,7 +482,6 @@ import type {
GenerateDiagramToCode,
NullableGridSize,
UIConfig,
Offsets,
} from "../types";
import type { RoughCanvas } from "roughjs/bin/canvas";
import type { Action, ActionResult } from "../actions/types";
@@ -610,10 +590,6 @@ const YOUTUBE_VIDEO_STATES = new Map<
const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4;
let IS_PLAIN_PASTE = false;
let IS_PLAIN_PASTE_TIMER = 0;
let PLAIN_PASTE_TOAST_SHOWN = false;
let lastPointerUp: (() => void) | null = null;
const gesture: Gesture = {
pointers: new Map(),
@@ -713,6 +689,9 @@ class App extends React.Component<AppProps, AppState> {
isDraggingScrollBar: () => isDraggingScrollBar,
});
public textTool: AppTextTool = new AppTextTool(this);
public clipboard: AppClipboard = new AppClipboard(this, {
getContainer: () => this.excalidrawContainerRef.current,
});
public text: AppText = new AppText(this, {
getContainer: () => this.excalidrawContainerRef.current,
getStylesPanelMode: () => this.stylesPanelMode,
@@ -3321,9 +3300,7 @@ class App extends React.Component<AppProps, AppState> {
App.resetTapTwice();
this.resetContextMenuTimer();
clearTimeout(IS_PLAIN_PASTE_TIMER);
IS_PLAIN_PASTE_TIMER = 0;
IS_PLAIN_PASTE = false;
this.clipboard.clearPlainPaste();
if (this.bindModeHandler) {
clearTimeout(this.bindModeHandler);
@@ -4163,7 +4140,7 @@ class App extends React.Component<AppProps, AppState> {
this.wheel.handle,
{ passive: false },
),
addEventListener(this.ownerDocument, EVENT.COPY, this.onCopy, {
addEventListener(this.ownerDocument, EVENT.COPY, this.clipboard.onCopy, {
passive: false,
}),
addEventListener(this.ownerDocument, EVENT.KEYUP, this.onKeyUp, {
@@ -4214,12 +4191,12 @@ class App extends React.Component<AppProps, AppState> {
addEventListener(
this.ownerDocument,
EVENT.PASTE,
this.pasteFromClipboard,
this.clipboard.pasteFromClipboard,
{
passive: false,
},
),
addEventListener(this.ownerDocument, EVENT.CUT, this.onCut, {
addEventListener(this.ownerDocument, EVENT.CUT, this.clipboard.onCut, {
passive: false,
}),
addEventListener(this.ownerWindow, EVENT.RESIZE, this.onResize, false),
@@ -4516,38 +4493,6 @@ class App extends React.Component<AppProps, AppState> {
});
}, SCROLL_TIMEOUT);
// Copy/paste
private onCut = withBatchedUpdates((event: ClipboardEvent) => {
if (!this.isInteractionEnabled()) {
return;
}
const isExcalidrawActive = this.excalidrawContainerRef.current?.contains(
this.ownerDocument.activeElement,
);
if (!isExcalidrawActive || isWritableElement(event.target)) {
return;
}
this.actionManager.executeAction(actionCut, "keyboard", event);
event.preventDefault();
event.stopPropagation();
});
private onCopy = withBatchedUpdates((event: ClipboardEvent) => {
if (!this.isInteractionEnabled()) {
return;
}
const isExcalidrawActive = this.excalidrawContainerRef.current?.contains(
this.ownerDocument.activeElement,
);
if (!isExcalidrawActive || isWritableElement(event.target)) {
return;
}
this.actionManager.executeAction(actionCopy, "keyboard", event);
event.preventDefault();
event.stopPropagation();
});
private static resetTapTwice() {
didTapTwice = false;
firstTapPosition = null;
@@ -4636,231 +4581,6 @@ class App extends React.Component<AppProps, AppState> {
}
};
// TODO: Cover with tests
private async insertClipboardContent(
data: ClipboardData,
dataTransferFiles: ParsedDataTransferFile[],
isPlainPaste: boolean,
) {
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{
clientX: this.viewport.lastPosition.x,
clientY: this.viewport.lastPosition.y,
},
this.state,
);
// ------------------- Error -------------------
if (data.errorMessage) {
this.setState({ errorMessage: data.errorMessage });
return;
}
// ------------------- Mixed content with no files -------------------
if (dataTransferFiles.length === 0 && !isPlainPaste && data.mixedContent) {
await this.addElementsFromMixedContentPaste(data.mixedContent, {
isPlainPaste,
sceneX,
sceneY,
});
return;
}
// ------------------- Spreadsheet -------------------
if (!isPlainPaste && data.text) {
const result = tryParseSpreadsheet(data.text);
if (result.ok) {
this.setState({
openDialog: {
name: "charts",
data: result.data,
rawText: data.text,
},
});
return;
}
}
// ------------------- Images or SVG code -------------------
const imageFiles = dataTransferFiles.map((data) => data.file);
if (imageFiles.length === 0 && data.text && !isPlainPaste) {
const trimmedText = data.text.trim();
if (trimmedText.startsWith("<svg") && trimmedText.endsWith("</svg>")) {
// ignore SVG validation/normalization which will be done during image
// initialization
imageFiles.push(SVGStringToFile(trimmedText));
}
}
if (imageFiles.length > 0) {
if (this.isToolSupported("image")) {
await this.insertImages(imageFiles, sceneX, sceneY);
} else {
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
}
return;
}
// ------------------- Elements -------------------
if (data.elements) {
const elements = (
data.programmaticAPI
? convertToExcalidrawElements(
data.elements as ExcalidrawElementSkeleton[],
)
: data.elements
) as readonly ExcalidrawElement[];
// TODO: remove formatting from elements if isPlainPaste
this.addElementsFromPasteOrLibrary({
elements,
files: data.files || null,
position:
this.editorInterface.formFactor === "desktop" ? "cursor" : "center",
retainSeed: isPlainPaste,
preserveFrameChildrenOrder: true,
});
return;
}
// ------------------- Only textual stuff remaining -------------------
if (!data.text) {
return;
}
// ------------------- Successful Mermaid -------------------
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw");
try {
const { elements: skeletonElements, files = {} } =
await api.parseMermaidToExcalidraw(data.text);
const elements = convertToExcalidrawElements(skeletonElements, {
regenerateIds: true,
});
this.addElementsFromPasteOrLibrary({
elements,
files,
position:
this.editorInterface.formFactor === "desktop" ? "cursor" : "center",
});
return;
} catch (err: any) {
console.warn(
`parsing pasted text as mermaid definition failed: ${err.message}`,
);
}
}
// ------------------- Pure embeddable URLs -------------------
const nonEmptyLines = normalizeEOL(data.text)
.split(/\n+/)
.map((s) => s.trim())
.filter(Boolean);
const embbeddableUrls = nonEmptyLines
.map((str) => maybeParseEmbedSrc(str))
.filter(
(string) =>
embeddableURLValidator(string, this.props.validateEmbeddable) &&
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
getEmbedLink(string)?.type === "video"),
);
if (
!isPlainPaste &&
embbeddableUrls.length > 0 &&
embbeddableUrls.length === nonEmptyLines.length
) {
const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
for (const url of embbeddableUrls) {
const prevEmbeddable: ExcalidrawEmbeddableElement | undefined =
embeddables[embeddables.length - 1];
const embeddable = this.insertEmbeddableElement({
sceneX: prevEmbeddable
? prevEmbeddable.x + prevEmbeddable.width + 20
: sceneX,
sceneY,
link: normalizeLink(url),
});
if (embeddable) {
embeddables.push(embeddable);
}
}
if (embeddables.length) {
this.store.scheduleCapture();
this.setState({
selectedElementIds: Object.fromEntries(
embeddables.map((embeddable) => [embeddable.id, true]),
),
});
}
return;
}
// ------------------- Text -------------------
this.addTextFromPaste(data.text, isPlainPaste);
}
public pasteFromClipboard = withBatchedUpdates(
async (event: ClipboardEvent) => {
if (!this.isInteractionEnabled()) {
return;
}
const isPlainPaste = !!IS_PLAIN_PASTE;
// #686
const target = this.ownerDocument.activeElement;
const isExcalidrawActive =
this.excalidrawContainerRef.current?.contains(target);
if (event && !isExcalidrawActive) {
return;
}
const elementUnderCursor = this.ownerDocument.elementFromPoint(
this.viewport.lastPosition.x,
this.viewport.lastPosition.y,
);
if (
event &&
(!(elementUnderCursor instanceof this.ownerWindow.HTMLCanvasElement) ||
isWritableElement(target))
) {
return;
}
// must be called in the same frame (thus before any awaits) as the paste
// event else some browsers (FF...) will clear the clipboardData
// (something something security)
const dataTransferList = await parseDataTransferEvent(event);
const filesList = dataTransferList.getFiles();
const data = await parseClipboard(dataTransferList, isPlainPaste);
if (this.props.onPaste) {
try {
if ((await this.props.onPaste(data, event)) === false) {
return;
}
} catch (error: any) {
console.error(error);
}
}
await this.insertClipboardContent(data, filesList, isPlainPaste);
this.setActiveTool(
{ type: this.state.preferredSelectionTool.type },
{ keepSelection: true },
);
event?.preventDefault();
},
);
addElementsFromPasteOrLibrary = (opts: {
elements: readonly ExcalidrawElement[];
files: BinaryFiles | null;
@@ -4970,255 +4690,6 @@ class App extends React.Component<AppProps, AppState> {
}
};
// TODO rewrite this to paste both text & images at the same time if
// pasted data contains both
private async addElementsFromMixedContentPaste(
mixedContent: PastedMixedContent,
{
isPlainPaste,
sceneX,
sceneY,
}: { isPlainPaste: boolean; sceneX: number; sceneY: number },
) {
if (
!isPlainPaste &&
mixedContent.some((node) => node.type === "imageUrl") &&
this.isToolSupported("image")
) {
const imageURLs = mixedContent
.filter((node) => node.type === "imageUrl")
.map((node) => node.value);
const responses = await Promise.all(
imageURLs.map(async (url) => {
try {
return { file: await ImageURLToFile(url) };
} catch (error: any) {
let errorMessage = error.message;
if (error.cause === "FETCH_ERROR") {
errorMessage = t("errors.failedToFetchImage");
} else if (error.cause === "UNSUPPORTED") {
errorMessage = t("errors.unsupportedFileType");
}
return { errorMessage };
}
}),
);
const imageFiles = responses
.filter((response): response is { file: File } => !!response.file)
.map((response) => response.file);
await this.insertImages(imageFiles, sceneX, sceneY);
const error = responses.find((response) => !!response.errorMessage);
if (error && error.errorMessage) {
this.setState({ errorMessage: error.errorMessage });
}
} else {
const textNodes = mixedContent.filter((node) => node.type === "text");
if (textNodes.length) {
this.addTextFromPaste(
textNodes.map((node) => node.value).join("\n\n"),
isPlainPaste,
);
}
}
}
private addTextFromPaste(text: string, isPlainPaste = false) {
const { x, y } = viewportCoordsToSceneCoords(
{
clientX: this.viewport.lastPosition.x,
clientY: this.viewport.lastPosition.y,
},
this.state,
);
const textElementProps = {
x,
y,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("text"),
strokeStyle: this.state.currentItemStrokeStyle,
roundness: null,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
text,
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
textAlign: DEFAULT_TEXT_ALIGN,
verticalAlign: DEFAULT_VERTICAL_ALIGN,
locked: false,
};
const fontString = getFontString({
fontSize: textElementProps.fontSize,
fontFamily: textElementProps.fontFamily,
});
const lineHeight = getLineHeight(textElementProps.fontFamily);
const LINE_GAP = 10;
const lines = isPlainPaste ? [text] : text.split("\n");
const createTextElements = (maxTextWidth: number) => {
let currentY = y;
return lines.reduce((acc: ExcalidrawTextElement[], line, idx) => {
const originalText = normalizeText(line).trim();
if (originalText.length) {
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
x,
y: currentY,
});
let metrics = measureText(originalText, fontString, lineHeight);
const isTextUnwrapped = metrics.width > maxTextWidth;
const text = isTextUnwrapped
? wrapText(originalText, fontString, maxTextWidth)
: originalText;
metrics = isTextUnwrapped
? measureText(text, fontString, lineHeight)
: metrics;
const startX = x - metrics.width / 2;
const startY = currentY - metrics.height / 2;
const element = newTextElement({
...textElementProps,
x: startX,
y: startY,
text,
originalText,
lineHeight,
autoResize: !isTextUnwrapped,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
acc.push(element);
currentY += element.height + LINE_GAP;
} else {
const prevLine = lines[idx - 1]?.trim();
// add paragraph only if previous line was not empty, IOW don't add
// more than one empty line
if (prevLine) {
currentY +=
getLineHeightInPx(textElementProps.fontSize, lineHeight) +
LINE_GAP;
}
}
return acc;
}, []);
};
// long texts wrap as a typed one does; wrapped to fit the view, all of
// it is brought in view, too — by moving it there (as far as the view
// would have to scroll), not the view
const pasteWithin = (offsets: Required<Offsets>) => {
const textElements = createTextElements(
this.text.getMaxTextWidth(offsets),
);
const scroll =
textElements.length && textElements.some((el) => !el.autoResize)
? scrollBoundsIntoView({
bounds: getCommonBounds(textElements),
appState: this.state,
offsets,
})
: null;
if (!scroll) {
return textElements;
}
const dx = scroll.scrollX - this.state.scrollX;
const dy = scroll.scrollY - this.state.scrollY;
return textElements.map((element) => {
const nextX = element.x + dx;
const nextY = element.y + dy;
// at the point it's pasted at: its center
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
x: nextX + element.width / 2,
y: nextY + element.height / 2,
});
return newElementWith(element, {
x: nextX,
y: nextY,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
});
};
// laid out as if the stats panel weren't there, to see where it lands:
// it counts only beside it
const offsets = this.text.getTextViewportOffsets();
let textElements = pasteWithin(offsets);
if (textElements.length === 0) {
return;
}
const getSelectedElementIds = () =>
makeNextSelectedElementIds(
Object.fromEntries(textElements.map((el) => [el.id, true])),
this.state,
);
if (this.viewport.getSideUIRect("stats")) {
// selected, it's in the stats panel, which grows to show its
// properties: rendered so (not captured yet), the panels are measured
// as they'll be — the styles panel, too, shown then, not reserved
flushSync(() => {
this.insertNewElements(textElements);
this.setState({ selectedElementIds: getSelectedElementIds() });
});
const besideOffsets = this.text.getTextViewportOffsets(
getCommonBounds(textElements),
);
if (
besideOffsets.left !== offsets.left ||
besideOffsets.right !== offsets.right
) {
// beside the stats panel, or the styles panel's not as reserved:
// laid out again, as the same elements (for anyone who's seen them
// rendered already)
const relaid = pasteWithin(besideOffsets);
const pastedIds = new Set(textElements.map((element) => element.id));
textElements = textElements.map((element, index) => {
const { x, y, width, height, text, autoResize, frameId } =
relaid[index];
return newElementWith(element, {
x,
y,
width,
height,
text,
autoResize,
frameId,
});
});
this.scene.replaceAllElements(
this.scene
.getElementsIncludingDeleted()
.filter((element) => !pastedIds.has(element.id)),
);
this.insertNewElements(textElements);
}
} else {
this.insertNewElements(textElements);
}
this.store.scheduleCapture();
this.setState({ selectedElementIds: getSelectedElementIds() });
if (
!isPlainPaste &&
textElements.length > 1 &&
PLAIN_PASTE_TOAST_SHOWN === false &&
this.editorInterface.formFactor !== "phone"
) {
this.setToast({
message: t("toast.pasteAsSingleElement", {
shortcut: getShortcutKey("CtrlOrCmd+Shift+V"),
}),
duration: 5000,
});
PLAIN_PASTE_TOAST_SHOWN = true;
}
}
setAppState: React.Component<any, AppState>["setState"] = (
state,
callback,
@@ -5733,14 +5204,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.V) {
IS_PLAIN_PASTE = event.shiftKey;
clearTimeout(IS_PLAIN_PASTE_TIMER);
// reset (100ms to be safe that we it runs after the ensuing
// paste event). Though, technically unnecessary to reset since we
// (re)set the flag before each paste event.
IS_PLAIN_PASTE_TIMER = this.ownerWindow.setTimeout(() => {
IS_PLAIN_PASTE = false;
}, 100);
this.clipboard.onPasteShortcut(event);
}
// prevent browser zoom in input fields
@@ -12431,7 +11895,7 @@ class App extends React.Component<AppProps, AppState> {
}
};
private insertImages = async (
public insertImages = async (
imageFiles: File[],
sceneX: number,
sceneY: number,
@@ -832,3 +832,95 @@ describe("clipboard - pasting mermaid definition", () => {
});
});
});
describe("copy and cut events", () => {
const dispatchClipboardEvent = (type: "copy" | "cut") => {
const clipboardData = new DataTransfer();
document.dispatchEvent(
Object.assign(new Event(type, { bubbles: true, cancelable: true }), {
clipboardData,
}),
);
return clipboardData;
};
it("should copy the selection on a copy event, and cut it on a cut event", async () => {
const rectangle = API.createElement({ type: "rectangle" });
API.setElements([rectangle]);
API.setSelectedElements([rectangle]);
const copied = dispatchClipboardEvent("copy");
await waitFor(() => {
expect(JSON.parse(copied.getData("text/plain")).elements).toEqual([
expect.objectContaining({ id: rectangle.id }),
]);
});
expect(h.elements[0].isDeleted).toBe(false);
const cut = dispatchClipboardEvent("cut");
await waitFor(() => {
expect(h.elements[0].isDeleted).toBe(true);
});
expect(JSON.parse(cut.getData("text/plain")).elements).toEqual([
expect.objectContaining({ id: rectangle.id }),
]);
});
});
describe("paste routing", () => {
it("should leave a paste to the host when its onPaste returns false", async () => {
unmountComponent();
const onPaste = vi.fn(() => false);
await render(
<Excalidraw
autoFocus={true}
handleKeyboardGlobally={true}
onPaste={onPaste}
/>,
);
pasteWithCtrlCmdV("hello");
await waitFor(() => {
expect(onPaste).toHaveBeenCalledWith(
expect.objectContaining({ text: "hello" }),
expect.anything(),
);
});
await sleep(50);
expect(h.elements.length).toBe(0);
});
it("should open the charts dialog for a pasted spreadsheet", async () => {
pasteWithCtrlCmdV("time\tvalue\n1\t10\n2\t20\n3\t30");
await waitFor(() => {
expect(h.state.openDialog?.name).toBe("charts");
});
expect(h.elements.length).toBe(0);
});
it("should paste the text of mixed content when images can't be inserted", async () => {
unmountComponent();
await render(
<Excalidraw
autoFocus={true}
handleKeyboardGlobally={true}
UIOptions={{ tools: { image: false } }}
/>,
);
document.dispatchEvent(
createPasteEvent({
types: {
"text/html":
'<p>hello</p><img src="https://example.com/a.png" /><p>world</p>',
},
}),
);
await waitFor(() => {
expect(h.elements.map((element) => (element as any).text)).toEqual([
"hello",
"world",
]);
});
});
});
+1 -1
View File
@@ -1198,7 +1198,7 @@ export type AppClassProperties = {
scene: App["scene"];
syncActionResult: App["syncActionResult"];
fonts: App["fonts"];
pasteFromClipboard: App["pasteFromClipboard"];
clipboard: App["clipboard"];
id: App["id"];
onInsertElements: App["onInsertElements"];
onExportImage: App["onExportImage"];