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 Apr 26, 2024
1 parent f58a0d5 commit b140631
Show file tree
Hide file tree
Showing 18 changed files with 547 additions and 71 deletions.
16 changes: 13 additions & 3 deletions src/actions/menu_items_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,23 @@ export const PASTE_AS_VALUE_ACTION = async (env: SpreadsheetChildEnv) => paste(e

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 target = env.model.getters.getSelectedZones();
if (osClipboard && osClipboard.content !== spreadsheetClipboard) {
interactivePasteFromOS(env, target, osClipboard.content, pasteOption);
if (osClipboard && osClipboard.content["text/plain"] !== spreadsheetClipboard) {
interactivePasteFromOS(
env,
target,
{
[ClipboardMIMEType.PlainText]: osClipboard.content["text/plain"],
[ClipboardMIMEType.OSpreadsheet]: osClipboard.content["web application/o-spreadsheet"]
? osClipboard.content["web application/o-spreadsheet"]
: undefined,
},
pasteOption
);
} else {
interactivePaste(env, target, pasteOption);
}
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
44 changes: 39 additions & 5 deletions src/components/grid/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { openLink } from "../../helpers/links";
import { isStaticTable } from "../../helpers/table_helpers";
import { interactiveCut } from "../../helpers/ui/cut_interactive";
import { interactivePaste, interactivePasteFromOS } from "../../helpers/ui/paste_interactive";
import { CURRENT_VERSION } from "../../migrations/data";
import { cellMenuRegistry } from "../../registries/menus/cell_menu_registry";
import { colMenuRegistry } from "../../registries/menus/col_menu_registry";
import {
Expand Down Expand Up @@ -639,15 +640,48 @@ export class Grid extends Component<Props, SpreadsheetChildEnv> {
}

if (clipboardData.types.indexOf(ClipboardMIMEType.PlainText) > -1) {
const content = clipboardData.getData(ClipboardMIMEType.PlainText);
const browserClipboardSpreadsheetContent = clipboardData.getData(
ClipboardMIMEType.OSpreadsheet
);
const parsedBrowserClipboardSpreadsheetContent = browserClipboardSpreadsheetContent
? JSON.parse(browserClipboardSpreadsheetContent)
: {};
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

if (
this.env.model.getters.getClipboardId() ===
parsedBrowserClipboardSpreadsheetContent.clipboardId
) {
/**
* In the Clipboard plugin, we added a new attribute
* 'clipboardId' that will be assigned a uuid once
* the class is instantiated.
* Therefore, if the clipboardId coming from the current
* model is equal to clipboardId stored in the OS clipboard,
* then we know that the paste action is actually coming from
* the same spreadsheet.
*/
interactivePaste(this.env, target);
} else {
interactivePasteFromOS(this.env, target, content);
try {
interactivePasteFromOS(this.env, target, {
[ClipboardMIMEType.PlainText]: clipboardData.getData(ClipboardMIMEType.PlainText),
[ClipboardMIMEType.Html]: clipboardData.getData(ClipboardMIMEType.Html),
[ClipboardMIMEType.OSpreadsheet]: browserClipboardSpreadsheetContent,
});
} catch (error) {
if (parsedBrowserClipboardSpreadsheetContent.version !== CURRENT_VERSION) {
this.env.raiseError(
_t(
"An unexpected error occurred while pasting content. This is probably due to a version mismatch."
)
);
}
interactivePasteFromOS(this.env, target, {
[ClipboardMIMEType.PlainText]: ev.clipboardData.getData(ClipboardMIMEType.PlainText),
});
}
}
if (isCutOperation) {
await this.env.clipboard.write({ [ClipboardMIMEType.PlainText]: "" });
Expand Down
17 changes: 13 additions & 4 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 @@ -30,14 +30,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 +58,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
13 changes: 11 additions & 2 deletions src/helpers/range.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { _t } from "../translation";
import {
CellPosition,
ClipboardRangeData,
CoreGetters,
Getters,
Range,
Expand Down Expand Up @@ -52,11 +53,19 @@ export class RangeImpl implements Range {
this.parts = _fixedParts;
}

static fromRange(range: Range, getters: CoreGetters): RangeImpl {
static fromRange(range: Range | ClipboardRangeData, getters: CoreGetters): RangeImpl {
if (range instanceof RangeImpl) {
return range;
}
return new RangeImpl(range, getters.getSheetSize);
return new RangeImpl(
{
zone: "_zone" in range ? range._zone : { left: -1, right: -1, top: -1, bottom: -1 },
parts: range.parts,
prefixSheet: range.prefixSheet,
sheetId: "",
},
getters.getSheetSize
);
}

get unboundedZone(): UnboundedZone {
Expand Down
10 changes: 7 additions & 3 deletions src/helpers/ui/paste_interactive.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CommandResult, DispatchResult } from "../..";
import { ClipboardContent, CommandResult, DispatchResult } from "../..";
import { _t } from "../../translation";
import { ClipboardPasteOptions, SpreadsheetChildEnv, Zone } from "../../types";

Expand Down Expand Up @@ -37,9 +37,13 @@ export function interactivePaste(
export function interactivePasteFromOS(
env: SpreadsheetChildEnv,
target: Zone[],
text: string,
clipboardContent: ClipboardContent,
pasteOption?: ClipboardPasteOptions
) {
const result = env.model.dispatch("PASTE_FROM_OS_CLIPBOARD", { target, text, pasteOption });
const result = env.model.dispatch("PASTE_FROM_OS_CLIPBOARD", {
target,
clipboardContent,
pasteOption,
});
handlePasteResult(env, result);
}
37 changes: 33 additions & 4 deletions src/plugins/ui_stateful/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { ClipboardHandler } from "../../clipboard_handlers/abstract_clipboard_ha
import { cellStyleToCss, cssPropertiesToCss } from "../../components/helpers";
import { SELECTION_BORDER_COLOR } from "../../constants";
import { getClipboardDataPositions } from "../../helpers/clipboard/clipboard_helpers";
import { isZoneValid, positions, union } from "../../helpers/index";
import { UuidGenerator, isZoneValid, positions, union } from "../../helpers/index";
import { CURRENT_VERSION } from "../../migrations/data";
import {
ClipboardContent,
ClipboardData,
Expand Down Expand Up @@ -47,6 +48,7 @@ export class ClipboardPlugin extends UIPlugin {
static layers = ["Clipboard"] as const;
static getters = [
"getClipboardContent",
"getClipboardId",
"getClipboardTextContent",
"isCutOperation",
"isPaintingFormat",
Expand All @@ -57,6 +59,7 @@ export class ClipboardPlugin extends UIPlugin {
private originSheetId?: UID;
private copiedData?: MinimalClipboardData;
private _isCutOperation?: boolean;
private clipboardId = new UuidGenerator().uuidv4();

// ---------------------------------------------------------------------------
// Command Handling
Expand All @@ -68,7 +71,9 @@ export class ClipboardPlugin extends UIPlugin {
const zones = this.getters.getSelectedZones();
return this.isCutAllowedOn(zones);
case "PASTE_FROM_OS_CLIPBOARD": {
const copiedData = this.convertOSClipboardData(cmd.text);
const copiedData = this.convertOSClipboardData(
cmd.clipboardContent[ClipboardMIMEType.PlainText]
);
const pasteOption =
cmd.pasteOption || (this.paintFormatStatus !== "inactive" ? "onlyFormat" : undefined);
return this.isPasteAllowed(cmd.target, copiedData, { pasteOption });
Expand Down Expand Up @@ -125,7 +130,13 @@ export class ClipboardPlugin extends UIPlugin {
this.copiedData = this.copy(cmd.type, zones);
break;
case "PASTE_FROM_OS_CLIPBOARD": {
this.copiedData = this.convertOSClipboardData(cmd.text);
if (cmd.clipboardContent[ClipboardMIMEType.OSpreadsheet]) {
this.copiedData = JSON.parse(cmd.clipboardContent[ClipboardMIMEType.OSpreadsheet]);
} else {
this.copiedData = this.convertOSClipboardData(
cmd.clipboardContent[ClipboardMIMEType.PlainText]
);
}
const pasteOption =
cmd.pasteOption || (this.paintFormatStatus !== "inactive" ? "onlyFormat" : undefined);
this.paste(cmd.target, {
Expand Down Expand Up @@ -278,7 +289,7 @@ export class ClipboardPlugin extends UIPlugin {
}
}

private convertOSClipboardData(clipboardData: string): {} {
private convertOSClipboardData(clipboardData: string | undefined): {} {
this._isCutOperation = false;
const handlers: ClipboardHandler<any>[] = clipboardHandlersRegistries.figureHandlers
.getAll()
Expand Down Expand Up @@ -460,13 +471,31 @@ export class ClipboardPlugin extends UIPlugin {
return this.getPlainTextContent();
}

getClipboardId(): string {
return this.clipboardId;
}

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

private getSerializedCellData(): string {
const zones = this.getters.getSelectedZones();
const clipboardData = this.getClipboardData(zones);
let copiedCellFormattingData: MinimalClipboardData = {};
for (const handler of this.selectClipboardHandlers(clipboardData)) {
const data = handler.copy(clipboardData);
copiedCellFormattingData = { ...copiedCellFormattingData, ...data };
}
copiedCellFormattingData.version = CURRENT_VERSION;
copiedCellFormattingData.clipboardId = this.clipboardId;
return JSON.stringify(copiedCellFormattingData);
}

private getPlainTextContent(): string {
if (!this.copiedData?.cells) {
return "\t";
Expand Down
1 change: 1 addition & 0 deletions src/types/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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 };
Expand Down
4 changes: 2 additions & 2 deletions src/types/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
} from "./misc";

import { ChartDefinition } from "./chart/chart";
import { ClipboardPasteOptions } from "./clipboard";
import { ClipboardContent, ClipboardPasteOptions } from "./clipboard";
import { FigureSize } from "./figure";
import { SearchOptions } from "./find_and_replace";
import { Image } from "./image";
Expand Down Expand Up @@ -754,7 +754,7 @@ export interface CancelPaintFormatCommand {
export interface PasteFromOSClipboardCommand {
type: "PASTE_FROM_OS_CLIPBOARD";
target: Zone[];
text: string;
clipboardContent: ClipboardContent;
pasteOption?: ClipboardPasteOptions;
}

Expand Down
6 changes: 6 additions & 0 deletions src/types/range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ export interface RangeData {
_zone: Zone | UnboundedZone;
_sheetId: UID;
}

export interface ClipboardRangeData {
_zone: Zone | UnboundedZone;
parts: RangePart[];
prefixSheet: boolean;
}
2 changes: 1 addition & 1 deletion src/types/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export interface TableElementStyle {
size?: number;
}

interface TableBorder extends Border {
export interface TableBorder extends Border {
// used to describe borders inside of a zone
horizontal?: BorderDescr;
vertical?: BorderDescr;
Expand Down

0 comments on commit b140631

Please sign in to comment.