Skip to content

Commit e499181

Browse files
authored
Fix paste into spreadsheets (#759)
* Fix paste in google sheets and excel by better encoding the HTML and switching how we store things into the paste buffer so it actually works * Fix tests
1 parent 3164bfd commit e499181

File tree

2 files changed

+72
-31
lines changed

2 files changed

+72
-31
lines changed

packages/core/src/data-editor/data-editor-fns.ts

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ export function decodeHTML(tableEl: HTMLTableElement): string[][] | undefined {
203203
return result;
204204
}
205205

206-
function escape(str: string): string {
206+
function escape(str: string, actuallyEscape: boolean): string {
207+
if (!actuallyEscape) return str;
207208
if (/[\t\n",]/.test(str)) {
208209
str = `"${str.replace(/"/g, '""')}"`;
209210
}
@@ -229,24 +230,30 @@ const formatBoolean = (val: boolean | BooleanEmpty | BooleanIndeterminate): stri
229230
}
230231
};
231232

232-
export function formatCell(cell: GridCell, index: number, raw: boolean, columnIndexes: readonly number[]) {
233+
export function formatCell(
234+
cell: GridCell,
235+
index: number,
236+
raw: boolean,
237+
columnIndexes: readonly number[],
238+
escapeValues: boolean
239+
) {
233240
const colIndex = columnIndexes[index];
234241
if (cell.span !== undefined && cell.span[0] !== colIndex) return "";
235242
if (cell.copyData !== undefined) {
236-
return escape(cell.copyData);
243+
return escape(cell.copyData, escapeValues);
237244
}
238245
switch (cell.kind) {
239246
case GridCellKind.Text:
240247
case GridCellKind.Number:
241-
return escape(raw ? cell.data?.toString() ?? "" : cell.displayData);
248+
return escape(raw ? cell.data?.toString() ?? "" : cell.displayData, escapeValues);
242249
case GridCellKind.Markdown:
243250
case GridCellKind.RowID:
244251
case GridCellKind.Uri:
245-
return escape(cell.data);
252+
return escape(cell.data, escapeValues);
246253
case GridCellKind.Image:
247254
case GridCellKind.Bubble:
248255
if (cell.data.length === 0) return "";
249-
return cell.data.reduce((pv, cv) => `${escape(pv)},${escape(cv)}`);
256+
return cell.data.reduce((pv, cv) => `${escape(pv, escapeValues)},${escape(cv, escapeValues)}`);
250257
case GridCellKind.Boolean:
251258
return formatBoolean(cell.data);
252259
case GridCellKind.Loading:
@@ -255,16 +262,18 @@ export function formatCell(cell: GridCell, index: number, raw: boolean, columnIn
255262
return raw ? "" : "************";
256263
case GridCellKind.Drilldown:
257264
if (cell.data.length === 0) return "";
258-
return cell.data.map(i => i.text).reduce((pv, cv) => `${escape(pv)},${escape(cv)}`);
265+
return cell.data
266+
.map(i => i.text)
267+
.reduce((pv, cv) => `${escape(pv, escapeValues)},${escape(cv, escapeValues)}`);
259268
case GridCellKind.Custom:
260-
return escape(cell.copyData);
269+
return escape(cell.copyData, escapeValues);
261270
default:
262271
assertNever(cell, `A cell was passed with an invalid kind: ${(cell as any).kind}`);
263272
}
264273
}
265274

266275
export function formatForCopy(cells: readonly (readonly GridCell[])[], columnIndexes: readonly number[]): string {
267-
return cells.map(row => row.map((a, b) => formatCell(a, b, false, columnIndexes)).join("\t")).join("\n");
276+
return cells.map(row => row.map((a, b) => formatCell(a, b, false, columnIndexes, true)).join("\t")).join("\n");
268277
}
269278

270279
export function copyToClipboard(
@@ -274,7 +283,51 @@ export function copyToClipboard(
274283
) {
275284
const str = formatForCopy(cells, columnIndexes);
276285

277-
if (window.navigator.clipboard?.write !== undefined || e !== undefined) {
286+
const styleTag = `<style type="text/css"><!--br {mso-data-placement:same-cell;}--></style>`;
287+
288+
// eslint-disable-next-line unicorn/consistent-function-scoping
289+
const copyWithWriteText = (s: string) => {
290+
void window.navigator.clipboard?.writeText(s);
291+
};
292+
293+
const copyWithWrite = (s: string, html: string): boolean => {
294+
if (window.navigator.clipboard?.write === undefined) return false;
295+
void window.navigator.clipboard.write([
296+
new ClipboardItem({
297+
// eslint-disable-next-line sonarjs/no-duplicate-string
298+
"text/plain": new Blob([s], { type: "text/plain" }),
299+
"text/html": new Blob([`${styleTag}<table>${html}</table>`], {
300+
type: "text/html",
301+
}),
302+
}),
303+
]);
304+
return true;
305+
};
306+
307+
const copyWithClipboardData = (s: string, html: string) => {
308+
try {
309+
if (e === undefined || e.clipboardData === null) throw new Error("No clipboard data");
310+
311+
// The following formatting for the `formattedHtml` variable ensures that when pasting,
312+
// spaces are preserved in both Google Sheets and Excel. This is done by:
313+
// 1. Replacing tabs with four spaces for consistency. Also google sheets disallows any tabs.
314+
// 2. Wrapping each space with a span element to prevent them from being collapsed or ignored during the
315+
// paste operation.
316+
const formattedHtml = `${styleTag}<table>${html
317+
.replace(/\t/g, " ")
318+
.replace(/ /g, "<span>&nbsp;</span>")}</table>`;
319+
320+
// This might fail if we had to await the thunk
321+
e?.clipboardData?.setData("text/plain", s);
322+
e?.clipboardData?.setData("text/html", formattedHtml);
323+
} catch {
324+
if (!copyWithWrite(s, html)) {
325+
copyWithWriteText(s);
326+
}
327+
}
328+
};
329+
330+
if (window.navigator.clipboard?.write !== undefined || e?.clipboardData !== undefined) {
278331
const rootEl = document.createElement("tbody");
279332

280333
for (const row of cells) {
@@ -288,31 +341,16 @@ export function copyToClipboard(
288341
link.innerText = cell.data;
289342
cellEl.append(link);
290343
} else {
291-
cellEl.innerText = formatCell(cell, i, true, columnIndexes);
344+
cellEl.innerText = formatCell(cell, i, true, columnIndexes, false);
292345
}
293346
rowEl.append(cellEl);
294347
}
295348

296349
rootEl.append(rowEl);
297350
}
298-
if (window.navigator.clipboard?.write !== undefined) {
299-
void window.navigator.clipboard.write([
300-
new ClipboardItem({
301-
"text/plain": new Blob([str], { type: "text/plain" }),
302-
"text/html": new Blob([`<table>${rootEl.outerHTML}</table>`], { type: "text/html" }),
303-
}),
304-
]);
305-
} else if (e !== undefined && e?.clipboardData !== null) {
306-
try {
307-
// This might fail if we had to await the thunk
308-
e.clipboardData.setData("text/plain", str);
309-
e.clipboardData.setData("text/html", `<table>${rootEl.outerHTML}</table>`);
310-
} catch {
311-
void window.navigator.clipboard?.writeText(str);
312-
}
313-
}
351+
void copyWithClipboardData(str, rootEl.outerHTML);
314352
} else {
315-
void window.navigator.clipboard?.writeText(str);
353+
void copyWithWriteText(str);
316354
}
317355

318356
e?.preventDefault();

packages/core/test/data-editor-fns.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ describe("data-editor-fns", () => {
3535
},
3636
0,
3737
false,
38-
[0]
38+
[0],
39+
true
3940
)
4041
).toEqual("");
4142
});
@@ -50,7 +51,8 @@ describe("data-editor-fns", () => {
5051
},
5152
0,
5253
false,
53-
[0]
54+
[0],
55+
true
5456
)
5557
).toEqual('"foo, bar",baz');
5658
});
@@ -66,7 +68,8 @@ describe("data-editor-fns", () => {
6668
},
6769
0,
6870
false,
69-
[0]
71+
[0],
72+
true
7073
)
7174
).toEqual("override");
7275
});

0 commit comments

Comments
 (0)