Skip to content

Commit 9e40c3d

Browse files
committed
Preserve literal quotes in view-mode paste
Move paste-specific quote handling into a dedicated parser so simple quoted fields no longer lose quote characters during paste. Keep legacy handling for quoted numeric single values while covering the new behavior with reducer, parser, and UI tests. Made-with: Cursor
1 parent bb80b60 commit 9e40c3d

5 files changed

Lines changed: 324 additions & 1 deletion

File tree

src/Spreadsheet.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,28 @@ describe("Spreadsheet Ref Methods", () => {
538538
expect(onActivate).toHaveBeenCalledWith(invalidPoint);
539539
});
540540

541+
test("paste preserves literal double quotes in a single value", () => {
542+
const onChange = jest.fn();
543+
render(
544+
<Spreadsheet
545+
{...EXAMPLE_PROPS}
546+
onChange={onChange}
547+
/>
548+
);
549+
const element = getSpreadsheetElement();
550+
const cell = safeQuerySelector(element, "td");
551+
fireEvent.mouseDown(cell);
552+
553+
const clipboardData = {
554+
getData: (type: string) => (type === "text/plain" ? '"a"' : ""),
555+
setData: () => {},
556+
};
557+
fireEvent.paste(document, { clipboardData });
558+
559+
const dataViewer = safeQuerySelector(cell, ".Spreadsheet__data-viewer");
560+
expect(dataViewer.textContent).toBe('"a"');
561+
});
562+
541563
test("ref is properly typed as SpreadsheetRef", () => {
542564
const ref = React.createRef<SpreadsheetRef>();
543565

src/paste.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { parsePastedText } from "./paste";
2+
3+
describe("parsePastedText()", () => {
4+
test("keeps literal double quotes when pasting a single value", () => {
5+
expect(parsePastedText('"Value"')).toEqual([[{ value: '"Value"' }]]);
6+
});
7+
8+
test("keeps legacy behavior for quoted numeric single values", () => {
9+
expect(parsePastedText('"123"')).toEqual([[{ value: "123" }]]);
10+
});
11+
12+
test("preserves literal quotes in simple quoted multi-cell fields", () => {
13+
expect(parsePastedText('"aa"\ta')).toEqual([
14+
[{ value: '"aa"' }, { value: "a" }],
15+
]);
16+
});
17+
18+
test("unquotes quoted fields that protect tab separators", () => {
19+
expect(parsePastedText('"Value\t1"\tValue2')).toEqual([
20+
[{ value: "Value\t1" }, { value: "Value2" }],
21+
]);
22+
});
23+
});

src/paste.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import * as Matrix from "./matrix";
2+
import * as Types from "./types";
3+
4+
/**
5+
* Preserve raw text for single-cell paste. Multi-cell paste uses a
6+
* clipboard-aware parser that only strips quotes from fields whose quoted
7+
* content actually contains separators or escaped quotes. Quoted numeric
8+
* single values keep the legacy behavior and are unquoted.
9+
*/
10+
export function parsePastedText(text: string): Matrix.Matrix<Types.CellBase> {
11+
if (/[\t\n\r]/.test(text)) {
12+
return splitClipboardText(text).map((row) => row.map((value) => ({ value })));
13+
}
14+
15+
return [[{ value: parseSinglePastedValue(text) }]];
16+
}
17+
18+
function parseSinglePastedValue(text: string): string {
19+
if (!text.startsWith('"') || !text.endsWith('"')) {
20+
return text;
21+
}
22+
23+
const unquoted = text.slice(1, -1);
24+
return isNumericValue(unquoted) ? unquoted : text;
25+
}
26+
27+
function isNumericValue(value: string): boolean {
28+
return /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(value);
29+
}
30+
31+
/**
32+
* Split clipboard text into a matrix of strings. Unlike Matrix.split(), a
33+
* simple quoted field such as `"aa"` keeps its quotes. Quotes are stripped
34+
* only when the field uses quoted-field syntax for separators or escaped
35+
* quotes.
36+
*/
37+
function splitClipboardText(text: string): string[][] {
38+
const rows: string[][] = [];
39+
const state = {
40+
currentRow: [] as string[],
41+
index: 0,
42+
};
43+
44+
while (state.index < text.length) {
45+
const field = parseClipboardField(text, state.index);
46+
state.currentRow.push(field.value);
47+
state.index = field.nextIndex;
48+
49+
if (state.index >= text.length) {
50+
break;
51+
}
52+
53+
if (text[state.index] === "\t") {
54+
state.index += 1;
55+
continue;
56+
}
57+
58+
const newlineLength = matchNewline(text, state.index);
59+
if (newlineLength > 0) {
60+
rows.push(state.currentRow);
61+
state.currentRow = [];
62+
state.index += newlineLength;
63+
}
64+
}
65+
66+
rows.push(state.currentRow);
67+
return rows;
68+
}
69+
70+
type ClipboardFieldResult = { value: string; nextIndex: number };
71+
72+
function parseClipboardField(
73+
text: string,
74+
start: number
75+
): ClipboardFieldResult {
76+
if (text[start] !== '"') {
77+
return parseLiteralField(text, start);
78+
}
79+
80+
const state = {
81+
unescaped: "",
82+
hasSpecialContent: false,
83+
index: start + 1,
84+
};
85+
86+
while (state.index < text.length) {
87+
const ch = text[state.index];
88+
89+
if (ch === '"') {
90+
if (text[state.index + 1] === '"') {
91+
state.unescaped += '"';
92+
state.hasSpecialContent = true;
93+
state.index += 2;
94+
continue;
95+
}
96+
97+
const afterQuote = state.index + 1;
98+
const atBoundary =
99+
afterQuote >= text.length || isClipboardSeparator(text[afterQuote]);
100+
101+
if (atBoundary && state.hasSpecialContent) {
102+
return { value: state.unescaped, nextIndex: afterQuote };
103+
}
104+
105+
if (atBoundary) {
106+
return { value: text.slice(start, afterQuote), nextIndex: afterQuote };
107+
}
108+
109+
break;
110+
}
111+
112+
if (isClipboardSeparator(ch)) {
113+
state.hasSpecialContent = true;
114+
}
115+
116+
state.unescaped += ch;
117+
state.index += 1;
118+
}
119+
120+
return parseLiteralField(text, start);
121+
}
122+
123+
function parseLiteralField(
124+
text: string,
125+
start: number
126+
): ClipboardFieldResult {
127+
const tabIndex = text.indexOf("\t", start);
128+
const lineFeedIndex = text.indexOf("\n", start);
129+
const carriageReturnIndex = text.indexOf("\r", start);
130+
const nextIndex = Math.min(
131+
tabIndex === -1 ? text.length : tabIndex,
132+
lineFeedIndex === -1 ? text.length : lineFeedIndex,
133+
carriageReturnIndex === -1 ? text.length : carriageReturnIndex
134+
);
135+
return { value: text.slice(start, nextIndex), nextIndex };
136+
}
137+
138+
function isClipboardSeparator(ch: string): boolean {
139+
return ch === "\t" || ch === "\n" || ch === "\r";
140+
}
141+
142+
function matchNewline(text: string, index: number): number {
143+
if (text[index] === "\r" && text[index + 1] === "\n") {
144+
return 2;
145+
}
146+
if (text[index] === "\n" || text[index] === "\r") {
147+
return 1;
148+
}
149+
return 0;
150+
}

src/reducer.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,133 @@ describe("reducer", () => {
227227
});
228228
});
229229

