diff --git a/src/Spreadsheet.test.tsx b/src/Spreadsheet.test.tsx index ec498ee5..4423966f 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 00000000..daca4550 --- /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 00000000..53a916bb --- /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 1963e08e..1c413ffc 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 cb370e6f..244aa314 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);