Skip to content

Commit

Permalink
[IMP] clipboard: preserve cell style and format when copy/pasting cro…
Browse files Browse the repository at this point in the history
…ss spreadsheets

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 invalidated 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.

Task: 3597039
  • Loading branch information
Rachico committed Jul 31, 2024
1 parent 649b9cb commit 8560e26
Show file tree
Hide file tree
Showing 27 changed files with 570 additions and 176 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] || "{}";
let 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 8560e26

Please sign in to comment.