Improve pasting (#723)
* switch to selection tool on paste * align pasting via contextMenu with pasting from event * ensure only plaintext can be pasted * fix findShapeByKey regression * simplify wysiwyg pasting * improve wysiwyg blurriness
This commit is contained in:
parent
ba1a39c9f3
commit
88eacc9da7
@ -3,6 +3,9 @@ import { ExcalidrawElement } from "./element/types";
|
|||||||
let CLIPBOARD = "";
|
let CLIPBOARD = "";
|
||||||
let PREFER_APP_CLIPBOARD = false;
|
let PREFER_APP_CLIPBOARD = false;
|
||||||
|
|
||||||
|
export const probablySupportsClipboardReadText =
|
||||||
|
"clipboard" in navigator && "readText" in navigator.clipboard;
|
||||||
|
|
||||||
export const probablySupportsClipboardWriteText =
|
export const probablySupportsClipboardWriteText =
|
||||||
"clipboard" in navigator && "writeText" in navigator.clipboard;
|
"clipboard" in navigator && "writeText" in navigator.clipboard;
|
||||||
|
|
||||||
@ -47,26 +50,33 @@ export function getAppClipboard(): {
|
|||||||
) {
|
) {
|
||||||
return { elements: clipboardElements };
|
return { elements: clipboardElements };
|
||||||
}
|
}
|
||||||
} catch (err) {}
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseClipboardEvent(
|
export async function getClipboardContent(
|
||||||
e: ClipboardEvent,
|
e: ClipboardEvent | null,
|
||||||
): {
|
): Promise<{
|
||||||
text?: string;
|
text?: string;
|
||||||
elements?: readonly ExcalidrawElement[];
|
elements?: readonly ExcalidrawElement[];
|
||||||
} {
|
}> {
|
||||||
try {
|
try {
|
||||||
const text = e.clipboardData?.getData("text/plain").trim();
|
const text = e
|
||||||
|
? e.clipboardData?.getData("text/plain").trim()
|
||||||
|
: probablySupportsClipboardReadText &&
|
||||||
|
(await navigator.clipboard.readText());
|
||||||
|
|
||||||
if (text && !PREFER_APP_CLIPBOARD) {
|
if (text && !PREFER_APP_CLIPBOARD) {
|
||||||
return { text };
|
return { text };
|
||||||
}
|
}
|
||||||
return getAppClipboard();
|
} catch (err) {
|
||||||
} catch (e) {}
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
return {};
|
return getAppClipboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
|
export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
|
||||||
|
@ -53,8 +53,36 @@ export function textWysiwyg({
|
|||||||
outline: "1px solid transparent",
|
outline: "1px solid transparent",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
minHeight: "1em",
|
minHeight: "1em",
|
||||||
|
backfaceVisibility: "hidden",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
editable.onpaste = ev => {
|
||||||
|
try {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection?.rangeCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selection.deleteFromDocument();
|
||||||
|
|
||||||
|
const text = ev.clipboardData!.getData("text").replace(/\r\n?/g, "\n");
|
||||||
|
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.innerText = text;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
range.insertNode(span);
|
||||||
|
|
||||||
|
// deselect
|
||||||
|
window.getSelection()!.removeAllRanges();
|
||||||
|
range.setStart(span, span.childNodes.length);
|
||||||
|
range.setEnd(span, span.childNodes.length);
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
editable.onkeydown = ev => {
|
editable.onkeydown = ev => {
|
||||||
if (ev.key === KEYS.ESCAPE) {
|
if (ev.key === KEYS.ESCAPE) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@ -92,6 +120,7 @@ export function textWysiwyg({
|
|||||||
function cleanup() {
|
function cleanup() {
|
||||||
editable.onblur = null;
|
editable.onblur = null;
|
||||||
editable.onkeydown = null;
|
editable.onkeydown = null;
|
||||||
|
editable.onpaste = null;
|
||||||
window.removeEventListener("wheel", stopEvent, true);
|
window.removeEventListener("wheel", stopEvent, true);
|
||||||
document.body.removeChild(editable);
|
document.body.removeChild(editable);
|
||||||
}
|
}
|
||||||
|
126
src/index.tsx
126
src/index.tsx
@ -46,6 +46,7 @@ import { ExcalidrawElement } from "./element/types";
|
|||||||
import {
|
import {
|
||||||
isWritableElement,
|
isWritableElement,
|
||||||
isInputLike,
|
isInputLike,
|
||||||
|
isToolIcon,
|
||||||
debounce,
|
debounce,
|
||||||
capitalizeString,
|
capitalizeString,
|
||||||
distance,
|
distance,
|
||||||
@ -100,11 +101,7 @@ import { t, languages, setLanguage, getLanguage } from "./i18n";
|
|||||||
import { StoredScenesList } from "./components/StoredScenesList";
|
import { StoredScenesList } from "./components/StoredScenesList";
|
||||||
import { HintViewer } from "./components/HintViewer";
|
import { HintViewer } from "./components/HintViewer";
|
||||||
|
|
||||||
import {
|
import { copyToAppClipboard, getClipboardContent } from "./clipboard";
|
||||||
getAppClipboard,
|
|
||||||
copyToAppClipboard,
|
|
||||||
parseClipboardEvent,
|
|
||||||
} from "./clipboard";
|
|
||||||
|
|
||||||
let { elements } = createScene();
|
let { elements } = createScene();
|
||||||
const { history } = createHistory();
|
const { history } = createHistory();
|
||||||
@ -474,48 +471,6 @@ export class App extends React.Component<any, AppState> {
|
|||||||
copyToAppClipboard(elements);
|
copyToAppClipboard(elements);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
private onPaste = (e: ClipboardEvent) => {
|
|
||||||
// #686
|
|
||||||
const target = document.activeElement;
|
|
||||||
const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
|
|
||||||
if (
|
|
||||||
elementUnderCursor instanceof HTMLCanvasElement &&
|
|
||||||
!isWritableElement(target)
|
|
||||||
) {
|
|
||||||
const data = parseClipboardEvent(e);
|
|
||||||
if (data.elements) {
|
|
||||||
this.addElementsFromPaste(data.elements);
|
|
||||||
} else if (data.text) {
|
|
||||||
const { x, y } = viewportCoordsToSceneCoords(
|
|
||||||
{ clientX: cursorX, clientY: cursorY },
|
|
||||||
this.state,
|
|
||||||
);
|
|
||||||
|
|
||||||
const element = newTextElement(
|
|
||||||
newElement(
|
|
||||||
"text",
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
this.state.currentItemStrokeColor,
|
|
||||||
this.state.currentItemBackgroundColor,
|
|
||||||
this.state.currentItemFillStyle,
|
|
||||||
this.state.currentItemStrokeWidth,
|
|
||||||
this.state.currentItemRoughness,
|
|
||||||
this.state.currentItemOpacity,
|
|
||||||
),
|
|
||||||
data.text,
|
|
||||||
this.state.currentItemFont,
|
|
||||||
);
|
|
||||||
|
|
||||||
element.isSelected = true;
|
|
||||||
|
|
||||||
elements = [...clearSelection(elements), element];
|
|
||||||
history.resumeRecording();
|
|
||||||
this.setState({});
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onUnload = () => {
|
private onUnload = () => {
|
||||||
isHoldingSpace = false;
|
isHoldingSpace = false;
|
||||||
@ -551,7 +506,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
|
|
||||||
public async componentDidMount() {
|
public async componentDidMount() {
|
||||||
document.addEventListener("copy", this.onCopy);
|
document.addEventListener("copy", this.onCopy);
|
||||||
document.addEventListener("paste", this.onPaste);
|
document.addEventListener("paste", this.pasteFromClipboard);
|
||||||
document.addEventListener("cut", this.onCut);
|
document.addEventListener("cut", this.onCut);
|
||||||
|
|
||||||
document.addEventListener("keydown", this.onKeyDown, false);
|
document.addEventListener("keydown", this.onKeyDown, false);
|
||||||
@ -583,7 +538,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
|
|
||||||
public componentWillUnmount() {
|
public componentWillUnmount() {
|
||||||
document.removeEventListener("copy", this.onCopy);
|
document.removeEventListener("copy", this.onCopy);
|
||||||
document.removeEventListener("paste", this.onPaste);
|
document.removeEventListener("paste", this.pasteFromClipboard);
|
||||||
document.removeEventListener("cut", this.onCut);
|
document.removeEventListener("cut", this.onCut);
|
||||||
|
|
||||||
document.removeEventListener("keydown", this.onKeyDown, false);
|
document.removeEventListener("keydown", this.onKeyDown, false);
|
||||||
@ -657,14 +612,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
!event.metaKey &&
|
!event.metaKey &&
|
||||||
this.state.draggingElement === null
|
this.state.draggingElement === null
|
||||||
) {
|
) {
|
||||||
if (!isHoldingSpace) {
|
this.selectShapeTool(shape);
|
||||||
setCursorForShape(shape);
|
|
||||||
}
|
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
|
||||||
document.activeElement.blur();
|
|
||||||
}
|
|
||||||
elements = clearSelection(elements);
|
|
||||||
this.setState({ elementType: shape });
|
|
||||||
// Undo action
|
// Undo action
|
||||||
} else if (event[KEYS.META] && /z/i.test(event.key)) {
|
} else if (event[KEYS.META] && /z/i.test(event.key)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -721,13 +669,65 @@ export class App extends React.Component<any, AppState> {
|
|||||||
copyToAppClipboard(elements);
|
copyToAppClipboard(elements);
|
||||||
};
|
};
|
||||||
|
|
||||||
private pasteFromClipboard = () => {
|
private pasteFromClipboard = async (e: ClipboardEvent | null) => {
|
||||||
const data = getAppClipboard();
|
// #686
|
||||||
if (data.elements) {
|
const target = document.activeElement;
|
||||||
this.addElementsFromPaste(data.elements);
|
const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
|
||||||
|
if (
|
||||||
|
// if no ClipboardEvent supplied, assume we're pasting via contextMenu
|
||||||
|
// thus these checks don't make sense
|
||||||
|
!e ||
|
||||||
|
(elementUnderCursor instanceof HTMLCanvasElement &&
|
||||||
|
!isWritableElement(target))
|
||||||
|
) {
|
||||||
|
const data = await getClipboardContent(e);
|
||||||
|
if (data.elements) {
|
||||||
|
this.addElementsFromPaste(data.elements);
|
||||||
|
} else if (data.text) {
|
||||||
|
const { x, y } = viewportCoordsToSceneCoords(
|
||||||
|
{ clientX: cursorX, clientY: cursorY },
|
||||||
|
this.state,
|
||||||
|
);
|
||||||
|
|
||||||
|
const element = newTextElement(
|
||||||
|
newElement(
|
||||||
|
"text",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
this.state.currentItemStrokeColor,
|
||||||
|
this.state.currentItemBackgroundColor,
|
||||||
|
this.state.currentItemFillStyle,
|
||||||
|
this.state.currentItemStrokeWidth,
|
||||||
|
this.state.currentItemRoughness,
|
||||||
|
this.state.currentItemOpacity,
|
||||||
|
),
|
||||||
|
data.text,
|
||||||
|
this.state.currentItemFont,
|
||||||
|
);
|
||||||
|
|
||||||
|
element.isSelected = true;
|
||||||
|
|
||||||
|
elements = [...clearSelection(elements), element];
|
||||||
|
history.resumeRecording();
|
||||||
|
}
|
||||||
|
this.selectShapeTool("selection");
|
||||||
|
e?.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private selectShapeTool(elementType: AppState["elementType"]) {
|
||||||
|
if (!isHoldingSpace) {
|
||||||
|
setCursorForShape(elementType);
|
||||||
|
}
|
||||||
|
if (isToolIcon(document.activeElement)) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
if (elementType !== "selection") {
|
||||||
|
elements = clearSelection(elements);
|
||||||
|
}
|
||||||
|
this.setState({ elementType });
|
||||||
|
}
|
||||||
|
|
||||||
setAppState = (obj: any) => {
|
setAppState = (obj: any) => {
|
||||||
this.setState(obj);
|
this.setState(obj);
|
||||||
};
|
};
|
||||||
@ -800,7 +800,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
options: [
|
options: [
|
||||||
navigator.clipboard && {
|
navigator.clipboard && {
|
||||||
label: t("labels.paste"),
|
label: t("labels.paste"),
|
||||||
action: () => this.pasteFromClipboard(),
|
action: () => this.pasteFromClipboard(null),
|
||||||
},
|
},
|
||||||
...this.actionManager.getContextMenuItems(action =>
|
...this.actionManager.getContextMenuItems(action =>
|
||||||
this.canvasOnlyActions.includes(action),
|
this.canvasOnlyActions.includes(action),
|
||||||
@ -826,7 +826,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
},
|
},
|
||||||
navigator.clipboard && {
|
navigator.clipboard && {
|
||||||
label: t("labels.paste"),
|
label: t("labels.paste"),
|
||||||
action: () => this.pasteFromClipboard(),
|
action: () => this.pasteFromClipboard(null),
|
||||||
},
|
},
|
||||||
...this.actionManager.getContextMenuItems(
|
...this.actionManager.getContextMenuItems(
|
||||||
action => !this.canvasOnlyActions.includes(action),
|
action => !this.canvasOnlyActions.includes(action),
|
||||||
|
@ -65,7 +65,7 @@ export const SHAPES = [
|
|||||||
),
|
),
|
||||||
value: "text",
|
value: "text",
|
||||||
},
|
},
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const shapesShortcutKeys = SHAPES.map((shape, index) => [
|
export const shapesShortcutKeys = SHAPES.map((shape, index) => [
|
||||||
shape.value[0],
|
shape.value[0],
|
||||||
@ -73,12 +73,9 @@ export const shapesShortcutKeys = SHAPES.map((shape, index) => [
|
|||||||
]).flat(1);
|
]).flat(1);
|
||||||
|
|
||||||
export function findShapeByKey(key: string) {
|
export function findShapeByKey(key: string) {
|
||||||
const defaultElement = "selection";
|
return (
|
||||||
return SHAPES.reduce((element, shape, index) => {
|
SHAPES.find((shape, index) => {
|
||||||
if (shape.value[0] !== key && key !== (index + 1).toString()) {
|
return shape.value[0] === key || key === (index + 1).toString();
|
||||||
return element;
|
})?.value || "selection"
|
||||||
}
|
);
|
||||||
|
|
||||||
return shape.value;
|
|
||||||
}, defaultElement);
|
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ExcalidrawElement } from "./element/types";
|
import { ExcalidrawElement } from "./element/types";
|
||||||
|
import { SHAPES } from "./shapes";
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
draggingElement: ExcalidrawElement | null;
|
draggingElement: ExcalidrawElement | null;
|
||||||
@ -8,7 +9,7 @@ export type AppState = {
|
|||||||
// element being edited, but not necessarily added to elements array yet
|
// element being edited, but not necessarily added to elements array yet
|
||||||
// (e.g. text element when typing into the input)
|
// (e.g. text element when typing into the input)
|
||||||
editingElement: ExcalidrawElement | null;
|
editingElement: ExcalidrawElement | null;
|
||||||
elementType: string;
|
elementType: typeof SHAPES[number]["value"];
|
||||||
elementLocked: boolean;
|
elementLocked: boolean;
|
||||||
exportBackground: boolean;
|
exportBackground: boolean;
|
||||||
currentItemStrokeColor: string;
|
currentItemStrokeColor: string;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user