|
@@ -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 {};
|
|
|
+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<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 "";
|
|
|
}
|
|
|
+};
|
|
|
|
|
|
- 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) {
|