Skip to content

Commit

Permalink
[IMP] clipboard: copy/pasting cross spreadsheets
Browse files Browse the repository at this point in the history
Before this commit, copying and pasting content cross spreadsheets removes
all cell style and format and only keeps cell values. This is because the
model clipboard is different from one instance to another.

This commit solves the issue by adding a new custom type in the os clipboard
(`application/o-spreadsheet`) and using the content saved in this key to
re-create the cell style and format in the new spreadsheet.

NOTE: After an assessment of the capabilities and limitations of most modern
browsers, here's the bottom line till the date of this commit:

For Google Chrome (and all chromium browsers: Opera, Edge, Brave,...):
read/write are supported for custom types; Therefore cross spreadsheet
copy/paste works like a charm.

For Safari and Mozilla Firefox: saving custom types in the os clipboard is not
supported; Therefore, cross spreadsheet copy/paste does not work.

closes #4053

Task: 3597039
Signed-off-by: Lucas Lefèvre (lul) <[email protected]>
  • Loading branch information
Rachico authored and LucasLefevre committed Aug 5, 2024
1 parent 6b26d71 commit 825f017
Show file tree
Hide file tree
Showing 28 changed files with 569 additions and 178 deletions.
17 changes: 13 additions & 4 deletions src/actions/menu_items_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,22 @@ export const PASTE_ACTION = async (env: SpreadsheetChildEnv) => paste(env);
export const PASTE_AS_VALUE_ACTION = async (env: SpreadsheetChildEnv) => paste(env, "asValue");

