Skip to content

Commit afd2b91

Browse files
6pac-aiclaude
andcommitted
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>
1 parent bdadd27 commit afd2b91

9 files changed

Lines changed: 435 additions & 305 deletions

cypress/e2e/clipboard-api.cy.ts

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

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,7 @@ describe('Example - Excel-compatible spreadsheet and Cell Selection', { retries:
6868
const plugin = win.grid.getPluginByName('CellExternalCopyManager');
6969
expect(plugin).to.exist;
7070

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();
71+
expect(() => plugin._decodeTabularData(win.grid, 'p1\tp2\tp3\tp4\tp5\tp6\tp7\tp8\tp9\tp10')).not.to.throw();
7672
});
7773

7874
cy.get('#myGrid [data-row=0] .slick-cell.l22.r22').should('have.text', 'p1');

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

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

0 commit comments

Comments
 (0)