From b140631c002525381c579f9488323c53cd92de17 Mon Sep 17 00:00:00 2001 From: "Mehdi Rachico (mera)" Date: Thu, 11 Apr 2024 13:45:33 +0200 Subject: [PATCH] [IMP] clipboard: keep cell formatting when copy/pasting cells from one 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 --- src/actions/menu_items_actions.ts | 16 +- src/clipboard_handlers/tables_clipboard.ts | 10 +- src/components/grid/grid.ts | 44 ++- .../clipboard/navigator_clipboard_wrapper.ts | 17 +- src/helpers/range.ts | 13 +- src/helpers/ui/paste_interactive.ts | 10 +- src/plugins/ui_stateful/clipboard.ts | 37 +- src/types/clipboard.ts | 1 + src/types/commands.ts | 4 +- src/types/range.ts | 6 + src/types/table.ts | 2 +- tests/clipboard/clipboard_plugin.test.ts | 352 +++++++++++++++++- tests/figures/figure_component.test.ts | 11 +- tests/grid/grid_component.test.ts | 18 +- tests/helpers/ui_helpers.test.ts | 8 +- tests/menus/menu_items_registry.test.ts | 35 +- tests/test_helpers/clipboard.ts | 23 +- tests/test_helpers/commands_helpers.ts | 11 +- 18 files changed, 547 insertions(+), 71 deletions(-) diff --git a/src/actions/menu_items_actions.ts b/src/actions/menu_items_actions.ts index 2da3ece720..ca9433277c 100644 --- a/src/actions/menu_items_actions.ts +++ b/src/actions/menu_items_actions.ts @@ -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); } diff --git a/src/clipboard_handlers/tables_clipboard.ts b/src/clipboard_handlers/tables_clipboard.ts index 020870d33b..22e691aff8 100644 --- a/src/clipboard_handlers/tables_clipboard.ts +++ b/src/clipboard_handlers/tables_clipboard.ts @@ -7,7 +7,7 @@ import { ClipboardPasteTarget, CoreTableType, HeaderIndex, - Range, + RangeData, Style, TableConfig, UID, @@ -21,7 +21,7 @@ interface TableStyle { } interface CopiedTable { - range: Range; + range: RangeData; config: TableConfig; type: CoreTableType; } @@ -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, }, @@ -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], }); } } @@ -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, diff --git a/src/components/grid/grid.ts b/src/components/grid/grid.ts index 90f994e910..2ad0e36221 100644 --- a/src/components/grid/grid.ts +++ b/src/components/grid/grid.ts @@ -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 { @@ -639,15 +640,48 @@ export class Grid extends Component { } 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]: "" }); diff --git a/src/helpers/clipboard/navigator_clipboard_wrapper.ts b/src/helpers/clipboard/navigator_clipboard_wrapper.ts index a12a117861..97fc96ce88 100644 --- a/src/helpers/clipboard/navigator_clipboard_wrapper.ts +++ b/src/helpers/clipboard/navigator_clipboard_wrapper.ts @@ -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; writeText(text: string): Promise; - readText(): Promise; + read(): Promise; } export function instantiateClipboard(): ClipboardInterface { @@ -30,14 +30,22 @@ class WebClipboardWrapper implements ClipboardInterface { } catch (e) {} } - async readText(): Promise { + async read(): Promise { 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"; @@ -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), }), ]; } diff --git a/src/helpers/range.ts b/src/helpers/range.ts index 5074269493..171ba806d3 100644 --- a/src/helpers/range.ts +++ b/src/helpers/range.ts @@ -1,6 +1,7 @@ import { _t } from "../translation"; import { CellPosition, + ClipboardRangeData, CoreGetters, Getters, Range, @@ -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 { diff --git a/src/helpers/ui/paste_interactive.ts b/src/helpers/ui/paste_interactive.ts index 6004065705..2e2622fdfb 100644 --- a/src/helpers/ui/paste_interactive.ts +++ b/src/helpers/ui/paste_interactive.ts @@ -1,4 +1,4 @@ -import { CommandResult, DispatchResult } from "../.."; +import { ClipboardContent, CommandResult, DispatchResult } from "../.."; import { _t } from "../../translation"; import { ClipboardPasteOptions, SpreadsheetChildEnv, Zone } from "../../types"; @@ -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); } diff --git a/src/plugins/ui_stateful/clipboard.ts b/src/plugins/ui_stateful/clipboard.ts index 97933babd8..c876ef5188 100644 --- a/src/plugins/ui_stateful/clipboard.ts +++ b/src/plugins/ui_stateful/clipboard.ts @@ -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, @@ -47,6 +48,7 @@ export class ClipboardPlugin extends UIPlugin { static layers = ["Clipboard"] as const; static getters = [ "getClipboardContent", + "getClipboardId", "getClipboardTextContent", "isCutOperation", "isPaintingFormat", @@ -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 @@ -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 }); @@ -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, { @@ -278,7 +289,7 @@ export class ClipboardPlugin extends UIPlugin { } } - private convertOSClipboardData(clipboardData: string): {} { + private convertOSClipboardData(clipboardData: string | undefined): {} { this._isCutOperation = false; const handlers: ClipboardHandler[] = clipboardHandlersRegistries.figureHandlers .getAll() @@ -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"; diff --git a/src/types/clipboard.ts b/src/types/clipboard.ts index c4415c7c46..8068a750b8 100644 --- a/src/types/clipboard.ts +++ b/src/types/clipboard.ts @@ -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 }; diff --git a/src/types/commands.ts b/src/types/commands.ts index 5fd615b943..97f271bc08 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -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"; @@ -754,7 +754,7 @@ export interface CancelPaintFormatCommand { export interface PasteFromOSClipboardCommand { type: "PASTE_FROM_OS_CLIPBOARD"; target: Zone[]; - text: string; + clipboardContent: ClipboardContent; pasteOption?: ClipboardPasteOptions; } diff --git a/src/types/range.ts b/src/types/range.ts index 50adcfefad..0680e186c9 100644 --- a/src/types/range.ts +++ b/src/types/range.ts @@ -22,3 +22,9 @@ export interface RangeData { _zone: Zone | UnboundedZone; _sheetId: UID; } + +export interface ClipboardRangeData { + _zone: Zone | UnboundedZone; + parts: RangePart[]; + prefixSheet: boolean; +} diff --git a/src/types/table.ts b/src/types/table.ts index a533475cac..67b793b3c3 100644 --- a/src/types/table.ts +++ b/src/types/table.ts @@ -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; diff --git a/tests/clipboard/clipboard_plugin.test.ts b/tests/clipboard/clipboard_plugin.test.ts index db0d758a93..10a6768198 100644 --- a/tests/clipboard/clipboard_plugin.test.ts +++ b/tests/clipboard/clipboard_plugin.test.ts @@ -1,7 +1,15 @@ -import { DEFAULT_BORDER_DESC } from "../../src/constants"; +import { DEFAULT_BORDER_DESC, LINK_COLOR } from "../../src/constants"; import { toCartesian, toZone, zoneToXc } from "../../src/helpers"; +import { urlRepresentation } from "../../src/helpers/links"; +import { TABLE_PRESETS } from "../../src/helpers/table_presets"; import { Model } from "../../src/model"; -import { ClipboardMIMEType, CommandResult, DEFAULT_LOCALE } from "../../src/types/index"; +import { SpreadsheetChildEnv, TableStyle } from "../../src/types"; +import { + ClipboardMIMEType, + CommandResult, + DEFAULT_LOCALE, + TableBorder, +} from "../../src/types/index"; import { XMLString } from "../../src/types/xlsx"; import { parseXML, xmlEscape } from "../../src/xlsx/helpers/xml_helpers"; import { MockClipboardData } from "../test_helpers/clipboard"; @@ -16,6 +24,7 @@ import { copyPasteCellsOnLeft, createSheet, createSheetWithName, + createTable, cut, deleteColumns, deleteRows, @@ -45,7 +54,14 @@ import { getEvaluatedCell, getStyle, } from "../test_helpers/getters_helpers"; -import { createEqualCF, getGrid, target, toRangesData } from "../test_helpers/helpers"; +import { + createEqualCF, + doAction, + getGrid, + makeTestEnv, + target, + toRangesData, +} from "../test_helpers/helpers"; let model: Model; @@ -184,9 +200,9 @@ describe("clipboard", () => { clipboardData.setData(ClipboardMIMEType.PlainText, "Excalibur"); const content = clipboardData.getData(ClipboardMIMEType.PlainText); - pasteFromOSClipboard(model, "C2", content); + pasteFromOSClipboard(model, "C2", { [ClipboardMIMEType.PlainText]: content }); expect(getCellContent(model, "C2")).toBe(content); - pasteFromOSClipboard(model, "C3", content, "onlyFormat"); + pasteFromOSClipboard(model, "C3", { [ClipboardMIMEType.PlainText]: content }, "onlyFormat"); expect(getCellContent(model, "C3")).toBe(""); }); @@ -568,7 +584,7 @@ describe("clipboard", () => { test("can paste multiple cells from os clipboard", () => { const model = new Model(); - pasteFromOSClipboard(model, "C1", "a\t1\nb\t2"); + pasteFromOSClipboard(model, "C1", { [ClipboardMIMEType.PlainText]: "a\t1\nb\t2" }); expect(getCellContent(model, "C1")).toBe("a"); expect(getCellContent(model, "C2")).toBe("b"); @@ -580,7 +596,9 @@ describe("clipboard", () => { const model = new Model(); const sheetId = model.getters.getActiveSheetId(); merge(model, "B2:C3"); - const result = pasteFromOSClipboard(model, "B2", "a\t1\nb\t2"); + const result = pasteFromOSClipboard(model, "B2", { + [ClipboardMIMEType.PlainText]: "a\t1\nb\t2", + }); expect(result).toBeCancelledBecause(CommandResult.WillRemoveExistingMerge); expect(model.getters.getMerges(sheetId).map(zoneToXc)).toEqual(["B2:C3"]); }); @@ -588,13 +606,13 @@ describe("clipboard", () => { test("pasting from OS will not change the viewport", () => { const model = new Model(); const viewport = model.getters.getActiveMainViewport(); - pasteFromOSClipboard(model, "C60", "a\t1\nb\t2"); + pasteFromOSClipboard(model, "C60", { [ClipboardMIMEType.PlainText]: "a\t1\nb\t2" }); expect(model.getters.getActiveMainViewport()).toEqual(viewport); }); test("pasting numbers from windows clipboard => interpreted as number", () => { const model = new Model(); - pasteFromOSClipboard(model, "C1", "1\r\n2\r\n3"); + pasteFromOSClipboard(model, "C1", { [ClipboardMIMEType.PlainText]: "1\r\n2\r\n3" }); expect(getCellContent(model, "C1")).toBe("1"); expect(getEvaluatedCell(model, "C1").value).toBe(1); @@ -1956,7 +1974,7 @@ describe("clipboard: pasting outside of sheet", () => { test("can paste multiple cells from os to outside of sheet", () => { const model = new Model(); createSheet(model, { activate: true, sheetId: "2", rows: 2, cols: 2 }); - pasteFromOSClipboard(model, "B2", "A\nque\tcoucou\nBOB"); + pasteFromOSClipboard(model, "B2", { [ClipboardMIMEType.PlainText]: "A\nque\tcoucou\nBOB" }); expect(getCellContent(model, "B2")).toBe("A"); expect(getCellContent(model, "B3")).toBe("que"); expect(getCellContent(model, "C3")).toBe("coucou"); @@ -1968,7 +1986,7 @@ describe("clipboard: pasting outside of sheet", () => { rows: 2, cols: 2, }); - pasteFromOSClipboard(model, "B2", "A\nque\tcoucou\tPatrick"); + pasteFromOSClipboard(model, "B2", { [ClipboardMIMEType.PlainText]: "A\nque\tcoucou\tPatrick" }); expect(getCellContent(model, "B2")).toBe("A"); expect(getCellContent(model, "B3")).toBe("que"); expect(getCellContent(model, "C3")).toBe("coucou"); @@ -1983,7 +2001,7 @@ describe("clipboard: pasting outside of sheet", () => { formulaArgSeparator: ";", thousandsSeparator: " ", }); - pasteFromOSClipboard(model, "A1", "=SUM(5 ; 3,14)"); + pasteFromOSClipboard(model, "A1", { [ClipboardMIMEType.PlainText]: "=SUM(5 ; 3,14)" }); expect(getCell(model, "A1")?.content).toBe("=SUM(5 , 3.14)"); expect(getEvaluatedCell(model, "A1").value).toBe(8.14); }); @@ -2259,3 +2277,313 @@ describe("clipboard: pasting outside of sheet", () => { }); }); }); + +describe("Copy/paste cell content and cell formatting from one given sheet to another sheet in an external spreadsheet", () => { + test("it should copy/paste a cell with basic formatting", () => { + const modelA = new Model(); + const modelB = new Model(); + const cellStyle = { bold: true, fillColor: "#00FF00", fontSize: 20 }; + + setCellContent(modelA, "B2", "b2"); + setStyle(modelA, "B2", cellStyle); + + expect(getCell(modelA, "B2")).toMatchObject({ + content: "b2", + style: cellStyle, + }); + + copy(modelA, "B2"); + const clipboardContent = modelA.getters.getClipboardContent(); + + expect(clipboardContent["text/plain"]).toBe("b2"); + + pasteFromOSClipboard(modelB, "D2", { + [ClipboardMIMEType.PlainText]: clipboardContent["text/plain"] + ? clipboardContent["text/plain"] + : "", + [ClipboardMIMEType.OSpreadsheet]: clipboardContent["web application/o-spreadsheet"], + }); + + expect(getCell(modelA, "B2")).toMatchObject({ + content: "b2", + }); + expect(getCell(modelB, "D2")).toMatchObject({ + content: "b2", + }); + expect(getStyle(modelA, "B2")).toMatchObject(cellStyle); + expect(getStyle(modelB, "D2")).toMatchObject(cellStyle); + }); + + test("it should copy/paste a cell with a border", () => { + const modelA = new Model(); + const modelB = new Model(); + + setCellContent(modelA, "B2", "b2"); + selectCell(modelA, "B2"); + setZoneBorders(modelA, { position: "top" }); + + expect(getBorder(modelA, "B2")).toEqual({ top: DEFAULT_BORDER_DESC }); + + copy(modelA, "B2"); + const clipboardContent = modelA.getters.getClipboardContent(); + + expect(clipboardContent["text/plain"]).toBe("b2"); + + pasteFromOSClipboard(modelB, "D2", { + [ClipboardMIMEType.PlainText]: clipboardContent["text/plain"] + ? clipboardContent["text/plain"] + : "", + [ClipboardMIMEType.OSpreadsheet]: clipboardContent["web application/o-spreadsheet"], + }); + + expect(getCell(modelA, "B2")).toMatchObject({ + content: "b2", + }); + expect(getCell(modelB, "D2")).toMatchObject({ + content: "b2", + }); + expect(getBorder(modelA, "B2")).toEqual({ top: DEFAULT_BORDER_DESC }); + expect(getBorder(modelB, "D2")).toEqual({ top: DEFAULT_BORDER_DESC }); + }); + + test("it should copy/paste a cell with a formula", () => { + const modelA = new Model(); + const modelB = new Model(); + + setCellContent(modelA, "A1", "=SUM(1,2)"); + setCellContent(modelA, "A2", "=SUM(1,2)"); + setCellFormat(modelA, "A2", "0%"); + setCellContent(modelA, "A3", "=DATE(2024,1,1)"); + setCellContent(modelA, "A4", "=DATE(2024,1,1)"); + setCellFormat(modelA, "A4", "m/d/yyyy hh:mm:ss a"); + + copy(modelA, "A1:A4"); + + const clipboardContent = modelA.getters.getClipboardContent(); + + pasteFromOSClipboard(modelB, "D1", { + [ClipboardMIMEType.PlainText]: clipboardContent["text/plain"] + ? clipboardContent["text/plain"] + : "", + [ClipboardMIMEType.OSpreadsheet]: clipboardContent["web application/o-spreadsheet"], + }); + + expect(getCell(modelA, "A1")?.content).toBe("=SUM(1,2)"); + expect(getCell(modelB, "D1")?.content).toBe("=SUM(1,2)"); + expect(getEvaluatedCell(modelA, "A1")?.value).toBe(3); + expect(getEvaluatedCell(modelB, "D1")?.value).toBe(3); + + expect(getCell(modelA, "A2")?.content).toBe("=SUM(1,2)"); + expect(getCell(modelB, "D2")?.content).toBe("=SUM(1,2)"); + expect(getEvaluatedCell(modelA, "A2")?.formattedValue).toBe("300%"); + expect(getEvaluatedCell(modelB, "D2")?.formattedValue).toBe("300%"); + + expect(getEvaluatedCell(modelA, "A3")?.formattedValue).toBe("1/1/2024"); + expect(getEvaluatedCell(modelB, "D3")?.formattedValue).toBe("1/1/2024"); + + expect(getCell(modelA, "A4")?.content).toBe("=DATE(2024,1,1)"); + expect(getCell(modelB, "D4")?.content).toBe("=DATE(2024,1,1)"); + expect(getEvaluatedCell(modelA, "A4")?.formattedValue).toBe("1/1/2024 12:00:00 AM"); + expect(getEvaluatedCell(modelB, "D4")?.formattedValue).toBe("1/1/2024 12:00:00 AM"); + }); + + test("it should copy/paste a cell with a hyperlink", () => { + const modelA = new Model(); + const modelB = new Model(); + const url = "https://www.odoo.com"; + const url_label = "Odoo Website"; + + setCellContent(modelA, "A1", `=HYPERLINK("${url}", "${url_label}")`); + const clipboardContent = modelA.getters.getClipboardContent(); + + copy(modelA, "A1"); + pasteFromOSClipboard(modelB, "D1", { + [ClipboardMIMEType.PlainText]: clipboardContent["text/plain"] + ? clipboardContent["text/plain"] + : "", + [ClipboardMIMEType.OSpreadsheet]: clipboardContent["web application/o-spreadsheet"], + }); + + const cell = getEvaluatedCell(modelB, "D1"); + expect(cell.link?.label).toBe(url_label); + expect(cell.link?.url).toBe(url); + expect(urlRepresentation(cell.link!, modelB.getters)).toBe(url); + expect(getCell(modelB, "D1")?.content).toBe(`=HYPERLINK("${url}", "${url_label}")`); + expect(getStyle(modelB, "D1")).toEqual({ textColor: LINK_COLOR }); + expect(getCellText(modelB, "D1")).toBe(`=HYPERLINK("${url}", "${url_label}")`); + }); + + test("it should copy/paste a table", () => { + const modelA = new Model(); + const modelB = new Model(); + const customBorderStyle: TableBorder = { bottom: { color: "#123456", style: "thin" } }; + const customStyle: TableStyle = { + wholeTable: { border: customBorderStyle }, + firstColumn: { style: { fillColor: "#234567" } }, + totalRow: { style: { textColor: "#345678" } }, + category: "dark", + colorName: "customColor", + }; + + TABLE_PRESETS["customStyle"] = customStyle; + + setCellContent(modelA, "A1", "col1"); + setCellContent(modelA, "B1", "col2"); + setCellContent(modelA, "A2", "1"); + setCellContent(modelA, "B2", "2"); + + createTable(modelA, "A1:B2", { styleId: "customStyle", totalRow: true, firstColumn: true }); + const tableA = modelA.getters.getCoreTables(modelA.getters.getActiveSheetId())[0]; + + expect(tableA).toMatchObject({ range: { zone: toZone("A1:B2") }, type: "static" }); + + const clipboardContent = modelA.getters.getClipboardContent(); + + copy(modelA, "A1:B2"); + pasteFromOSClipboard(modelB, "D1", { + [ClipboardMIMEType.PlainText]: clipboardContent["text/plain"] + ? clipboardContent["text/plain"] + : "", + [ClipboardMIMEType.OSpreadsheet]: clipboardContent["web application/o-spreadsheet"], + }); + + const tableB = modelB.getters.getCoreTables(modelA.getters.getActiveSheetId())[0]; + + expect(tableB).toMatchObject({ range: { zone: toZone("D1:E2") }, type: "static" }); + expect(tableB.config).toMatchObject({ + hasFilters: true, + totalRow: true, + firstColumn: true, + lastColumn: false, + numberOfHeaders: 1, + bandedRows: true, + bandedColumns: false, + automaticAutofill: true, + styleId: "customStyle", + }); + + delete TABLE_PRESETS["customStyle"]; + }); + + test("it should copy/paste from Edit menu", async () => { + const envA: SpreadsheetChildEnv = makeTestEnv(); + const envB: SpreadsheetChildEnv = makeTestEnv(); + const modelA: Model = envA.model; + const modelB: Model = envB.model; + + const cellStyle = { bold: true, fillColor: "#00FF00", fontSize: 20 }; + + setCellContent(modelA, "B2", "b2"); + setStyle(modelA, "B2", cellStyle); + + expect(getCell(modelA, "B2")).toMatchObject({ + content: "b2", + style: cellStyle, + }); + + selectCell(modelA, "B2"); + doAction(["edit", "copy"], envA); + + /** + * Copy the clipboard from envA to envB because + * in this context we need to simulate that + * the clipboard is shared between the two environments + * given that in a real world scenario we are using one + * clipboard which is the machine clipboard. + */ + envB.clipboard = envA.clipboard; + + selectCell(modelB, "D2"); + await doAction(["edit", "paste"], envB); + + expect(getCell(modelA, "B2")).toMatchObject({ + content: "b2", + }); + expect(getCell(modelB, "D2")).toMatchObject({ + content: "b2", + }); + expect(getStyle(modelA, "B2")).toMatchObject(cellStyle); + expect(getStyle(modelB, "D2")).toMatchObject(cellStyle); + }); + + test("it should copy/paste a cell with the cell content and format copied last from an external spreadsheet", () => { + const modelA = new Model(); + const modelB = new Model(); + const cellStyle = { bold: true, fillColor: "#00FF00", fontSize: 20 }; + + setCellContent(modelA, "A1", "a1"); + setStyle(modelA, "A1", cellStyle); + setCellContent(modelB, "C1", "c1"); + setStyle(modelB, "C1", cellStyle); + + expect(getCell(modelA, "A1")).toMatchObject({ + content: "a1", + style: cellStyle, + }); + + expect(getCell(modelB, "C1")).toMatchObject({ + content: "c1", + style: cellStyle, + }); + + copy(modelB, "C1"); + copy(modelA, "A1"); + const clipboardContent = modelA.getters.getClipboardContent(); + + expect(clipboardContent["text/plain"]).toBe("a1"); + + pasteFromOSClipboard(modelB, "B1", { + [ClipboardMIMEType.PlainText]: clipboardContent["text/plain"] + ? clipboardContent["text/plain"] + : "", + [ClipboardMIMEType.OSpreadsheet]: clipboardContent["web application/o-spreadsheet"], + }); + + expect(getCell(modelA, "A1")).toMatchObject({ + content: "a1", + }); + expect(getCell(modelB, "B1")).toMatchObject({ + content: "a1", + }); + expect(getStyle(modelA, "A1")).toMatchObject(cellStyle); + expect(getStyle(modelB, "B1")).toMatchObject(cellStyle); + }); + + test("it should copy/paste a formula cell with dependencies", () => { + const modelA = new Model({ sheets: [{ id: "sheetA" }] }); + const modelB = new Model({ sheets: [{ id: "sheetB" }] }); + + setCellContent(modelA, "A1", "1"); + setCellContent(modelA, "A2", "2"); + setCellContent(modelA, "A3", "=SUM(1,2)"); + setCellContent(modelA, "B1", "4"); + setCellContent(modelA, "B2", "5"); + setCellContent(modelA, "B3", "6"); + setCellContent(modelA, "C1", "=A1*B1"); + setCellContent(modelA, "C2", "=A2*B2"); + setCellContent(modelA, "C3", "=A3*B3"); + + copy(modelA, "A1:C3"); + const clipboardContent = modelA.getters.getClipboardContent(); + + pasteFromOSClipboard(modelB, "E1", { + [ClipboardMIMEType.PlainText]: clipboardContent["text/plain"] + ? clipboardContent["text/plain"] + : "", + [ClipboardMIMEType.OSpreadsheet]: clipboardContent["web application/o-spreadsheet"], + }); + + expect(getCell(modelA, "C1")?.content).toBe("=A1*B1"); + expect(getCell(modelB, "G1")?.content).toBe("=E1*F1"); + expect(getEvaluatedCell(modelA, "C1")?.value).toBe(4); + expect(getEvaluatedCell(modelB, "G1")?.value).toBe(4); + expect(getCell(modelA, "C2")?.content).toBe("=A2*B2"); + expect(getCell(modelB, "G2")?.content).toBe("=E2*F2"); + expect(getEvaluatedCell(modelA, "C2")?.value).toBe(10); + expect(getEvaluatedCell(modelB, "G2")?.value).toBe(10); + expect(getCell(modelA, "C3")?.content).toBe("=A3*B3"); + expect(getCell(modelB, "G3")?.content).toBe("=E3*F3"); + expect(getEvaluatedCell(modelA, "C3")?.value).toBe(18); + expect(getEvaluatedCell(modelB, "G3")?.value).toBe(18); + }); +}); diff --git a/tests/figures/figure_component.test.ts b/tests/figures/figure_component.test.ts index 92fa9ea5ab..c4e30b509b 100644 --- a/tests/figures/figure_component.test.ts +++ b/tests/figures/figure_component.test.ts @@ -11,6 +11,7 @@ import { import { figureRegistry } from "../../src/registries"; import { CreateFigureCommand, Figure, Pixel, SpreadsheetChildEnv, UID } from "../../src/types"; +import { ClipboardMIMEType } from "../../src/types/clipboard"; import { activateSheet, addColumns, @@ -518,9 +519,10 @@ describe("figures", () => { await simulateClick(".o-figure"); await simulateClick(".o-figure-menu-item"); await simulateClick(".o-menu div[data-name='copy']"); - const envClipBoardContent = await env.clipboard.readText(); + const envClipBoardContent = await env.clipboard.read(); if (envClipBoardContent.status === "ok") { - expect(envClipBoardContent.content).toEqual( + const envClipboardTextContent = envClipBoardContent.content[ClipboardMIMEType.PlainText]; + expect(envClipboardTextContent).toEqual( model.getters.getClipboardContent()["text/plain"] ); } @@ -536,9 +538,10 @@ describe("figures", () => { await simulateClick(".o-figure"); await simulateClick(".o-figure-menu-item"); await simulateClick(".o-menu div[data-name='cut']"); - const envClipBoardContent = await env.clipboard.readText(); + const envClipBoardContent = await env.clipboard.read(); if (envClipBoardContent.status === "ok") { - expect(envClipBoardContent.content).toEqual( + const envClipboardTextContent = envClipBoardContent.content[ClipboardMIMEType.PlainText]; + expect(envClipboardTextContent).toEqual( model.getters.getClipboardContent()["text/plain"] ); } diff --git a/tests/grid/grid_component.test.ts b/tests/grid/grid_component.test.ts index e2a6096b62..645b3eb37c 100644 --- a/tests/grid/grid_component.test.ts +++ b/tests/grid/grid_component.test.ts @@ -1369,7 +1369,10 @@ describe("Copy paste keyboard shortcut", () => { setCellContent(model, "A1", "things"); selectCell(model, "A1"); document.body.dispatchEvent(getClipboardEvent("copy", clipboardData)); - expect(clipboardData.content).toEqual({ "text/plain": "things", "text/html": "things" }); + expect(clipboardData.content).toMatchObject({ + "text/plain": "things", + "text/html": "things", + }); selectCell(model, "A2"); document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); expect(getCellContent(model, "A2")).toEqual("things"); @@ -1379,7 +1382,10 @@ describe("Copy paste keyboard shortcut", () => { setCellContent(model, "A1", "things"); selectCell(model, "A1"); document.body.dispatchEvent(getClipboardEvent("cut", clipboardData)); - expect(clipboardData.content).toEqual({ "text/plain": "things", "text/html": "things" }); + expect(clipboardData.content).toMatchObject({ + "text/plain": "things", + "text/html": "things", + }); selectCell(model, "A2"); document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); expect(getCellContent(model, "A1")).toEqual(""); @@ -1541,7 +1547,9 @@ describe("Copy paste keyboard shortcut", () => { createChart(model, {}, "chartId"); model.dispatch("SELECT_FIGURE", { id: "chartId" }); document.body.dispatchEvent(getClipboardEvent("copy", clipboardData)); - expect(clipboardData.content).toEqual({ "text/plain": "\t" }); + expect(clipboardData.content).toMatchObject({ + "text/plain": "\t", + }); document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); expect(model.getters.getChartIds(sheetId)).toHaveLength(2); }); @@ -1551,7 +1559,9 @@ describe("Copy paste keyboard shortcut", () => { createChart(model, {}, "chartId"); model.dispatch("SELECT_FIGURE", { id: "chartId" }); document.body.dispatchEvent(getClipboardEvent("cut", clipboardData)); - expect(clipboardData.content).toEqual({ "text/plain": "\t" }); + expect(clipboardData.content).toMatchObject({ + "text/plain": "\t", + }); document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); expect(model.getters.getChartIds(sheetId)).toHaveLength(1); expect(model.getters.getChartIds(sheetId)[0]).not.toEqual("chartId"); diff --git a/tests/helpers/ui_helpers.test.ts b/tests/helpers/ui_helpers.test.ts index 5e8051c886..75843f1621 100644 --- a/tests/helpers/ui_helpers.test.ts +++ b/tests/helpers/ui_helpers.test.ts @@ -252,7 +252,9 @@ describe("UI Helpers", () => { const clipboardString = "a\t1\nb\t2"; test("Can interactive paste", () => { - interactivePasteFromOS(env, target("D2"), clipboardString); + interactivePasteFromOS(env, target("D2"), { + "text/plain": clipboardString, + }); expect(getCellContent(model, "D2")).toBe("a"); expect(getCellContent(model, "E2")).toBe("1"); expect(getCellContent(model, "D3")).toBe("b"); @@ -262,7 +264,9 @@ describe("UI Helpers", () => { test("Pasting content that will destroy a merge will notify the user", async () => { merge(model, "B2:C3"); selectCell(model, "A1"); - interactivePasteFromOS(env, model.getters.getSelectedZones(), clipboardString); + interactivePasteFromOS(env, model.getters.getSelectedZones(), { + "text/plain": clipboardString, + }); expect(notifyUserTextSpy).toHaveBeenCalledWith( PasteInteractiveContent.willRemoveExistingMerge.toString() ); diff --git a/tests/menus/menu_items_registry.test.ts b/tests/menus/menu_items_registry.test.ts index a8d17bd860..3e35525c8a 100644 --- a/tests/menus/menu_items_registry.test.ts +++ b/tests/menus/menu_items_registry.test.ts @@ -30,7 +30,12 @@ import { updateLocale, updateTableConfig, } from "../test_helpers/commands_helpers"; -import { getCell, getCellContent, getEvaluatedCell } from "../test_helpers/getters_helpers"; +import { + getCell, + getCellContent, + getEvaluatedCell, + getStyle, +} from "../test_helpers/getters_helpers"; import { clearFunctions, doAction, @@ -45,6 +50,8 @@ import { } from "../test_helpers/helpers"; import { Model } from "../../src"; +import { ClipboardMIMEType } from "../../src/types"; + import { ComposerStore } from "../../src/components/composer/composer/composer_store"; import { FONT_SIZES } from "../../src/constants"; import { functionRegistry } from "../../src/functions"; @@ -152,13 +159,13 @@ describe("Menu Item actions", () => { }); test("Edit -> paste from OS clipboard if copied from outside world last", async () => { + setCellContent(model, "A1", "a1"); + selectCell(model, "A1"); doAction(["edit", "copy"], env); // first copy from grid await env.clipboard.writeText("Then copy in OS clipboard"); + selectCell(model, "C3"); await doAction(["edit", "paste"], env); - expect(dispatch).toHaveBeenCalledWith("PASTE_FROM_OS_CLIPBOARD", { - text: "Then copy in OS clipboard", - target: [{ bottom: 0, left: 0, right: 0, top: 0 }], - }); + expect(getCellContent(model, "C3")).toEqual("Then copy in OS clipboard"); }); test("Edit -> paste if copied from grid last", async () => { @@ -187,7 +194,9 @@ describe("Menu Item actions", () => { selectCell(model, "A1"); await doAction(["edit", "paste_special", "paste_special_format"], env); expect(dispatch).toHaveBeenCalledWith("PASTE_FROM_OS_CLIPBOARD", { - text: "Copy in OS clipboard", + clipboardContent: { + [ClipboardMIMEType.PlainText]: "Copy in OS clipboard", + }, target: target("A1"), pasteOption: "onlyFormat", }); @@ -202,11 +211,7 @@ describe("Menu Item actions", () => { await env.clipboard.writeText("Then copy in OS clipboard"); selectCell(model, "A1"); await doAction(["edit", "paste_special", "paste_special_format"], env); - expect(dispatch).toHaveBeenCalledWith("PASTE_FROM_OS_CLIPBOARD", { - text: "Then copy in OS clipboard", - target: target("A1"), - pasteOption: "onlyFormat", - }); + expect(getStyle(model, "A1").fillColor).toBeUndefined(); expect(getCellContent(model, "A1")).toEqual(""); }); @@ -235,7 +240,9 @@ describe("Menu Item actions", () => { await doAction(["edit", "paste_special", "paste_special_value"], env); expect(dispatch).toHaveBeenCalledWith("PASTE_FROM_OS_CLIPBOARD", { target: target("A1"), - text, + clipboardContent: { + [ClipboardMIMEType.PlainText]: text, + }, pasteOption: "asValue", }); }); @@ -255,7 +262,9 @@ describe("Menu Item actions", () => { await doAction(["edit", "paste_special", "paste_special_format"], env); expect(dispatch).toHaveBeenCalledWith("PASTE_FROM_OS_CLIPBOARD", { target: target("A1"), - text, + clipboardContent: { + [ClipboardMIMEType.PlainText]: text, + }, pasteOption: "onlyFormat", }); }); diff --git a/tests/test_helpers/clipboard.ts b/tests/test_helpers/clipboard.ts index 6de1bbe5cb..ce65680b5f 100644 --- a/tests/test_helpers/clipboard.ts +++ b/tests/test_helpers/clipboard.ts @@ -5,18 +5,31 @@ import { import { ClipboardContent, ClipboardMIMEType } from "../../src/types"; export class MockClipboard implements ClipboardInterface { - private content: string | undefined = "Some random clipboard content"; + private content: ClipboardContent = {}; - async readText(): Promise { - return { status: "ok", content: this.content || "" }; + async read(): Promise { + return { + status: "ok", + content: { + [ClipboardMIMEType.PlainText]: this.content[ClipboardMIMEType.PlainText], + [ClipboardMIMEType.Html]: this.content[ClipboardMIMEType.Html], + [ClipboardMIMEType.OSpreadsheet]: this.content[ClipboardMIMEType.OSpreadsheet], + }, + }; } async writeText(text: string): Promise { - this.content = text; + this.content[ClipboardMIMEType.PlainText] = text; + this.content[ClipboardMIMEType.Html] = ""; + this.content[ClipboardMIMEType.OSpreadsheet] = ""; } async write(content: ClipboardContent) { - this.content = content[ClipboardMIMEType.PlainText]; + this.content = { + [ClipboardMIMEType.PlainText]: content[ClipboardMIMEType.PlainText], + [ClipboardMIMEType.Html]: content[ClipboardMIMEType.Html], + [ClipboardMIMEType.OSpreadsheet]: content[ClipboardMIMEType.OSpreadsheet], + }; } } diff --git a/tests/test_helpers/commands_helpers.ts b/tests/test_helpers/commands_helpers.ts index 8b9ba2fd41..361cb8052b 100644 --- a/tests/test_helpers/commands_helpers.ts +++ b/tests/test_helpers/commands_helpers.ts @@ -10,6 +10,8 @@ import { Border, BorderData, ChartDefinition, + ClipboardContent, + ClipboardMIMEType, ClipboardPasteOptions, CreateSheetCommand, CreateTableStyleCommand, @@ -338,11 +340,16 @@ export function paste( export function pasteFromOSClipboard( model: Model, range: string, - content: string, + content: ClipboardContent, pasteOption?: ClipboardPasteOptions ): DispatchResult { return model.dispatch("PASTE_FROM_OS_CLIPBOARD", { - text: content, + clipboardContent: { + [ClipboardMIMEType.PlainText]: content["text/plain"], + [ClipboardMIMEType.OSpreadsheet]: content["web application/o-spreadsheet"] + ? content["web application/o-spreadsheet"] + : undefined, + }, target: target(range), pasteOption, });