async function paste(env: SpreadsheetChildEnv, pasteOption?: ClipboardPasteOptions) {
const spreadsheetClipboard = env.model.getters.getClipboardTextContent();
const osClipboard = await env.clipboard.readText();

const osClipboard = await env.clipboard.read();
switch (osClipboard.status) {
case "ok":
const htmlDocument = new DOMParser().parseFromString(
osClipboard.content[ClipboardMIMEType.Html] ?? "<div></div>",
"text/xml"
);
const osClipboardSpreadsheetContent =
osClipboard.content[ClipboardMIMEType.OSpreadsheet] || "{}";
const clipboardId =
JSON.parse(osClipboardSpreadsheetContent).clipboardId ??
htmlDocument.querySelector("div")?.getAttribute("data-clipboard-id");

const target = env.model.getters.getSelectedZones();
if (osClipboard && osClipboard.content !== spreadsheetClipboard) {

if (env.model.getters.getClipboardId() !== clipboardId) {
interactivePasteFromOS(env, target, osClipboard.content, pasteOption);
} else {
interactivePaste(env, target, pasteOption);
Expand Down
38 changes: 22 additions & 16 deletions src/clipboard_handlers/cell_clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ export class CellClipboardHandler extends AbstractCellClipboardHandler<
}
}
cellsInRow.push({
cell,
content: cell?.content ?? "",
style: cell?.style,
format: cell?.format,
tokens: cell?.isFormula
? cell.compiledFormula.tokens.map(({ value, type }) => ({ value, type }))
: [],
border: this.getters.getCellBorder(position) || undefined,
evaluatedCell,
position,
Expand Down Expand Up @@ -194,7 +199,7 @@ export class CellClipboardHandler extends AbstractCellClipboardHandler<
private clearClippedZones(content: ClipboardContent) {
for (const row of content.cells) {
for (const cell of row) {
if (cell.cell) {
if (cell.position) {
this.dispatch("CLEAR_CELL", cell.position);
}
}
Expand Down Expand Up @@ -234,7 +239,7 @@ export class CellClipboardHandler extends AbstractCellClipboardHandler<
) {
const { sheetId, col, row } = target;
const targetCell = this.getters.getEvaluatedCell(target);
const originFormat = origin.cell?.format ?? origin.evaluatedCell.format;
const originFormat = origin?.format ?? origin.evaluatedCell.format;

if (clipboardOption?.pasteOption === "asValue") {
const locale = this.getters.getLocale();
Expand All @@ -246,29 +251,33 @@ export class CellClipboardHandler extends AbstractCellClipboardHandler<
if (clipboardOption?.pasteOption === "onlyFormat") {
this.dispatch("UPDATE_CELL", {
...target,
style: origin.cell?.style ?? null,
style: origin?.style ?? null,
format: originFormat ?? targetCell.format,
});
return;
}

let content = origin.cell?.content;
if (origin.cell?.isFormula && !clipboardOption?.isCutOperation) {
let content = origin?.content;
if (origin?.tokens && origin.tokens.length > 0 && !clipboardOption?.isCutOperation) {
content = this.getters.getTranslatedCellFormula(
sheetId,
col - origin.position.col,
row - origin.position.row,
origin.cell.compiledFormula
origin.tokens
);
} else if (origin?.tokens && origin.tokens.length > 0) {
content = this.getters.getFormulaMovedInSheet(
origin.position.sheetId,
sheetId,
origin.tokens
);
} else if (origin.cell?.isFormula) {
content = this.getters.getFormulaMovedInSheet(sheetId, origin.cell.compiledFormula);
}
if (content !== "" || origin.cell?.format || origin.cell?.style) {
if (content !== "" || origin?.format || origin?.style) {
this.dispatch("UPDATE_CELL", {
...target,
content,
style: origin.cell?.style || null,
format: origin.cell?.format,
style: origin?.style || null,
format: origin?.format,
});
} else if (targetCell) {
this.dispatch("CLEAR_CELL", target);
Expand All @@ -293,10 +302,7 @@ export class CellClipboardHandler extends AbstractCellClipboardHandler<
for (let i = 0; i < rowLength; i++) {
const content = canonicalizeNumberValue(row[i] || "", locale);
cells.push({
cell: {
isFormula: false,
content,
},
content: content,
evaluatedCell: {
formattedValue: content,
},
Expand Down
10 changes: 5 additions & 5 deletions src/clipboard_handlers/tables_clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
ClipboardPasteTarget,
CoreTableType,
HeaderIndex,
Range,
RangeData,
Style,
TableConfig,
UID,
Expand All @@ -21,7 +21,7 @@ interface TableStyle {
}

interface CopiedTable {
range: Range;
range: RangeData;
config: TableConfig;
type: CoreTableType;
}
Expand Down Expand Up @@ -68,7 +68,7 @@ export class TableClipboardHandler extends AbstractCellClipboardHandler<
}
tableCellsInRow.push({
table: {
range: coreTable.range,
range: coreTable.range.rangeData,
config: coreTable.config,
type: coreTable.type,
},
Expand Down Expand Up @@ -125,7 +125,7 @@ export class TableClipboardHandler extends AbstractCellClipboardHandler<
if (tableCell.table) {
this.dispatch("REMOVE_TABLE", {
sheetId: content.sheetId,
target: [tableCell.table.range.zone],
target: [this.getters.getRangeFromRangeData(tableCell.table.range).zone],
});
}
}
Expand Down Expand Up @@ -168,7 +168,7 @@ export class TableClipboardHandler extends AbstractCellClipboardHandler<
) {
if (tableCell.table && !options?.pasteOption) {
const { range: tableRange } = tableCell.table;
const zoneDims = zoneToDimension(tableRange.zone);
const zoneDims = zoneToDimension(this.getters.getRangeFromRangeData(tableRange).zone);
const newTableZone = {
left: position.col,
top: position.row,
Expand Down
58 changes: 25 additions & 33 deletions src/components/grid/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import { Store, useStore } from "../../store_engine";
import { DOMFocusableElementStore } from "../../stores/DOM_focus_store";
import { ArrayFormulaHighlight } from "../../stores/array_formula_highlight";
import { HighlightStore } from "../../stores/highlight_store";
import { _t } from "../../translation";
import {
Align,
CellValueType,
Expand Down Expand Up @@ -595,17 +594,11 @@ export class Grid extends Component<Props, SpreadsheetChildEnv> {
this.menuState.menuItems = registries[type].getMenuItems();
}

copy(cut: boolean, ev: ClipboardEvent) {
async copy(cut: boolean, ev: ClipboardEvent) {
if (!this.gridEl.contains(document.activeElement)) {
return;
}

const clipboardData = ev.clipboardData;
if (!clipboardData) {
this.displayWarningCopyPasteNotSupported();
return;
}

/* If we are currently editing a cell, let the default behavior */
if (this.composerFocusStore.activeComposer.editionMode !== "inactive") {
return;
Expand All @@ -616,9 +609,7 @@ export class Grid extends Component<Props, SpreadsheetChildEnv> {
this.env.model.dispatch("COPY");
}
const content = this.env.model.getters.getClipboardContent();
for (const type in content) {
clipboardData.setData(type, content[type]);
}
await this.env.clipboard.write(content);
ev.preventDefault();
}

Expand All @@ -627,32 +618,33 @@ export class Grid extends Component<Props, SpreadsheetChildEnv> {
return;
}

const clipboardData = ev.clipboardData;
if (!clipboardData) {
this.displayWarningCopyPasteNotSupported();
ev.preventDefault();

const clipboard = await this.env.clipboard.read();
if (clipboard.status !== "ok") {
return;
}
const htmlDocument = new DOMParser().parseFromString(
clipboard.content[ClipboardMIMEType.Html] ?? "<div></div>",
"text/xml"
);
const osClipboardSpreadsheetContent = clipboard.content[ClipboardMIMEType.OSpreadsheet] || "{}";

if (clipboardData.types.indexOf(ClipboardMIMEType.PlainText) > -1) {
ev.preventDefault();
const content = clipboardData.getData(ClipboardMIMEType.PlainText);
const target = this.env.model.getters.getSelectedZones();
const clipboardString = this.env.model.getters.getClipboardTextContent();
const isCutOperation = this.env.model.getters.isCutOperation();
if (clipboardString === content) {
// the paste actually comes from o-spreadsheet itself
interactivePaste(this.env, target);
} else {
interactivePasteFromOS(this.env, target, content);
}
if (isCutOperation) {
await this.env.clipboard.write({ [ClipboardMIMEType.PlainText]: "" });
}
}
}
const target = this.env.model.getters.getSelectedZones();
const isCutOperation = this.env.model.getters.isCutOperation();

private displayWarningCopyPasteNotSupported() {
this.env.raiseError(_t("Copy/Paste is not supported in this browser."));
const clipboardId =
JSON.parse(osClipboardSpreadsheetContent).clipboardId ??
htmlDocument.querySelector("div")?.getAttribute("data-clipboard-id");

if (this.env.model.getters.getClipboardId() === clipboardId) {
interactivePaste(this.env, target);
} else {
interactivePasteFromOS(this.env, target, clipboard.content);
}
if (isCutOperation) {
await this.env.clipboard.write({ [ClipboardMIMEType.PlainText]: "" });
}
}

private clearFormatting() {
Expand Down
62 changes: 50 additions & 12 deletions src/helpers/clipboard/navigator_clipboard_wrapper.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { ClipboardContent, ClipboardMIMEType } from "./../../types/clipboard";

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

export interface ClipboardInterface {
write(clipboardContent: ClipboardContent): Promise<void>;
writeText(text: string): Promise<void>;
readText(): Promise<ClipboardReadResult>;
read(): Promise<ClipboardReadResult>;
}

export function instantiateClipboard(): ClipboardInterface {
Expand All @@ -19,9 +19,29 @@ class WebClipboardWrapper implements ClipboardInterface {
constructor(private clipboard: Clipboard | undefined) {}

async write(clipboardContent: ClipboardContent): Promise<void> {
try {
this.clipboard?.write(this.getClipboardItems(clipboardContent));
} catch (e) {}
if (this.clipboard?.write) {
try {
await this.clipboard?.write(this.getClipboardItems(clipboardContent));
} catch (e) {
/**
* Some browsers (e.g firefox, safari) do not support writing
* custom mimetypes in the clipboard. Therefore, we try to catch
* any errors and fallback on writing only standard mimetypes to
* prevent the whole copy action from crashing.
*/
await this.clipboard?.write([
new ClipboardItem({
[ClipboardMIMEType.PlainText]: this.getBlob(
clipboardContent,
ClipboardMIMEType.PlainText
),
[ClipboardMIMEType.Html]: this.getBlob(clipboardContent, ClipboardMIMEType.Html),
}),
]);
}
} else {
await this.writeText(clipboardContent[ClipboardMIMEType.PlainText] ?? "");
}
}

async writeText(text: string): Promise<void> {
Expand All @@ -30,18 +50,35 @@ class WebClipboardWrapper implements ClipboardInterface {
} catch (e) {}
}

async readText(): Promise<ClipboardReadResult> {
async read(): Promise<ClipboardReadResult> {
let permissionResult: PermissionStatus | undefined = undefined;
try {
//@ts-ignore - clipboard-read is not implemented in all browsers
permissionResult = await navigator.permissions.query({ name: "clipboard-read" });
} catch (e) {}
try {
const clipboardContent = await this.clipboard!.readText();
return { status: "ok", content: clipboardContent };
} catch (e) {
const status = permissionResult?.state === "denied" ? "permissionDenied" : "notImplemented";
return { status };
if (this.clipboard?.read) {
try {
const clipboardItems = await this.clipboard.read();
const clipboardContent: ClipboardContent = {};
for (const item of clipboardItems) {
for (const type of item.types) {
const blob = await item.getType(type);
const text = await blob.text();
clipboardContent[type as ClipboardMIMEType] = text;
}
}
return { status: "ok", content: clipboardContent };
} catch (e) {
const status = permissionResult?.state === "denied" ? "permissionDenied" : "notImplemented";
return { status };
}
} else {
return {
status: "ok",
content: {
[ClipboardMIMEType.PlainText]: await this.clipboard?.readText(),
},
};
}
}

Expand All @@ -50,6 +87,7 @@ class WebClipboardWrapper implements ClipboardInterface {
new ClipboardItem({
[ClipboardMIMEType.PlainText]: this.getBlob(content, ClipboardMIMEType.PlainText),
[ClipboardMIMEType.Html]: this.getBlob(content, ClipboardMIMEType.Html),
[ClipboardMIMEType.OSpreadsheet]: this.getBlob(content, ClipboardMIMEType.OSpreadsheet),
}),
];
}
Expand Down
Loading

0 comments on commit 825f017

Please sign in to comment.