From f9304d86951048c03be98701671af0f09eabdbed Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Fri, 7 Aug 2026 22:23:36 +0930 Subject: [PATCH 1/3] feat!: replace decoy-textarea clipboard handling with the async Clipboard API BREAKING CHANGE: CellExternalCopyManager now copies via navigator.clipboard.writeText and pastes via navigator.clipboard.readText (secure context required). The bodyElement and clipboardPasteDelay options are removed, _decodeTabularData takes the clipboard text instead of a textarea, and paste completes asynchronously. New clipboardWriteOverride / clipboardReadOverride plugin options replace the transport where the Clipboard API is unavailable. The row split in _decodeTabularData now treats CRLF as one delimiter (the decoy textarea normalized CRLF away; readText does not). Co-Authored-By: Claude Fable 5 --- cypress/e2e/clipboard-api.cy.ts | 220 +++++++++++++++++ ...example-excel-compatible-spreadsheet.cy.ts | 6 +- .../quirk-clipboard-paste-event-driven.cy.ts | 97 -------- examples/example-clipboard-api.html | 124 ++++++++++ examples/example-plugin-contextmenu.html | 27 +-- ...ample-plugin-hybridselectionmodel-esm.html | 27 +-- .../example-plugin-hybridselectionmodel.html | 27 +-- src/models/excelCopyBufferOption.interface.ts | 10 +- src/plugins/slick.cellexternalcopymanager.ts | 224 +++++++----------- 9 files changed, 446 insertions(+), 316 deletions(-) create mode 100644 cypress/e2e/clipboard-api.cy.ts delete mode 100644 cypress/e2e/quirk-clipboard-paste-event-driven.cy.ts create mode 100644 examples/example-clipboard-api.html diff --git a/cypress/e2e/clipboard-api.cy.ts b/cypress/e2e/clipboard-api.cy.ts new file mode 100644 index 000000000..5408855f2 --- /dev/null +++ b/cypress/e2e/clipboard-api.cy.ts @@ -0,0 +1,220 @@ +/** + * Contract test for CellExternalCopyManager's Clipboard-API transport. + * + * Copy serializes the selected ranges in memory and writes the tab/CRLF text + * with navigator.clipboard.writeText; paste reads with navigator.clipboard + * .readText and decodes the text directly. There is no decoy textarea and no + * delay option. clipboardWriteOverride / clipboardReadOverride replace the + * transport (e.g. non-secure contexts); when the Clipboard API is missing and + * no override is set, the failure surfaces as a console error, never a throw. + * + * The spec is SELF-HOSTING (harness served via cy.intercept; no example page) + * and stubs navigator.clipboard for determinism — the same pattern + * slickgrid-universal uses in its unit tests. The stub also captures the exact + * serialized text, which real-clipboard tests cannot assert. The end-to-end + * path through the REAL clipboard (realPress Ctrl+C / Ctrl+V in Electron) is + * covered by example-excel-compatible-spreadsheet.cy.ts. + * + * Grid A pastes through column editors (editor.applyValue); grid B has no + * editors, covering the raw field-assignment path plus both override hooks. + * Each test visits the harness itself so retries always start from a fresh + * page. ?noclip=1 removes the stub to simulate an unavailable Clipboard API. + */ + +const harnessHtml = ` + + + + Harness: Clipboard API copy manager + + + + +
+
+ + + + + + + + + + +`; + +describe('CellExternalCopyManager - Clipboard API transport', { retries: 1 }, () => { + const visitHarness = (query = '') => { + cy.intercept('GET', '/clipboard-api-harness.html*', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/clipboard-api-harness.html${query}`); + cy.window().its('grid').should('exist'); + }; + const cellSelector = (gridId: string, row: number, cellClass: string) => + `#${gridId} .slick-row[data-row="${row}"] .slick-cell.${cellClass}`; + + it('should copy the selected range through navigator.clipboard.writeText and cancel highlight on Escape', () => { + visitHarness(); + cy.window().then((win: any) => { + win.selectRange(win.grid, 1, 1, 2, 2); + win.pressKey(win.grid, 'c', { ctrlKey: true }); + }); + cy.window().its('clipStore.text', { timeout: 4000 }) + .should('eq', 'A1\tB1\r\nA2\tB2\r\n'); + cy.window().its('clipStore.writes').should('eq', 1); + cy.get('#myGrid .slick-cell.copied').should('have.length', 4); + cy.window().then((win: any) => { + expect(win.document.querySelectorAll('textarea').length, 'no decoy textarea in the DOM').to.eq(0); + win.pressKey(win.grid, 'Escape'); + }); + cy.get('#myGrid .slick-cell.copied').should('have.length', 0); + cy.window().its('copyCancelledEvents').should('eq', 1); + }); + + it('should paste clipboard text read from navigator.clipboard.readText into the grid', () => { + visitHarness(); + cy.window().then((win: any) => { + win.clipStore.text = 'X\tY\r\nZ\tW\r\n'; + win.selectRange(win.grid, 5, 1, 5, 1); + win.pressKey(win.grid, 'v', { ctrlKey: true }); + }); + cy.get(cellSelector('myGrid', 5, 'l1'), { timeout: 4000 }).should('have.text', 'X'); + cy.get(cellSelector('myGrid', 5, 'l2')).should('have.text', 'Y'); + cy.get(cellSelector('myGrid', 6, 'l1')).should('have.text', 'Z'); + cy.get(cellSelector('myGrid', 6, 'l2')).should('have.text', 'W'); + cy.window().then((win: any) => { + expect(win.clipStore.reads, 'one readText call').to.eq(1); + expect(win.getData()[5].a, 'underlying data updated').to.eq('X'); + expect(win.getData()[6].b, 'underlying data updated').to.eq('W'); + expect(win.pasteEvents, 'onPasteCells notified').to.eq(1); + expect(win.document.querySelectorAll('textarea').length, 'no decoy textarea in the DOM').to.eq(0); + }); + }); + + it('should route copy and paste through the override hooks without touching navigator.clipboard', () => { + visitHarness(); + cy.window().then((win: any) => { + win.selectRange(win.gridOv, 0, 1, 0, 2); + win.pressKey(win.gridOv, 'c', { ctrlKey: true }); + }); + cy.window().its('ovStore.text', { timeout: 4000 }).should('eq', 'OA0\tOB0\r\n'); + cy.window().then((win: any) => { + win.ovStore.text = 'P\tQ\r\n'; + win.selectRange(win.gridOv, 2, 1, 2, 1); + win.pressKey(win.gridOv, 'v', { ctrlKey: true }); + }); + cy.get(cellSelector('gridOv', 2, 'l1'), { timeout: 4000 }).should('have.text', 'P'); + cy.get(cellSelector('gridOv', 2, 'l2')).should('have.text', 'Q'); + cy.window().then((win: any) => { + expect(win.ovStore.writes, 'override write used').to.eq(1); + expect(win.ovStore.reads, 'override read used').to.eq(1); + expect(win.getDataOv()[2].a, 'raw field assignment (no editor)').to.eq('P'); + expect(win.clipStore.writes, 'navigator.clipboard.writeText never called').to.eq(0); + expect(win.clipStore.reads, 'navigator.clipboard.readText never called').to.eq(0); + }); + }); + + it('should surface an unavailable Clipboard API as a console error, not a throw', () => { + visitHarness('?noclip=1'); + cy.window().then((win: any) => { + win.selectRange(win.grid, 1, 1, 1, 1); + win.pressKey(win.grid, 'c', { ctrlKey: true }); + }); + cy.window().its('clipErrors', { timeout: 4000 }).should('eq', 1); + cy.window().then((win: any) => { + win.pressKey(win.grid, 'v', { ctrlKey: true }); + }); + cy.window().its('clipErrors', { timeout: 4000 }).should('eq', 2); + cy.window().then((win: any) => { + expect(win.grid.getActiveCell(), 'grid still responsive after failures').to.deep.include({ row: 1, cell: 1 }); + expect(win.getData()[1].a, 'no paste happened').to.eq('A1'); + }); + }); +}); diff --git a/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts b/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts index 2b0342a9d..0be13f551 100644 --- a/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts +++ b/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts @@ -68,11 +68,7 @@ describe('Example - Excel-compatible spreadsheet and Cell Selection', { retries: const plugin = win.grid.getPluginByName('CellExternalCopyManager'); expect(plugin).to.exist; - const ta = win.document.createElement('textarea'); - ta.value = 'p1\tp2\tp3\tp4\tp5\tp6\tp7\tp8\tp9\tp10'; - win.document.body.appendChild(ta); - - expect(() => plugin._decodeTabularData(win.grid, ta)).not.to.throw(); + expect(() => plugin._decodeTabularData(win.grid, 'p1\tp2\tp3\tp4\tp5\tp6\tp7\tp8\tp9\tp10')).not.to.throw(); }); cy.get('#myGrid [data-row=0] .slick-cell.l22.r22').should('have.text', 'p1'); diff --git a/cypress/e2e/quirk-clipboard-paste-event-driven.cy.ts b/cypress/e2e/quirk-clipboard-paste-event-driven.cy.ts deleted file mode 100644 index dcc54b1af..000000000 --- a/cypress/e2e/quirk-clipboard-paste-event-driven.cy.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Regression test for the clipboard paste-delay race. - * - * CellExternalCopyManager pasted by focusing a hidden decoy textarea and reading - * it back after a FIXED setTimeout (CLIPBOARD_PASTE_DELAY, default 100ms). Under - * machine load the timeout could fire before the browser delivered the paste into - * the textarea — silently losing or truncating the paste. This was the mechanism - * behind long-standing intermittent failures of the excel-spreadsheet spec. - * - * The fix decodes on the decoy's 'input' event (which fires once the pasted value - * is populated) and keeps the timeout only as a fallback. - * - * The spec is SELF-HOSTING: the repro harness is served from this file via - * cy.intercept (no page is added to examples/). Determinism trick: the harness - * sets clipboardPasteDelay to 6000ms. With the event-driven decode the paste - * completes ~immediately; on the fixed-delay code nothing happens until the 6s - * fallback. Asserting the pasted value within a 2.5s window therefore FAILS on - * the pre-fix code and PASSES with the fix — no reliance on machine-load timing. - */ - -const harnessHtml = ` - - - - Harness: clipboard paste delay - - - - -
- - - - - - - - - - -`; - -describe('Quirk - clipboard paste must decode on delivery, not on a fixed delay', { retries: 1 }, () => { - const cellSelector = (row: number, cellClass: string) => `#myGrid .slick-row[data-row="${row}"] .slick-cell.${cellClass}`; - - // visit + paste live in ONE test so a retry re-visits and gets a FRESH page: - // otherwise a failed first attempt's pending 6s fallback timer eventually - // decodes its paste into the persistent page, and the retried attempt would - // see that late-landed value and pass spuriously on unfixed code. - it('should paste a copied cell promptly even with a huge clipboardPasteDelay', () => { - cy.intercept('GET', '/quirk-clipboard-paste-harness.html', { - headers: { 'content-type': 'text/html' }, - body: harnessHtml, - }); - cy.visit(`${Cypress.config('baseUrl')}/quirk-clipboard-paste-harness.html`); - cy.window().its('grid').should('exist'); - - // copy A2 ("A2"), move down, paste into A3 - cy.get(cellSelector(2, 'l1')).click(); - cy.get('.slick-cell.active').realPress(['Control', 'C']); - cy.get('.slick-cell.active').type('{downarrow}'); - cy.get('.slick-cell.active').realPress(['Control', 'V']); - - // event-driven decode lands the value ~immediately; the pre-fix fixed-delay - // path would leave the cell unchanged until the 6s fallback, so this 2.5s - // window separates the two behaviors deterministically - cy.get(cellSelector(3, 'l1'), { timeout: 2500 }).should('have.text', 'A2'); - cy.window().then((win: any) => { - expect(win.getData()[3].a, 'underlying data updated').to.eq('A2'); - }); - }); -}); diff --git a/examples/example-clipboard-api.html b/examples/example-clipboard-api.html new file mode 100644 index 000000000..fce41afb8 --- /dev/null +++ b/examples/example-clipboard-api.html @@ -0,0 +1,124 @@ + + + + + + SlickGrid demo: Clipboard API copy manager (temporary, do not merge) + + + + + +

CellExternalCopyManager on the async Clipboard API (TEMPORARY demo, do not merge)

+ +

Grid 1 — real clipboard (navigator.clipboard.writeText / readText)

+
+ +

Grid 2 — clipboardWriteOverride / clipboardReadOverride (app-managed clipboard)

+
+

App-managed clipboard (filled by Ctrl+C in Grid 2, read by Ctrl+V in Grid 2 — editable):

+ + +

Event log

+
+ + + + + + + + + + + + diff --git a/examples/example-plugin-contextmenu.html b/examples/example-plugin-contextmenu.html index e821a002b..90d4f4940 100644 --- a/examples/example-plugin-contextmenu.html +++ b/examples/example-plugin-contextmenu.html @@ -220,31 +220,10 @@

View Source:

return ("0000" + (Math.floor(Math.random() * maxVal) + 1)).slice(-numDigits) } - // Copy text to clipboard, on IE it's easy to copy a text, we can just call the clipboard - // but on other browsers this is insecure and we need to use the following trick to copy a cell, - // by creating a temp div and change the text value, we can then call the execCommand to copy which only works with dom element + // Copy text to clipboard using the asynchronous Clipboard API (requires a secure context: https or localhost) function copyCellValue(textToCopy) { - try { - if (window.clipboardData) { - window.clipboardData.setData("Text", textToCopy); - } else { - var range = document.createRange(); - var tmpElem = document.createElement('div'); - tmpElem.style.position = 'absolute'; - tmpElem.style.left = '-1000px'; - tmpElem.style.top = '-1000px'; - tmpElem.textContent = textToCopy; - document.body.appendChild(tmpElem); - range.selectNodeContents(tmpElem.get(0)); - var selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - var success = document.execCommand("copy", false, null); - if (success) { - tmpElem.remove(); - } - } - } catch (e) { } + navigator.clipboard.writeText(textToCopy) + .catch(function (err) { console.error("Unable to write to clipboard. Error: " + err); }); } function showContextCommandsAndOptions(showBothList) { diff --git a/examples/example-plugin-hybridselectionmodel-esm.html b/examples/example-plugin-hybridselectionmodel-esm.html index ee9e0e5b3..9396ba826 100644 --- a/examples/example-plugin-hybridselectionmodel-esm.html +++ b/examples/example-plugin-hybridselectionmodel-esm.html @@ -229,31 +229,10 @@

View Source:

return ("0000" + (Math.floor(Math.random() * maxVal) + 1)).slice(-numDigits) } - // Copy text to clipboard, on IE it's easy to copy a text, we can just call the clipboard - // but on other browsers this is insecure and we need to use the following trick to copy a cell, - // by creating a temp div and change the text value, we can then call the execCommand to copy which only works with dom element + // Copy text to clipboard using the asynchronous Clipboard API (requires a secure context: https or localhost) function copyCellValue(textToCopy) { - try { - if (window.clipboardData) { - window.clipboardData.setData("Text", textToCopy); - } else { - let range = document.createRange(); - let tmpElem = document.createElement('div'); - tmpElem.style.position = 'absolute'; - tmpElem.style.left = '-1000px'; - tmpElem.style.top = '-1000px'; - tmpElem.textContent = textToCopy; - document.body.appendChild(tmpElem); - range.selectNodeContents(tmpElem.get(0)); - let selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - let success = document.execCommand("copy", false, null); - if (success) { - tmpElem.remove(); - } - } - } catch (e) { } + navigator.clipboard.writeText(textToCopy) + .catch(function (err) { console.error("Unable to write to clipboard. Error: " + err); }); } function loadData(count) { diff --git a/examples/example-plugin-hybridselectionmodel.html b/examples/example-plugin-hybridselectionmodel.html index 39ece8b99..2bf7fe896 100644 --- a/examples/example-plugin-hybridselectionmodel.html +++ b/examples/example-plugin-hybridselectionmodel.html @@ -229,31 +229,10 @@

View Source:

return ("0000" + (Math.floor(Math.random() * maxVal) + 1)).slice(-numDigits) } - // Copy text to clipboard, on IE it's easy to copy a text, we can just call the clipboard - // but on other browsers this is insecure and we need to use the following trick to copy a cell, - // by creating a temp div and change the text value, we can then call the execCommand to copy which only works with dom element + // Copy text to clipboard using the asynchronous Clipboard API (requires a secure context: https or localhost) function copyCellValue(textToCopy) { - try { - if (window.clipboardData) { - window.clipboardData.setData("Text", textToCopy); - } else { - var range = document.createRange(); - var tmpElem = document.createElement('div'); - tmpElem.style.position = 'absolute'; - tmpElem.style.left = '-1000px'; - tmpElem.style.top = '-1000px'; - tmpElem.textContent = textToCopy; - document.body.appendChild(tmpElem); - range.selectNodeContents(tmpElem.get(0)); - var selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - var success = document.execCommand("copy", false, null); - if (success) { - tmpElem.remove(); - } - } - } catch (e) { } + navigator.clipboard.writeText(textToCopy) + .catch(function (err) { console.error("Unable to write to clipboard. Error: " + err); }); } function loadData(count) { diff --git a/src/models/excelCopyBufferOption.interface.ts b/src/models/excelCopyBufferOption.interface.ts index 8248cefd8..76b3bba85 100644 --- a/src/models/excelCopyBufferOption.interface.ts +++ b/src/models/excelCopyBufferOption.interface.ts @@ -5,8 +5,11 @@ export interface ExcelCopyBufferOption { /** defaults to 2000(ms), delay in ms to wait before clearing the selection after a paste action */ clearCopySelectionDelay?: number; - /** defaults to 100(ms), delay in ms to wait before executing focus/paste */ - clipboardPasteDelay?: number; + /** option to replace the default clipboard write (`navigator.clipboard.writeText`) with a custom function, e.g. for non-secure contexts where the Clipboard API is unavailable */ + clipboardWriteOverride?: (text: string) => void | Promise; + + /** option to replace the default clipboard read (`navigator.clipboard.readText`) with a custom function, e.g. for non-secure contexts where the Clipboard API is unavailable */ + clipboardReadOverride?: () => string | Promise; /** defaults to "copied", sets the css className used for copied cells. */ copiedCellStyle?: string; @@ -26,9 +29,6 @@ export interface ExcelCopyBufferOption { /** set to true and the plugin will take the name property from each column (which is usually what appears in your header) and put that as the first row of the text that's copied to the clipboard */ includeHeaderWhenCopying?: boolean; - /** option to specify a custom DOM element which to will be added the hidden textbox. It's useful if the grid is inside a modal dialog. */ - bodyElement?: HTMLElement; - /** optional handler to run when copy action initializes */ onCopyInit?: () => void; diff --git a/src/plugins/slick.cellexternalcopymanager.ts b/src/plugins/slick.cellexternalcopymanager.ts index ae4328d51..19e3d63a9 100644 --- a/src/plugins/slick.cellexternalcopymanager.ts +++ b/src/plugins/slick.cellexternalcopymanager.ts @@ -8,15 +8,16 @@ const SlickRange = IIFE_ONLY ? Slick.Range : SlickRange_; const Utils = IIFE_ONLY ? Slick.Utils : Utils_; const CLEAR_COPY_SELECTION_DELAY = 2000; -const CLIPBOARD_PASTE_DELAY = 100; /*** This manager enables users to copy/paste data from/to an external Spreadsheet application such as MS-Excel® or OpenOffice-Spreadsheet. - Since it is not possible to access directly the clipboard in javascript, the plugin uses - a trick to do it's job. After detecting the keystroke, we dynamically create a textarea - where the browser copies/pastes the serialized data. + The clipboard transport is the asynchronous Clipboard API (navigator.clipboard), + which requires a secure context (https or localhost) and, for paste, the browser's + clipboard-read permission. The clipboardWriteOverride/clipboardReadOverride options + replace the transport for environments where the Clipboard API is unavailable or the + host application manages the clipboard itself. options: copiedCellStyle : sets the css className used for copied cells. default : "copied" @@ -25,7 +26,8 @@ const CLIPBOARD_PASTE_DELAY = 100; dataItemColumnValueSetter : option to specify a custom column value setter function clipboardCommandHandler : option to specify a custom handler for paste actions includeHeaderWhenCopying : set to true and the plugin will take the name property from each column (which is usually what appears in your header) and put that as the first row of the text that's copied to the clipboard - bodyElement: option to specify a custom DOM element which to will be added the hidden textbox. It's useful if the grid is inside a modal dialog. + clipboardWriteOverride: option to replace the default clipboard write (navigator.clipboard.writeText) with a custom function + clipboardReadOverride: option to replace the default clipboard read (navigator.clipboard.readText) with a custom function onCopyInit: optional handler to run when copy action initializes onCopySuccess: optional handler to run when copy action is complete newRowCreator: function to add rows to table if paste overflows bottom of table, if this function is not provided new rows will be ignored. @@ -43,7 +45,6 @@ export class SlickCellExternalCopyManager implements SlickPlugin { // -- // protected props protected _grid!: SlickGrid; - protected _bodyElement: HTMLElement; protected _copiedRanges: SlickRange_[] | null = null; protected _clearCopyTI?: number; protected _copiedCellStyle: string; @@ -52,18 +53,10 @@ export class SlickCellExternalCopyManager implements SlickPlugin { protected _onCopySuccess?: (rowCount: number) => void; protected _options: ExcelCopyBufferOption; - protected keyCodes = { - 'C': 67, - 'V': 86, - 'ESC': 27, - 'INSERT': 45 - }; - constructor(options: ExcelCopyBufferOption) { this._options = options || {}; this._copiedCellStyleLayerKey = this._options.copiedCellStyleLayerKey || 'copy-manager'; this._copiedCellStyle = this._options.copiedCellStyle || 'copied'; - this._bodyElement = this._options.bodyElement || document.body; this._onCopyInit = this._options.onCopyInit || undefined; this._onCopySuccess = this._options.onCopySuccess || undefined; } @@ -161,23 +154,9 @@ export class SlickCellExternalCopyManager implements SlickPlugin { } - protected _createTextBox(innerText: string) { - const scrollPos = document.documentElement.scrollTop || document.body.scrollTop; - const ta = document.createElement('textarea'); - ta.style.position = 'absolute'; - ta.style.opacity = '0'; - ta.value = innerText; - ta.style.top = `${scrollPos}px`; - this._bodyElement.appendChild(ta); - ta.select(); - - return ta; - } - - protected _decodeTabularData(grid: SlickGrid, ta: HTMLTextAreaElement) { + protected _decodeTabularData(grid: SlickGrid, clipText: string) { const columns = grid.getColumns(); - const clipText = ta.value; - const clipRows = clipText.split(/[\n\f\r]/); + const clipRows = clipText.split(/\r\n|[\n\f\r]/); // trim trailing CR if present if (clipRows[clipRows.length - 1] === '') { clipRows.pop(); @@ -186,7 +165,6 @@ export class SlickCellExternalCopyManager implements SlickPlugin { let j = 0; const clippedRange: any[] = []; - ta.remove(); for (let i = 0; i < clipRows.length; i++) { if (clipRows[i] !== '') { clippedRange[j++] = clipRows[i].split('\t'); @@ -370,133 +348,105 @@ export class SlickCellExternalCopyManager implements SlickPlugin { } } - protected handleKeyDown(e: SlickEventData): boolean | void { - let ranges: SlickRange_[]; - if (!this._grid.getEditorLock().isActive() || this._grid.getOptions().autoEdit) { - if (e.which === this.keyCodes.ESC) { - if (this._copiedRanges) { - e.preventDefault(); - this.clearCopySelection(); - this.onCopyCancelled.notify({ ranges: this._copiedRanges }); - this._copiedRanges = null; + protected async handleKeyDown(e: SlickEventData): Promise { + try { + let ranges: SlickRange_[]; + if (!this._grid.getEditorLock().isActive() || this._grid.getOptions().autoEdit) { + if (e.key === 'Escape') { + if (this._copiedRanges) { + e.preventDefault(); + this.clearCopySelection(); + this.onCopyCancelled.notify({ ranges: this._copiedRanges }); + this._copiedRanges = null; + } } - } - if ((e.which === this.keyCodes.C || e.which === this.keyCodes.INSERT) && (e.ctrlKey || e.metaKey) && !e.shiftKey) { // CTRL+C or CTRL+INS - if (typeof this._onCopyInit === 'function') { - this._onCopyInit.call(this); - } - ranges = this._grid.getSelectionModel()?.getSelectedRanges() ?? []; - if (ranges.length !== 0) { - this._copiedRanges = ranges; - this.markCopySelection(ranges); - this.onCopyCells.notify({ ranges }); - - const columns = this._grid.getColumns(); - const fromRow = Math.min(...ranges.map((range) => range.fromRow)); - const fromCell = Math.min(...ranges.map((range) => range.fromCell)); - const toRow = Math.max(...ranges.map((range) => range.toRow)); - const toCell = Math.max(...ranges.map((range) => range.toCell)); - let clipText = ''; - - const clipTextRows: string[] = []; - if (this._options.includeHeaderWhenCopying) { - const clipTextHeaders: string[] = []; - for (let j = fromCell; j <= toCell; j++) { - const column = columns[j]; - const isSelectedColumn = ranges.some((range) => j >= range.fromCell && j <= range.toCell); - if (column) { - const colName: string = column.name instanceof HTMLElement - ? (column.name as HTMLElement).innerHTML - : column.name as string; - if (colName.length > 0 && !column.hidden) { - clipTextHeaders.push(isSelectedColumn ? this.getHeaderValueForColumn(column) || '' : ''); + if ((e.key?.toLowerCase() === 'c' || e.key === 'Insert') && (e.ctrlKey || e.metaKey) && !e.shiftKey) { // CTRL+C or CTRL+INS + if (typeof this._onCopyInit === 'function') { + this._onCopyInit.call(this); + } + ranges = this._grid.getSelectionModel()?.getSelectedRanges() ?? []; + if (ranges.length !== 0) { + e.preventDefault(); + this._copiedRanges = ranges; + this.markCopySelection(ranges); + this.onCopyCells.notify({ ranges }); + + const columns = this._grid.getColumns(); + const fromRow = Math.min(...ranges.map((range) => range.fromRow)); + const fromCell = Math.min(...ranges.map((range) => range.fromCell)); + const toRow = Math.max(...ranges.map((range) => range.toRow)); + const toCell = Math.max(...ranges.map((range) => range.toCell)); + let clipText = ''; + + const clipTextRows: string[] = []; + if (this._options.includeHeaderWhenCopying) { + const clipTextHeaders: string[] = []; + for (let j = fromCell; j <= toCell; j++) { + const column = columns[j]; + const isSelectedColumn = ranges.some((range) => j >= range.fromCell && j <= range.toCell); + if (column) { + const colName: string = column.name instanceof HTMLElement + ? (column.name as HTMLElement).innerHTML + : column.name as string; + if (colName.length > 0 && !column.hidden) { + clipTextHeaders.push(isSelectedColumn ? this.getHeaderValueForColumn(column) || '' : ''); + } } } + clipTextRows.push(clipTextHeaders.join('\t')); } - clipTextRows.push(clipTextHeaders.join('\t')); - } - for (let i = fromRow; i <= toRow; i++) { - const clipTextCells: string[] = []; - const dt = this._grid.getDataItem(i); - for (let j = fromCell; j <= toCell; j++) { - const column = columns[j]; - if (column) { - const colName: string = column.name instanceof HTMLElement - ? (column.name as HTMLElement).innerHTML - : column.name as string; - if (colName.length > 0 && !column.hidden) { - clipTextCells.push( - ranges.some((range) => range.contains(i, j)) ? this.getDataItemValueForColumn(dt, column, e) : '' - ); + for (let i = fromRow; i <= toRow; i++) { + const clipTextCells: string[] = []; + const dt = this._grid.getDataItem(i); + for (let j = fromCell; j <= toCell; j++) { + const column = columns[j]; + if (column) { + const colName: string = column.name instanceof HTMLElement + ? (column.name as HTMLElement).innerHTML + : column.name as string; + if (colName.length > 0 && !column.hidden) { + clipTextCells.push( + ranges.some((range) => range.contains(i, j)) ? this.getDataItemValueForColumn(dt, column, e) : '' + ); + } } } + clipTextRows.push(clipTextCells.join('\t')); } - clipTextRows.push(clipTextCells.join('\t')); - } - clipText += clipTextRows.join('\r\n') + '\r\n'; + clipText += clipTextRows.join('\r\n') + '\r\n'; - if ((window as any).clipboardData) { - (window as any).clipboardData.setData('Text', clipText); - return true; - } - else { - const focusEl = document.activeElement as HTMLElement; - const ta = this._createTextBox(clipText); - ta.focus(); - - window.setTimeout(() => { - ta.remove(); - // restore focus when possible - focusEl - ? focusEl.focus() - : console.log('No element to restore focus to after copy?'); - }, this._options?.clipboardPasteDelay ?? CLIPBOARD_PASTE_DELAY); + const clipboardWriteFn = this._options.clipboardWriteOverride; + if (clipboardWriteFn) { + await clipboardWriteFn(clipText); + } else { + await navigator.clipboard.writeText(clipText); + } if (typeof this._onCopySuccess === 'function') { - let rowCount = 0; // If it's cell selection, use the toRow/fromRow fields - if (ranges.length === 1) { - rowCount = (ranges[0].toRow + 1) - ranges[0].fromRow; - } else { - rowCount = ranges.length; - } + const rowCount = ranges.length === 1 ? (ranges[0].toRow + 1) - ranges[0].fromRow : ranges.length; this._onCopySuccess(rowCount); } return false; } } - } - if (!this._options.readOnlyMode && ( - (e.which === this.keyCodes.V && (e.ctrlKey || e.metaKey) && !e.shiftKey) - || (e.which === this.keyCodes.INSERT && e.shiftKey && !e.ctrlKey) - )) { // CTRL+V or Shift+INS - const focusEl = document.activeElement as HTMLElement; - const ta = this._createTextBox(''); - - // decode as soon as the browser delivers the paste into the decoy textarea - // (its 'input' event fires once the value is populated) instead of only - // after a fixed delay: under machine load the delay could elapse BEFORE the - // paste was delivered, silently losing or truncating the paste. The timeout - // remains as a fallback for any path where no input event arrives; the - // once-guard matters because _decodeTabularData removes the textarea. - let fallbackTimer: number | undefined; - let decoded = false; - const decode = () => { - if (decoded) { return; } - decoded = true; - window.clearTimeout(fallbackTimer); - this._decodeTabularData(this._grid, ta); - // restore focus when possible - focusEl?.focus(); - }; - ta.addEventListener('input', decode, { once: true }); - fallbackTimer = window.setTimeout(decode, this._options?.clipboardPasteDelay ?? CLIPBOARD_PASTE_DELAY); - return false; + if (!this._options.readOnlyMode && ( + (e.key?.toLowerCase() === 'v' && (e.ctrlKey || e.metaKey) && !e.shiftKey) + || (e.key === 'Insert' && e.shiftKey && !e.ctrlKey) + )) { // CTRL+V or Shift+INS + e.preventDefault(); + const clipboardReadFn = this._options.clipboardReadOverride; + const clipText = clipboardReadFn ? await clipboardReadFn() : await navigator.clipboard.readText(); + this._decodeTabularData(this._grid, clipText); + return false; + } } + } catch (err) { + console.error(`Unable to read/write to clipboard. Please check your browser settings or permissions. Error: ${err}`); } } From 2062d27eb8ac6f58d8e0a4468562bd31a1335899 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Sun, 9 Aug 2026 20:34:44 +0930 Subject: [PATCH 2/3] test: stub the clipboard transport in the excel spec for CI determinism Headless CI runners deny real clipboard access (focus/permission), so the realPress Ctrl+C/Ctrl+V test now stubs navigator.clipboard while keeping the real keystroke pipeline; the real-hardware path stays a manual check. Co-Authored-By: Claude Fable 5 --- cypress/e2e/clipboard-api.cy.ts | 8 +++++--- .../e2e/example-excel-compatible-spreadsheet.cy.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/clipboard-api.cy.ts b/cypress/e2e/clipboard-api.cy.ts index 5408855f2..3106de681 100644 --- a/cypress/e2e/clipboard-api.cy.ts +++ b/cypress/e2e/clipboard-api.cy.ts @@ -11,9 +11,11 @@ * The spec is SELF-HOSTING (harness served via cy.intercept; no example page) * and stubs navigator.clipboard for determinism — the same pattern * slickgrid-universal uses in its unit tests. The stub also captures the exact - * serialized text, which real-clipboard tests cannot assert. The end-to-end - * path through the REAL clipboard (realPress Ctrl+C / Ctrl+V in Electron) is - * covered by example-excel-compatible-spreadsheet.cy.ts. + * serialized text, which real-clipboard tests cannot assert. The full realPress + * Ctrl+C / Ctrl+V keystroke path is covered by + * example-excel-compatible-spreadsheet.cy.ts with the same transport stub — + * headless CI runners deny real clipboard access (focus/permission), so the + * real-hardware path is a manual check. * * Grid A pastes through column editors (editor.applyValue); grid B has no * editors, covering the raw field-assignment path plus both override hooks. diff --git a/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts b/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts index 0be13f551..1ed5b54a3 100644 --- a/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts +++ b/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts @@ -6,6 +6,20 @@ describe('Example - Excel-compatible spreadsheet and Cell Selection', { retries: }); it('should click on cell B2, copy value, ArrowDown, paste value, ArrowRight, and expect to be in column C', () => { + // stub the Clipboard API transport: headless CI runners deny real clipboard + // access (focus/permission), so realPress drives the full keystroke path + // while the transport stays deterministic + cy.window().then((win: any) => { + const store = { text: '' }; + Object.defineProperty(win.navigator, 'clipboard', { + configurable: true, + value: { + writeText: (t: string) => { store.text = t; return Promise.resolve(); }, + readText: () => Promise.resolve(store.text), + }, + }); + }); + cy.getCell(2, 2, '', { parentSelector: '#myGrid', rowHeight: cellHeight }) .as('cell_B2') .click(); From b994876106f5333e50542672ceb9b62fb2cd9e96 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Wed, 16 Sep 2026 11:16:22 +0930 Subject: [PATCH 3/3] test: move the non-contiguous range copy test onto the Clipboard API transport The multi-range gap test arrived with the `hidden` column work on next-v6 and stubbed `window.clipboardData` - the legacy IE transport this branch removes - then asserted synchronously. Against the async Clipboard API it captured nothing and the copied text read back as empty. It now stubs `navigator.clipboard` the same way the other tests in this spec do, and retries the assertion because the copy handler awaits the write. The expectation is unchanged, so it still covers what it was written for: gaps preserved between two non-contiguous ranges. Co-Authored-By: Claude Opus 5 --- ...example-excel-compatible-spreadsheet.cy.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts b/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts index 1ed5b54a3..0f4b45d10 100644 --- a/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts +++ b/cypress/e2e/example-excel-compatible-spreadsheet.cy.ts @@ -90,28 +90,29 @@ describe('Example - Excel-compatible spreadsheet and Cell Selection', { retries: }); it('should preserve gaps when copying multiple non-contiguous ranges', () => { + const store = { text: '' }; + cy.window().then((win: any) => { - const previousClipboardData = win.clipboardData; - let copiedText = ''; - Object.defineProperty(win, 'clipboardData', { + Object.defineProperty(win.navigator, 'clipboard', { configurable: true, value: { - setData: (_format: string, text: string) => { copiedText = text; } - } + writeText: (t: string) => { store.text = t; return Promise.resolve(); }, + readText: () => Promise.resolve(store.text), + }, }); - const selectionModel = win.grid.getSelectionModel(); - selectionModel.setSelectedRanges([ - new win.Slick.Range(1, 1, 1, 2), - new win.Slick.Range(2, 3, 2, 3) - ]); + const selectionModel = win.grid.getSelectionModel(); + selectionModel.setSelectedRanges([ + new win.Slick.Range(1, 1, 1, 2), + new win.Slick.Range(2, 3, 2, 3) + ]); const copyEvent = new win.KeyboardEvent('keydown', { key: 'c', code: 'KeyC', ctrlKey: true, bubbles: true }); Object.defineProperty(copyEvent, 'which', { value: 67 }); win.grid.getCanvasNode().dispatchEvent(copyEvent); - - expect(copiedText).to.eq('1\t2\t\r\n\t\t4\r\n'); - Object.defineProperty(win, 'clipboardData', { configurable: true, value: previousClipboardData }); }); + + // the copy handler awaits the clipboard write, so retry until the stub has the text + cy.wrap(store).its('text').should('eq', '1\t2\t\r\n\t\t4\r\n'); }); }); });