diff --git a/src/actions/menu_items_actions.ts b/src/actions/menu_items_actions.ts index 49dd76a1f6..b7cc62e32a 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] || "{}"; + const 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 1f6437e785..19dc76ea9e 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,16 +341,16 @@ export class CellPlugin extends CorePlugin implements CoreState { */ private getFormulaCellContent( sheetId: UID, - compiledFormula: RangeCompiledFormula, + tokens: Token[], dependencies: Range[], useFixedReference: boolean = false ): string { if (!dependencies.length) { - return concat(compiledFormula.tokens.map((token) => token.value)); + return concat(tokens.map((token) => token.value)); } 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 }); @@ -362,25 +363,22 @@ 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); + return this.getFormulaCellContent(sheetId, tokens, adaptedDependencies); } - 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); + return this.getFormulaCellContent(targetSheetId, tokens, adaptedDependencies); } 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..dec5e5a66f 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,32 @@ 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 | undefined { + const data = { + version: CURRENT_VERSION, + clipboardId: this.clipboardId, + }; + if (this.copiedData && "figureId" in this.copiedData) { + return JSON.stringify(data); + } + return JSON.stringify({ + ...data, + ...this.copiedData, + }); + } + private getPlainTextContent(): string { if (!this.copiedData?.cells) { return "\t"; @@ -506,8 +536,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 +548,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 +558,7 @@ export class ClipboardPlugin extends UIPlugin { return ""; } - let htmlTable = ''; + let htmlTable = `
`; for (const row of cells) { htmlTable += ""; for (const cell of row) { @@ -543,7 +573,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 7e6dabe569..709526e371 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_figure_plugin.test.ts b/tests/clipboard/clipboard_figure_plugin.test.ts index b77c335daa..d9eb82d2a0 100644 --- a/tests/clipboard/clipboard_figure_plugin.test.ts +++ b/tests/clipboard/clipboard_figure_plugin.test.ts @@ -1,6 +1,6 @@ import { CommandResult, Model } from "../../src"; import { DEFAULT_CELL_HEIGHT, DEFAULT_CELL_WIDTH } from "../../src/constants"; -import { UID } from "../../src/types"; +import { ClipboardMIMEType, UID } from "../../src/types"; import { BarChartDefinition } from "../../src/types/chart"; import { activateSheet, @@ -176,6 +176,17 @@ describe.each(["chart", "image"])("Clipboard for %s figures", (type: string) => }); }); + test("Chart clipboard content is not serialized at copy", () => { + model.dispatch("SELECT_FIGURE", { id: figureId }); + copy(model); + const clipboardSpreadsheetContent = JSON.parse( + model.getters.getClipboardContent()[ClipboardMIMEType.OSpreadsheet]! + ); + expect(clipboardSpreadsheetContent.figureId).toBe(undefined); + expect(clipboardSpreadsheetContent.copiedFigure).toBe(undefined); + expect(clipboardSpreadsheetContent.copiedChart).toBe(undefined); + }); + describe("Paste command result", () => { test("Cannot paste with empty target", () => { model.dispatch("SELECT_FIGURE", { id: figureId }); diff --git a/tests/clipboard/clipboard_plugin.test.ts b/tests/clipboard/clipboard_plugin.test.ts index 946d9a4554..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); @@ -2291,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"); @@ -2303,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"); @@ -2318,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); }); @@ -2623,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, });