Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/Spreadsheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Spreadsheet
{...EXAMPLE_PROPS}
onChange={onChange}
/>
);
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<SpreadsheetRef>();

Expand Down
23 changes: 23 additions & 0 deletions src/paste.test.ts
Original file line number Diff line number Diff line change
@@ -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" }],
]);
});
});
150 changes: 150 additions & 0 deletions src/paste.ts
Original file line number Diff line number Diff line change
@@ -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<Types.CellBase> {
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;
}
127 changes: 127 additions & 0 deletions src/reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
3 changes: 2 additions & 1 deletion src/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading