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

@ -4,19 +4,23 @@ import { AppState } from "./types";
import { t } from "./i18n"; import { t } from "./i18n";
import { DEFAULT_VERTICAL_ALIGN } from "./constants"; import { DEFAULT_VERTICAL_ALIGN } from "./constants";
interface Spreadsheet { export interface Spreadsheet {
yAxisLabel: string | null; yAxisLabel: string | null;
labels: string[] | null; labels: string[] | null;
values: number[]; values: number[];
} }
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
export const MALFORMED_SPREADSHEET = "MALFORMED_SPREADSHEET";
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
type ParseSpreadsheetResult = 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; error: string;
}; };
@ -38,12 +42,12 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult {
const numCols = cells[0].length; const numCols = cells[0].length;
if (numCols > 2) { if (numCols > 2) {
return { type: "malformed spreadsheet", error: t("charts.tooManyColumns") }; return { type: MALFORMED_SPREADSHEET, error: t("charts.tooManyColumns") };
} }
if (numCols === 1) { if (numCols === 1) {
if (!isNumericColumn(cells, 0)) { if (!isNumericColumn(cells, 0)) {
return { type: "not a spreadsheet" }; return { type: NOT_SPREADSHEET };
} }
const hasHeader = tryParseNumber(cells[0][0]) === null; const hasHeader = tryParseNumber(cells[0][0]) === null;
@ -52,11 +56,11 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult {
); );
if (values.length < 2) { if (values.length < 2) {
return { type: "not a spreadsheet" }; return { type: NOT_SPREADSHEET };
} }
return { return {
type: "spreadsheet", type: VALID_SPREADSHEET,
spreadsheet: { spreadsheet: {
yAxisLabel: hasHeader ? cells[0][0] : null, yAxisLabel: hasHeader ? cells[0][0] : null,
labels: null, labels: null,
@ -69,7 +73,7 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult {
if (!isNumericColumn(cells, valueColumnIndex)) { if (!isNumericColumn(cells, valueColumnIndex)) {
return { return {
type: "malformed spreadsheet", type: MALFORMED_SPREADSHEET,
error: t("charts.noNumericColumn"), error: t("charts.noNumericColumn"),
}; };
} }
@ -79,11 +83,11 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult {
const rows = hasHeader ? cells.slice(1) : cells; const rows = hasHeader ? cells.slice(1) : cells;
if (rows.length < 2) { if (rows.length < 2) {
return { type: "not a spreadsheet" }; return { type: NOT_SPREADSHEET };
} }
return { return {
type: "spreadsheet", type: VALID_SPREADSHEET,
spreadsheet: { spreadsheet: {
yAxisLabel: hasHeader ? cells[0][valueColumnIndex] : null, yAxisLabel: hasHeader ? cells[0][valueColumnIndex] : null,
labels: rows.map((row) => row[labelColumnIndex]), labels: rows.map((row) => row[labelColumnIndex]),
@ -114,7 +118,7 @@ export function tryParseSpreadsheet(text: string): ParseSpreadsheetResult {
.map((line) => line.trim().split("\t")); .map((line) => line.trim().split("\t"));
if (lines.length === 0) { if (lines.length === 0) {
return { type: "not a spreadsheet" }; return { type: NOT_SPREADSHEET };
} }
const numColsFirstLine = lines[0].length; const numColsFirstLine = lines[0].length;
@ -123,13 +127,13 @@ export function tryParseSpreadsheet(text: string): ParseSpreadsheetResult {
); );
if (!isASpreadsheet) { if (!isASpreadsheet) {
return { type: "not a spreadsheet" }; return { type: NOT_SPREADSHEET };
} }
const result = tryParseCells(lines); const result = tryParseCells(lines);
if (result.type !== "spreadsheet") { if (result.type !== VALID_SPREADSHEET) {
const transposedResults = tryParseCells(transposeCells(lines)); const transposedResults = tryParseCells(transposeCells(lines));
if (transposedResults.type === "spreadsheet") { if (transposedResults.type === VALID_SPREADSHEET) {
return transposedResults; return transposedResults;
} }
} }

View File

@ -5,7 +5,20 @@ import {
import { getSelectedElements } from "./scene"; import { getSelectedElements } from "./scene";
import { AppState } from "./types"; import { AppState } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export"; 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 CLIPBOARD = "";
let PREFER_APP_CLIPBOARD = false; let PREFER_APP_CLIPBOARD = false;
@ -22,86 +35,126 @@ export const probablySupportsClipboardBlob =
"ClipboardItem" in window && "ClipboardItem" in window &&
"toBlob" in HTMLCanvasElement.prototype; "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[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, 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 { 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; PREFER_APP_CLIPBOARD = false;
} catch { await copyTextToSystemClipboard(json);
// if clearing system clipboard didn't work, we should prefer in-app } catch (err) {
// clipboard even if there's text in system clipboard on paste, because
// we can't be sure of the order of copy operations
PREFER_APP_CLIPBOARD = true; PREFER_APP_CLIPBOARD = true;
console.error(err);
} }
}; };
export const getAppClipboard = (): { const getAppClipboard = (): Partial<ElementsClipboard> => {
elements?: readonly ExcalidrawElement[];
} => {
if (!CLIPBOARD) { if (!CLIPBOARD) {
return {}; return {};
} }
try { try {
const clipboardElements = JSON.parse(CLIPBOARD); return JSON.parse(CLIPBOARD);
if (
Array.isArray(clipboardElements) &&
clipboardElements.length > 0 &&
clipboardElements[0].type // need to implement a better check here...
) {
return { elements: clipboardElements };
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
}
return {}; return {};
}
}; };
export const getClipboardContent = async ( const parsePotentialSpreadsheet = (
appState: AppState, text: string,
cursorX: number, ): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
cursorY: number, 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, event: ClipboardEvent | null,
): Promise<{ ): Promise<string> => {
text?: string;
elements?: readonly ExcalidrawElement[];
error?: string;
}> => {
try { try {
const text = event const text = event
? event.clipboardData?.getData("text/plain").trim() ? event.clipboardData?.getData("text/plain").trim()
: probablySupportsClipboardReadText && : probablySupportsClipboardReadText &&
(await navigator.clipboard.readText()); (await navigator.clipboard.readText());
if (text && !PREFER_APP_CLIPBOARD && !text.includes(SVG_EXPORT_TAG)) { return text || "";
const result = tryParseSpreadsheet(text); } catch {
if (result.type === "spreadsheet") { return "";
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); * 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) => 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) => { export const copyTextToSystemClipboard = async (text: string | null) => {
let copied = false; let copied = false;
if (probablySupportsClipboardWriteText) { if (probablySupportsClipboardWriteText) {

View File

@ -100,8 +100,8 @@ import { getDefaultAppState } from "../appState";
import { t, getLanguage } from "../i18n"; import { t, getLanguage } from "../i18n";
import { import {
copyToAppClipboard, copyToClipboard,
getClipboardContent, parseClipboard,
probablySupportsClipboardBlob, probablySupportsClipboardBlob,
probablySupportsClipboardWriteText, probablySupportsClipboardWriteText,
} from "../clipboard"; } from "../clipboard";
@ -174,6 +174,7 @@ import {
shouldEnableBindingForPointerEvent, shouldEnableBindingForPointerEvent,
} from "../element/binding"; } from "../element/binding";
import { MaybeTransformHandleType } from "../element/transformHandles"; import { MaybeTransformHandleType } from "../element/transformHandles";
import { renderSpreadsheet } from "../charts";
/** /**
* @param func handler taking at most single parameter (event). * @param func handler taking at most single parameter (event).
@ -872,7 +873,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}); });
private copyAll = () => { private copyAll = () => {
copyToAppClipboard(this.scene.getElements(), this.state); copyToClipboard(this.scene.getElements(), this.state);
}; };
private copyToClipboardAsPng = () => { private copyToClipboardAsPng = () => {
@ -960,14 +961,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
) { ) {
return; return;
} }
const data = await getClipboardContent( const data = await parseClipboard(event);
this.state, if (data.errorMessage) {
cursorX, this.setState({ errorMessage: data.errorMessage });
cursorY, } else if (data.spreadsheet) {
event, this.addElementsFromPasteOrLibrary(
renderSpreadsheet(this.state, data.spreadsheet, cursorX, cursorY),
); );
if (data.error) {
alert(data.error);
} else if (data.elements) { } else if (data.elements) {
this.addElementsFromPasteOrLibrary(data.elements); this.addElementsFromPasteOrLibrary(data.elements);
} else if (data.text) { } else if (data.text) {

View File

@ -371,6 +371,9 @@ const LayerUI = ({
onUsernameChange={onUsernameChange} onUsernameChange={onUsernameChange}
onRoomCreate={onRoomCreate} onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy} onRoomDestroy={onRoomDestroy}
setErrorMessage={(message: string) =>
setAppState({ errorMessage: message })
}
/> />
</Stack.Row> </Stack.Row>
<BackgroundPickerAndDarkModeToggle <BackgroundPickerAndDarkModeToggle

View File

@ -100,6 +100,9 @@ export const MobileMenu = ({
onUsernameChange={onUsernameChange} onUsernameChange={onUsernameChange}
onRoomCreate={onRoomCreate} onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy} onRoomDestroy={onRoomDestroy}
setErrorMessage={(message: string) =>
setAppState({ errorMessage: message })
}
/> />
<BackgroundPickerAndDarkModeToggle <BackgroundPickerAndDarkModeToggle
actionManager={actionManager} actionManager={actionManager}

View File

@ -16,6 +16,7 @@ const RoomModal = ({
onRoomCreate, onRoomCreate,
onRoomDestroy, onRoomDestroy,
onPressingEnter, onPressingEnter,
setErrorMessage,
}: { }: {
activeRoomLink: string; activeRoomLink: string;
username: string; username: string;
@ -23,11 +24,16 @@ const RoomModal = ({
onRoomCreate: () => void; onRoomCreate: () => void;
onRoomDestroy: () => void; onRoomDestroy: () => void;
onPressingEnter: () => void; onPressingEnter: () => void;
setErrorMessage: (message: string) => void;
}) => { }) => {
const roomLinkInput = useRef<HTMLInputElement>(null); const roomLinkInput = useRef<HTMLInputElement>(null);
const copyRoomLink = () => { const copyRoomLink = async () => {
copyTextToSystemClipboard(activeRoomLink); try {
await copyTextToSystemClipboard(activeRoomLink);
} catch (error) {
setErrorMessage(error.message);
}
if (roomLinkInput.current) { if (roomLinkInput.current) {
roomLinkInput.current.select(); roomLinkInput.current.select();
} }
@ -127,6 +133,7 @@ export const RoomDialog = ({
onUsernameChange, onUsernameChange,
onRoomCreate, onRoomCreate,
onRoomDestroy, onRoomDestroy,
setErrorMessage,
}: { }: {
isCollaborating: AppState["isCollaborating"]; isCollaborating: AppState["isCollaborating"];
collaboratorCount: number; collaboratorCount: number;
@ -134,6 +141,7 @@ export const RoomDialog = ({
onUsernameChange: (username: string) => void; onUsernameChange: (username: string) => void;
onRoomCreate: () => void; onRoomCreate: () => void;
onRoomDestroy: () => void; onRoomDestroy: () => void;
setErrorMessage: (message: string) => void;
}) => { }) => {
const [modalIsShown, setModalIsShown] = useState(false); const [modalIsShown, setModalIsShown] = useState(false);
const [activeRoomLink, setActiveRoomLink] = useState(""); const [activeRoomLink, setActiveRoomLink] = useState("");
@ -182,6 +190,7 @@ export const RoomDialog = ({
onRoomCreate={onRoomCreate} onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy} onRoomDestroy={onRoomDestroy}
onPressingEnter={handleClose} onPressingEnter={handleClose}
setErrorMessage={setErrorMessage}
/> />
</Dialog> </Dialog>
)} )}

View File

@ -12,7 +12,7 @@ import { fileSave } from "browser-nativefs";
import { t } from "../i18n"; import { t } from "../i18n";
import { import {
copyCanvasToClipboardAsPng, copyCanvasToClipboardAsPng,
copyCanvasToClipboardAsSvg, copyTextToSystemClipboard,
} from "../clipboard"; } from "../clipboard";
import { serializeAsJSON } from "./json"; import { serializeAsJSON } from "./json";
@ -317,7 +317,7 @@ export const exportCanvas = async (
}); });
return; return;
} else if (type === "clipboard-svg") { } else if (type === "clipboard-svg") {
copyCanvasToClipboardAsSvg(tempSvg); copyTextToSystemClipboard(tempSvg.outerHTML);
return; return;
} }
} }