Skip to content

Commit aa82c57

Browse files
sadmann7cursoragent
andcommitted
fix: preserve multiline content when pasting TSV data
- Add parseTsv to handle multiline cells instead of splitting every newline - Auto-detect column count from clipboard when grid has more columns - Add countTabs helper, optimize with single-pass parsing Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8252731 commit aa82c57

3 files changed

Lines changed: 94 additions & 4 deletions

File tree

src/hooks/test/use-data-grid.test.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,51 @@ describe("useDataGrid", () => {
10331033
]),
10341034
);
10351035
});
1036+
1037+
it("should preserve multiline content within cells when pasting", async () => {
1038+
const onPaste = vi.fn().mockResolvedValue(undefined);
1039+
mockClipboard.readText.mockResolvedValue(
1040+
"Alice\tKickflip\t95\nBob\tTrick with\nmultiple\nlines\t98",
1041+
);
1042+
1043+
const { result } = renderHook(
1044+
() =>
1045+
useDataGrid({
1046+
data: testData,
1047+
columns: testColumns,
1048+
onPaste,
1049+
}),
1050+
{ wrapper: createWrapper() },
1051+
);
1052+
1053+
act(() => {
1054+
result.current.tableMeta.onCellClick?.(0, "name");
1055+
});
1056+
1057+
await act(async () => {
1058+
await result.current.tableMeta.onCellsPaste?.();
1059+
});
1060+
1061+
expect(onPaste).toHaveBeenCalledWith(
1062+
expect.arrayContaining([
1063+
expect.objectContaining({
1064+
rowIndex: 0,
1065+
columnId: "score",
1066+
value: 95,
1067+
}),
1068+
expect.objectContaining({
1069+
rowIndex: 1,
1070+
columnId: "trick",
1071+
value: "Trick with\nmultiple\nlines",
1072+
}),
1073+
expect.objectContaining({
1074+
rowIndex: 1,
1075+
columnId: "score",
1076+
value: 98,
1077+
}),
1078+
]),
1079+
);
1080+
});
10361081
});
10371082

10381083
describe("cut operations", () => {

src/hooks/use-data-grid.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
getScrollDirection,
3131
matchSelectOption,
3232
parseCellKey,
33+
parseTsv,
3334
scrollCellIntoView,
3435
} from "@/lib/data-grid";
3536
import type {
@@ -699,10 +700,7 @@ function useDataGrid<TData>({
699700
if (!clipboardText) return;
700701
}
701702

702-
const pastedRows = clipboardText
703-
.split("\n")
704-
.filter((row) => row.length > 0);
705-
const pastedData = pastedRows.map((row) => row.split("\t"));
703+
const pastedData = parseTsv(clipboardText, navigableColumnIds.length);
706704

707705
const startRowIndex = currentState.focusedCell.rowIndex;
708706
const startColIndex = navigableColumnIds.indexOf(

src/lib/data-grid.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,53 @@ export function scrollCellIntoView<TData>(params: {
256256
container.scrollLeft += scrollDelta;
257257
}
258258

259+
function countTabs(s: string): number {
260+
let n = 0;
261+
for (let i = 0; i < s.length; i++) if (s[i] === "\t") n++;
262+
return n;
263+
}
264+
265+
export function parseTsv(
266+
text: string,
267+
fallbackColumnCount: number,
268+
): string[][] {
269+
const lines = text.split("\n");
270+
let maxTabs = 0;
271+
for (const line of lines) {
272+
const n = countTabs(line);
273+
if (n > maxTabs) maxTabs = n;
274+
}
275+
const cols = maxTabs > 0 ? maxTabs + 1 : fallbackColumnCount;
276+
if (cols <= 0) return [];
277+
278+
const rows: string[][] = [];
279+
let buf = "";
280+
281+
for (const line of lines) {
282+
const tc = countTabs(line);
283+
284+
if (tc === cols - 1) {
285+
if (buf && countTabs(buf) === cols - 1) rows.push(buf.split("\t"));
286+
buf = "";
287+
rows.push(line.split("\t"));
288+
} else if (tc === 0 && rows.length > 0 && !buf) {
289+
const last = rows[rows.length - 1];
290+
if (last) {
291+
const cell = last[cols - 1];
292+
if (cell !== undefined) last[cols - 1] = `${cell}\n${line}`;
293+
}
294+
} else {
295+
buf = buf ? `${buf}\n${line}` : line;
296+
}
297+
}
298+
299+
if (buf && countTabs(buf) === cols - 1) rows.push(buf.split("\t"));
300+
301+
return rows.length > 0
302+
? rows
303+
: lines.filter((l) => l.length > 0).map((l) => l.split("\t"));
304+
}
305+
259306
export function getIsInPopover(element: unknown): boolean {
260307
if (!(element instanceof Element)) return false;
261308

0 commit comments

Comments
 (0)