System clipboard (#2117)

This commit is contained in:
David Luzar 2020-09-04 14:58:32 +02:00 committed by GitHub
parent 950ec66907
commit 47dba05c91
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 155 additions and 91 deletions

View file

@ -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<ElementsClipboard> => {
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 {};
};
export const getClipboardContent = async (
appState: AppState,
cursorX: number,
cursorY: number,
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;
};
/**
* 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<string> => {
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 "";
}
};
/**
* 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();
}
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) {