diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index 8d5ed2a30a..07240f0e9e 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -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 { diff --git a/packages/excalidraw/components/App.clipboard.ts b/packages/excalidraw/components/App.clipboard.ts new file mode 100644 index 0000000000..068d0a6613 --- /dev/null +++ b/packages/excalidraw/components/App.clipboard.ts @@ -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("")) { + // 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[] = []; + 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) => { + 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; + } + } +} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 510fdef114..aa846b0844 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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 { 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 { 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 { 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 { 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 { }); }, 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 { } }; - // 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("")) { - // 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[] = []; - 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 { } }; - // 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) => { - 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["setState"] = ( state, callback, @@ -5733,14 +5204,7 @@ class App extends React.Component { } 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 { } }; - private insertImages = async ( + public insertImages = async ( imageFiles: File[], sceneX: number, sceneY: number, diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx index 1dfd026f40..6eee5ddee9 100644 --- a/packages/excalidraw/tests/clipboard.test.tsx +++ b/packages/excalidraw/tests/clipboard.test.tsx @@ -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( + , + ); + + 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( + , + ); + + document.dispatchEvent( + createPasteEvent({ + types: { + "text/html": + '

hello

world

', + }, + }), + ); + await waitFor(() => { + expect(h.elements.map((element) => (element as any).text)).toEqual([ + "hello", + "world", + ]); + }); + }); +}); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 423221cad6..7b35930d33 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -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"];