230+
describe("paste", () => {
231+
test("keeps literal double quotes when pasting a single value", () => {
232+
const state: Types.StoreState = {
233+
...INITIAL_STATE,
234+
active: Point.ORIGIN,
235+
model: new Model(createFormulaParser, createEmptyMatrix(1, 1)),
236+
selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)),
237+
};
238+
239+
const nextState = reducer(state, Actions.paste('"Value"'));
240+
241+
expect(Matrix.get(Point.ORIGIN, nextState.model.data)).toEqual({
242+
value: '"Value"',
243+
});
244+
expect(Matrix.get(Point.ORIGIN, nextState.model.evaluatedData)).toEqual({
245+
value: '"Value"',
246+
});
247+
});
248+
249+
test("keeps legacy behavior for quoted numeric single values", () => {
250+
const state: Types.StoreState = {
251+
...INITIAL_STATE,
252+
active: Point.ORIGIN,
253+
model: new Model(createFormulaParser, createEmptyMatrix(1, 1)),
254+
selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)),
255+
};
256+
257+
const nextState = reducer(state, Actions.paste('"123"'));
258+
259+
expect(Matrix.get(Point.ORIGIN, nextState.model.data)).toEqual({
260+
value: "123",
261+
});
262+
});
263+
264+
test("keeps escaped quotes as raw text when pasting a single value", () => {
265+
const state: Types.StoreState = {
266+
...INITIAL_STATE,
267+
active: Point.ORIGIN,
268+
model: new Model(createFormulaParser, createEmptyMatrix(1, 1)),
269+
selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)),
270+
};
271+
272+
const nextState = reducer(
273+
state,
274+
Actions.paste('"He said ""hello"""')
275+
);
276+
277+
expect(Matrix.get(Point.ORIGIN, nextState.model.data)).toEqual({
278+
value: '"He said ""hello"""',
279+
});
280+
});
281+
282+
test("preserves literal quotes in simple quoted multi-cell fields", () => {
283+
const state: Types.StoreState = {
284+
...INITIAL_STATE,
285+
active: Point.ORIGIN,
286+
model: new Model(createFormulaParser, createEmptyMatrix(1, 2)),
287+
selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)),
288+
};
289+
290+
const nextState = reducer(state, Actions.paste('"aa"\ta'));
291+
292+
expect(Matrix.get(Point.ORIGIN, nextState.model.data)).toEqual({
293+
value: '"aa"',
294+
});
295+
expect(
296+
Matrix.get({ row: Point.ORIGIN.row, column: Point.ORIGIN.column + 1 }, nextState.model.data)
297+
).toEqual({
298+
value: "a",
299+
});
300+
});
301+
302+
test("unquotes quoted fields that protect tab separators", () => {
303+
const state: Types.StoreState = {
304+
...INITIAL_STATE,
305+
active: Point.ORIGIN,
306+
model: new Model(createFormulaParser, createEmptyMatrix(1, 2)),
307+
selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)),
308+
};
309+
310+
const nextState = reducer(state, Actions.paste('"Value\t1"\tValue2'));
311+
312+
expect(Matrix.get(Point.ORIGIN, nextState.model.data)).toEqual({
313+
value: "Value\t1",
314+
});
315+
expect(
316+
Matrix.get({ row: Point.ORIGIN.row, column: Point.ORIGIN.column + 1 }, nextState.model.data)
317+
).toEqual({
318+
value: "Value2",
319+
});
320+
});
321+
322+
test("preserves literal quotes in real-world multi-row paste", () => {
323+
const state: Types.StoreState = {
324+
...INITIAL_STATE,
325+
active: Point.ORIGIN,
326+
model: new Model(createFormulaParser, createEmptyMatrix(5, 5)),
327+
selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)),
328+
};
329+
const text = [
330+
'"aa"\ta\tDrugA\tDrugA\t',
331+
'"aa"\ta\tDrugA\tDrugA\t',
332+
'"aa"\ta\tDrugA\tDrugA\t',
333+
'\t\tDrugA\tDrugA\tDrugA',
334+
'\t\t\tDrugA\tDrugA',
335+
].join("\n");
336+
337+
const nextState = reducer(state, Actions.paste(text));
338+
339+
expect(Matrix.get({ row: 0, column: 0 }, nextState.model.data)).toEqual({
340+
value: '"aa"',
341+
});
342+
expect(Matrix.get({ row: 1, column: 0 }, nextState.model.data)).toEqual({
343+
value: '"aa"',
344+
});
345+
expect(Matrix.get({ row: 2, column: 0 }, nextState.model.data)).toEqual({
346+
value: '"aa"',
347+
});
348+
expect(Matrix.get({ row: 3, column: 2 }, nextState.model.data)).toEqual({
349+
value: "DrugA",
350+
});
351+
expect(Matrix.get({ row: 4, column: 3 }, nextState.model.data)).toEqual({
352+
value: "DrugA",
353+
});
354+
});
355+
});
356+
230357
describe("hasKeyDownHandler", () => {
231358
const cases = [
232359
["returns true for handled key", INITIAL_STATE, "Enter", true],

src/reducer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { isActive } from "./util";
1414
import * as Actions from "./actions";
1515
import { Model, updateCellValue, createFormulaParser } from "./engine";
16+
import { parsePastedText } from "./paste";
1617

1718
export const INITIAL_STATE: Types.StoreState = {
1819
active: null,
@@ -178,7 +179,7 @@ export default function reducer(
178179
return state;
179180
}
180181

181-
const copied = Matrix.split(text, (value) => ({ value }));
182+
const copied = parsePastedText(text);
182183
const copiedSize = Matrix.getSize(copied);
183184

184185
const selectedRange = state.selected.toRange(state.model.data);

0 commit comments

Comments
 (0)