diff --git a/src/components/autofill/autofill.ts b/src/components/autofill/autofill.ts index 380754b432..0e25abe47b 100644 --- a/src/components/autofill/autofill.ts +++ b/src/components/autofill/autofill.ts @@ -1,7 +1,7 @@ import { Component, useState, xml } from "@odoo/owl"; import { AUTOFILL_EDGE_LENGTH } from "../../constants"; import { clip } from "../../helpers"; -import { HeaderIndex, SpreadsheetChildEnv } from "../../types"; +import { DOMCoordinates, HeaderIndex, SpreadsheetChildEnv } from "../../types"; import { css, cssPropertiesToCss } from "../helpers/css"; import { dragAndDropBeyondTheViewport } from "../helpers/drag_and_drop"; @@ -41,16 +41,11 @@ css/* scss */ ` interface Props { isVisible: boolean; - position: Position; -} - -interface Position { - top: HeaderIndex; - left: HeaderIndex; + position: DOMCoordinates; } interface State { - position: Position; + position: DOMCoordinates; handler: boolean; } @@ -61,31 +56,31 @@ export class Autofill extends Component { isVisible: Boolean, }; state: State = useState({ - position: { left: 0, top: 0 }, + position: { x: 0, y: 0 }, handler: false, }); get style() { - const { left, top } = this.props.position; + const { x, y } = this.props.position; return cssPropertiesToCss({ - top: `${top}px`, - left: `${left}px`, + top: `${y}px`, + left: `${x}px`, visibility: this.props.isVisible ? "visible" : "hidden", }); } get handlerStyle() { - const { left, top } = this.state.handler ? this.state.position : this.props.position; + const { x, y } = this.state.handler ? this.state.position : this.props.position; return cssPropertiesToCss({ - top: `${top}px`, - left: `${left}px`, + top: `${y}px`, + left: `${x}px`, }); } get styleNextValue() { - const { left, top } = this.state.position; + const { x, y } = this.state.position; return cssPropertiesToCss({ - top: `${top + 5}px`, - left: `${left + 15}px`, + top: `${y + 5}px`, + left: `${x + 15}px`, }); } @@ -103,8 +98,8 @@ export class Autofill extends Component { let lastCol: HeaderIndex | undefined; let lastRow: HeaderIndex | undefined; const start = { - left: ev.clientX - this.props.position.left, - top: ev.clientY - this.props.position.top, + left: ev.clientX - this.props.position.x, + top: ev.clientY - this.props.position.y, }; const onMouseUp = () => { this.state.handler = false; @@ -114,8 +109,8 @@ export class Autofill extends Component { const onMouseMove = (col: HeaderIndex, row: HeaderIndex, ev: MouseEvent) => { this.state.position = { - left: ev.clientX - start.left, - top: ev.clientY - start.top, + x: ev.clientX - start.left, + y: ev.clientY - start.top, }; if (lastCol !== col || lastRow !== row) { const activeSheetId = this.env.model.getters.getActiveSheetId(); diff --git a/src/components/grid/grid.ts b/src/components/grid/grid.ts index 0d11702b7a..be7e79b3bb 100644 --- a/src/components/grid/grid.ts +++ b/src/components/grid/grid.ts @@ -73,6 +73,7 @@ import { useWheelHandler } from "../helpers/wheel_hook"; import { Highlight } from "../highlight/highlight/highlight"; import { Menu, MenuState } from "../menu/menu"; import { PaintFormatStore } from "../paint_format_button/paint_format_store"; +import { PasteToolbox } from "../paste_toolbox/paste_toolbox"; import { CellPopoverStore } from "../popover"; import { Popover } from "../popover/popover"; import { HorizontalScrollBar, VerticalScrollBar } from "../scrollbar/"; @@ -126,6 +127,7 @@ export class Grid extends Component { HeadersOverlay, Menu, Autofill, + PasteToolbox, ClientTag, Highlight, Popover, @@ -251,7 +253,7 @@ export class Grid extends Component { } else if (this.paintFormatStore.isActive) { this.paintFormatStore.cancel(); } else { - this.env.model.dispatch("CLEAN_CLIPBOARD_HIGHLIGHT"); + this.env.model.dispatch("CLEAR_CLIPBOARD_HIGHLIGHT"); } }, "Ctrl+A": () => this.env.model.selection.loopSelection(), @@ -419,12 +421,20 @@ export class Grid extends Component { return this.gridRef.el; } - getAutofillPosition() { + getBottomSelectionPosition(): DOMCoordinates { const zone = this.env.model.getters.getSelectedZone(); const rect = this.env.model.getters.getVisibleRect(zone); return { - left: rect.x + rect.width - AUTOFILL_EDGE_LENGTH / 2, - top: rect.y + rect.height - AUTOFILL_EDGE_LENGTH / 2, + x: rect.x + rect.width, + y: rect.y + rect.height, + }; + } + + getAutofillPosition(): DOMCoordinates { + const { x, y } = this.getBottomSelectionPosition(); + return { + x: x - AUTOFILL_EDGE_LENGTH / 2, + y: y - AUTOFILL_EDGE_LENGTH / 2, }; } @@ -439,6 +449,10 @@ export class Grid extends Component { return !(rect.width === 0 || rect.height === 0); } + get isPasteToolboxVisible(): boolean { + return this.env.model.getters.canModifyPaste(); + } + onGridResized({ height, width }: DOMDimension) { this.env.model.dispatch("RESIZE_SHEETVIEW", { width: width, diff --git a/src/components/grid/grid.xml b/src/components/grid/grid.xml index 577124b601..99b0a5d0ab 100644 --- a/src/components/grid/grid.xml +++ b/src/components/grid/grid.xml @@ -44,6 +44,11 @@ /> + void; +} + +export class PasteToolbox extends Component { + static template = "o-spreadsheet-PasteToolbox"; + static props = { + position: Object, + onClosed: Function, + }; + static components = { Menu }; + + private menuState: MenuState = useState({ + isOpen: false, + position: { x: 0, y: 0 }, + menuItems: [], + }); + + private toolboxRef = useRef("toolboxButton"); + + get style() { + const { x, y } = this.props.position; + return cssPropertiesToCss({ + left: `${x}px`, + top: `${y + 5}px`, + }); + } + + showMenu() { + this.menuState.isOpen = true; + const { x, y } = this.toolboxRef.el!.getBoundingClientRect(); + this.menuState.menuItems = this.getMenuItems(); + this.menuState.position = { x, y }; + } + + onMenuClosed() { + this.menuState.isOpen = false; + this.props.onClosed(); + } + + private getMenuItems() { + return createActions([ + { + ...paste, + icon: undefined, + id: "paste", + execute: (env) => { + env.model.dispatch("REQUEST_UNDO"); + paste.execute?.(this.env); + }, + }, + { + name: pasteSpecialValue.name, + id: "paste_special_value", + execute: (env) => { + env.model.dispatch("REQUEST_UNDO"); + pasteSpecialValue.execute?.(env); + }, + }, + { + name: pasteSpecialFormat.name, + id: "paste_special_format", + execute: (env) => { + env.model.dispatch("REQUEST_UNDO"); + pasteSpecialFormat.execute?.(env); + }, + }, + ]); + } +} diff --git a/src/components/paste_toolbox/paste_toolbox.xml b/src/components/paste_toolbox/paste_toolbox.xml new file mode 100644 index 0000000000..a88adfa2d0 --- /dev/null +++ b/src/components/paste_toolbox/paste_toolbox.xml @@ -0,0 +1,18 @@ + + +
+ + +
+ + + diff --git a/src/constants.ts b/src/constants.ts index 2086160c45..c440dc0e86 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -261,6 +261,7 @@ export const LOADING = "Loading..."; export enum ComponentsImportance { Grid = 0, Highlight = 5, + Clipboard = 6, HeaderGroupingButton = 6, Figure = 10, ScrollBar = 15, diff --git a/src/plugins/ui_stateful/clipboard.ts b/src/plugins/ui_stateful/clipboard.ts index 14e6c32b88..d5b04487ec 100644 --- a/src/plugins/ui_stateful/clipboard.ts +++ b/src/plugins/ui_stateful/clipboard.ts @@ -25,7 +25,7 @@ import { isCoreCommand, } from "../../types/index"; import { xmlEscape } from "../../xlsx/helpers/xml_helpers"; -import { UIPlugin } from "../ui_plugin"; +import { UIPlugin, UIPluginConfig } from "../ui_plugin"; interface InsertDeleteCellsTargets { cut: Zone[]; @@ -57,14 +57,27 @@ export class ClipboardPlugin extends UIPlugin { "getClipboardId", "getClipboardTextContent", "isCutOperation", + "canModifyPaste", ] as const; private status: "visible" | "invisible" = "invisible"; private originSheetId?: UID; private copiedData?: MinimalClipboardData; private _isCutOperation: boolean = false; + private recentlyPasted: boolean = false; private clipboardId = new UuidGenerator().uuidv4(); + constructor(config: UIPluginConfig) { + super(config); + this.selection.observe(this, { + handleEvent: this.handleEvent.bind(this), + }); + } + + handleEvent() { + this.recentlyPasted = false; + } + // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- @@ -118,6 +131,9 @@ export class ClipboardPlugin extends UIPlugin { } handle(cmd: Command) { + if (isCoreCommand(cmd)) { + this.recentlyPasted = false; + } switch (cmd.type) { case "COPY": case "CUT": @@ -126,6 +142,7 @@ export class ClipboardPlugin extends UIPlugin { this.originSheetId = this.getters.getActiveSheetId(); this.copiedData = this.copy(zones); this._isCutOperation = cmd.type === "CUT"; + this.recentlyPasted = false; break; case "PASTE_FROM_OS_CLIPBOARD": { this._isCutOperation = false; @@ -141,6 +158,7 @@ export class ClipboardPlugin extends UIPlugin { isCutOperation: false, }); this.status = "invisible"; + this.recentlyPasted = true; break; } case "PASTE": { @@ -154,6 +172,9 @@ export class ClipboardPlugin extends UIPlugin { if (this._isCutOperation) { this.copiedData = undefined; this._isCutOperation = false; + this.recentlyPasted = false; + } else { + this.recentlyPasted = true; } break; } @@ -191,9 +212,12 @@ export class ClipboardPlugin extends UIPlugin { }); } break; - case "CLEAN_CLIPBOARD_HIGHLIGHT": + case "CLEAR_CLIPBOARD_HIGHLIGHT": this.status = "invisible"; break; + case "CLEAR_PASTE_HANDLER": + this.recentlyPasted = false; + break; case "DELETE_CELL": { const { cut, paste } = this.getDeleteCellsTargets(cmd.zone, cmd.shiftDimension); if (!isZoneValid(cut[0])) { @@ -553,6 +577,10 @@ export class ClipboardPlugin extends UIPlugin { return this._isCutOperation ?? false; } + canModifyPaste(): boolean { + return this.recentlyPasted ?? false; + } + // --------------------------------------------------------------------------- // Private methods // --------------------------------------------------------------------------- diff --git a/src/types/commands.ts b/src/types/commands.ts index 9ee1b06656..3691f06e0b 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -761,8 +761,12 @@ export interface RepeatPasteCommand { pasteOption?: ClipboardPasteOptions; } -export interface CleanClipBoardHighlightCommand { - type: "CLEAN_CLIPBOARD_HIGHLIGHT"; +export interface ClearClipBoardHighlightCommand { + type: "CLEAR_CLIPBOARD_HIGHLIGHT"; +} + +export interface ClearPasteHander { + type: "CLEAR_PASTE_HANDLER"; } export interface AutoFillCellCommand { @@ -1104,7 +1108,7 @@ export type LocalCommand = | CopyPasteCellsAboveCommand | CopyPasteCellsOnLeftCommand | RepeatPasteCommand - | CleanClipBoardHighlightCommand + | ClearClipBoardHighlightCommand | AutoFillCellCommand | PasteFromOSClipboardCommand | AutoresizeColumnsCommand @@ -1144,7 +1148,8 @@ export type LocalCommand = | DuplicatePivotInNewSheetCommand | InsertPivotWithTableCommand | SplitPivotFormulaCommand - | PaintFormat; + | PaintFormat + | ClearPasteHander; export type Command = CoreCommand | LocalCommand; diff --git a/tests/grid/__snapshots__/grid_component.test.ts.snap b/tests/grid/__snapshots__/grid_component.test.ts.snap index 68fc50d299..13ac269d80 100644 --- a/tests/grid/__snapshots__/grid_component.test.ts.snap +++ b/tests/grid/__snapshots__/grid_component.test.ts.snap @@ -122,6 +122,7 @@ exports[`Grid component simple rendering snapshot 1`] = ` +
+ +
+
+ +
+ Paste +
+
+ Ctrl+V +
+ + + +
+
+ +
+
+ +
+ Paste as value +
+ + + +
+
+ +
+
+ +
+ Paste format only +
+ + + +
+
+ +
+`; diff --git a/tests/paste_toolbox/paste_toolbox_component.test.ts b/tests/paste_toolbox/paste_toolbox_component.test.ts new file mode 100644 index 0000000000..9ca0a9b4b4 --- /dev/null +++ b/tests/paste_toolbox/paste_toolbox_component.test.ts @@ -0,0 +1,126 @@ +import { Model, Spreadsheet } from "../../src"; +import { PASTE_ACTION as pasteAction } from "../../src/actions/menu_items_actions"; +import { MockClipboardData, getClipboardEvent } from "../test_helpers/clipboard"; +import { + copy, + paste, + pasteFromOSClipboard, + setCellContent, + setSelection, +} from "../test_helpers/commands_helpers"; +import { click } from "../test_helpers/dom_helper"; +import { getCell } from "../test_helpers/getters_helpers"; +import { mountSpreadsheet, nextTick } from "../test_helpers/helpers"; + +describe("Paste toolbox", () => { + let model: Model; + let fixture: HTMLElement; + let parent: Spreadsheet; + beforeEach(async () => { + // mount a spreadsheet with a paste toolbox + ({ model, fixture, parent } = await mountSpreadsheet()); + }); + + test("Activates on paste", async () => { + setCellContent(model, "A1", "1"); + copy(model, "A1"); + paste(model, "A2"); + await nextTick(); + expect(fixture.querySelector(".o-paste")).not.toBeNull(); + }); + + test("Activates on OS paste", async () => { + pasteFromOSClipboard(model, "A2", { text: "1\n2" }); + await nextTick(); + expect(fixture.querySelector(".o-paste")).not.toBeNull(); + }); + + test("Clicking the paste toolbox opens a menu", async () => { + setCellContent(model, "A1", "1"); + copy(model, "A1"); + paste(model, "A2"); + await nextTick(); + await click(fixture, ".o-paste"); + expect(fixture.querySelector(".o-menu")).not.toBeNull(); + }); + + test("closes when changing paste target (i.e. the selection)", async () => { + setCellContent(model, "A1", "1"); + copy(model, "A1"); + paste(model, "A2"); + await nextTick(); + expect(fixture.querySelector(".o-paste")).not.toBeNull(); + model.selection.selectCell(1, 1); + await nextTick(); + expect(fixture.querySelector(".o-paste")).toBeNull(); + paste(model, "A2"); + await nextTick(); + await click(fixture, ".o-paste"); + expect(fixture.querySelector(".o-menu")).not.toBeNull(); + model.selection.selectCell(1, 1); + await nextTick(); + expect(fixture.querySelector(".o-menu")).toBeNull(); + }); + + test("Menu items are correct", async () => { + setCellContent(model, "A1", "1"); + copy(model, "A1"); + paste(model, "A2"); + await nextTick(); + await click(fixture, ".o-paste"); + expect(fixture.querySelector(".o-menu")).toMatchSnapshot(); + }); + + test("Can cycle through the paste options", async () => { + model.dispatch("UPDATE_CELL", { + sheetId: model.getters.getActiveSheetId(), + col: 0, + row: 0, + style: { bold: true }, + content: "1", + format: "m/d/yyyy", + }); + + // required to polulate the os clipboard + const clipboardData = new MockClipboardData(); + document.body.dispatchEvent(getClipboardEvent("copy", clipboardData)); + parent.env.clipboard.write(clipboardData.content); + + setSelection(model, ["A3"]); + await pasteAction(parent.env); + expect(getCell(model, "A3")).toMatchObject({ + content: "1", + style: { bold: true }, + format: "m/d/yyyy", + }); + await nextTick(); + + // as Value + await click(fixture, ".o-paste"); + await click(fixture, ".o-menu-item[data-name=paste_special_value]"); + expect(getCell(model, "A3")).toMatchObject({ + content: "1", + style: undefined, + format: "m/d/yyyy", + }); + // as format only + await nextTick(); + await click(fixture, ".o-paste"); + await click(fixture, ".o-menu-item[data-name=paste_special_format]"); + expect(getCell(model, "A3")).toMatchObject({ + content: "", + style: { bold: true }, + format: "m/d/yyyy", + }); + + // as normal paste + await nextTick(); + await click(fixture, ".o-paste"); + await click(fixture, ".o-menu-item[data-name=paste]"); + expect(getCell(model, "A3")).toMatchObject({ + content: "1", + style: { bold: true }, + format: "m/d/yyyy", + }); + }); +}); diff --git a/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap b/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap index a531f42323..8e53f3f89e 100644 --- a/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap +++ b/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap @@ -756,6 +756,7 @@ exports[`Simple Spreadsheet Component simple rendering snapshot 1`] = ` +