From 47dba05c91172a1858561b25437961b5764d554e Mon Sep 17 00:00:00 2001 From: David Luzar Date: Fri, 4 Sep 2020 14:58:32 +0200 Subject: [PATCH] System clipboard (#2117) --- src/charts.ts | 34 +++---- src/clipboard.ts | 165 +++++++++++++++++++++------------- src/components/App.tsx | 22 ++--- src/components/LayerUI.tsx | 3 + src/components/MobileMenu.tsx | 3 + src/components/RoomDialog.tsx | 13 ++- src/data/index.ts | 4 +- 7 files changed, 154 insertions(+), 90 deletions(-) diff --git a/src/charts.ts b/src/charts.ts index 89a4fed37..a00f064c5 100644 --- a/src/charts.ts +++ b/src/charts.ts @@ -4,19 +4,23 @@ import { AppState } from "./types"; import { t } from "./i18n"; import { DEFAULT_VERTICAL_ALIGN } from "./constants"; -interface Spreadsheet { +export interface Spreadsheet { yAxisLabel: string | null; labels: string[] | null; values: number[]; } +export const NOT_SPREADSHEET = "NOT_SPREADSHEET"; +export const MALFORMED_SPREADSHEET = "MALFORMED_SPREADSHEET"; +export const VALID_SPREADSHEET = "VALID_SPREADSHEET"; + type ParseSpreadsheetResult = | { - type: "not a spreadsheet"; + type: typeof NOT_SPREADSHEET; } - | { type: "spreadsheet"; spreadsheet: Spreadsheet } + | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } | { - type: "malformed spreadsheet"; + type: typeof MALFORMED_SPREADSHEET; error: string; }; @@ -38,12 +42,12 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult { const numCols = cells[0].length; if (numCols > 2) { - return { type: "malformed spreadsheet", error: t("charts.tooManyColumns") }; + return { type: MALFORMED_SPREADSHEET, error: t("charts.tooManyColumns") }; } if (numCols === 1) { if (!isNumericColumn(cells, 0)) { - return { type: "not a spreadsheet" }; + return { type: NOT_SPREADSHEET }; } const hasHeader = tryParseNumber(cells[0][0]) === null; @@ -52,11 +56,11 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult { ); if (values.length < 2) { - return { type: "not a spreadsheet" }; + return { type: NOT_SPREADSHEET }; } return { - type: "spreadsheet", + type: VALID_SPREADSHEET, spreadsheet: { yAxisLabel: hasHeader ? cells[0][0] : null, labels: null, @@ -69,7 +73,7 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult { if (!isNumericColumn(cells, valueColumnIndex)) { return { - type: "malformed spreadsheet", + type: MALFORMED_SPREADSHEET, error: t("charts.noNumericColumn"), }; } @@ -79,11 +83,11 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult { const rows = hasHeader ? cells.slice(1) : cells; if (rows.length < 2) { - return { type: "not a spreadsheet" }; + return { type: NOT_SPREADSHEET }; } return { - type: "spreadsheet", + type: VALID_SPREADSHEET, spreadsheet: { yAxisLabel: hasHeader ? cells[0][valueColumnIndex] : null, labels: rows.map((row) => row[labelColumnIndex]), @@ -114,7 +118,7 @@ export function tryParseSpreadsheet(text: string): ParseSpreadsheetResult { .map((line) => line.trim().split("\t")); if (lines.length === 0) { - return { type: "not a spreadsheet" }; + return { type: NOT_SPREADSHEET }; } const numColsFirstLine = lines[0].length; @@ -123,13 +127,13 @@ export function tryParseSpreadsheet(text: string): ParseSpreadsheetResult { ); if (!isASpreadsheet) { - return { type: "not a spreadsheet" }; + return { type: NOT_SPREADSHEET }; } const result = tryParseCells(lines); - if (result.type !== "spreadsheet") { + if (result.type !== VALID_SPREADSHEET) { const transposedResults = tryParseCells(transposeCells(lines)); - if (transposedResults.type === "spreadsheet") { + if (transposedResults.type === VALID_SPREADSHEET) { return transposedResults; } } diff --git a/src/clipboard.ts b/src/clipboard.ts index 3ce3b2c4e..87522ecf8 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -5,7 +5,20 @@ import { import { getSelectedElements } from "./scene"; import { AppState } from "./types"; import { SVG_EXPORT_TAG } from "./scene/export"; -import { tryParseSpreadsheet, renderSpreadsheet } from "./charts"; +import { + tryParseSpreadsheet, + Spreadsheet, + VALID_SPREADSHEET, + MALFORMED_SPREADSHEET, +} from "./charts"; + +const TYPE_ELEMENTS = "excalidraw/elements"; + +type ElementsClipboard = { + type: typeof TYPE_ELEMENTS; + created: number; + elements: ExcalidrawElement[]; +}; let CLIPBOARD = ""; let PREFER_APP_CLIPBOARD = false; @@ -22,86 +35,126 @@ export const probablySupportsClipboardBlob = "ClipboardItem" in window && "toBlob" in HTMLCanvasElement.prototype; -export const copyToAppClipboard = async ( +const isElementsClipboard = (contents: any): contents is ElementsClipboard => { + if (contents?.type === TYPE_ELEMENTS) { + return true; + } + return false; +}; + +export const copyToClipboard = async ( elements: readonly NonDeletedExcalidrawElement[], appState: AppState, ) => { - CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState)); + const contents: ElementsClipboard = { + type: TYPE_ELEMENTS, + created: Date.now(), + elements: getSelectedElements(elements, appState), + }; + const json = JSON.stringify(contents); + CLIPBOARD = json; try { - // when copying to in-app clipboard, clear system clipboard so that if - // system clip contains text on paste we know it was copied *after* user - // copied elements, and thus we should prefer the text content. - await copyTextToSystemClipboard(null); PREFER_APP_CLIPBOARD = false; - } catch { - // if clearing system clipboard didn't work, we should prefer in-app - // clipboard even if there's text in system clipboard on paste, because - // we can't be sure of the order of copy operations + await copyTextToSystemClipboard(json); + } catch (err) { PREFER_APP_CLIPBOARD = true; + console.error(err); } }; -export const getAppClipboard = (): { - elements?: readonly ExcalidrawElement[]; -} => { +const getAppClipboard = (): Partial => { if (!CLIPBOARD) { return {}; } try { - const clipboardElements = JSON.parse(CLIPBOARD); - - if ( - Array.isArray(clipboardElements) && - clipboardElements.length > 0 && - clipboardElements[0].type // need to implement a better check here... - ) { - return { elements: clipboardElements }; - } + return JSON.parse(CLIPBOARD); } catch (error) { console.error(error); + return {}; } +}; - return {}; +const parsePotentialSpreadsheet = ( + text: string, +): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => { + const result = tryParseSpreadsheet(text); + if (result.type === VALID_SPREADSHEET) { + return { spreadsheet: result.spreadsheet }; + } else if (result.type === MALFORMED_SPREADSHEET) { + return { errorMessage: result.error }; + } + return null; }; -export const getClipboardContent = async ( - appState: AppState, - cursorX: number, - cursorY: number, +/** + * Retrieves content from system clipboard (either from ClipboardEvent or + * via async clipboard API if supported) + */ +const getSystemClipboard = async ( event: ClipboardEvent | null, -): Promise<{ - text?: string; - elements?: readonly ExcalidrawElement[]; - error?: string; -}> => { +): Promise => { try { const text = event ? event.clipboardData?.getData("text/plain").trim() : probablySupportsClipboardReadText && (await navigator.clipboard.readText()); - if (text && !PREFER_APP_CLIPBOARD && !text.includes(SVG_EXPORT_TAG)) { - const result = tryParseSpreadsheet(text); - if (result.type === "spreadsheet") { - return { - elements: renderSpreadsheet( - appState, - result.spreadsheet, - cursorX, - cursorY, - ), - }; - } else if (result.type === "malformed spreadsheet") { - return { error: result.error }; - } - return { text }; - } - } catch (error) { - console.error(error); + return text || ""; + } catch { + return ""; } +}; - return getAppClipboard(); +/** + * Attemps to parse clipboard. Prefers system clipboard. + */ +export const parseClipboard = async ( + event: ClipboardEvent | null, +): Promise<{ + spreadsheet?: Spreadsheet; + elements?: readonly ExcalidrawElement[]; + text?: string; + errorMessage?: string; +}> => { + const systemClipboard = await getSystemClipboard(event); + + // if system clipboard empty, couldn't be resolved, or contains previously + // copied excalidraw scene as SVG, fall back to previously copied excalidraw + // elements + if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) { + return getAppClipboard(); + } + + // if system clipboard contains spreadsheet, use it even though it's + // technically possible it's staler than in-app clipboard + const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard); + if (spreadsheetResult) { + return spreadsheetResult; + } + + const appClipboardData = getAppClipboard(); + + try { + const systemClipboardData = JSON.parse(systemClipboard); + // system clipboard elements are newer than in-app clipboard + if ( + isElementsClipboard(systemClipboardData) && + (!appClipboardData?.created || + appClipboardData.created < systemClipboardData.created) + ) { + return { elements: systemClipboardData.elements }; + } + // in-app clipboard is newer than system clipboard + return appClipboardData; + } catch { + // system clipboard doesn't contain excalidraw elements → return plaintext + // unless we set a flag to prefer in-app clipboard because browser didn't + // support storing to system clipboard on copy + return PREFER_APP_CLIPBOARD && appClipboardData.elements + ? appClipboardData + : { text: systemClipboard }; + } }; export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => @@ -122,14 +175,6 @@ export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => } }); -export const copyCanvasToClipboardAsSvg = async (svgroot: SVGSVGElement) => { - try { - await navigator.clipboard.writeText(svgroot.outerHTML); - } catch (error) { - console.error(error); - } -}; - export const copyTextToSystemClipboard = async (text: string | null) => { let copied = false; if (probablySupportsClipboardWriteText) { diff --git a/src/components/App.tsx b/src/components/App.tsx index dcc89c7ea..138cff266 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -100,8 +100,8 @@ import { getDefaultAppState } from "../appState"; import { t, getLanguage } from "../i18n"; import { - copyToAppClipboard, - getClipboardContent, + copyToClipboard, + parseClipboard, probablySupportsClipboardBlob, probablySupportsClipboardWriteText, } from "../clipboard"; @@ -174,6 +174,7 @@ import { shouldEnableBindingForPointerEvent, } from "../element/binding"; import { MaybeTransformHandleType } from "../element/transformHandles"; +import { renderSpreadsheet } from "../charts"; /** * @param func handler taking at most single parameter (event). @@ -872,7 +873,7 @@ class App extends React.Component { }); private copyAll = () => { - copyToAppClipboard(this.scene.getElements(), this.state); + copyToClipboard(this.scene.getElements(), this.state); }; private copyToClipboardAsPng = () => { @@ -960,14 +961,13 @@ class App extends React.Component { ) { return; } - const data = await getClipboardContent( - this.state, - cursorX, - cursorY, - event, - ); - if (data.error) { - alert(data.error); + const data = await parseClipboard(event); + if (data.errorMessage) { + this.setState({ errorMessage: data.errorMessage }); + } else if (data.spreadsheet) { + this.addElementsFromPasteOrLibrary( + renderSpreadsheet(this.state, data.spreadsheet, cursorX, cursorY), + ); } else if (data.elements) { this.addElementsFromPasteOrLibrary(data.elements); } else if (data.text) { diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index f03c96919..44b0d2a95 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -371,6 +371,9 @@ const LayerUI = ({ onUsernameChange={onUsernameChange} onRoomCreate={onRoomCreate} onRoomDestroy={onRoomDestroy} + setErrorMessage={(message: string) => + setAppState({ errorMessage: message }) + } /> + setAppState({ errorMessage: message }) + } /> void; onRoomDestroy: () => void; onPressingEnter: () => void; + setErrorMessage: (message: string) => void; }) => { const roomLinkInput = useRef(null); - const copyRoomLink = () => { - copyTextToSystemClipboard(activeRoomLink); + const copyRoomLink = async () => { + try { + await copyTextToSystemClipboard(activeRoomLink); + } catch (error) { + setErrorMessage(error.message); + } if (roomLinkInput.current) { roomLinkInput.current.select(); } @@ -127,6 +133,7 @@ export const RoomDialog = ({ onUsernameChange, onRoomCreate, onRoomDestroy, + setErrorMessage, }: { isCollaborating: AppState["isCollaborating"]; collaboratorCount: number; @@ -134,6 +141,7 @@ export const RoomDialog = ({ onUsernameChange: (username: string) => void; onRoomCreate: () => void; onRoomDestroy: () => void; + setErrorMessage: (message: string) => void; }) => { const [modalIsShown, setModalIsShown] = useState(false); const [activeRoomLink, setActiveRoomLink] = useState(""); @@ -182,6 +190,7 @@ export const RoomDialog = ({ onRoomCreate={onRoomCreate} onRoomDestroy={onRoomDestroy} onPressingEnter={handleClose} + setErrorMessage={setErrorMessage} /> )} diff --git a/src/data/index.ts b/src/data/index.ts index fd6e02d57..eebc3dabd 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -12,7 +12,7 @@ import { fileSave } from "browser-nativefs"; import { t } from "../i18n"; import { copyCanvasToClipboardAsPng, - copyCanvasToClipboardAsSvg, + copyTextToSystemClipboard, } from "../clipboard"; import { serializeAsJSON } from "./json"; @@ -317,7 +317,7 @@ export const exportCanvas = async ( }); return; } else if (type === "clipboard-svg") { - copyCanvasToClipboardAsSvg(tempSvg); + copyTextToSystemClipboard(tempSvg.outerHTML); return; } }