From 78fff7774e98843640ccb97dff31261a05b9c82a Mon Sep 17 00:00:00 2001 From: "Mehdi Rachico (mera)" Date: Thu, 11 Apr 2024 13:45:33 +0200 Subject: [PATCH] [IMP] clipboard: preserve cell style and format when copy/pasting cross spreadsheets Before this commit, copying and pasting content cross spreadsheets removes all cell style and format and only keeps cell values. This is because the model clipboard is invalidated from one instance to another. This commit solves the issue by adding a new custom type in the os clipboard (`application/o-spreadsheet`) and using the content saved in this key to re-create the cell style and format in the new spreadsheet. NOTE: After an assessment of the capabilities and limitations of most modern browsers, here's the bottom line till the date of this commit: For Google Chrome (and all chromium browsers: Opera, Edge, Brave,...): read/write are supported for custom types; Therefore cross spreadsheet copy/paste works like a charm. For Safari and Mozilla Firefox: saving custom types in the os clipboard is not supported; Therefore, cross spreadsheet copy/paste does not work. Task: 3597039 --- src/actions/menu_items_actions.ts | 17 +- src/clipboard_handlers/cell_clipboard.ts | 38 ++-- src/clipboard_handlers/tables_clipboard.ts | 10 +- src/components/grid/grid.ts | 58 +++-- .../clipboard/navigator_clipboard_wrapper.ts | 62 +++++- src/helpers/ui/paste_interactive.ts | 36 ++- src/plugins/core/cell.ts | 32 +-- .../evaluation_conditional_format.ts | 7 +- .../evaluation_data_validation.ts | 7 +- src/plugins/ui_feature/sort.ts | 2 +- src/plugins/ui_stateful/clipboard.ts | 40 +++- src/registries/autofill_modifiers.ts | 2 +- src/types/clipboard.ts | 1 + src/types/commands.ts | 4 +- src/types/misc.ts | 9 +- src/types/table.ts | 2 +- .../clipboard_plugin.test.ts.snap | 3 - tests/clipboard/clipboard_plugin.test.ts | 209 ++++++++++++++++-- tests/evaluation/formulas.test.ts | 7 +- tests/figures/figure_component.test.ts | 11 +- tests/grid/grid_component.test.ts | 53 ++++- tests/helpers/ui_helpers.test.ts | 8 +- tests/menus/menu_items_registry.test.ts | 57 +++-- ...u_items_registry_cross_spreadsheet.test.ts | 42 ++++ .../spreadsheet/spreadsheet_component.test.ts | 1 - tests/test_helpers/clipboard.ts | 23 +- tests/test_helpers/commands_helpers.ts | 5 +- 27 files changed, 570 insertions(+), 176 deletions(-) delete mode 100644 tests/clipboard/__snapshots__/clipboard_plugin.test.ts.snap create mode 100644 tests/menus/menu_items_registry_cross_spreadsheet.test.ts diff --git a/src/actions/menu_items_actions.ts b/src/actions/menu_items_actions.ts index 49dd76a1f6..1aa1fa0bc1 100644 --- a/src/actions/menu_items_actions.ts +++ b/src/actions/menu_items_actions.ts @@ -51,13 +51,22 @@ export const PASTE_ACTION = async (env: SpreadsheetChildEnv) => paste(env); export const PASTE_AS_VALUE_ACTION = async (env: SpreadsheetChildEnv) => paste(env, "asValue"); async function paste(env: SpreadsheetChildEnv, pasteOption?: ClipboardPasteOptions) { - const spreadsheetClipboard = env.model.getters.getClipboardTextContent(); - const osClipboard = await env.clipboard.readText(); - + const osClipboard = await env.clipboard.read(); switch (osClipboard.status) { case "ok": + const htmlDocument = new DOMParser().parseFromString( + osClipboard.content[ClipboardMIMEType.Html] ?? "
", + "text/xml" + ); + const osClipboardSpreadsheetContent = + osClipboard.content[ClipboardMIMEType.OSpreadsheet] || "{}"; + let clipboardId = + JSON.parse(osClipboardSpreadsheetContent).clipboardId ?? + htmlDocument.querySelector("div")?.getAttribute("data-clipboard-id"); + const target = env.model.getters.getSelectedZones(); - if (osClipboard && osClipboard.content !== spreadsheetClipboard) { + + if (env.model.getters.getClipboardId() !== clipboardId) { interactivePasteFromOS(env, target, osClipboard.content, pasteOption); } else { interactivePaste(env, target, pasteOption); diff --git a/src/clipboard_handlers/cell_clipboard.ts b/src/clipboard_handlers/cell_clipboard.ts index 3c686aa8dd..8f7ce83141 100644 --- a/src/clipboard_handlers/cell_clipboard.ts +++ b/src/clipboard_handlers/cell_clipboard.ts @@ -78,7 +78,12 @@ export class CellClipboardHandler extends AbstractCellClipboardHandler< } } cellsInRow.push({ - cell, + content: cell?.content ?? "", + style: cell?.style, + format: cell?.format, + tokens: cell?.isFormula + ? cell.compiledFormula.tokens.map(({ value, type }) => ({ value, type })) + : [], border: this.getters.getCellBorder(position) || undefined, evaluatedCell, position, @@ -194,7 +199,7 @@ export class CellClipboardHandler extends AbstractCellClipboardHandler< private clearClippedZones(content: ClipboardContent) { for (const row of content.cells) { for (const cell of row) { - if (cell.cell) { + if (cell.position) { this.dispatch("CLEAR_CELL", cell.position); } } @@ -234,7 +239,7 @@ export class CellClipboardHandler extends AbstractCellClipboardHandler< ) { const { sheetId, col, row } = target; const targetCell = this.getters.getEvaluatedCell(target); - const originFormat = origin.cell?.format ?? origin.evaluatedCell.format; + const originFormat = origin?.format ?? origin.evaluatedCell.format; if (clipboardOption?.pasteOption === "asValue") { const locale = this.getters.getLocale(); @@ -246,29 +251,33 @@ export class CellClipboardHandler extends AbstractCellClipboardHandler< if (clipboardOption?.pasteOption === "onlyFormat") { this.dispatch("UPDATE_CELL", { ...target, - style: origin.cell?.style ?? null, + style: origin?.style ?? null, format: originFormat ?? targetCell.format, }); return; } - let content = origin.cell?.content; - if (origin.cell?.isFormula && !clipboardOption?.isCutOperation) { + let content = origin?.content; + if (origin?.tokens && origin.tokens.length > 0 && !clipboardOption?.isCutOperation) { content = this.getters.getTranslatedCellFormula( sheetId, col - origin.position.col, row - origin.position.row, - origin.cell.compiledFormula + origin.tokens + ); + } else if (origin?.tokens && origin.tokens.length > 0) { + content = this.getters.getFormulaMovedInSheet( + origin.position.sheetId, + sheetId, + origin.tokens ); - } else if (origin.cell?.isFormula) { - content = this.getters.getFormulaMovedInSheet(sheetId, origin.cell.compiledFormula); } - if (content !== "" || origin.cell?.format || origin.cell?.style) { + if (content !== "" || origin?.format || origin?.style) { this.dispatch("UPDATE_CELL", { ...target, content, - style: origin.cell?.style || null, - format: origin.cell?.format, + style: origin?.style || null, + format: origin?.format, }); } else if (targetCell) { this.dispatch("CLEAR_CELL", target); @@ -293,10 +302,7 @@ export class CellClipboardHandler extends AbstractCellClipboardHandler< for (let i = 0; i < rowLength; i++) { const content = canonicalizeNumberValue(row[i] || "", locale); cells.push({ - cell: { - isFormula: false, - content, - }, + content: content, evaluatedCell: { formattedValue: content, }, diff --git a/src/clipboard_handlers/tables_clipboard.ts b/src/clipboard_handlers/tables_clipboard.ts index dade0b090d..228a735143 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; } @@ -68,7 +68,7 @@ export class TableClipboardHandler extends AbstractCellClipboardHandler< } tableCellsInRow.push({ table: { - range: coreTable.range, + range: coreTable.range.rangeData, config: coreTable.config, type: coreTable.type, }, @@ -125,7 +125,7 @@ export class TableClipboardHandler extends AbstractCellClipboardHandler< if (tableCell.table) { this.dispatch("REMOVE_TABLE", { sheetId: content.sheetId, - target: [tableCell.table.range.zone], + target: [this.getters.getRangeFromRangeData(tableCell.table.range).zone], }); } } @@ -168,7 +168,7 @@ export class TableClipboardHandler extends AbstractCellClipboardHandler< ) { if (tableCell.table && !options?.pasteOption) { const { range: tableRange } = tableCell.table; - const zoneDims = zoneToDimension(tableRange.zone); + const zoneDims = zoneToDimension(this.getters.getRangeFromRangeData(tableRange).zone); const newTableZone = { left: position.col, top: position.row, diff --git a/src/components/grid/grid.ts b/src/components/grid/grid.ts index 422cf0e0c7..0a92d826c3 100644 --- a/src/components/grid/grid.ts +++ b/src/components/grid/grid.ts @@ -37,7 +37,6 @@ import { Store, useStore } from "../../store_engine"; import { DOMFocusableElementStore } from "../../stores/DOM_focus_store"; import { ArrayFormulaHighlight } from "../../stores/array_formula_highlight"; import { HighlightStore } from "../../stores/highlight_store"; -import { _t } from "../../translation"; import { Align, CellValueType, @@ -595,17 +594,11 @@ export class Grid extends Component { this.menuState.menuItems = registries[type].getMenuItems(); } - copy(cut: boolean, ev: ClipboardEvent) { + async copy(cut: boolean, ev: ClipboardEvent) { if (!this.gridEl.contains(document.activeElement)) { return; } - const clipboardData = ev.clipboardData; - if (!clipboardData) { - this.displayWarningCopyPasteNotSupported(); - return; - } - /* If we are currently editing a cell, let the default behavior */ if (this.composerFocusStore.activeComposer.editionMode !== "inactive") { return; @@ -616,9 +609,7 @@ export class Grid extends Component { this.env.model.dispatch("COPY"); } const content = this.env.model.getters.getClipboardContent(); - for (const type in content) { - clipboardData.setData(type, content[type]); - } + await this.env.clipboard.write(content); ev.preventDefault(); } @@ -627,32 +618,33 @@ export class Grid extends Component { return; } - const clipboardData = ev.clipboardData; - if (!clipboardData) { - this.displayWarningCopyPasteNotSupported(); + ev.preventDefault(); + + const clipboard = await this.env.clipboard.read(); + if (clipboard.status !== "ok") { return; } + const htmlDocument = new DOMParser().parseFromString( + clipboard.content[ClipboardMIMEType.Html] ?? "
", + "text/xml" + ); + const osClipboardSpreadsheetContent = clipboard.content[ClipboardMIMEType.OSpreadsheet] || "{}"; - if (clipboardData.types.indexOf(ClipboardMIMEType.PlainText) > -1) { - ev.preventDefault(); - const content = clipboardData.getData(ClipboardMIMEType.PlainText); - const target = this.env.model.getters.getSelectedZones(); - const clipboardString = this.env.model.getters.getClipboardTextContent(); - const isCutOperation = this.env.model.getters.isCutOperation(); - if (clipboardString === content) { - // the paste actually comes from o-spreadsheet itself - interactivePaste(this.env, target); - } else { - interactivePasteFromOS(this.env, target, content); - } - if (isCutOperation) { - await this.env.clipboard.write({ [ClipboardMIMEType.PlainText]: "" }); - } - } - } + const target = this.env.model.getters.getSelectedZones(); + const isCutOperation = this.env.model.getters.isCutOperation(); - private displayWarningCopyPasteNotSupported() { - this.env.raiseError(_t("Copy/Paste is not supported in this browser.")); + const clipboardId = + JSON.parse(osClipboardSpreadsheetContent).clipboardId ?? + htmlDocument.querySelector("div")?.getAttribute("data-clipboard-id"); + + if (this.env.model.getters.getClipboardId() === clipboardId) { + interactivePaste(this.env, target); + } else { + interactivePasteFromOS(this.env, target, clipboard.content); + } + if (isCutOperation) { + await this.env.clipboard.write({ [ClipboardMIMEType.PlainText]: "" }); + } } private clearFormatting() { diff --git a/src/helpers/clipboard/navigator_clipboard_wrapper.ts b/src/helpers/clipboard/navigator_clipboard_wrapper.ts index a12a117861..7f4e389ae3 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 { @@ -19,9 +19,29 @@ class WebClipboardWrapper implements ClipboardInterface { constructor(private clipboard: Clipboard | undefined) {} async write(clipboardContent: ClipboardContent): Promise { - try { - this.clipboard?.write(this.getClipboardItems(clipboardContent)); - } catch (e) {} + if (this.clipboard?.write) { + try { + await this.clipboard?.write(this.getClipboardItems(clipboardContent)); + } catch (e) { + /** + * Some browsers (e.g firefox, safari) do not support writing + * custom mimetypes in the clipboard. Therefore, we try to catch + * any errors and fallback on writing only standard mimetypes to + * prevent the whole copy action from crashing. + */ + await this.clipboard?.write([ + new ClipboardItem({ + [ClipboardMIMEType.PlainText]: this.getBlob( + clipboardContent, + ClipboardMIMEType.PlainText + ), + [ClipboardMIMEType.Html]: this.getBlob(clipboardContent, ClipboardMIMEType.Html), + }), + ]); + } + } else { + await this.writeText(clipboardContent[ClipboardMIMEType.PlainText] ?? ""); + } } async writeText(text: string): Promise { @@ -30,18 +50,35 @@ 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(); - return { status: "ok", content: clipboardContent }; - } catch (e) { - const status = permissionResult?.state === "denied" ? "permissionDenied" : "notImplemented"; - return { status }; + if (this.clipboard?.read) { + try { + const clipboardItems = await this.clipboard.read(); + const clipboardContent: ClipboardContent = {}; + for (const item of clipboardItems) { + for (const type of item.types) { + const blob = await item.getType(type); + const text = await blob.text(); + clipboardContent[type as ClipboardMIMEType] = text; + } + } + return { status: "ok", content: clipboardContent }; + } catch (e) { + const status = permissionResult?.state === "denied" ? "permissionDenied" : "notImplemented"; + return { status }; + } + } else { + return { + status: "ok", + content: { + [ClipboardMIMEType.PlainText]: await this.clipboard?.readText(), + }, + }; } } @@ -50,6 +87,7 @@ class WebClipboardWrapper implements ClipboardInterface { new ClipboardItem({ [ClipboardMIMEType.PlainText]: this.getBlob(content, ClipboardMIMEType.PlainText), [ClipboardMIMEType.Html]: this.getBlob(content, ClipboardMIMEType.Html), + [ClipboardMIMEType.OSpreadsheet]: this.getBlob(content, ClipboardMIMEType.OSpreadsheet), }), ]; } diff --git a/src/helpers/ui/paste_interactive.ts b/src/helpers/ui/paste_interactive.ts index c5bbe8231d..3eb5d97d07 100644 --- a/src/helpers/ui/paste_interactive.ts +++ b/src/helpers/ui/paste_interactive.ts @@ -1,5 +1,8 @@ +import { CURRENT_VERSION } from "../../migrations/data"; import { _t } from "../../translation"; import { + ClipboardContent, + ClipboardMIMEType, ClipboardPasteOptions, CommandResult, DispatchResult, @@ -42,9 +45,38 @@ 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 }); + let result: DispatchResult; + // We do not trust the clipboard content to be accurate and comprehensive. + // Therefore, to ensure reliability, we handle unexpected errors that may + // arise from content that would not be suitable for the current version. + try { + result = env.model.dispatch("PASTE_FROM_OS_CLIPBOARD", { + target, + clipboardContent, + pasteOption, + }); + } catch (error) { + const parsedSpreadsheetContent = clipboardContent[ClipboardMIMEType.OSpreadsheet] + ? JSON.parse(clipboardContent[ClipboardMIMEType.OSpreadsheet]) + : {}; + if (parsedSpreadsheetContent.version && parsedSpreadsheetContent.version !== CURRENT_VERSION) { + env.raiseError( + _t( + "An unexpected error occurred while pasting content.\ + This is probably due to a spreadsheet version mismatch." + ) + ); + } + result = env.model.dispatch("PASTE_FROM_OS_CLIPBOARD", { + target, + clipboardContent: { + [ClipboardMIMEType.PlainText]: clipboardContent[ClipboardMIMEType.PlainText], + }, + pasteOption, + }); + } handlePasteResult(env, result); } diff --git a/src/plugins/core/cell.ts b/src/plugins/core/cell.ts index 19192e9c84..cb5e0dbb92 100644 --- a/src/plugins/core/cell.ts +++ b/src/plugins/core/cell.ts @@ -1,5 +1,6 @@ import { DEFAULT_STYLE } from "../../constants"; import { Token, compile } from "../../formulas"; +import { compileTokens } from "../../formulas/compiler"; import { isEvaluationError, toString } from "../../functions/helpers"; import { deepEquals, isExcelCompatible, recomputeZones } from "../../helpers"; import { parseLiteral } from "../../helpers/cells"; @@ -340,13 +341,13 @@ export class CellPlugin extends CorePlugin implements CoreState { */ private getFormulaCellContent( sheetId: UID, - compiledFormula: RangeCompiledFormula, + tokens: Token[], dependencies: Range[], useFixedReference: boolean = false ): string { let rangeIndex = 0; return concat( - compiledFormula.tokens.map((token) => { + tokens.map((token) => { if (token.type === "REFERENCE") { const range = dependencies[rangeIndex++]; return this.getters.getRangeString(range, sheetId, { useFixedReference }); @@ -359,25 +360,30 @@ export class CellPlugin extends CorePlugin implements CoreState { /* * Constructs a formula string based on an initial formula and a translation vector */ - getTranslatedCellFormula( - sheetId: UID, - offsetX: number, - offsetY: number, - compiledFormula: RangeCompiledFormula - ) { + getTranslatedCellFormula(sheetId: UID, offsetX: number, offsetY: number, tokens: Token[]) { const adaptedDependencies = this.getters.createAdaptedRanges( - compiledFormula.dependencies, + compileTokens(tokens).dependencies.map((d) => this.getters.getRangeFromSheetXC(sheetId, d)), offsetX, offsetY, sheetId ); - return this.getFormulaCellContent(sheetId, compiledFormula, adaptedDependencies); + if (adaptedDependencies.length > 0) { + return this.getFormulaCellContent(sheetId, tokens, adaptedDependencies); + } else { + return concat(tokens.map(({ value }) => value)); + } } - getFormulaMovedInSheet(targetSheetId: UID, compiledFormula: RangeCompiledFormula) { - const dependencies = compiledFormula.dependencies; + getFormulaMovedInSheet(originSheetId: UID, targetSheetId: UID, tokens: Token[]) { + const dependencies = compileTokens(tokens).dependencies.map((d) => + this.getters.getRangeFromSheetXC(originSheetId, d) + ); const adaptedDependencies = this.getters.removeRangesSheetPrefix(targetSheetId, dependencies); - return this.getFormulaCellContent(targetSheetId, compiledFormula, adaptedDependencies); + if (adaptedDependencies.length > 0) { + return this.getFormulaCellContent(targetSheetId, tokens, adaptedDependencies); + } else { + return concat(tokens.map(({ value }) => value)); + } } getCellStyle(position: CellPosition): Style { diff --git a/src/plugins/ui_core_views/evaluation_conditional_format.ts b/src/plugins/ui_core_views/evaluation_conditional_format.ts index 154314b24f..08da3f0691 100644 --- a/src/plugins/ui_core_views/evaluation_conditional_format.ts +++ b/src/plugins/ui_core_views/evaluation_conditional_format.ts @@ -114,12 +114,7 @@ export class EvaluationConditionalFormatPlugin extends UIPlugin { sheetId, col - zone.left, row - zone.top, - { - ...compiledFormula, - dependencies: compiledFormula.dependencies.map((d) => - this.getters.getRangeFromSheetXC(sheetId, d) - ), - } + compiledFormula.tokens ); } return value; diff --git a/src/plugins/ui_core_views/evaluation_data_validation.ts b/src/plugins/ui_core_views/evaluation_data_validation.ts index 6728c49f3a..885045b211 100644 --- a/src/plugins/ui_core_views/evaluation_data_validation.ts +++ b/src/plugins/ui_core_views/evaluation_data_validation.ts @@ -210,12 +210,7 @@ export class EvaluationDataValidationPlugin extends UIPlugin { sheetId, offset.col, offset.row, - { - ...formula, - dependencies: formula.dependencies.map((d) => - this.getters.getRangeFromSheetXC(sheetId, d) - ), - } + formula.tokens ); const evaluated = this.getters.evaluateFormula(sheetId, translatedFormula); diff --git a/src/plugins/ui_feature/sort.ts b/src/plugins/ui_feature/sort.ts index 4fbbae3156..9d42f750f6 100644 --- a/src/plugins/ui_feature/sort.ts +++ b/src/plugins/ui_feature/sort.ts @@ -157,7 +157,7 @@ export class SortPlugin extends UIPlugin { sheetId, 0, newRow - position.row, - cell.compiledFormula + cell.compiledFormula.tokens ); } newCellValues.style = cell.style; diff --git a/src/plugins/ui_stateful/clipboard.ts b/src/plugins/ui_stateful/clipboard.ts index d02b27d8d7..9c918f8f25 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, @@ -48,6 +49,7 @@ export class ClipboardPlugin extends UIPlugin { static layers = ["Clipboard"] as const; static getters = [ "getClipboardContent", + "getClipboardId", "getClipboardTextContent", "isCutOperation", "isPaintingFormat", @@ -58,6 +60,7 @@ export class ClipboardPlugin extends UIPlugin { private originSheetId?: UID; private copiedData?: MinimalClipboardData; private _isCutOperation: boolean = false; + private clipboardId = new UuidGenerator().uuidv4(); // --------------------------------------------------------------------------- // Command Handling @@ -69,7 +72,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, isCutOperation: false }); @@ -131,7 +136,13 @@ export class ClipboardPlugin extends UIPlugin { break; case "PASTE_FROM_OS_CLIPBOARD": { this._isCutOperation = false; - 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, this.copiedData, { @@ -490,13 +501,26 @@ 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.getSerializedGridData(), }; } + private getSerializedGridData(): string { + return JSON.stringify({ + ...this.copiedData, + version: CURRENT_VERSION, + clipboardId: this.clipboardId, + }); + } + private getPlainTextContent(): string { if (!this.copiedData?.cells) { return "\t"; @@ -506,8 +530,8 @@ export class ClipboardPlugin extends UIPlugin { .map((cells) => { return cells .map((c) => - this.getters.shouldShowFormulas() && c.cell?.isFormula - ? c.cell?.content || "" + this.getters.shouldShowFormulas() && c?.tokens?.length + ? c?.content || "" : c.evaluatedCell?.formattedValue || "" ) .join("\t"); @@ -518,7 +542,7 @@ export class ClipboardPlugin extends UIPlugin { private getHTMLContent(): string | undefined { if (!this.copiedData?.cells) { - return undefined; + return `
\t
`; } const cells = this.copiedData.cells; if (cells.length === 1 && cells[0].length === 1) { @@ -528,7 +552,7 @@ export class ClipboardPlugin extends UIPlugin { return ""; } - let htmlTable = ''; + let htmlTable = `
`; for (const row of cells) { htmlTable += ""; for (const cell of row) { @@ -543,7 +567,7 @@ export class ClipboardPlugin extends UIPlugin { } htmlTable += ""; } - htmlTable += "
"; + htmlTable += ""; return htmlTable; } diff --git a/src/registries/autofill_modifiers.ts b/src/registries/autofill_modifiers.ts index ac41b10741..0198ea84b7 100644 --- a/src/registries/autofill_modifiers.ts +++ b/src/registries/autofill_modifiers.ts @@ -106,7 +106,7 @@ autofillModifiersRegistry return { cellData: {} }; } const sheetId = data.sheetId; - const content = getters.getTranslatedCellFormula(sheetId, x, y, cell.compiledFormula); + const content = getters.getTranslatedCellFormula(sheetId, x, y, cell.compiledFormula.tokens); return { cellData: { border: data.border, diff --git a/src/types/clipboard.ts b/src/types/clipboard.ts index 94ed3f7612..846c0ae726 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 3dca58cbfc..02df7b4868 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"; @@ -756,7 +756,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/misc.ts b/src/types/misc.ts index 574a4d659b..20dfcb2f6c 100644 --- a/src/types/misc.ts +++ b/src/types/misc.ts @@ -1,4 +1,4 @@ -import { Cell, CellValue, EvaluatedCell } from "./cells"; +import { CellValue, EvaluatedCell } from "./cells"; import { CommandResult } from "./commands"; // ----------------------------------------------------------------------------- @@ -199,10 +199,13 @@ export function isMatrix(x: any): x is Matrix { } export interface ClipboardCell { - cell?: Cell; evaluatedCell: EvaluatedCell; - border?: Border; position: CellPosition; + content: string; + style?: Style | undefined; + format?: Format | undefined; + tokens?: Token[]; + border?: Border; } export interface HeaderDimensions { 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/__snapshots__/clipboard_plugin.test.ts.snap b/tests/clipboard/__snapshots__/clipboard_plugin.test.ts.snap deleted file mode 100644 index 393f4eecdd..0000000000 --- a/tests/clipboard/__snapshots__/clipboard_plugin.test.ts.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`clipboard Copied cells HTML Copied HTML table snapshot 1`] = `"
12
3
"`; diff --git a/tests/clipboard/clipboard_plugin.test.ts b/tests/clipboard/clipboard_plugin.test.ts index f1b74480c7..667349dad2 100644 --- a/tests/clipboard/clipboard_plugin.test.ts +++ b/tests/clipboard/clipboard_plugin.test.ts @@ -1,7 +1,8 @@ import { clipboardHandlersRegistries } from "../../src/clipboard_handlers"; -import { DEFAULT_BORDER_DESC } from "../../src/constants"; -import { toCartesian, toZone, zoneToXc } from "../../src/helpers"; +import { DEFAULT_BORDER_DESC, LINK_COLOR } from "../../src/constants"; +import { markdownLink, toCartesian, toZone, zoneToXc } from "../../src/helpers"; import { getClipboardDataPositions } from "../../src/helpers/clipboard/clipboard_helpers"; +import { urlRepresentation } from "../../src/helpers/links"; import { Model } from "../../src/model"; import { ClipboardMIMEType, @@ -203,9 +204,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(""); }); @@ -552,7 +553,8 @@ describe("clipboard", () => { setCellContent(model, "A2", "3"); copy(model, "A1:B2"); const htmlContent = model.getters.getClipboardContent()[ClipboardMIMEType.Html]!; - expect(htmlContent).toMatchSnapshot(); + const expectedHtmlContent = `
12
3
`; + expect(htmlContent).toBe(expectedHtmlContent); }); test("Copied group of cells are represented as a valid HTML table in the clipboard", async () => { @@ -563,7 +565,7 @@ describe("clipboard", () => { const htmlContent = model.getters.getClipboardContent()[ClipboardMIMEType.Html]!; const parsedHTML = parseXML(new XMLString(htmlContent), "text/html"); - expect(parsedHTML.body.firstElementChild?.tagName).toBe("TABLE"); + expect(parsedHTML.body.firstElementChild?.tagName).toBe("DIV"); const tableRows = parsedHTML.querySelectorAll("tr"); expect(tableRows).toHaveLength(2); expect(tableRows[0].querySelectorAll("td")).toHaveLength(2); @@ -663,7 +665,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"); @@ -675,7 +677,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"]); }); @@ -683,13 +687,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); @@ -1458,6 +1462,18 @@ describe("clipboard", () => { }); }); + test("can cut and paste an invalid formula", () => { + const model = new Model(); + setCellContent(model, "A1", "=(+)"); + setCellContent(model, "A2", "=C1{C2"); + cut(model, "A1:A2"); + paste(model, "C1"); + expect(getCellText(model, "C1")).toBe("=(+)"); + expect(getCellText(model, "C2")).toBe("=C1{C2"); + expect(getCellText(model, "A1")).toBe(""); + expect(getCellText(model, "A2")).toBe(""); + }); + test("cut/paste a formula with references does not update references in the formula", () => { const model = new Model(); setCellContent(model, "A1", "=SUM(C1:C2)"); @@ -2279,7 +2295,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"); @@ -2291,7 +2307,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"); @@ -2306,7 +2322,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); }); @@ -2611,6 +2627,173 @@ describe("clipboard: pasting outside of sheet", () => { }); }); +describe("cross spreadsheet copy/paste", () => { + test("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", clipboardContent); + + expect(getCell(modelA, "B2")?.content).toBe("b2"); + expect(getCell(modelB, "D2")?.content).toBe("b2"); + expect(getStyle(modelA, "B2")).toEqual(cellStyle); + expect(getStyle(modelB, "D2")).toEqual(cellStyle); + }); + + test("should copy/paste a cell with a border", () => { + const modelA = new Model(); + const modelB = new Model(); + + selectCell(modelA, "B2"); + setZoneBorders(modelA, { position: "top" }); + + expect(getBorder(modelA, "B2")).toEqual({ top: DEFAULT_BORDER_DESC }); + + copy(modelA, "B2"); + const clipboardContent = modelA.getters.getClipboardContent(); + + pasteFromOSClipboard(modelB, "D2", clipboardContent); + + expect(getBorder(modelA, "B2")).toEqual({ top: DEFAULT_BORDER_DESC }); + expect(getBorder(modelB, "D2")).toEqual({ top: DEFAULT_BORDER_DESC }); + }); + + test("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"); + setCellContent(modelA, "A5", "=SOMME(1,2)"); + + copy(modelA, "A1:A5"); + const clipboardContent = modelA.getters.getClipboardContent(); + pasteFromOSClipboard(modelB, "D1", clipboardContent); + + expect(getCell(modelB, "D1")?.content).toBe("=SUM(1,2)"); + expect(getCell(modelB, "D2")?.content).toBe("=SUM(1,2)"); + expect(getCell(modelB, "D3")?.content).toBe("=DATE(2024,1,1)"); + expect(getCell(modelB, "D4")?.content).toBe("=DATE(2024,1,1)"); + expect(getCell(modelB, "D5")?.content).toBe("=SOMME(1,2)"); + }); + + test("should copy/paste a cell with a markdown link", () => { + const modelA = new Model(); + const modelB = new Model(); + const url = "https://www.odoo.com"; + const urlLabel = "Odoo Website"; + + setCellContent(modelA, "A1", markdownLink(urlLabel, url)); + copy(modelA, "A1"); + const clipboardContent = modelA.getters.getClipboardContent(); + pasteFromOSClipboard(modelB, "D1", clipboardContent); + + const cell = getEvaluatedCell(modelB, "D1"); + expect(cell.link?.label).toBe(urlLabel); + expect(cell.link?.url).toBe(url); + expect(urlRepresentation(cell.link!, modelB.getters)).toBe(url); + expect(getCell(modelB, "D1")?.content).toBe("[Odoo Website](https://www.odoo.com)"); + expect(getStyle(modelB, "D1")).toEqual({ textColor: LINK_COLOR }); + expect(getCellText(modelB, "D1")).toBe("Odoo Website"); + }); + + test("should copy/paste a table", () => { + const modelA = new Model(); + const modelB = new Model(); + + createTable(modelA, "A1:B2"); + const tableA = modelA.getters.getCoreTables(modelA.getters.getActiveSheetId())[0]; + + expect(tableA).toMatchObject({ range: { zone: toZone("A1:B2") }, type: "static" }); + + copy(modelA, "A1:B2"); + const clipboardContent = modelA.getters.getClipboardContent(); + pasteFromOSClipboard(modelB, "D1", clipboardContent); + + const tableB = modelB.getters.getCoreTables(modelA.getters.getActiveSheetId())[0]; + + expect(tableB).toMatchObject({ range: { zone: toZone("D1:E2") }, type: "static" }); + expect(tableB.config).toEqual(tableA.config); + }); + + test("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("should copy/paste a formula cell with dependencies", () => { + const modelA = new Model({ sheets: [{ id: "sheetA" }] }); + const modelB = new Model({ sheets: [{ id: "sheetB" }] }); + + setCellContent(modelA, "C1", "=A1*B1"); + setCellContent(modelA, "C2", "=A2*B2"); + setCellContent(modelA, "C3", "=A3*B3"); + + copy(modelA, "A1:C3"); + pasteFromOSClipboard(modelB, "E1", modelA.getters.getClipboardContent()); + + expect(getCell(modelB, "G1")?.content).toBe("=E1*F1"); + expect(getCell(modelB, "G2")?.content).toBe("=E2*F2"); + expect(getCell(modelB, "G3")?.content).toBe("=E3*F3"); + }); +}); + test("Can use clipboard handlers to paste in a sheet other than the active sheet", () => { model = new Model(); const sheetId = model.getters.getActiveSheetId(); diff --git a/tests/evaluation/formulas.test.ts b/tests/evaluation/formulas.test.ts index be95ed1b2f..854d76a075 100644 --- a/tests/evaluation/formulas.test.ts +++ b/tests/evaluation/formulas.test.ts @@ -13,7 +13,12 @@ function moveFormula(model: Model, formula: string, offsetX: number, offsetY: nu const sheetId = model.getters.getActiveSheetId(); setCellContent(model, "A1", formula); const cell = getCell(model, "A1") as FormulaCell; - return model.getters.getTranslatedCellFormula(sheetId, offsetX, offsetY, cell.compiledFormula); + return model.getters.getTranslatedCellFormula( + sheetId, + offsetX, + offsetY, + cell.compiledFormula.tokens + ); } describe("createAdaptedRanges", () => { diff --git a/tests/figures/figure_component.test.ts b/tests/figures/figure_component.test.ts index 60c34720ed..d30e38dc27 100644 --- a/tests/figures/figure_component.test.ts +++ b/tests/figures/figure_component.test.ts @@ -12,6 +12,7 @@ import { figureRegistry } from "../../src/registries"; import { CreateFigureCommand, Pixel, SpreadsheetChildEnv, UID } from "../../src/types"; import { FigureComponent } from "../../src/components/figures/figure/figure"; +import { ClipboardMIMEType } from "../../src/types/clipboard"; import { activateSheet, addColumns, @@ -525,9 +526,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"] ); } @@ -543,9 +545,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 1c27d1a81a..7873a356c1 100644 --- a/tests/grid/grid_component.test.ts +++ b/tests/grid/grid_component.test.ts @@ -1361,18 +1361,26 @@ describe("Copy paste keyboard shortcut", () => { }); test("Can paste from OS", async () => { + await parent.env.clipboard.writeText("Excalibur"); selectCell(model, "A1"); - clipboardData.setText("Excalibur"); document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); + await nextTick(); expect(getCellContent(model, "A1")).toEqual("Excalibur"); }); + test("Can copy/paste cells", async () => { setCellContent(model, "A1", "things"); selectCell(model, "A1"); document.body.dispatchEvent(getClipboardEvent("copy", clipboardData)); - expect(clipboardData.content).toEqual({ "text/plain": "things", "text/html": "things" }); + const clipboard = await parent.env.clipboard.read!(); + const clipboardContent = "content" in clipboard ? clipboard.content : {}; + expect(clipboardContent).toMatchObject({ + "text/plain": "things", + "text/html": "things", + }); selectCell(model, "A2"); document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); + await nextTick(); expect(getCellContent(model, "A2")).toEqual("things"); }); @@ -1380,9 +1388,15 @@ 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" }); + const clipboard = await parent.env.clipboard.read!(); + const clipboardContent = "content" in clipboard ? clipboard.content : {}; + expect(clipboardContent).toMatchObject({ + "text/plain": "things", + "text/html": "things", + }); selectCell(model, "A2"); document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); + await nextTick(); expect(getCellContent(model, "A1")).toEqual(""); expect(getCellContent(model, "A2")).toEqual("things"); }); @@ -1396,6 +1410,7 @@ describe("Copy paste keyboard shortcut", () => { setStyle(model, "A1", { bold: false }); selectCell(model, "A2"); document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); + await nextTick(); expect(getCellContent(model, "A2")).toEqual("things"); expect(getStyle(model, "A2")).toEqual({ bold: true }); expect(getCell(model, "A1")).toBe(undefined); @@ -1406,10 +1421,13 @@ describe("Copy paste keyboard shortcut", () => { setCellContent(model, "A1", "1"); setCellFormat(model, "A1", "m/d/yyyy"); document.body.dispatchEvent(getClipboardEvent("cut", clipboardData)); - expect(clipboardData.getData(ClipboardMIMEType.PlainText)).toEqual(getCellContent(model, "A1")); + const clipboard = await parent.env.clipboard.read!(); + const clipboardContent = "content" in clipboard ? clipboard.content : {}; + expect(clipboardContent[ClipboardMIMEType.PlainText]).toEqual(getCellContent(model, "A1")); model.dispatch("SET_FORMULA_VISIBILITY", { show: false }); selectCell(model, "A2"); document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); + await nextTick(); expect(getCellContent(model, "A2")).toEqual("12/31/1899"); }); @@ -1417,23 +1435,29 @@ describe("Copy paste keyboard shortcut", () => { setCellContent(model, "A1", "1"); setCellFormat(model, "A1", "m/d/yyyy"); document.body.dispatchEvent(getClipboardEvent("cut", clipboardData)); - expect(clipboardData.getData(ClipboardMIMEType.PlainText)).toEqual( + let clipboard = await parent.env.clipboard.read!(); + let clipboardContent = "content" in clipboard ? clipboard.content : {}; + expect(clipboardContent[ClipboardMIMEType.PlainText]).toEqual( getEvaluatedCell(model, "A1").formattedValue ); selectCell(model, "A2"); document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); + await nextTick(); expect(getCellContent(model, "A2")).toEqual("12/31/1899"); model.dispatch("SET_FORMULA_VISIBILITY", { show: true }); setCellContent(model, "B1", "1"); selectCell(model, "B1"); document.body.dispatchEvent(getClipboardEvent("cut", clipboardData)); - expect(clipboardData.getData(ClipboardMIMEType.PlainText)).toEqual( + clipboard = await parent.env.clipboard.read!(); + clipboardContent = "content" in clipboard ? clipboard.content : {}; + expect(clipboardContent[ClipboardMIMEType.PlainText]).toEqual( getEvaluatedCell(model, "B1").formattedValue ); model.dispatch("SET_FORMULA_VISIBILITY", { show: false }); selectCell(model, "B2"); document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); + await nextTick(); expect(getCellContent(model, "B2")).toEqual("1"); }); @@ -1446,7 +1470,6 @@ describe("Copy paste keyboard shortcut", () => { // Fake OS clipboard should have the same content // to make paste come from spreadsheet clipboard // which support paste as values - parent.env.clipboard.writeText(content); selectCell(model, "A2"); document.activeElement!.dispatchEvent( new KeyboardEvent("keydown", { key: "V", ctrlKey: true, bubbles: true, shiftKey: true }) @@ -1542,8 +1565,12 @@ describe("Copy paste keyboard shortcut", () => { createChart(model, { type: "bar" }, "chartId"); model.dispatch("SELECT_FIGURE", { id: "chartId" }); document.body.dispatchEvent(getClipboardEvent("copy", clipboardData)); - expect(clipboardData.content).toEqual({ "text/plain": "\t" }); - document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); + const clipboard = await parent.env.clipboard.read!(); + const clipboardContent = "content" in clipboard ? clipboard.content : {}; + expect(clipboardContent).toMatchObject({ + "text/plain": "\t", + }); + await document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); expect(model.getters.getChartIds(sheetId)).toHaveLength(2); }); @@ -1552,8 +1579,12 @@ describe("Copy paste keyboard shortcut", () => { createChart(model, { type: "bar" }, "chartId"); model.dispatch("SELECT_FIGURE", { id: "chartId" }); document.body.dispatchEvent(getClipboardEvent("cut", clipboardData)); - expect(clipboardData.content).toEqual({ "text/plain": "\t" }); - document.body.dispatchEvent(getClipboardEvent("paste", clipboardData)); + const clipboard = await parent.env.clipboard.read!(); + const clipboardContent = "content" in clipboard ? clipboard.content : {}; + expect(clipboardContent).toMatchObject({ + "text/plain": "\t", + }); + await 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 6d1740b59e..9b5f53a336 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 a2b0cb218e..05c4ef627d 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 { CellComposerStore } from "../../src/components/composer/composer/cell_composer_store"; import { FONT_SIZES } from "../../src/constants"; import { functionRegistry } from "../../src/functions"; @@ -168,31 +175,31 @@ describe("Menu Item actions", () => { }); test("Edit -> copy", () => { - const spyWriteClipboard = jest.spyOn(env.clipboard, "write"); + const spyWriteClipboard = jest.spyOn(env.clipboard!, "write"); doAction(["edit", "copy"], env); expect(dispatch).toHaveBeenCalledWith("COPY"); expect(spyWriteClipboard).toHaveBeenCalledWith(model.getters.getClipboardContent()); }); test("Edit -> cut", () => { - const spyWriteClipboard = jest.spyOn(env.clipboard, "write"); + const spyWriteClipboard = jest.spyOn(env.clipboard!, "write"); doAction(["edit", "cut"], env); expect(dispatch).toHaveBeenCalledWith("CUT"); expect(spyWriteClipboard).toHaveBeenCalledWith(model.getters.getClipboardContent()); }); 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"); + 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 () => { - await env.clipboard.writeText("First copy in OS clipboard"); + await env.clipboard!.writeText("First copy in OS clipboard"); doAction(["edit", "copy"], env); // then copy from grid await doAction(["edit", "paste"], env); interactivePaste(env, target("A1")); @@ -213,11 +220,15 @@ describe("Menu Item actions", () => { }); test("Paste only-format from OS clipboard should paste nothing", async () => { - await env.clipboard.writeText("Copy in OS clipboard"); + await env.clipboard!.writeText("Copy in OS clipboard"); 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", + [ClipboardMIMEType.Html]: "", + [ClipboardMIMEType.OSpreadsheet]: "", + }, target: target("A1"), pasteOption: "onlyFormat", }); @@ -229,14 +240,10 @@ describe("Menu Item actions", () => { setStyle(model, "C1", { fillColor: "#FA0000" }); selectCell(model, "C1"); doAction(["edit", "copy"], env); // first copy from grid - await env.clipboard.writeText("Then copy in OS clipboard"); + 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(""); }); @@ -261,11 +268,15 @@ describe("Menu Item actions", () => { test("Edit -> paste_special -> paste_special_value from OS clipboard", async () => { const text = "in OS clipboard"; - await env.clipboard.writeText(text); + await env.clipboard!.writeText(text); await doAction(["edit", "paste_special", "paste_special_value"], env); expect(dispatch).toHaveBeenCalledWith("PASTE_FROM_OS_CLIPBOARD", { target: target("A1"), - text, + clipboardContent: { + [ClipboardMIMEType.PlainText]: text, + [ClipboardMIMEType.Html]: "", + [ClipboardMIMEType.OSpreadsheet]: "", + }, pasteOption: "asValue", }); }); @@ -281,11 +292,15 @@ describe("Menu Item actions", () => { test("Edit -> paste_special -> paste_special_format from OS clipboard", async () => { const text = "in OS clipboard"; - await env.clipboard.writeText(text); + await env.clipboard!.writeText(text); await doAction(["edit", "paste_special", "paste_special_format"], env); expect(dispatch).toHaveBeenCalledWith("PASTE_FROM_OS_CLIPBOARD", { target: target("A1"), - text, + clipboardContent: { + [ClipboardMIMEType.PlainText]: text, + [ClipboardMIMEType.Html]: "", + [ClipboardMIMEType.OSpreadsheet]: "", + }, pasteOption: "onlyFormat", }); }); diff --git a/tests/menus/menu_items_registry_cross_spreadsheet.test.ts b/tests/menus/menu_items_registry_cross_spreadsheet.test.ts new file mode 100644 index 0000000000..bbc0423a74 --- /dev/null +++ b/tests/menus/menu_items_registry_cross_spreadsheet.test.ts @@ -0,0 +1,42 @@ +import { SpreadsheetChildEnv } from "../../src/types"; +import { selectCell, setCellContent, setStyle } from "../test_helpers/commands_helpers"; +import { getCell } from "../test_helpers/getters_helpers"; +import { doAction, makeTestEnv } from "../test_helpers/helpers"; + +import { Model } from "../../src"; + +describe("cross spreadsheet copy/paste", () => { + test("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, "A1", "a1"); + setStyle(modelA, "A1", cellStyle); + expect(getCell(modelA, "A1")).toMatchObject({ + content: "a1", + style: cellStyle, + }); + + selectCell(modelA, "A1"); + await 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, "B1"); + await doAction(["edit", "paste"], envB); + + expect(getCell(modelB, "B1")?.content).toEqual("a1"); + expect(getCell(modelB, "B1")?.style).toMatchObject(cellStyle); + }); +}); diff --git a/tests/spreadsheet/spreadsheet_component.test.ts b/tests/spreadsheet/spreadsheet_component.test.ts index b36b07a580..7517ca6e8b 100644 --- a/tests/spreadsheet/spreadsheet_component.test.ts +++ b/tests/spreadsheet/spreadsheet_component.test.ts @@ -358,7 +358,6 @@ describe("Composer / selectionInput interactions", () => { })); }); - jest.setTimeout(500000000); test("Switching from selection input to composer should update the highlihts", async () => { const composerStore = env.getStore(CellComposerStore); //open cf sidepanel diff --git a/tests/test_helpers/clipboard.ts b/tests/test_helpers/clipboard.ts index bec423d838..7743a58278 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 61e81e5430..2b4cabe87e 100644 --- a/tests/test_helpers/commands_helpers.ts +++ b/tests/test_helpers/commands_helpers.ts @@ -11,6 +11,7 @@ import { BorderData, ChartDefinition, ChartWithAxisDefinition, + ClipboardContent, ClipboardPasteOptions, CreateSheetCommand, CreateTableStyleCommand, @@ -328,11 +329,11 @@ 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: content, target: target(range), pasteOption, });