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
@ -14,6 +14,7 @@ import {
|
|||||||
} from "../element/bounds";
|
} from "../element/bounds";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
const enableActionFlipHorizontal = (
|
const enableActionFlipHorizontal = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@ -63,7 +64,8 @@ export const actionFlipVertical = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.shiftKey && event.code === "KeyV",
|
keyTest: (event) =>
|
||||||
|
event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
|
||||||
contextItemLabel: "labels.flipVertical",
|
contextItemLabel: "labels.flipVertical",
|
||||||
contextItemPredicate: (elements, appState) =>
|
contextItemPredicate: (elements, appState) =>
|
||||||
enableActionFlipVertical(elements, appState),
|
enableActionFlipVertical(elements, appState),
|
||||||
|
@ -8,6 +8,7 @@ import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
|||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||||
import { isInitializedImageElement } from "./element/typeChecks";
|
import { isInitializedImageElement } from "./element/typeChecks";
|
||||||
import { isPromiseLike } from "./utils";
|
import { isPromiseLike } from "./utils";
|
||||||
|
import { normalizeText } from "./element/textElement";
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
@ -109,16 +110,16 @@ const parsePotentialSpreadsheet = (
|
|||||||
* Retrieves content from system clipboard (either from ClipboardEvent or
|
* Retrieves content from system clipboard (either from ClipboardEvent or
|
||||||
* via async clipboard API if supported)
|
* via async clipboard API if supported)
|
||||||
*/
|
*/
|
||||||
const getSystemClipboard = async (
|
export const getSystemClipboard = async (
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const text = event
|
const text = event
|
||||||
? event.clipboardData?.getData("text/plain").trim()
|
? event.clipboardData?.getData("text/plain")
|
||||||
: probablySupportsClipboardReadText &&
|
: probablySupportsClipboardReadText &&
|
||||||
(await navigator.clipboard.readText());
|
(await navigator.clipboard.readText());
|
||||||
|
|
||||||
return text || "";
|
return normalizeText(text || "").trim();
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@ -129,19 +130,24 @@ const getSystemClipboard = async (
|
|||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const systemClipboard = await getSystemClipboard(event);
|
const systemClipboard = await getSystemClipboard(event);
|
||||||
|
|
||||||
// if system clipboard empty, couldn't be resolved, or contains previously
|
// if system clipboard empty, couldn't be resolved, or contains previously
|
||||||
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
|
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
|
||||||
// elements
|
// elements
|
||||||
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
|
if (
|
||||||
|
!systemClipboard ||
|
||||||
|
(!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
|
||||||
|
) {
|
||||||
return getAppClipboard();
|
return getAppClipboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
// if system clipboard contains spreadsheet, use it even though it's
|
// if system clipboard contains spreadsheet, use it even though it's
|
||||||
// technically possible it's staler than in-app clipboard
|
// technically possible it's staler than in-app clipboard
|
||||||
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
|
const spreadsheetResult =
|
||||||
|
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
|
||||||
if (spreadsheetResult) {
|
if (spreadsheetResult) {
|
||||||
return spreadsheetResult;
|
return spreadsheetResult;
|
||||||
}
|
}
|
||||||
@ -154,6 +160,9 @@ export const parseClipboard = async (
|
|||||||
return {
|
return {
|
||||||
elements: systemClipboardData.elements,
|
elements: systemClipboardData.elements,
|
||||||
files: systemClipboardData.files,
|
files: systemClipboardData.files,
|
||||||
|
text: isPlainPaste
|
||||||
|
? JSON.stringify(systemClipboardData.elements, null, 2)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@ -161,7 +170,12 @@ export const parseClipboard = async (
|
|||||||
// unless we set a flag to prefer in-app clipboard because browser didn't
|
// unless we set a flag to prefer in-app clipboard because browser didn't
|
||||||
// support storing to system clipboard on copy
|
// support storing to system clipboard on copy
|
||||||
return PREFER_APP_CLIPBOARD && appClipboardData.elements
|
return PREFER_APP_CLIPBOARD && appClipboardData.elements
|
||||||
? appClipboardData
|
? {
|
||||||
|
...appClipboardData,
|
||||||
|
text: isPlainPaste
|
||||||
|
? JSON.stringify(appClipboardData.elements, null, 2)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
: { text: systemClipboard };
|
: { text: systemClipboard };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -222,6 +222,7 @@ import {
|
|||||||
updateObject,
|
updateObject,
|
||||||
setEraserCursor,
|
setEraserCursor,
|
||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
|
getShortcutKey,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import ContextMenu, { ContextMenuOption } from "./ContextMenu";
|
import ContextMenu, { ContextMenuOption } from "./ContextMenu";
|
||||||
import LayerUI from "./LayerUI";
|
import LayerUI from "./LayerUI";
|
||||||
@ -249,6 +250,7 @@ import throttle from "lodash.throttle";
|
|||||||
import { fileOpen, FileSystemHandle } from "../data/filesystem";
|
import { fileOpen, FileSystemHandle } from "../data/filesystem";
|
||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
|
getApproxLineHeight,
|
||||||
getApproxMinLineHeight,
|
getApproxMinLineHeight,
|
||||||
getApproxMinLineWidth,
|
getApproxMinLineWidth,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
@ -326,6 +328,10 @@ let invalidateContextMenu = false;
|
|||||||
// to rAF. See #5439
|
// to rAF. See #5439
|
||||||
let THROTTLE_NEXT_RENDER = true;
|
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;
|
let lastPointerUp: ((event: any) => void) | null = null;
|
||||||
const gesture: Gesture = {
|
const gesture: Gesture = {
|
||||||
pointers: new Map(),
|
pointers: new Map(),
|
||||||
@ -1452,6 +1458,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
private pasteFromClipboard = withBatchedUpdates(
|
private pasteFromClipboard = withBatchedUpdates(
|
||||||
async (event: ClipboardEvent | null) => {
|
async (event: ClipboardEvent | null) => {
|
||||||
|
const isPlainPaste = !!(IS_PLAIN_PASTE && event);
|
||||||
|
|
||||||
// #686
|
// #686
|
||||||
const target = document.activeElement;
|
const target = document.activeElement;
|
||||||
const isExcalidrawActive =
|
const isExcalidrawActive =
|
||||||
@ -1462,8 +1470,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
|
const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
|
||||||
if (
|
if (
|
||||||
// if no ClipboardEvent supplied, assume we're pasting via contextMenu
|
|
||||||
// thus these checks don't make sense
|
|
||||||
event &&
|
event &&
|
||||||
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
|
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
|
||||||
isWritableElement(target))
|
isWritableElement(target))
|
||||||
@ -1476,9 +1482,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// (something something security)
|
// (something something security)
|
||||||
let file = event?.clipboardData?.files[0];
|
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();
|
const string = data.text.trim();
|
||||||
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
||||||
// ignore SVG validation/normalization which will be done during image
|
// ignore SVG validation/normalization which will be done during image
|
||||||
@ -1511,9 +1517,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.errorMessage) {
|
if (data.errorMessage) {
|
||||||
this.setState({ errorMessage: data.errorMessage });
|
this.setState({ errorMessage: data.errorMessage });
|
||||||
} else if (data.spreadsheet) {
|
} else if (data.spreadsheet && !isPlainPaste) {
|
||||||
this.setState({
|
this.setState({
|
||||||
pasteDialog: {
|
pasteDialog: {
|
||||||
data: data.spreadsheet,
|
data: data.spreadsheet,
|
||||||
@ -1521,13 +1528,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (data.elements) {
|
} else if (data.elements) {
|
||||||
|
// TODO remove formatting from elements if isPlainPaste
|
||||||
this.addElementsFromPasteOrLibrary({
|
this.addElementsFromPasteOrLibrary({
|
||||||
elements: data.elements,
|
elements: data.elements,
|
||||||
files: data.files || null,
|
files: data.files || null,
|
||||||
position: "cursor",
|
position: "cursor",
|
||||||
});
|
});
|
||||||
} else if (data.text) {
|
} else if (data.text) {
|
||||||
this.addTextFromPaste(data.text);
|
this.addTextFromPaste(data.text, isPlainPaste);
|
||||||
}
|
}
|
||||||
this.setActiveTool({ type: "selection" });
|
this.setActiveTool({ type: "selection" });
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
@ -1634,13 +1642,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setActiveTool({ type: "selection" });
|
this.setActiveTool({ type: "selection" });
|
||||||
};
|
};
|
||||||
|
|
||||||
private addTextFromPaste(text: any) {
|
private addTextFromPaste(text: string, isPlainPaste = false) {
|
||||||
const { x, y } = viewportCoordsToSceneCoords(
|
const { x, y } = viewportCoordsToSceneCoords(
|
||||||
{ clientX: cursorX, clientY: cursorY },
|
{ clientX: cursorX, clientY: cursorY },
|
||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
const element = newTextElement({
|
const textElementProps = {
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
strokeColor: this.state.currentItemStrokeColor,
|
strokeColor: this.state.currentItemStrokeColor,
|
||||||
@ -1657,13 +1665,76 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
textAlign: this.state.currentItemTextAlign,
|
textAlign: this.state.currentItemTextAlign,
|
||||||
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||||
locked: false,
|
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.replaceAllElements([
|
||||||
...this.scene.getElementsIncludingDeleted(),
|
...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();
|
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
|
// prevent browser zoom in input fields
|
||||||
if (event[KEYS.CTRL_OR_CMD] && isWritableElement(event.target)) {
|
if (event[KEYS.CTRL_OR_CMD] && isWritableElement(event.target)) {
|
||||||
if (event.code === CODES.MINUS || event.code === CODES.EQUAL) {
|
if (event.code === CODES.MINUS || event.code === CODES.EQUAL) {
|
||||||
|
@ -289,6 +289,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.paste")}
|
label={t("labels.paste")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
|
||||||
/>
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.pasteAsPlaintext")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
|
||||||
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.copyAsPng")}
|
label={t("labels.copyAsPng")}
|
||||||
shortcuts={[getShortcutKey("Shift+Alt+C")]}
|
shortcuts={[getShortcutKey("Shift+Alt+C")]}
|
||||||
|
@ -25,6 +25,7 @@ import {
|
|||||||
getContainerDims,
|
getContainerDims,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
measureText,
|
measureText,
|
||||||
|
normalizeText,
|
||||||
wrapText,
|
wrapText,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||||
@ -133,12 +134,13 @@ export const newTextElement = (
|
|||||||
containerId?: ExcalidrawRectangleElement["id"];
|
containerId?: ExcalidrawRectangleElement["id"];
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawTextElement> => {
|
): 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 offsets = getTextElementPositionOffsets(opts, metrics);
|
||||||
const textElement = newElementWith(
|
const textElement = newElementWith(
|
||||||
{
|
{
|
||||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||||
text: opts.text,
|
text,
|
||||||
fontSize: opts.fontSize,
|
fontSize: opts.fontSize,
|
||||||
fontFamily: opts.fontFamily,
|
fontFamily: opts.fontFamily,
|
||||||
textAlign: opts.textAlign,
|
textAlign: opts.textAlign,
|
||||||
@ -149,7 +151,7 @@ export const newTextElement = (
|
|||||||
height: metrics.height,
|
height: metrics.height,
|
||||||
baseline: metrics.baseline,
|
baseline: metrics.baseline,
|
||||||
containerId: opts.containerId || null,
|
containerId: opts.containerId || null,
|
||||||
originalText: opts.text,
|
originalText: text,
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
@ -19,6 +19,16 @@ import { AppState } from "../types";
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { isImageElement } from "./typeChecks";
|
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 = (
|
export const redrawTextBoundingBox = (
|
||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
container: ExcalidrawElement | null,
|
container: ExcalidrawElement | null,
|
||||||
|
@ -21,6 +21,7 @@ import {
|
|||||||
getContainerDims,
|
getContainerDims,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
measureText,
|
measureText,
|
||||||
|
normalizeText,
|
||||||
wrapText,
|
wrapText,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import {
|
import {
|
||||||
@ -32,16 +33,6 @@ import App from "../components/App";
|
|||||||
import { getMaxContainerWidth } from "./newElement";
|
import { getMaxContainerWidth } from "./newElement";
|
||||||
import { parseClipboard } from "../clipboard";
|
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 = (
|
const getTransform = (
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
@ -279,7 +270,7 @@ export const textWysiwyg = ({
|
|||||||
if (onChange) {
|
if (onChange) {
|
||||||
editable.onpaste = async (event) => {
|
editable.onpaste = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const clipboardData = await parseClipboard(event);
|
const clipboardData = await parseClipboard(event, true);
|
||||||
if (!clipboardData.text) {
|
if (!clipboardData.text) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"labels": {
|
"labels": {
|
||||||
"paste": "Paste",
|
"paste": "Paste",
|
||||||
|
"pasteAsPlaintext": "Paste as plaintext",
|
||||||
"pasteCharts": "Paste charts",
|
"pasteCharts": "Paste charts",
|
||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
"multiSelect": "Add element to selection",
|
"multiSelect": "Add element to selection",
|
||||||
@ -392,7 +393,8 @@
|
|||||||
"fileSaved": "File saved.",
|
"fileSaved": "File saved.",
|
||||||
"fileSavedToFilename": "Saved to {filename}",
|
"fileSavedToFilename": "Saved to {filename}",
|
||||||
"canvas": "canvas",
|
"canvas": "canvas",
|
||||||
"selection": "selection"
|
"selection": "selection",
|
||||||
|
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor"
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"ffffff": "White",
|
"ffffff": "White",
|
||||||
|
184
src/tests/clipboard.test.tsx
Normal file
184
src/tests/clipboard.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user