Skip to content

Commit 66e842a

Browse files
6pac-aiclaude
andauthored
feat!: replace decoy-textarea clipboard handling with the async Clipboard API (#1270)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c37b4ee commit 66e842a

9 files changed

Lines changed: 476 additions & 329 deletions

cypress/e2e/clipboard-api.cy.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/**
2+
* Contract test for CellExternalCopyManager's Clipboard-API transport.
3+
*
4+
* Copy serializes the selected ranges in memory and writes the tab/CRLF text
5+
* with navigator.clipboard.writeText; paste reads with navigator.clipboard
6+
* .readText and decodes the text directly. There is no decoy textarea and no
7+
* delay option. clipboardWriteOverride / clipboardReadOverride replace the
8+
* transport (e.g. non-secure contexts); when the Clipboard API is missing and
9+
* no override is set, the failure surfaces as a console error, never a throw.
10+
*
11+
* The spec is SELF-HOSTING (harness served via cy.intercept; no example page)
12+
* and stubs navigator.clipboard for determinism — the same pattern
13+
* slickgrid-universal uses in its unit tests. The stub also captures the exact
14+
* serialized text, which real-clipboard tests cannot assert. The full realPress
15+
* Ctrl+C / Ctrl+V keystroke path is covered by
16+
* example-excel-compatible-spreadsheet.cy.ts with the same transport stub —
17+
* headless CI runners deny real clipboard access (focus/permission), so the
18+
* real-hardware path is a manual check.
19+
*
20+
* Grid A pastes through column editors (editor.applyValue); grid B has no
21+
* editors, covering the raw field-assignment path plus both override hooks.
22+
* Each test visits the harness itself so retries always start from a fresh
23+
* page. ?noclip=1 removes the stub to simulate an unavailable Clipboard API.
24+
*/
25+
26+
const harnessHtml = `<!doctype html>
27+
<html lang="en">
28+
<head>
29+
<meta charset="utf-8">
30+
<title>Harness: Clipboard API copy manager</title>
31+
<link rel="stylesheet" href="/dist/styles/css/slick-alpine-theme.css"/>
32+
<style> #myGrid, #gridOv { width: 700px; height: 300px; } </style>
33+
</head>
34+
<body>
35+
<div id="myGrid"></div>
36+
<div id="gridOv"></div>
37+
<script src="/dist/browser/slick.core.js"></script>
38+
<script src="/dist/browser/slick.interactions.js"></script>
39+
<script src="/dist/browser/slick.grid.js"></script>
40+
<script src="/dist/browser/plugins/slick.cellrangedecorator.js"></script>
41+
<script src="/dist/browser/plugins/slick.cellrangeselector.js"></script>
42+
<script src="/dist/browser/plugins/slick.cellselectionmodel.js"></script>
43+
<script src="/dist/browser/plugins/slick.cellexternalcopymanager.js"></script>
44+
<script src="/dist/browser/slick.editors.js"></script>
45+
<script>
46+
var clipStore = { text: null, writes: 0, reads: 0 };
47+
window.clipStore = clipStore;
48+
Object.defineProperty(navigator, 'clipboard', {
49+
configurable: true,
50+
value: {
51+
writeText: function (t) { clipStore.text = t; clipStore.writes++; return Promise.resolve(); },
52+
readText: function () { clipStore.reads++; return Promise.resolve(clipStore.text); }
53+
}
54+
});
55+
if (location.search.indexOf('noclip') >= 0) {
56+
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
57+
}
58+
59+
window.clipErrors = 0;
60+
var origConsoleError = console.error;
61+
console.error = function () {
62+
if (String(arguments[0]).indexOf('Unable to read/write to clipboard') === 0) { window.clipErrors++; }
63+
return origConsoleError.apply(console, arguments);
64+
};
65+
66+
var columns = [
67+
{ id: 'id', name: '#', field: 'id', width: 60 },
68+
{ id: 'a', name: 'A', field: 'a', width: 120, editor: Slick.Editors.Text },
69+
{ id: 'b', name: 'B', field: 'b', width: 120, editor: Slick.Editors.Text },
70+
{ id: 'c', name: 'C', field: 'c', width: 120, editor: Slick.Editors.Text }
71+
];
72+
var data = [];
73+
for (var i = 0; i < 30; i++) {
74+
data.push({ id: i, a: 'A' + i, b: 'B' + i, c: 'C' + i });
75+
}
76+
var grid = new Slick.Grid('#myGrid', data, columns, {
77+
enableCellNavigation: true,
78+
enableColumnReorder: false,
79+
editable: true,
80+
autoEdit: false,
81+
rowHeight: 25
82+
});
83+
grid.setSelectionModel(new Slick.CellSelectionModel());
84+
grid.registerPlugin(new Slick.CellExternalCopyManager({ includeHeaderWhenCopying: false }));
85+
window.grid = grid;
86+
window.getData = function () { return data; };
87+
window.pasteEvents = 0;
88+
window.copyCancelledEvents = 0;
89+
grid.getPluginByName('CellExternalCopyManager').onPasteCells.subscribe(function () { window.pasteEvents++; });
90+
grid.getPluginByName('CellExternalCopyManager').onCopyCancelled.subscribe(function () { window.copyCancelledEvents++; });
91+
92+
var ovStore = { text: null, writes: 0, reads: 0 };
93+
window.ovStore = ovStore;
94+
var columnsOv = [
95+
{ id: 'id', name: '#', field: 'id', width: 60 },
96+
{ id: 'a', name: 'A', field: 'a', width: 120 },
97+
{ id: 'b', name: 'B', field: 'b', width: 120 },
98+
{ id: 'c', name: 'C', field: 'c', width: 120 }
99+
];
100+
var dataOv = [];
101+
for (var k = 0; k < 10; k++) {
102+
dataOv.push({ id: k, a: 'OA' + k, b: 'OB' + k, c: 'OC' + k });
103+
}
104+
var gridOv = new Slick.Grid('#gridOv', dataOv, columnsOv, {
105+
enableCellNavigation: true,
106+
enableColumnReorder: false,
107+
rowHeight: 25
108+
});
109+
gridOv.setSelectionModel(new Slick.CellSelectionModel());
110+
gridOv.registerPlugin(new Slick.CellExternalCopyManager({
111+
includeHeaderWhenCopying: false,
112+
clipboardWriteOverride: function (t) { ovStore.text = t; ovStore.writes++; },
113+
clipboardReadOverride: function () { ovStore.reads++; return ovStore.text; }
114+
}));
115+
window.gridOv = gridOv;
116+
window.getDataOv = function () { return dataOv; };
117+
118+
window.selectRange = function (g, r1, c1, r2, c2) {
119+
g.setActiveCell(r1, c1);
120+
g.getSelectionModel().setSelectedRanges([new Slick.Range(r1, c1, r2, c2)]);
121+
};
122+
window.pressKey = function (g, key, mods) {
123+
var node = g.getActiveCellNode() || g.getContainerNode();
124+
node.dispatchEvent(new KeyboardEvent('keydown', {
125+
key: key, bubbles: true, cancelable: true,
126+
ctrlKey: !!(mods && mods.ctrlKey), shiftKey: !!(mods && mods.shiftKey)
127+
}));
128+
};
129+
</script>
130+
</body>
131+
</html>`;
132+
133+
describe('CellExternalCopyManager - Clipboard API transport', { retries: 1 }, () => {
134+
const visitHarness = (query = '') => {
135+
cy.intercept('GET', '/clipboard-api-harness.html*', {
136+
headers: { 'content-type': 'text/html' },
137+
body: harnessHtml,
138+
});
139+
cy.visit(`${Cypress.config('baseUrl')}/clipboard-api-harness.html${query}`);
140+
cy.window().its('grid').should('exist');
141+
};
142+
const cellSelector = (gridId: string, row: number, cellClass: string) =>
143+
`#${gridId} .slick-row[data-row="${row}"] .slick-cell.${cellClass}`;
144+
145+
it('should copy the selected range through navigator.clipboard.writeText and cancel highlight on Escape', () => {
146+
visitHarness();
147+
cy.window().then((win: any) => {
148+
win.selectRange(win.grid, 1, 1, 2, 2);
149+
win.pressKey(win.grid, 'c', { ctrlKey: true });
150+
});
151+
cy.window().its('clipStore.text', { timeout: 4000 })
152+
.should('eq', 'A1\tB1\r\nA2\tB2\r\n');
153+
cy.window().its('clipStore.writes').should('eq', 1);
154+
cy.get('#myGrid .slick-cell.copied').should('have.length', 4);
155+
cy.window().then((win: any) => {
156+
expect(win.document.querySelectorAll('textarea').length, 'no decoy textarea in the DOM').to.eq(0);
157+
win.pressKey(win.grid, 'Escape');
158+
});
159+
cy.get('#myGrid .slick-cell.copied').should('have.length', 0);
160+
cy.window().its('copyCancelledEvents').should('eq', 1);
161+
});
162+
163+
it('should paste clipboard text read from navigator.clipboard.readText into the grid', () => {
164+
visitHarness();
165+
cy.window().then((win: any) => {
166+
win.clipStore.text = 'X\tY\r\nZ\tW\r\n';
167+
win.selectRange(win.grid, 5, 1, 5, 1);
168+
win.pressKey(win.grid, 'v', { ctrlKey: true });
169+
});
170+
cy.get(cellSelector('myGrid', 5, 'l1'), { timeout: 4000 }).should('have.text', 'X');
171+
cy.get(cellSelector('myGrid', 5, 'l2')).should('have.text', 'Y');
172+
cy.get(cellSelector('myGrid', 6, 'l1')).should('have.text', 'Z');
173+
cy.get(cellSelector('myGrid', 6, 'l2')).should('have.text', 'W');
174+
cy.window().then((win: any) => {
175+
expect(win.clipStore.reads, 'one readText call').to.eq(1);
176+
expect(win.getData()[5].a, 'underlying data updated').to.eq('X');
177+
expect(win.getData()[6].b, 'underlying data updated').to.eq('W');
178+
expect(win.pasteEvents, 'onPasteCells notified').to.eq(1);
179+
expect(win.document.querySelectorAll('textarea').length, 'no decoy textarea in the DOM').to.eq(0);
180+
});
181+
});
182+
183+
it('should route copy and paste through the override hooks without touching navigator.clipboard', () => {
184+
visitHarness();
185+
cy.window().then((win: any) => {
186+
win.selectRange(win.gridOv, 0, 1, 0, 2);
187+
win.pressKey(win.gridOv, 'c', { ctrlKey: true });
188+
});
189+
cy.window().its('ovStore.text', { timeout: 4000 }).should('eq', 'OA0\tOB0\r\n');
190+
cy.window().then((win: any) => {
191+
win.ovStore.text = 'P\tQ\r\n';
192+
win.selectRange(win.gridOv, 2, 1, 2, 1);
193+
win.pressKey(win.gridOv, 'v', { ctrlKey: true });
194+
});
195+
cy.get(cellSelector('gridOv', 2, 'l1'), { timeout: 4000 }).should('have.text', 'P');
196+
cy.get(cellSelector('gridOv', 2, 'l2')).should('have.text', 'Q');
197+
cy.window().then((win: any) => {
198+
expect(win.ovStore.writes, 'override write used').to.eq(1);
199+
expect(win.ovStore.reads, 'override read used').to.eq(1);
200+
expect(win.getDataOv()[2].a, 'raw field assignment (no editor)').to.eq('P');
201+
expect(win.clipStore.writes, 'navigator.clipboard.writeText never called').to.eq(0);
202+
expect(win.clipStore.reads, 'navigator.clipboard.readText never called').to.eq(0);
203+
});
204+
});
205+
206+
it('should surface an unavailable Clipboard API as a console error, not a throw', () => {
207+
visitHarness('?noclip=1');
208+
cy.window().then((win: any) => {
209+
win.selectRange(win.grid, 1, 1, 1, 1);
210+
win.pressKey(win.grid, 'c', { ctrlKey: true });
211+
});
212+
cy.window().its('clipErrors', { timeout: 4000 }).should('eq', 1);
213+
cy.window().then((win: any) => {
214+
win.pressKey(win.grid, 'v', { ctrlKey: true });
215+
});
216+
cy.window().its('clipErrors', { timeout: 4000 }).should('eq', 2);
217+
cy.window().then((win: any) => {
218+
expect(win.grid.getActiveCell(), 'grid still responsive after failures').to.deep.include({ row: 1, cell: 1 });
219+
expect(win.getData()[1].a, 'no paste happened').to.eq('A1');
220+
});
221+
});
222+
});

cypress/e2e/example-excel-compatible-spreadsheet.cy.ts

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ describe('Example - Excel-compatible spreadsheet and Cell Selection', { retries:
66
});
77

88
it('should click on cell B2, copy value, ArrowDown, paste value, ArrowRight, and expect to be in column C', () => {
9+
// stub the Clipboard API transport: headless CI runners deny real clipboard
10+
// access (focus/permission), so realPress drives the full keystroke path
11+
// while the transport stays deterministic
12+
cy.window().then((win: any) => {
13+
const store = { text: '' };
14+
Object.defineProperty(win.navigator, 'clipboard', {
15+
configurable: true,
16+
value: {
17+
writeText: (t: string) => { store.text = t; return Promise.resolve(); },
18+
readText: () => Promise.resolve(store.text),
19+
},
20+
});
21+
});
22+
923
cy.getCell(2, 2, '', { parentSelector: '#myGrid', rowHeight: cellHeight })
1024
.as('cell_B2')
1125
.click();
@@ -68,40 +82,37 @@ describe('Example - Excel-compatible spreadsheet and Cell Selection', { retries:
6882
const plugin = win.grid.getPluginByName('CellExternalCopyManager');
6983
expect(plugin).to.exist;
7084

71-
const ta = win.document.createElement('textarea');
72-
ta.value = 'p1\tp2\tp3\tp4\tp5\tp6\tp7\tp8\tp9\tp10';
73-
win.document.body.appendChild(ta);
74-
75-
expect(() => plugin._decodeTabularData(win.grid, ta)).not.to.throw();
85+
expect(() => plugin._decodeTabularData(win.grid, 'p1\tp2\tp3\tp4\tp5\tp6\tp7\tp8\tp9\tp10')).not.to.throw();
7686
});
7787

7888
cy.get('#myGrid [data-row=0] .slick-cell.l22.r22').should('have.text', 'p1');
7989
cy.get('#myGrid [data-row=0] .slick-cell.l26.r26').should('have.text', 'p5');
8090
});
8191

8292
it('should preserve gaps when copying multiple non-contiguous ranges', () => {
93+
const store = { text: '' };
94+
8395
cy.window().then((win: any) => {
84-
const previousClipboardData = win.clipboardData;
85-
let copiedText = '';
86-
Object.defineProperty(win, 'clipboardData', {
96+
Object.defineProperty(win.navigator, 'clipboard', {
8797
configurable: true,
8898
value: {
89-
setData: (_format: string, text: string) => { copiedText = text; }
90-
}
99+
writeText: (t: string) => { store.text = t; return Promise.resolve(); },
100+
readText: () => Promise.resolve(store.text),
101+
},
91102
});
92103

93-
const selectionModel = win.grid.getSelectionModel();
94-
selectionModel.setSelectedRanges([
95-
new win.Slick.Range(1, 1, 1, 2),
96-
new win.Slick.Range(2, 3, 2, 3)
97-
]);
104+
const selectionModel = win.grid.getSelectionModel();
105+
selectionModel.setSelectedRanges([
106+
new win.Slick.Range(1, 1, 1, 2),
107+
new win.Slick.Range(2, 3, 2, 3)
108+
]);
98109
const copyEvent = new win.KeyboardEvent('keydown', { key: 'c', code: 'KeyC', ctrlKey: true, bubbles: true });
99110
Object.defineProperty(copyEvent, 'which', { value: 67 });
100111
win.grid.getCanvasNode().dispatchEvent(copyEvent);
101-
102-
expect(copiedText).to.eq('1\t2\t\r\n\t\t4\r\n');
103-
Object.defineProperty(win, 'clipboardData', { configurable: true, value: previousClipboardData });
104112
});
113+
114+
// the copy handler awaits the clipboard write, so retry until the stub has the text
115+
cy.wrap(store).its('text').should('eq', '1\t2\t\r\n\t\t4\r\n');
105116
});
106117
});
107118
});

cypress/e2e/quirk-clipboard-paste-event-driven.cy.ts

Lines changed: 0 additions & 97 deletions
This file was deleted.

0 commit comments

Comments
 (0)