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);