diff --git a/src/clipboard.ts b/src/clipboard.ts index 3bb50930..f0917557 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -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) { diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index ba6e6eaf..de2fe0c8 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -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); } diff --git a/src/index.tsx b/src/index.tsx index fc67ac33..3b461362 100644 --- a/src/index.tsx +++ b/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 { 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 { 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 { 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 { !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 { 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 { 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 { }, navigator.clipboard && { label: t("labels.paste"), - action: () => this.pasteFromClipboard(), + action: () => this.pasteFromClipboard(null), }, ...this.actionManager.getContextMenuItems( action => !this.canvasOnlyActions.includes(action), diff --git a/src/shapes.tsx b/src/shapes.tsx index 91db7faa..8d3dd427 100644 --- a/src/shapes.tsx +++ b/src/shapes.tsx @@ -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" + ); } diff --git a/src/types.ts b/src/types.ts index e570ec5c..7862b0e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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;