diff --git a/src/actions/actionFlip.ts b/src/actions/actionFlip.ts index f88b8b26..8314d767 100644 --- a/src/actions/actionFlip.ts +++ b/src/actions/actionFlip.ts @@ -14,6 +14,7 @@ import { } from "../element/bounds"; import { isLinearElement } from "../element/typeChecks"; import { LinearElementEditor } from "../element/linearElementEditor"; +import { KEYS } from "../keys"; const enableActionFlipHorizontal = ( elements: readonly ExcalidrawElement[], @@ -63,7 +64,8 @@ export const actionFlipVertical = register({ commitToHistory: true, }; }, - keyTest: (event) => event.shiftKey && event.code === "KeyV", + keyTest: (event) => + event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD], contextItemLabel: "labels.flipVertical", contextItemPredicate: (elements, appState) => enableActionFlipVertical(elements, appState), diff --git a/src/clipboard.ts b/src/clipboard.ts index 4e2c0622..e5d0712d 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -8,6 +8,7 @@ import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { isInitializedImageElement } from "./element/typeChecks"; import { isPromiseLike } from "./utils"; +import { normalizeText } from "./element/textElement"; type ElementsClipboard = { type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; @@ -109,16 +110,16 @@ const parsePotentialSpreadsheet = ( * Retrieves content from system clipboard (either from ClipboardEvent or * via async clipboard API if supported) */ -const getSystemClipboard = async ( +export const getSystemClipboard = async ( event: ClipboardEvent | null, ): Promise => { try { const text = event - ? event.clipboardData?.getData("text/plain").trim() + ? event.clipboardData?.getData("text/plain") : probablySupportsClipboardReadText && (await navigator.clipboard.readText()); - return text || ""; + return normalizeText(text || "").trim(); } catch { return ""; } @@ -129,19 +130,24 @@ const getSystemClipboard = async ( */ export const parseClipboard = async ( event: ClipboardEvent | null, + isPlainPaste = false, ): Promise => { const systemClipboard = await getSystemClipboard(event); // if system clipboard empty, couldn't be resolved, or contains previously // copied excalidraw scene as SVG, fall back to previously copied excalidraw // elements - if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) { + if ( + !systemClipboard || + (!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG)) + ) { return getAppClipboard(); } // if system clipboard contains spreadsheet, use it even though it's // technically possible it's staler than in-app clipboard - const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard); + const spreadsheetResult = + !isPlainPaste && parsePotentialSpreadsheet(systemClipboard); if (spreadsheetResult) { return spreadsheetResult; } @@ -154,6 +160,9 @@ export const parseClipboard = async ( return { elements: systemClipboardData.elements, files: systemClipboardData.files, + text: isPlainPaste + ? JSON.stringify(systemClipboardData.elements, null, 2) + : undefined, }; } } catch (e) {} @@ -161,7 +170,12 @@ export const parseClipboard = async ( // unless we set a flag to prefer in-app clipboard because browser didn't // support storing to system clipboard on copy return PREFER_APP_CLIPBOARD && appClipboardData.elements - ? appClipboardData + ? { + ...appClipboardData, + text: isPlainPaste + ? JSON.stringify(appClipboardData.elements, null, 2) + : undefined, + } : { text: systemClipboard }; }; diff --git a/src/components/App.tsx b/src/components/App.tsx index 9b7dd422..be62bc5c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -222,6 +222,7 @@ import { updateObject, setEraserCursor, updateActiveTool, + getShortcutKey, } from "../utils"; import ContextMenu, { ContextMenuOption } from "./ContextMenu"; import LayerUI from "./LayerUI"; @@ -249,6 +250,7 @@ import throttle from "lodash.throttle"; import { fileOpen, FileSystemHandle } from "../data/filesystem"; import { bindTextToShapeAfterDuplication, + getApproxLineHeight, getApproxMinLineHeight, getApproxMinLineWidth, getBoundTextElement, @@ -326,6 +328,10 @@ let invalidateContextMenu = false; // to rAF. See #5439 let THROTTLE_NEXT_RENDER = true; +let IS_PLAIN_PASTE = false; +let IS_PLAIN_PASTE_TIMER = 0; +let PLAIN_PASTE_TOAST_SHOWN = false; + let lastPointerUp: ((event: any) => void) | null = null; const gesture: Gesture = { pointers: new Map(), @@ -1452,6 +1458,8 @@ class App extends React.Component { private pasteFromClipboard = withBatchedUpdates( async (event: ClipboardEvent | null) => { + const isPlainPaste = !!(IS_PLAIN_PASTE && event); + // #686 const target = document.activeElement; const isExcalidrawActive = @@ -1462,8 +1470,6 @@ class App extends React.Component { const elementUnderCursor = document.elementFromPoint(cursorX, cursorY); if ( - // if no ClipboardEvent supplied, assume we're pasting via contextMenu - // thus these checks don't make sense event && (!(elementUnderCursor instanceof HTMLCanvasElement) || isWritableElement(target)) @@ -1476,9 +1482,9 @@ class App extends React.Component { // (something something security) let file = event?.clipboardData?.files[0]; - const data = await parseClipboard(event); + const data = await parseClipboard(event, isPlainPaste); - if (!file && data.text) { + if (!file && data.text && !isPlainPaste) { const string = data.text.trim(); if (string.startsWith("")) { // ignore SVG validation/normalization which will be done during image @@ -1511,9 +1517,10 @@ class App extends React.Component { console.error(error); } } + if (data.errorMessage) { this.setState({ errorMessage: data.errorMessage }); - } else if (data.spreadsheet) { + } else if (data.spreadsheet && !isPlainPaste) { this.setState({ pasteDialog: { data: data.spreadsheet, @@ -1521,13 +1528,14 @@ class App extends React.Component { }, }); } else if (data.elements) { + // TODO remove formatting from elements if isPlainPaste this.addElementsFromPasteOrLibrary({ elements: data.elements, files: data.files || null, position: "cursor", }); } else if (data.text) { - this.addTextFromPaste(data.text); + this.addTextFromPaste(data.text, isPlainPaste); } this.setActiveTool({ type: "selection" }); event?.preventDefault(); @@ -1634,13 +1642,13 @@ class App extends React.Component { this.setActiveTool({ type: "selection" }); }; - private addTextFromPaste(text: any) { + private addTextFromPaste(text: string, isPlainPaste = false) { const { x, y } = viewportCoordsToSceneCoords( { clientX: cursorX, clientY: cursorY }, this.state, ); - const element = newTextElement({ + const textElementProps = { x, y, strokeColor: this.state.currentItemStrokeColor, @@ -1657,13 +1665,76 @@ class App extends React.Component { textAlign: this.state.currentItemTextAlign, verticalAlign: DEFAULT_VERTICAL_ALIGN, locked: false, - }); + }; + + const LINE_GAP = 10; + let currentY = y; + + const lines = isPlainPaste ? [text] : text.split("\n"); + const textElements = lines.reduce( + (acc: ExcalidrawTextElement[], line, idx) => { + const text = line.trim(); + + if (text.length) { + const element = newTextElement({ + ...textElementProps, + x, + y: currentY, + text, + }); + 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) { + const defaultLineHeight = getApproxLineHeight( + getFontString({ + fontSize: textElementProps.fontSize, + fontFamily: textElementProps.fontFamily, + }), + ); + + currentY += defaultLineHeight + LINE_GAP; + } + } + + return acc; + }, + [], + ); + + if (textElements.length === 0) { + return; + } this.scene.replaceAllElements([ ...this.scene.getElementsIncludingDeleted(), - element, + ...textElements, ]); - this.setState({ selectedElementIds: { [element.id]: true } }); + + this.setState({ + selectedElementIds: Object.fromEntries( + textElements.map((el) => [el.id, true]), + ), + }); + + if ( + !isPlainPaste && + textElements.length > 1 && + PLAIN_PASTE_TOAST_SHOWN === false && + !this.device.isMobile + ) { + this.setToast({ + message: t("toast.pasteAsSingleElement", { + shortcut: getShortcutKey("CtrlOrCmd+Shift+V"), + }), + duration: 5000, + }); + PLAIN_PASTE_TOAST_SHOWN = true; + } + this.history.resumeRecording(); } @@ -1873,6 +1944,17 @@ 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 = window.setTimeout(() => { + IS_PLAIN_PASTE = false; + }, 100); + } + // prevent browser zoom in input fields if (event[KEYS.CTRL_OR_CMD] && isWritableElement(event.target)) { if (event.code === CODES.MINUS || event.code === CODES.EQUAL) { diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index dac14758..9a23fad0 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -289,6 +289,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { label={t("labels.paste")} shortcuts={[getShortcutKey("CtrlOrCmd+V")]} /> + => { - const metrics = measureText(opts.text, getFontString(opts)); + const text = normalizeText(opts.text); + const metrics = measureText(text, getFontString(opts)); const offsets = getTextElementPositionOffsets(opts, metrics); const textElement = newElementWith( { ..._newElementBase("text", opts), - text: opts.text, + text, fontSize: opts.fontSize, fontFamily: opts.fontFamily, textAlign: opts.textAlign, @@ -149,7 +151,7 @@ export const newTextElement = ( height: metrics.height, baseline: metrics.baseline, containerId: opts.containerId || null, - originalText: opts.text, + originalText: text, }, {}, ); diff --git a/src/element/textElement.ts b/src/element/textElement.ts index b45127a3..399a5be2 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -19,6 +19,16 @@ import { AppState } from "../types"; import { getSelectedElements } from "../scene"; import { isImageElement } from "./typeChecks"; +export const normalizeText = (text: string) => { + return ( + text + // replace tabs with spaces so they render and measure correctly + .replace(/\t/g, " ") + // normalize newlines + .replace(/\r?\n|\r/g, "\n") + ); +}; + export const redrawTextBoundingBox = ( textElement: ExcalidrawTextElement, container: ExcalidrawElement | null, diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 20bd187a..d015ee00 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -21,6 +21,7 @@ import { getContainerDims, getContainerElement, measureText, + normalizeText, wrapText, } from "./textElement"; import { @@ -32,16 +33,6 @@ import App from "../components/App"; import { getMaxContainerWidth } from "./newElement"; import { parseClipboard } from "../clipboard"; -const normalizeText = (text: string) => { - return ( - text - // replace tabs with spaces so they render and measure correctly - .replace(/\t/g, " ") - // normalize newlines - .replace(/\r?\n|\r/g, "\n") - ); -}; - const getTransform = ( width: number, height: number, @@ -279,7 +270,7 @@ export const textWysiwyg = ({ if (onChange) { editable.onpaste = async (event) => { event.preventDefault(); - const clipboardData = await parseClipboard(event); + const clipboardData = await parseClipboard(event, true); if (!clipboardData.text) { return; } diff --git a/src/locales/en.json b/src/locales/en.json index f767640b..9fedaa3f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,6 +1,7 @@ { "labels": { "paste": "Paste", + "pasteAsPlaintext": "Paste as plaintext", "pasteCharts": "Paste charts", "selectAll": "Select all", "multiSelect": "Add element to selection", @@ -392,7 +393,8 @@ "fileSaved": "File saved.", "fileSavedToFilename": "Saved to {filename}", "canvas": "canvas", - "selection": "selection" + "selection": "selection", + "pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor" }, "colors": { "ffffff": "White", diff --git a/src/tests/clipboard.test.tsx b/src/tests/clipboard.test.tsx new file mode 100644 index 00000000..66e5ce74 --- /dev/null +++ b/src/tests/clipboard.test.tsx @@ -0,0 +1,184 @@ +import ReactDOM from "react-dom"; +import { render, waitFor, GlobalTestState } from "./test-utils"; +import { Pointer, Keyboard } from "./helpers/ui"; +import ExcalidrawApp from "../excalidraw-app"; +import { KEYS } from "../keys"; +import { getApproxLineHeight } from "../element/textElement"; +import { getFontString } from "../utils"; +import { getElementBounds } from "../element"; +import { NormalizedZoomValue } from "../types"; + +const { h } = window; + +const mouse = new Pointer("mouse"); + +jest.mock("../keys.ts", () => { + const actual = jest.requireActual("../keys.ts"); + return { + __esmodule: true, + ...actual, + isDarwin: false, + KEYS: { + ...actual.KEYS, + CTRL_OR_CMD: "ctrlKey", + }, + }; +}); + +const setClipboardText = (text: string) => { + Object.assign(navigator, { + clipboard: { + readText: () => text, + }, + }); +}; + +const sendPasteEvent = () => { + const clipboardEvent = new Event("paste", { + bubbles: true, + cancelable: true, + composed: true, + }); + + // set `clipboardData` properties. + // @ts-ignore + clipboardEvent.clipboardData = { + getData: () => window.navigator.clipboard.readText(), + files: [], + }; + + document.dispatchEvent(clipboardEvent); +}; + +const pasteWithCtrlCmdShiftV = () => { + Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { + //triggering keydown with an empty clipboard + Keyboard.keyPress(KEYS.V); + //triggering paste event with faked clipboard + sendPasteEvent(); + }); +}; + +const pasteWithCtrlCmdV = () => { + Keyboard.withModifierKeys({ ctrl: true }, () => { + //triggering keydown with an empty clipboard + Keyboard.keyPress(KEYS.V); + //triggering paste event with faked clipboard + sendPasteEvent(); + }); +}; + +const sleep = (ms: number) => { + return new Promise((resolve) => setTimeout(() => resolve(null), ms)); +}; + +beforeEach(async () => { + ReactDOM.unmountComponentAtNode(document.getElementById("root")!); + + localStorage.clear(); + + mouse.reset(); + + await render(); + h.app.setAppState({ zoom: { value: 1 as NormalizedZoomValue } }); + setClipboardText(""); + Object.assign(document, { + elementFromPoint: () => GlobalTestState.canvas, + }); +}); + +describe("paste text as single lines", () => { + it("should create an element for each line when copying with Ctrl/Cmd+V", async () => { + const text = "sajgfakfn\naaksfnknas\nakefnkasf"; + setClipboardText(text); + pasteWithCtrlCmdV(); + await waitFor(() => { + expect(h.elements.length).toEqual(text.split("\n").length); + }); + }); + + it("should ignore empty lines when creating an element for each line", async () => { + const text = "\n\nsajgfakfn\n\n\naaksfnknas\n\nakefnkasf\n\n\n"; + setClipboardText(text); + pasteWithCtrlCmdV(); + await waitFor(() => { + expect(h.elements.length).toEqual(3); + }); + }); + + it("should not create any element if clipboard has only new lines", async () => { + const text = "\n\n\n\n\n"; + setClipboardText(text); + pasteWithCtrlCmdV(); + await waitFor(async () => { + await sleep(50); // elements lenght will always be zero if we don't wait, since paste is async + expect(h.elements.length).toEqual(0); + }); + }); + + it("should space items correctly", async () => { + const text = "hkhkjhki\njgkjhffjh\njgkjhffjh"; + const lineHeight = + getApproxLineHeight( + getFontString({ + fontSize: h.app.state.currentItemFontSize, + fontFamily: h.app.state.currentItemFontFamily, + }), + ) + + 10 / h.app.state.zoom.value; + mouse.moveTo(100, 100); + setClipboardText(text); + pasteWithCtrlCmdV(); + await waitFor(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [fx, firstElY] = getElementBounds(h.elements[0]); + for (let i = 1; i < h.elements.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [fx, elY] = getElementBounds(h.elements[i]); + expect(elY).toEqual(firstElY + lineHeight * i); + } + }); + }); + + it("should leave a space for blank new lines", async () => { + const text = "hkhkjhki\n\njgkjhffjh"; + const lineHeight = + getApproxLineHeight( + getFontString({ + fontSize: h.app.state.currentItemFontSize, + fontFamily: h.app.state.currentItemFontFamily, + }), + ) + + 10 / h.app.state.zoom.value; + mouse.moveTo(100, 100); + setClipboardText(text); + pasteWithCtrlCmdV(); + await waitFor(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [fx, firstElY] = getElementBounds(h.elements[0]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [lx, lastElY] = getElementBounds(h.elements[1]); + expect(lastElY).toEqual(firstElY + lineHeight * 2); + }); + }); +}); + +describe("paste text as a single element", () => { + it("should create single text element when copying text with Ctrl/Cmd+Shift+V", async () => { + const text = "sajgfakfn\naaksfnknas\nakefnkasf"; + setClipboardText(text); + pasteWithCtrlCmdShiftV(); + await waitFor(() => { + expect(h.elements.length).toEqual(1); + }); + }); + it("should not create any element when only new lines in clipboard", async () => { + const text = "\n\n\n\n"; + setClipboardText(text); + pasteWithCtrlCmdShiftV(); + await waitFor(async () => { + await sleep(50); + expect(h.elements.length).toEqual(0); + }); + }); +});