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

@ -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")]}