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.

Task: 3597039
  • Loading branch information
Rachico committed Jul 11, 2024
1 parent 117a5e5 commit 68682e4
Show file tree
Hide file tree
Showing 28 changed files with 603 additions and 176 deletions.
26 changes: 22 additions & 4 deletions src/actions/menu_items_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,31 @@ 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();

if (!env.clipboard) {
return;
}
const osClipboard = await env.clipboard.read();
switch (osClipboard.status) {
case "ok":
const clipboardContent = osClipboard.content;
const htmlDocument = new DOMParser().parseFromString(
clipboardContent[ClipboardMIMEType.Html] ?? "<div></div>",
"text/xml"
);
const osClipboardSpreadsheetContent =
clipboardContent[ClipboardMIMEType.OSpreadsheet] &&
clipboardContent[ClipboardMIMEType.OSpreadsheet].length > 0
? clipboardContent[ClipboardMIMEType.OSpreadsheet]
: "{}";

const parsedOSClipboardSpreadsheetContent = JSON.parse(osClipboardSpreadsheetContent);
let clipboardId =
parsedOSClipboardSpreadsheetContent.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
39 changes: 23 additions & 16 deletions src/clipboard_handlers/cell_clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,13 @@ export class CellClipboardHandler extends AbstractCellClipboardHandler<
}
}
cellsInRow.push({
cell,
content: cell?.content ?? "",
style: cell?.style,
format: cell?.format,
tokens:
cell && "compiledFormula" in cell
? cell.compiledFormula.tokens.map(({ value, type }) => ({ value, type }))
: [],
border: this.getters.getCellBorder(position) || undefined,
evaluatedCell,
position,
Expand Down Expand Up @@ -194,7 +200,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 +240,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 +252,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 +303,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
66 changes: 43 additions & 23 deletions src/components/grid/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
Align,
CellValueType,
Client,
ClipboardContent,
ClipboardMIMEType,
DOMCoordinates,
DOMDimension,
Expand Down Expand Up @@ -597,13 +598,12 @@ 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) {
if (!this.env.clipboard) {
this.displayWarningCopyPasteNotSupported();
return;
}
Expand All @@ -618,9 +618,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 @@ -629,27 +627,49 @@ export class Grid extends Component<Props, SpreadsheetChildEnv> {
return;
}

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

if (!this.env.clipboard) {
this.displayWarningCopyPasteNotSupported();
return;
}

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]: "" });
}
let clipboardContent: ClipboardContent = {};
let htmlDocument: Document = new DOMParser().parseFromString("<div></div>", "text/xml");
let osClipboardSpreadsheetContent: string = "{}";

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

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

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

if (this.env.model.getters.getClipboardId() === clipboardId) {
/**
* Pasting in the same spreadsheet
*/
interactivePaste(this.env, target);
} else {
interactivePasteFromOS(this.env, target, clipboardContent);
}
if (isCutOperation) {
await this.env.clipboard.write({ [ClipboardMIMEType.PlainText]: "" });
}
}

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
2 changes: 1 addition & 1 deletion src/helpers/range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class RangeImpl implements Range {
if (range instanceof RangeImpl) {
return range;
}
return new RangeImpl(range, getters.getSheetSize);
throw new TypeError(`Expected instance of type RangeImpl got ${range.constructor.name}`);
}

get unboundedZone(): UnboundedZone {
Expand Down
Loading

0 comments on commit 68682e4

Please sign in to comment.