Skip to content

Commit

Permalink
[IMP] clipboard: keep cell formatting when copy/pasting cells from on…
Browse files Browse the repository at this point in the history
…e spreadsheet to another

Currently, copy/pasting cells from one spreadsheet to another external spreadsheet removes all cell formatting 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 browser clipboard object and using the content saved in this key to re-create the cell formatting in the new spreadsheet.

Task: 3597039
  • Loading branch information
Rachico committed May 15, 2024
1 parent 90e298a commit 8870877
Show file tree
Hide file tree
Showing 32 changed files with 679 additions and 203 deletions.
4 changes: 2 additions & 2 deletions src/actions/edit_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const copy: ActionSpec = {
isReadonlyAllowed: true,
execute: async (env) => {
env.model.dispatch("COPY");
await env.clipboard.write(env.model.getters.getClipboardContent());
await env.clipboard?.write(env.model.getters.getClipboardContent());
},
icon: "o-spreadsheet-Icon.COPY",
};
Expand All @@ -39,7 +39,7 @@ export const cut: ActionSpec = {
description: "Ctrl+X",
execute: async (env) => {
interactiveCut(env);
await env.clipboard.write(env.model.getters.getClipboardContent());
await env.clipboard?.write(env.model.getters.getClipboardContent());
},
icon: "o-spreadsheet-Icon.CUT",
};
Expand Down
62 changes: 33 additions & 29 deletions src/actions/menu_items_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,35 +51,39 @@ 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();

switch (osClipboard.status) {
case "ok":
const target = env.model.getters.getSelectedZones();
if (osClipboard && osClipboard.content !== spreadsheetClipboard) {
interactivePasteFromOS(env, target, osClipboard.content, pasteOption);
} else {
interactivePaste(env, target, pasteOption);
}
if (env.model.getters.isCutOperation() && pasteOption !== "asValue") {
await env.clipboard.write({ [ClipboardMIMEType.PlainText]: "" });
}
break;
case "notImplemented":
env.raiseError(
_t(
"Pasting from the context menu is not supported in this browser. Use keyboard shortcuts ctrl+c / ctrl+v instead."
)
);
break;
case "permissionDenied":
env.raiseError(
_t(
"Access to the clipboard denied by the browser. Please enable clipboard permission for this page in your browser settings."
)
);
break;
const osClipboard = await env.clipboard?.read();
if (osClipboard) {
switch (osClipboard.status) {
case "ok":
const osClipboardSpreadsheetContent = osClipboard.content[ClipboardMIMEType.OSpreadsheet]
? osClipboard.content[ClipboardMIMEType.OSpreadsheet]
: "{}";
const parsedSpreadsheetContent = JSON.parse(osClipboardSpreadsheetContent);
const target = env.model.getters.getSelectedZones();
if (env.model.getters.getClipboardId() !== parsedSpreadsheetContent.clipboardId) {
interactivePasteFromOS(env, target, osClipboard.content, pasteOption);
} else {
interactivePaste(env, target, pasteOption);
}
if (env.model.getters.isCutOperation() && pasteOption !== "asValue") {
await env.clipboard?.write({ [ClipboardMIMEType.PlainText]: "" });
}
break;
case "notImplemented":
env.raiseError(
_t(
"Pasting from the context menu is not supported in this browser. Use keyboard shortcuts ctrl+c / ctrl+v instead."
)
);
break;
case "permissionDenied":
env.raiseError(
_t(
"Access to the clipboard denied by the browser. Please enable clipboard permission for this page in your browser settings."
)
);
break;
}
}
}

Expand Down
31 changes: 17 additions & 14 deletions src/clipboard_handlers/cell_clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,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 @@ -180,7 +186,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 @@ -220,7 +226,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 @@ -232,27 +238,27 @@ 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;
}

const content =
origin.cell && origin.cell.isFormula && !clipboardOption?.isCutOperation
origin && origin.tokens && origin.tokens?.length > 0 && !clipboardOption?.isCutOperation
? this.getters.getTranslatedCellFormula(
sheetId,
col - origin.position.col,
row - origin.position.row,
origin.cell.compiledFormula
origin.tokens ?? []
)
: origin.cell?.content;
if (content !== "" || origin.cell?.format || origin.cell?.style) {
: origin?.content;
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 @@ -277,10 +283,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 @@ -71,7 +71,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 @@ -131,7 +131,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 @@ -174,7 +174,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
73 changes: 57 additions & 16 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 @@ -600,7 +601,7 @@ 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;
}
Expand All @@ -621,8 +622,16 @@ 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]);
if (this.env.clipboard) {
await this.env.clipboard?.write(content);
} else {
/** If the used browser does not support
* navigator.clipboard.write(), store data in
* the ClipboardEvent instance instead.
*/
for (const type in content) {
clipboardData.setData(type, content[type]);
}
}
ev.preventDefault();
}
Expand All @@ -638,21 +647,53 @@ export class Grid extends Component<Props, SpreadsheetChildEnv> {
return;
}

if (clipboardData.types.indexOf(ClipboardMIMEType.PlainText) > -1) {
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 browserClipboardSpreadsheetContent: string = "{}";

if (this.env.clipboard) {
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"
);
browserClipboardSpreadsheetContent =
clipboardContent[ClipboardMIMEType.OSpreadsheet] &&
clipboardContent[ClipboardMIMEType.OSpreadsheet].length > 0
? clipboardContent[ClipboardMIMEType.OSpreadsheet]
: "{}";
}
} else {
browserClipboardSpreadsheetContent = clipboardData.getData(ClipboardMIMEType.OSpreadsheet);
clipboardContent = {
[ClipboardMIMEType.PlainText]: clipboardData.getData(ClipboardMIMEType.PlainText),
[ClipboardMIMEType.Html]: clipboardData.getData(ClipboardMIMEType.Html),
[ClipboardMIMEType.OSpreadsheet]: browserClipboardSpreadsheetContent,
};
}

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

const parsedBrowserClipboardSpreadsheetContent = JSON.parse(browserClipboardSpreadsheetContent);
const 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]: "" });
}
ev.preventDefault();
}

private displayWarningCopyPasteNotSupported() {
Expand Down
27 changes: 22 additions & 5 deletions src/helpers/clipboard/navigator_clipboard_wrapper.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
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 {
export function instantiateClipboard(): ClipboardInterface | undefined {
if (!navigator.clipboard?.write) {
/** If browser's navigator.clipboard is not defined or if the write
* method is not supported in the browser's navigator.clipboard, we
* do not instantiate the env clipboard to be able to later check in
* the grid if we can copy/paste with it.
*/
return undefined;
}
return new WebClipboardWrapper(navigator.clipboard);
}

Expand All @@ -30,14 +38,22 @@ 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();
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";
Expand All @@ -50,6 +66,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 @@ -56,7 +56,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

0 comments on commit 8870877

Please sign in to comment.