mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user