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 48e389d395..1b5fb8f8c8 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -21,7 +21,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"; @@ -738,7 +738,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 a777741029..c6d62595db 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 f71b347793..6321157518 100644 --- a/tests/menus/menu_items_registry.test.ts +++ b/tests/menus/menu_items_registry.test.ts @@ -29,7 +29,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, @@ -44,6 +49,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"; @@ -151,13 +158,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 () => { @@ -186,7 +193,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", }); @@ -201,11 +210,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(""); }); @@ -234,7 +239,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", }); }); @@ -254,7 +261,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 97d54994a9..089bab7df6 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, DataValidationCriterion, @@ -337,11 +339,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, });