From 9e40c3d8a4f7c54e7f48f5a2205144411116ef40 Mon Sep 17 00:00:00 2001 From: jabelic Date: Wed, 25 Mar 2026 00:40:37 +0900 Subject: [PATCH] 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 --- src/Spreadsheet.test.tsx | 22 ++++++ src/paste.test.ts | 23 ++++++ src/paste.ts | 150 +++++++++++++++++++++++++++++++++++++++ src/reducer.test.ts | 127 +++++++++++++++++++++++++++++++++ src/reducer.ts | 3 +- 5 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 src/paste.test.ts create mode 100644 src/paste.ts diff --git a/src/Spreadsheet.test.tsx b/src/Spreadsheet.test.tsx index ec498ee51..4423966fe 100644 --- a/src/Spreadsheet.test.tsx +++ b/src/Spreadsheet.test.tsx @@ -538,6 +538,28 @@ describe("Spreadsheet Ref Methods", () => { expect(onActivate).toHaveBeenCalledWith(invalidPoint); }); + test("paste preserves literal double quotes in a single value", () => { + const onChange = jest.fn(); + render( + + ); + const element = getSpreadsheetElement(); + const cell = safeQuerySelector(element, "td"); + fireEvent.mouseDown(cell); + + const clipboardData = { + getData: (type: string) => (type === "text/plain" ? '"a"' : ""), + setData: () => {}, + }; + fireEvent.paste(document, { clipboardData }); + + const dataViewer = safeQuerySelector(cell, ".Spreadsheet__data-viewer"); + expect(dataViewer.textContent).toBe('"a"'); + }); + test("ref is properly typed as SpreadsheetRef", () => { const ref = React.createRef(); diff --git a/src/paste.test.ts b/src/paste.test.ts new file mode 100644 index 000000000..daca4550c --- /dev/null +++ b/src/paste.test.ts @@ -0,0 +1,23 @@ +import { parsePastedText } from "./paste"; + +describe("parsePastedText()", () => { + test("keeps literal double quotes when pasting a single value", () => { + expect(parsePastedText('"Value"')).toEqual([[{ value: '"Value"' }]]); + }); + + test("keeps legacy behavior for quoted numeric single values", () => { + expect(parsePastedText('"123"')).toEqual([[{ value: "123" }]]); + }); + + test("preserves literal quotes in simple quoted multi-cell fields", () => { + expect(parsePastedText('"aa"\ta')).toEqual([ + [{ value: '"aa"' }, { value: "a" }], + ]); + }); + + test("unquotes quoted fields that protect tab separators", () => { + expect(parsePastedText('"Value\t1"\tValue2')).toEqual([ + [{ value: "Value\t1" }, { value: "Value2" }], + ]); + }); +}); diff --git a/src/paste.ts b/src/paste.ts new file mode 100644 index 000000000..53a916bb5 --- /dev/null +++ b/src/paste.ts @@ -0,0 +1,150 @@ +import * as Matrix from "./matrix"; +import * as Types from "./types"; + +/** + * Preserve raw text for single-cell paste. Multi-cell paste uses a + * clipboard-aware parser that only strips quotes from fields whose quoted + * content actually contains separators or escaped quotes. Quoted numeric + * single values keep the legacy behavior and are unquoted. + */ +export function parsePastedText(text: string): Matrix.Matrix { + if (/[\t\n\r]/.test(text)) { + return splitClipboardText(text).map((row) => row.map((value) => ({ value }))); + } + + return [[{ value: parseSinglePastedValue(text) }]]; +} + +function parseSinglePastedValue(text: string): string { + if (!text.startsWith('"') || !text.endsWith('"')) { + return text; + } + + const unquoted = text.slice(1, -1); + return isNumericValue(unquoted) ? unquoted : text; +} + +function isNumericValue(value: string): boolean { + return /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(value); +} + +/** + * Split clipboard text into a matrix of strings. Unlike Matrix.split(), a + * simple quoted field such as `"aa"` keeps its quotes. Quotes are stripped + * only when the field uses quoted-field syntax for separators or escaped + * quotes. + */ +function splitClipboardText(text: string): string[][] { + const rows: string[][] = []; + const state = { + currentRow: [] as string[], + index: 0, + }; + + while (state.index < text.length) { + const field = parseClipboardField(text, state.index); + state.currentRow.push(field.value); + state.index = field.nextIndex; + + if (state.index >= text.length) { + break; + } + + if (text[state.index] === "\t") { + state.index += 1; + continue; + } + + const newlineLength = matchNewline(text, state.index); + if (newlineLength > 0) { + rows.push(state.currentRow); + state.currentRow = []; + state.index += newlineLength; + } + } + + rows.push(state.currentRow); + return rows; +} + +type ClipboardFieldResult = { value: string; nextIndex: number }; + +function parseClipboardField( + text: string, + start: number +): ClipboardFieldResult { + if (text[start] !== '"') { + return parseLiteralField(text, start); + } + + const state = { + unescaped: "", + hasSpecialContent: false, + index: start + 1, + }; + + while (state.index < text.length) { + const ch = text[state.index]; + + if (ch === '"') { + if (text[state.index + 1] === '"') { + state.unescaped += '"'; + state.hasSpecialContent = true; + state.index += 2; + continue; + } + + const afterQuote = state.index + 1; + const atBoundary = + afterQuote >= text.length || isClipboardSeparator(text[afterQuote]); + + if (atBoundary && state.hasSpecialContent) { + return { value: state.unescaped, nextIndex: afterQuote }; + } + + if (atBoundary) { + return { value: text.slice(start, afterQuote), nextIndex: afterQuote }; + } + + break; + } + + if (isClipboardSeparator(ch)) { + state.hasSpecialContent = true; + } + + state.unescaped += ch; + state.index += 1; + } + + return parseLiteralField(text, start); +} + +function parseLiteralField( + text: string, + start: number +): ClipboardFieldResult { + const tabIndex = text.indexOf("\t", start); + const lineFeedIndex = text.indexOf("\n", start); + const carriageReturnIndex = text.indexOf("\r", start); + const nextIndex = Math.min( + tabIndex === -1 ? text.length : tabIndex, + lineFeedIndex === -1 ? text.length : lineFeedIndex, + carriageReturnIndex === -1 ? text.length : carriageReturnIndex + ); + return { value: text.slice(start, nextIndex), nextIndex }; +} + +function isClipboardSeparator(ch: string): boolean { + return ch === "\t" || ch === "\n" || ch === "\r"; +} + +function matchNewline(text: string, index: number): number { + if (text[index] === "\r" && text[index + 1] === "\n") { + return 2; + } + if (text[index] === "\n" || text[index] === "\r") { + return 1; + } + return 0; +} diff --git a/src/reducer.test.ts b/src/reducer.test.ts index 1963e08ec..1c413ffc9 100644 --- a/src/reducer.test.ts +++ b/src/reducer.test.ts @@ -227,6 +227,133 @@ describe("reducer", () => { }); }); +describe("paste", () => { + test("keeps literal double quotes when pasting a single value", () => { + const state: Types.StoreState = { + ...INITIAL_STATE, + active: Point.ORIGIN, + model: new Model(createFormulaParser, createEmptyMatrix(1, 1)), + selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)), + }; + + const nextState = reducer(state, Actions.paste('"Value"')); + + expect(Matrix.get(Point.ORIGIN, nextState.model.data)).toEqual({ + value: '"Value"', + }); + expect(Matrix.get(Point.ORIGIN, nextState.model.evaluatedData)).toEqual({ + value: '"Value"', + }); + }); + + test("keeps legacy behavior for quoted numeric single values", () => { + const state: Types.StoreState = { + ...INITIAL_STATE, + active: Point.ORIGIN, + model: new Model(createFormulaParser, createEmptyMatrix(1, 1)), + selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)), + }; + + const nextState = reducer(state, Actions.paste('"123"')); + + expect(Matrix.get(Point.ORIGIN, nextState.model.data)).toEqual({ + value: "123", + }); + }); + + test("keeps escaped quotes as raw text when pasting a single value", () => { + const state: Types.StoreState = { + ...INITIAL_STATE, + active: Point.ORIGIN, + model: new Model(createFormulaParser, createEmptyMatrix(1, 1)), + selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)), + }; + + const nextState = reducer( + state, + Actions.paste('"He said ""hello"""') + ); + + expect(Matrix.get(Point.ORIGIN, nextState.model.data)).toEqual({ + value: '"He said ""hello"""', + }); + }); + + test("preserves literal quotes in simple quoted multi-cell fields", () => { + const state: Types.StoreState = { + ...INITIAL_STATE, + active: Point.ORIGIN, + model: new Model(createFormulaParser, createEmptyMatrix(1, 2)), + selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)), + }; + + const nextState = reducer(state, Actions.paste('"aa"\ta')); + + expect(Matrix.get(Point.ORIGIN, nextState.model.data)).toEqual({ + value: '"aa"', + }); + expect( + Matrix.get({ row: Point.ORIGIN.row, column: Point.ORIGIN.column + 1 }, nextState.model.data) + ).toEqual({ + value: "a", + }); + }); + + test("unquotes quoted fields that protect tab separators", () => { + const state: Types.StoreState = { + ...INITIAL_STATE, + active: Point.ORIGIN, + model: new Model(createFormulaParser, createEmptyMatrix(1, 2)), + selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)), + }; + + const nextState = reducer(state, Actions.paste('"Value\t1"\tValue2')); + + expect(Matrix.get(Point.ORIGIN, nextState.model.data)).toEqual({ + value: "Value\t1", + }); + expect( + Matrix.get({ row: Point.ORIGIN.row, column: Point.ORIGIN.column + 1 }, nextState.model.data) + ).toEqual({ + value: "Value2", + }); + }); + + test("preserves literal quotes in real-world multi-row paste", () => { + const state: Types.StoreState = { + ...INITIAL_STATE, + active: Point.ORIGIN, + model: new Model(createFormulaParser, createEmptyMatrix(5, 5)), + selected: new RangeSelection(new PointRange(Point.ORIGIN, Point.ORIGIN)), + }; + const text = [ + '"aa"\ta\tDrugA\tDrugA\t', + '"aa"\ta\tDrugA\tDrugA\t', + '"aa"\ta\tDrugA\tDrugA\t', + '\t\tDrugA\tDrugA\tDrugA', + '\t\t\tDrugA\tDrugA', + ].join("\n"); + + const nextState = reducer(state, Actions.paste(text)); + + expect(Matrix.get({ row: 0, column: 0 }, nextState.model.data)).toEqual({ + value: '"aa"', + }); + expect(Matrix.get({ row: 1, column: 0 }, nextState.model.data)).toEqual({ + value: '"aa"', + }); + expect(Matrix.get({ row: 2, column: 0 }, nextState.model.data)).toEqual({ + value: '"aa"', + }); + expect(Matrix.get({ row: 3, column: 2 }, nextState.model.data)).toEqual({ + value: "DrugA", + }); + expect(Matrix.get({ row: 4, column: 3 }, nextState.model.data)).toEqual({ + value: "DrugA", + }); + }); +}); + describe("hasKeyDownHandler", () => { const cases = [ ["returns true for handled key", INITIAL_STATE, "Enter", true], diff --git a/src/reducer.ts b/src/reducer.ts index cb370e6fd..244aa3149 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -13,6 +13,7 @@ import { import { isActive } from "./util"; import * as Actions from "./actions"; import { Model, updateCellValue, createFormulaParser } from "./engine"; +import { parsePastedText } from "./paste"; export const INITIAL_STATE: Types.StoreState = { active: null, @@ -178,7 +179,7 @@ export default function reducer( return state; } - const copied = Matrix.split(text, (value) => ({ value })); + const copied = parsePastedText(text); const copiedSize = Matrix.getSize(copied); const selectedRange = state.selected.toRange(state.model.data);