mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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:
parent
d2181847be
commit
baf9651d34
9 changed files with 324 additions and 33 deletions
|
@ -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) {
|
||||
|
|
|
@ -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")]}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue