Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FIX] clipboard: Fix clipboard cross-browser coverage #5063

Open
wants to merge 1 commit into
base: 18.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 4 additions & 10 deletions src/actions/menu_items_actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CellPopoverStore } from "../components/popover";
import { DEFAULT_FIGURE_HEIGHT, DEFAULT_FIGURE_WIDTH } from "../constants";
import { parseOSClipboardContent } from "../helpers/clipboard/clipboard_helpers";
import {
getChartPositionAtCenterOfViewport,
getSmartChartDefinition,
Expand Down Expand Up @@ -54,20 +55,13 @@ async function paste(env: SpreadsheetChildEnv, pasteOption?: ClipboardPasteOptio
const osClipboard = await env.clipboard.read();
switch (osClipboard.status) {
case "ok":
const htmlDocument = new DOMParser().parseFromString(
osClipboard.content[ClipboardMIMEType.Html] ?? "<div></div>",
"text/html"
);
const osClipboardSpreadsheetContent =
osClipboard.content[ClipboardMIMEType.OSpreadsheet] || "{}";
const clipboardId =
JSON.parse(osClipboardSpreadsheetContent).clipboardId ??
htmlDocument.querySelector("div")?.getAttribute("data-clipboard-id");
const clipboardContent = parseOSClipboardContent(osClipboard.content);
const clipboardId = clipboardContent.data?.clipboardId;

const target = env.model.getters.getSelectedZones();

if (env.model.getters.getClipboardId() !== clipboardId) {
interactivePasteFromOS(env, target, osClipboard.content, pasteOption);
interactivePasteFromOS(env, target, clipboardContent, pasteOption);
} else {
interactivePaste(env, target, pasteOption);
}
Expand Down
29 changes: 10 additions & 19 deletions src/components/grid/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
HEADER_WIDTH,
SCROLLBAR_WIDTH,
} from "../../constants";
import { parseOSClipboardContent } from "../../helpers/clipboard/clipboard_helpers";
import { isInside } from "../../helpers/index";
import { openLink } from "../../helpers/links";
import { isStaticTable } from "../../helpers/table_helpers";
Expand Down Expand Up @@ -626,32 +627,22 @@ export class Grid extends Component<Props, SpreadsheetChildEnv> {
if (!clipboardData) {
return;
}
const clipboardDataTextContent = clipboardData?.getData(ClipboardMIMEType.PlainText);
const clipboardDataHtmlContent = clipboardData?.getData(ClipboardMIMEType.Html);
const htmlDocument = new DOMParser().parseFromString(
clipboardDataHtmlContent ?? "<div></div>",
"text/html"
);
const osClipboardSpreadsheetContent =
clipboardData.getData(ClipboardMIMEType.OSpreadsheet) || "{}";

const osClipboard = {
content: {
[ClipboardMIMEType.PlainText]: clipboardData?.getData(ClipboardMIMEType.PlainText),
[ClipboardMIMEType.Html]: clipboardData?.getData(ClipboardMIMEType.Html),
},
};

const target = this.env.model.getters.getSelectedZones();
const isCutOperation = this.env.model.getters.isCutOperation();

const clipboardId =
JSON.parse(osClipboardSpreadsheetContent).clipboardId ??
htmlDocument.querySelector("div")?.getAttribute("data-clipboard-id");

const clipboardContent = parseOSClipboardContent(osClipboard.content);
const clipboardId = clipboardContent.data?.clipboardId;
if (this.env.model.getters.getClipboardId() === clipboardId) {
interactivePaste(this.env, target);
} else {
const clipboardContent = {
[ClipboardMIMEType.PlainText]: clipboardDataTextContent,
[ClipboardMIMEType.Html]: clipboardDataHtmlContent,
};
if (osClipboardSpreadsheetContent !== "{}") {
clipboardContent[ClipboardMIMEType.OSpreadsheet] = osClipboardSpreadsheetContent;
}
interactivePasteFromOS(this.env, target, clipboardContent);
}
if (isCutOperation) {
Expand Down
24 changes: 23 additions & 1 deletion src/helpers/clipboard/clipboard_helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { ClipboardCellData, UID, Zone } from "../../types";
import {
ClipboardCellData,
ClipboardMIMEType,
ImportClipboardContent,
OSClipboardContent,
UID,
Zone,
} from "../../types";
import { mergeOverlappingZones, positions } from "../zones";

export function getClipboardDataPositions(sheetId: UID, zones: Zone[]): ClipboardCellData {
Expand Down Expand Up @@ -54,3 +61,18 @@ export function getPasteZones<T>(target: Zone[], content: T[][]): Zone[] {
height = content.length;
return target.map((t) => splitZoneForPaste(t, width, height)).flat();
}

export function parseOSClipboardContent(content: OSClipboardContent): ImportClipboardContent {
const htmlDocument = new DOMParser().parseFromString(
content[ClipboardMIMEType.Html] ?? "<div></div>",
"text/html"
);
const oSheetClipboardData = htmlDocument
.querySelector("div")
?.getAttribute("data-osheet-clipboard");
Comment on lines +70 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious: are there any pro/cons of parsing the HTML rather than using a regex ? And of putting the data in an attribute rather than a HTML comment ?

const spreadsheetContent = oSheetClipboardData && JSON.parse(oSheetClipboardData);
return {
text: content[ClipboardMIMEType.PlainText],
data: spreadsheetContent,
};
}
21 changes: 7 additions & 14 deletions src/helpers/clipboard/navigator_clipboard_wrapper.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { ClipboardContent, ClipboardMIMEType } from "./../../types/clipboard";
import { ClipboardMIMEType, OSClipboardContent } from "./../../types/clipboard";

export type ClipboardReadResult =
| { status: "ok"; content: ClipboardContent }
| { status: "ok"; content: OSClipboardContent }
| { status: "permissionDenied" | "notImplemented" };

export interface ClipboardInterface {
write(clipboardContent: ClipboardContent): Promise<void>;
write(clipboardContent: OSClipboardContent): Promise<void>;
writeText(text: string): Promise<void>;
read(): Promise<ClipboardReadResult>;
}
Expand All @@ -18,7 +18,7 @@ class WebClipboardWrapper implements ClipboardInterface {
// Can be undefined because navigator.clipboard doesn't exist in old browsers
constructor(private clipboard: Clipboard | undefined) {}

async write(clipboardContent: ClipboardContent): Promise<void> {
async write(clipboardContent: OSClipboardContent): Promise<void> {
if (this.clipboard?.write) {
try {
await this.clipboard?.write(this.getClipboardItems(clipboardContent));
Expand Down Expand Up @@ -60,7 +60,7 @@ class WebClipboardWrapper implements ClipboardInterface {
if (this.clipboard?.read) {
try {
const clipboardItems = await this.clipboard.read();
const clipboardContent: ClipboardContent = {};
const clipboardContent: OSClipboardContent = {};
for (const item of clipboardItems) {
for (const type of item.types) {
const blob = await item.getType(type);
Expand All @@ -83,22 +83,15 @@ class WebClipboardWrapper implements ClipboardInterface {
}
}

private getClipboardItems(content: ClipboardContent): ClipboardItems {
private getClipboardItems(content: OSClipboardContent): ClipboardItems {
const clipboardItemData = {
[ClipboardMIMEType.PlainText]: this.getBlob(content, ClipboardMIMEType.PlainText),
[ClipboardMIMEType.Html]: this.getBlob(content, ClipboardMIMEType.Html),
};
const spreadsheetData = content[ClipboardMIMEType.OSpreadsheet];
if (spreadsheetData) {
clipboardItemData[ClipboardMIMEType.OSpreadsheet] = this.getBlob(
content,
ClipboardMIMEType.OSpreadsheet
);
}
return [new ClipboardItem(clipboardItemData)];
}

private getBlob(clipboardContent: ClipboardContent, type: ClipboardMIMEType): Blob {
private getBlob(clipboardContent: OSClipboardContent, type: ClipboardMIMEType): Blob {
return new Blob([clipboardContent[type] || ""], {
type,
});
Expand Down
14 changes: 6 additions & 8 deletions src/helpers/ui/paste_interactive.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { CURRENT_VERSION } from "../../migrations/data";
import { _t } from "../../translation";
import {
ClipboardContent,
ClipboardMIMEType,
ClipboardPasteOptions,
CommandResult,
DispatchResult,
ImportClipboardContent,
SpreadsheetChildEnv,
Zone,
} from "../../types";
Expand Down Expand Up @@ -45,7 +44,7 @@ export function interactivePaste(
export function interactivePasteFromOS(
env: SpreadsheetChildEnv,
target: Zone[],
clipboardContent: ClipboardContent,
clipboardContent: ImportClipboardContent,
pasteOption?: ClipboardPasteOptions
) {
let result: DispatchResult;
Expand All @@ -59,10 +58,9 @@ export function interactivePasteFromOS(
pasteOption,
});
} catch (error) {
const parsedSpreadsheetContent = clipboardContent[ClipboardMIMEType.OSpreadsheet]
? JSON.parse(clipboardContent[ClipboardMIMEType.OSpreadsheet])
: {};
if (parsedSpreadsheetContent.version && parsedSpreadsheetContent.version !== CURRENT_VERSION) {
const parsedSpreadsheetContent = clipboardContent.data;

if (parsedSpreadsheetContent?.version !== CURRENT_VERSION) {
env.raiseError(
_t(
"An unexpected error occurred while pasting content.\
Expand All @@ -73,7 +71,7 @@ export function interactivePasteFromOS(
result = env.model.dispatch("PASTE_FROM_OS_CLIPBOARD", {
target,
clipboardContent: {
[ClipboardMIMEType.PlainText]: clipboardContent[ClipboardMIMEType.PlainText],
text: clipboardContent.text,
},
pasteOption,
});
Expand Down
83 changes: 42 additions & 41 deletions src/plugins/ui_stateful/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { getClipboardDataPositions } from "../../helpers/clipboard/clipboard_hel
import { UuidGenerator, isZoneValid, union } from "../../helpers/index";
import { CURRENT_VERSION } from "../../migrations/data";
import {
ClipboardContent,
ClipboardData,
ClipboardMIMEType,
ClipboardOptions,
ClipboardPasteTarget,
OSClipboardContent,
} from "../../types/clipboard";
import {
ClipboardCell,
Expand Down Expand Up @@ -39,6 +39,11 @@ type MinimalClipboardData = {
figureId?: UID;
[key: string]: unknown;
};

export interface HtmlClipboardData extends MinimalClipboardData {
version?: number;
clipboardId?: string;
}
/**
* Clipboard Plugin
*
Expand Down Expand Up @@ -70,9 +75,7 @@ export class ClipboardPlugin extends UIPlugin {
const zones = this.getters.getSelectedZones();
return this.isCutAllowedOn(zones);
case "PASTE_FROM_OS_CLIPBOARD": {
const copiedData = this.convertOSClipboardData(
cmd.clipboardContent[ClipboardMIMEType.PlainText] ?? ""
);
const copiedData = this.convertOSClipboardData(cmd.clipboardContent.text ?? "");
const pasteOption = cmd.pasteOption;
return this.isPasteAllowed(cmd.target, copiedData, { pasteOption, isCutOperation: false });
}
Expand Down Expand Up @@ -126,12 +129,12 @@ export class ClipboardPlugin extends UIPlugin {
break;
case "PASTE_FROM_OS_CLIPBOARD": {
this._isCutOperation = false;
if (cmd.clipboardContent[ClipboardMIMEType.OSpreadsheet]) {
this.copiedData = JSON.parse(cmd.clipboardContent[ClipboardMIMEType.OSpreadsheet]);

const htmlData = cmd.clipboardContent.data;
if (htmlData) {
this.copiedData = htmlData;
} else {
this.copiedData = this.convertOSClipboardData(
cmd.clipboardContent[ClipboardMIMEType.PlainText] ?? ""
);
this.copiedData = this.convertOSClipboardData(cmd.clipboardContent.text ?? "");
}
const pasteOption = cmd.pasteOption;
this.paste(cmd.target, this.copiedData, {
Expand Down Expand Up @@ -476,26 +479,25 @@ export class ClipboardPlugin extends UIPlugin {
return this.clipboardId;
}

getClipboardContent(): ClipboardContent {
getClipboardContent(): OSClipboardContent {
return {
[ClipboardMIMEType.PlainText]: this.getPlainTextContent(),
[ClipboardMIMEType.Html]: this.getHTMLContent(),
[ClipboardMIMEType.OSpreadsheet]: this.getSerializedGridData(),
};
}

private getSerializedGridData(): string {
private getgridData(): HtmlClipboardData {
const data = {
version: CURRENT_VERSION,
clipboardId: this.clipboardId,
};
if (this.copiedData && "figureId" in this.copiedData) {
return JSON.stringify(data);
return data;
}
return JSON.stringify({
return {
...data,
...this.copiedData,
});
};
}

private getPlainTextContent(): string {
Expand All @@ -518,36 +520,35 @@ export class ClipboardPlugin extends UIPlugin {
}

private getHTMLContent(): string {
if (!this.copiedData?.cells) {
return `<div data-clipboard-id="${this.clipboardId}">\t</div>`;
}
const cells = this.copiedData.cells;
if (cells.length === 1 && cells[0].length === 1) {
return `<div data-clipboard-id="${this.clipboardId}">${this.getters.getCellText(
cells[0][0].position
)}</div>`;
}
if (!cells[0][0]) {
let innerHTML: string = "";
const cells = this.copiedData?.cells;
if (!cells) {
innerHTML = "\t";
} else if (cells.length === 1 && cells[0].length === 1) {
innerHTML = `${this.getters.getCellText(cells[0][0].position)}`;
} else if (!cells[0][0]) {
return "";
}

let htmlTable = `<div data-clipboard-id="${this.clipboardId}"><table border="1" style="border-collapse:collapse">`;
for (const row of cells) {
htmlTable += "<tr>";
for (const cell of row) {
if (!cell) {
continue;
} else {
let htmlTable = `<table border="1" style="border-collapse:collapse">`;
for (const row of cells) {
htmlTable += "<tr>";
for (const cell of row) {
if (!cell) {
continue;
}
const cssStyle = cssPropertiesToCss(
cellStyleToCss(this.getters.getCellComputedStyle(cell.position))
);
const cellText = this.getters.getCellText(cell.position);
htmlTable += `<td style="${cssStyle}">` + xmlEscape(cellText) + "</td>";
}
const cssStyle = cssPropertiesToCss(
cellStyleToCss(this.getters.getCellComputedStyle(cell.position))
);
const cellText = this.getters.getCellText(cell.position);
htmlTable += `<td style="${cssStyle}">` + xmlEscape(cellText) + "</td>";
htmlTable += "</tr>";
}
htmlTable += "</tr>";
htmlTable += "</table>";
innerHTML = htmlTable;
}
htmlTable += "</table></div>";
return htmlTable;
const serializedData = JSON.stringify(this.getgridData());
return `<div data-osheet-clipboard='${xmlEscape(serializedData)}'>${innerHTML}</div>`;
}

isCutOperation(): boolean {
Expand Down
9 changes: 7 additions & 2 deletions src/types/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { HtmlClipboardData } from "../plugins/ui_stateful";
import { HeaderIndex, UID, Zone } from "./misc";

export enum ClipboardMIMEType {
PlainText = "text/plain",
Html = "text/html",
OSpreadsheet = "web application/o-spreadsheet",
}

export type ClipboardContent = { [type in ClipboardMIMEType]?: string };
export type OSClipboardContent = { [type in ClipboardMIMEType]?: string };

export type ImportClipboardContent = {
text?: string;
data?: HtmlClipboardData;
};

export interface ClipboardOptions {
isCutOperation: boolean;
Expand Down
Loading