feat: changed text copy/paste behaviour (#5786)

Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Antonio Della Fortuna <a.dellafortuna00@gmail.com>
This commit is contained in:
Antonio Della Fortuna 2022-11-26 23:44:26 +01:00 committed by GitHub
parent d2181847be
commit baf9651d34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 324 additions and 33 deletions

View File

@ -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),

View File

@ -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<string> => {
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<ClipboardData> => {
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 };
};

View File

@ -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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
// (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("<svg") && string.endsWith("</svg>")) {
// ignore SVG validation/normalization which will be done during image
@ -1511,9 +1517,10 @@ class App extends React.Component<AppProps, AppState> {
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<AppProps, AppState> {
},
});
} 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<AppProps, AppState> {
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<AppProps, AppState> {
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<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 = 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) {

View File

@ -289,6 +289,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.paste")}
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/>
<Shortcut
label={t("labels.pasteAsPlaintext")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
/>
<Shortcut
label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]}

View File

@ -25,6 +25,7 @@ import {
getContainerDims,
getContainerElement,
measureText,
normalizeText,
wrapText,
} from "./textElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
@ -133,12 +134,13 @@ export const newTextElement = (
containerId?: ExcalidrawRectangleElement["id"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
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<ExcalidrawTextElement>("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,
},
{},
);

View File

@ -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,

View File

@ -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;
}

View File

@ -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",

View File

@ -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(<ExcalidrawApp />);
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);
});
});
});