mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
System clipboard (#2117)
This commit is contained in:
parent
950ec66907
commit
47dba05c91
7 changed files with 155 additions and 91 deletions
167
src/clipboard.ts
167
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<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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue