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 PREFER_APP_CLIPBOARD = false;
|
||||
|
||||
export const probablySupportsClipboardReadText =
|
||||
"clipboard" in navigator && "readText" in navigator.clipboard;
|
||||
|
||||
export const probablySupportsClipboardWriteText =
|
||||
"clipboard" in navigator && "writeText" in navigator.clipboard;
|
||||
|
||||
@ -47,26 +50,33 @@ export function getAppClipboard(): {
|
||||
) {
|
||||
return { elements: clipboardElements };
|
||||
}
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function parseClipboardEvent(
|
||||
e: ClipboardEvent,
|
||||
): {
|
||||
export async function getClipboardContent(
|
||||
e: ClipboardEvent | null,
|
||||
): Promise<{
|
||||
text?: string;
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
} {
|
||||
}> {
|
||||
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) {
|
||||
return { text };
|
||||
}
|
||||
return getAppClipboard();
|
||||
} catch (e) {}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
return {};
|
||||
return getAppClipboard();
|
||||
}
|
||||
|
||||
export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
|
||||
|
@ -53,8 +53,36 @@ export function textWysiwyg({
|
||||
outline: "1px solid transparent",
|
||||
whiteSpace: "nowrap",
|
||||
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 => {
|
||||
if (ev.key === KEYS.ESCAPE) {
|
||||
ev.preventDefault();
|
||||
@ -92,6 +120,7 @@ export function textWysiwyg({
|
||||
function cleanup() {
|
||||
editable.onblur = null;
|
||||
editable.onkeydown = null;
|
||||
editable.onpaste = null;
|
||||
window.removeEventListener("wheel", stopEvent, true);
|
||||
document.body.removeChild(editable);
|
||||
}
|
||||
|
126
src/index.tsx
126
src/index.tsx
@ -46,6 +46,7 @@ import { ExcalidrawElement } from "./element/types";
|
||||
import {
|
||||
isWritableElement,
|
||||
isInputLike,
|
||||
isToolIcon,
|
||||
debounce,
|
||||
capitalizeString,
|
||||
distance,
|
||||
@ -100,11 +101,7 @@ import { t, languages, setLanguage, getLanguage } from "./i18n";
|
||||
import { StoredScenesList } from "./components/StoredScenesList";
|
||||
import { HintViewer } from "./components/HintViewer";
|
||||
|
||||
import {
|
||||
getAppClipboard,
|
||||
copyToAppClipboard,
|
||||
parseClipboardEvent,
|
||||
} from "./clipboard";
|
||||
import { copyToAppClipboard, getClipboardContent } from "./clipboard";
|
||||
|
||||
let { elements } = createScene();
|
||||
const { history } = createHistory();
|
||||
@ -474,48 +471,6 @@ export class App extends React.Component<any, AppState> {
|
||||
copyToAppClipboard(elements);
|
||||
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 = () => {
|
||||
isHoldingSpace = false;
|
||||
@ -551,7 +506,7 @@ export class App extends React.Component<any, AppState> {
|
||||
|
||||
public async componentDidMount() {
|
||||
document.addEventListener("copy", this.onCopy);
|
||||
document.addEventListener("paste", this.onPaste);
|
||||
document.addEventListener("paste", this.pasteFromClipboard);
|
||||
document.addEventListener("cut", this.onCut);
|
||||
|
||||
document.addEventListener("keydown", this.onKeyDown, false);
|
||||
@ -583,7 +538,7 @@ export class App extends React.Component<any, AppState> {
|
||||
|
||||
public componentWillUnmount() {
|
||||
document.removeEventListener("copy", this.onCopy);
|
||||
document.removeEventListener("paste", this.onPaste);
|
||||
document.removeEventListener("paste", this.pasteFromClipboard);
|
||||
document.removeEventListener("cut", this.onCut);
|
||||
|
||||
document.removeEventListener("keydown", this.onKeyDown, false);
|
||||
@ -657,14 +612,7 @@ export class App extends React.Component<any, AppState> {
|
||||
!event.metaKey &&
|
||||
this.state.draggingElement === null
|
||||
) {
|
||||
if (!isHoldingSpace) {
|
||||
setCursorForShape(shape);
|
||||
}
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
elements = clearSelection(elements);
|
||||
this.setState({ elementType: shape });
|
||||
this.selectShapeTool(shape);
|
||||
// Undo action
|
||||
} else if (event[KEYS.META] && /z/i.test(event.key)) {
|
||||
event.preventDefault();
|
||||
@ -721,13 +669,65 @@ export class App extends React.Component<any, AppState> {
|
||||
copyToAppClipboard(elements);
|
||||
};
|
||||
|
||||
private pasteFromClipboard = () => {
|
||||
const data = getAppClipboard();
|
||||
if (data.elements) {
|
||||
this.addElementsFromPaste(data.elements);
|
||||
private pasteFromClipboard = async (e: ClipboardEvent | null) => {
|
||||
// #686
|
||||
const target = document.activeElement;
|
||||
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) => {
|
||||
this.setState(obj);
|
||||
};
|
||||
@ -800,7 +800,7 @@ export class App extends React.Component<any, AppState> {
|
||||
options: [
|
||||
navigator.clipboard && {
|
||||
label: t("labels.paste"),
|
||||
action: () => this.pasteFromClipboard(),
|
||||
action: () => this.pasteFromClipboard(null),
|
||||
},
|
||||
...this.actionManager.getContextMenuItems(action =>
|
||||
this.canvasOnlyActions.includes(action),
|
||||
@ -826,7 +826,7 @@ export class App extends React.Component<any, AppState> {
|
||||
},
|
||||
navigator.clipboard && {
|
||||
label: t("labels.paste"),
|
||||
action: () => this.pasteFromClipboard(),
|
||||
action: () => this.pasteFromClipboard(null),
|
||||
},
|
||||
...this.actionManager.getContextMenuItems(
|
||||
action => !this.canvasOnlyActions.includes(action),
|
||||
|
@ -65,7 +65,7 @@ export const SHAPES = [
|
||||
),
|
||||
value: "text",
|
||||
},
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const shapesShortcutKeys = SHAPES.map((shape, index) => [
|
||||
shape.value[0],
|
||||
@ -73,12 +73,9 @@ export const shapesShortcutKeys = SHAPES.map((shape, index) => [
|
||||
]).flat(1);
|
||||
|
||||
export function findShapeByKey(key: string) {
|
||||
const defaultElement = "selection";
|
||||
return SHAPES.reduce((element, shape, index) => {
|
||||
if (shape.value[0] !== key && key !== (index + 1).toString()) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return shape.value;
|
||||
}, defaultElement);
|
||||
return (
|
||||
SHAPES.find((shape, index) => {
|
||||
return shape.value[0] === key || key === (index + 1).toString();
|
||||
})?.value || "selection"
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ExcalidrawElement } from "./element/types";
|
||||
import { SHAPES } from "./shapes";
|
||||
|
||||
export type AppState = {
|
||||
draggingElement: ExcalidrawElement | null;
|
||||
@ -8,7 +9,7 @@ export type AppState = {
|
||||
// element being edited, but not necessarily added to elements array yet
|
||||
// (e.g. text element when typing into the input)
|
||||
editingElement: ExcalidrawElement | null;
|
||||
elementType: string;
|
||||
elementType: typeof SHAPES[number]["value"];
|
||||
elementLocked: boolean;
|
||||
exportBackground: boolean;
|
||||
currentItemStrokeColor: string;
|
||||
|
Loading…
x
Reference in New Issue
Block a user