Skip to content
Merged
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
166 changes: 166 additions & 0 deletions e2e/auto-expand.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { test, expect } from '@playwright/test';
import { copy, ctrl, drag, go, paste } from './utils';

const STORY = 'restriction-autoexpand--auto-expand';

const setMode = async (page: any, mode: string) => {
await page.locator(`.mode-selector input[value='${mode}']`).check();
};

const text = (page: any, address: string) =>
page.locator(`[data-address='${address}'] .gs-cell-rendered`).textContent();

test('paste past the edge grows the sheet, and undo shrinks it back', async ({ page }) => {
await go(page, STORY);
await setMode(page, 'both');

// A1:C3 pasted at C4 on a 5x4 sheet needs rows 4..6 and cols C..E.
await drag(page, 'A1', 'C3');
await copy(page);
await page.locator("[data-address='C4']").click();
await paste(page);

expect(await text(page, 'E6')).toBe('z');
expect(await text(page, 'C4')).toBe('1');
// The grown rows/cols would sit off-screen, so the grid scrolls the pasted corner into view.
await expect(page.locator("[data-address='E6']")).toBeInViewport();

await ctrl(page, 'z');
await expect(page.locator("[data-address='E6']")).toHaveCount(0);
await expect(page.locator("[data-address='A6']")).toHaveCount(0);
expect(await text(page, 'C4')).toBe('');
});

test("paste past the edge is clipped with 'none'", async ({ page }) => {
await go(page, STORY);
await setMode(page, 'none');

await drag(page, 'A1', 'C3');
await copy(page);
await page.locator("[data-address='C4']").click();
await paste(page);

expect(await text(page, 'D5')).toBe('b');
await expect(page.locator("[data-address='A6']")).toHaveCount(0);
// Nothing grew, so nothing scrolled.
expect(await page.locator('.gs-tabular').evaluate((el) => el.scrollTop)).toBe(0);
await expect(page.locator("[data-address='E1']")).toHaveCount(0);
});

test("'vertical' grows rows only", async ({ page }) => {
await go(page, STORY);
await setMode(page, 'vertical');

await drag(page, 'A1', 'C3');
await copy(page);
await page.locator("[data-address='C4']").click();
await paste(page);

expect(await text(page, 'D6')).toBe('c');
await expect(page.locator("[data-address='E1']")).toHaveCount(0);
});

test('autofill held past the last row adds provisional rows, committed on release', async ({ page }) => {
await go(page, STORY);
await setMode(page, 'vertical');

// Select the 1,2,3 series and drag its fill handle below the grid, then hold.
await drag(page, 'A1', 'A3');
const hb = await page.locator("[data-address='A3'] .gs-autofill-drag").boundingBox();
const tb = await page.locator('.gs-tabular').boundingBox();
await page.mouse.move(hb!.x + hb!.width / 2, hb!.y + hb!.height / 2);
await page.mouse.down();
await page.mouse.move(hb!.x + hb!.width / 2, tb!.y + tb!.height + 20, { steps: 5 });
// Provisional rows appear one by one (Handsontable-style) — display only, the sheet is untouched.
await expect(page.locator('.gs-ghost-row').first()).toBeAttached();
await page.waitForTimeout(1200);
await expect(page.locator('.gs-root1')).toHaveAttribute('data-rows', '5');
expect(await page.locator('.gs-ghost-row').count()).toBeGreaterThan(1);
await page.mouse.up();

await expect(page.locator('.gs-ghost-cell')).toHaveCount(0);
// Rows were appended on release and the series continued into them.
const rows = Number(await page.locator('.gs-root1').getAttribute('data-rows'));
expect(rows).toBeGreaterThan(6);
expect(await text(page, `A${rows}`)).toBe(String(rows));

// One undo removes both the rows and the fill.
await ctrl(page, 'z');
await expect(page.locator('.gs-root1')).toHaveAttribute('data-rows', '5');
await page.locator('.gs-tabular').evaluate((el) => (el.scrollTop = 0));
expect(await text(page, 'A4')).toBe('');
});

test('provisional rows vanish when the fill is dragged back before release', async ({ page }) => {
await go(page, STORY);
await setMode(page, 'vertical');

await drag(page, 'A1', 'A3');
const hb = await page.locator("[data-address='A3'] .gs-autofill-drag").boundingBox();
const tb = await page.locator('.gs-tabular').boundingBox();
await page.mouse.move(hb!.x + hb!.width / 2, hb!.y + hb!.height / 2);
await page.mouse.down();
await page.mouse.move(hb!.x + hb!.width / 2, tb!.y + tb!.height + 20, { steps: 5 });
await page.waitForTimeout(600);
// Back onto the selection itself (no fill), then release.
await page.locator('.gs-tabular').evaluate((el) => (el.scrollTop = 0));
await page.locator("[data-address='A2']").hover();
await page.mouse.up();

await expect(page.locator('.gs-ghost-cell')).toHaveCount(0);
await expect(page.locator('.gs-root1')).toHaveAttribute('data-rows', '5');
});

for (const dir of ['down', 'right'] as const) {
test(`leaving an enlarged box before its whitespace fills keeps growing (${dir})`, async ({ page }) => {
// Regression: with whitespace left in a resized box, exiting the box snapped the target back to
// the last real row/col (edge scroll handle) and the provisional ghosts stopped growing.
await page.setViewportSize({ width: 800, height: 700 });
await go(page, STORY);
const b = await page.locator('.gs-main').boundingBox();
await page.mouse.move(b!.x + b!.width - 3, b!.y + b!.height - 3);
await page.mouse.down();
await page.mouse.move(b!.x + b!.width + 150, b!.y + b!.height + 230, { steps: 8 });
await page.mouse.up();

await page.locator("[data-address='A1']").click();
const hb = await page.locator("[data-address='A1'] .gs-autofill-drag").boundingBox();
const tb = await page.locator('.gs-tabular').boundingBox();
const grid = await page.locator('.gs-tabular-inner').boundingBox();
await page.mouse.move(hb!.x + 3, hb!.y + 3);
await page.mouse.down();
if (dir === 'down') {
await page.mouse.move(hb!.x + 3, grid!.y + grid!.height + 10, { steps: 5 }); // a little into the whitespace
await page.mouse.move(hb!.x + 3, tb!.y + tb!.height + 40, { steps: 2 }); // straight out of the box
} else {
await page.mouse.move(grid!.x + grid!.width + 10, hb!.y + 3, { steps: 5 });
await page.mouse.move(tb!.x + tb!.width + 40, hb!.y + 3, { steps: 2 });
}
await page.waitForTimeout(1000);
const ghosts = dir === 'down' ? '.gs-ghost-row' : '.gs-ghost-th-top';
expect(await page.locator(ghosts).count()).toBeGreaterThan(4);
await page.mouse.up();
});
}

test('provisional rows stop growing once the pointer is back over a cell', async ({ page }) => {
await page.setViewportSize({ width: 800, height: 700 });
await go(page, STORY);
await setMode(page, 'vertical');

await drag(page, 'A1', 'A3');
const hb = await page.locator("[data-address='A3'] .gs-autofill-drag").boundingBox();
const tb = await page.locator('.gs-tabular').boundingBox();
await page.mouse.move(hb!.x + 3, hb!.y + 3);
await page.mouse.down();
await page.mouse.move(hb!.x + 3, tb!.y + tb!.height + 30, { steps: 4 }); // past the edge: grows
await page.waitForTimeout(800);
// Back inside the grid, over a (ghost) cell, and hold still.
await page.mouse.move(hb!.x + 3, tb!.y + tb!.height - 30, { steps: 3 });
await page.waitForTimeout(300);
const settled = await page.locator('.gs-ghost-row').count();
expect(settled).toBeGreaterThan(1);
await page.waitForTimeout(1000);
expect(await page.locator('.gs-ghost-row').count()).toBe(settled);
await page.mouse.up();
});
22 changes: 22 additions & 0 deletions packages/docs/src/content/docs/api-reference/props.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ The GridSheet component accepts the following props to configure its behavior an
| `showFormulaBar` | `boolean` | `true` | Show the formula bar. |
| `mode` | `'light' \| 'dark'` | `'light'` | Color mode. |
| `eager` | `boolean` | `false` | Opt into eager resolution for this sheet (see below). |
| `autoExpand` | `'none' \| 'vertical' \| 'horizontal' \| 'both'` | `'none'` | Grow the sheet when a paste or autofill runs past the last row/column (see below). |

### `eager`

Expand All @@ -55,6 +56,27 @@ const all = toValueMatrix(sheet); // off-screen rows come back resolved too

If you only need full resolution occasionally (e.g. right before saving) rather than continuously, skip the option and call [`sheet.resolveAll()`](/api-reference/sheet) followed by `await sheet.waitForPending()` on demand — that keeps interactive editing fully virtualized and pays the cost only when you ask for it.

### `autoExpand`

By default, a paste or an autofill that runs past the sheet's last row or column is clipped: the overflow is dropped. Set `autoExpand` to grow the sheet instead.

| Value | Behavior |
|-------|----------|
| `'none'` | Clip the overflow (default). |
| `'vertical'` | Append the missing rows. |
| `'horizontal'` | Append the missing columns. |
| `'both'` | Append rows and columns as needed. |

```tsx
<GridSheet initialCells={cells} options={{ autoExpand: 'both', limits: { maxRows: 1000 } }} />
```

- It applies to pasting (copied cells, cut cells, and text/HTML from the clipboard) and to dragging the autofill handle. While dragging, ghost rows/columns are shown past the edge so the drag can reach cells that don't exist yet.
- New rows/columns copy the layout (style, size, policy) of the current last row/column, like inserting rows below the last one.
- Growth is capped by `limits.maxRows` / `limits.maxCols`; anything beyond the limit is still clipped.
- The appended rows/columns and the write are a single undo step.
- Programmatic writes through `sheet.copy()`, `sheet.move()` and `sheet.writeRawCellMatrix()` follow the same setting.

## Initial Cells

The `initialCells` prop defines the starting data and configuration for your spreadsheet. This is where you specify cell values, styles, renderers, policies, and other cell-specific settings.
Expand Down
138 changes: 138 additions & 0 deletions packages/engine/src/auto-expand.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { Sheet, createRegistry, buildInitialCells, Autofill } from './index';
import type { AutoExpandType, SheetLimits } from './index';

// autoExpand: a paste / autofill that runs past the sheet's edge appends the missing rows/cols
// (per direction, capped by maxRows/maxCols) and the growth undoes together with the write.
describe('autoExpand', () => {
const make = (autoExpand: AutoExpandType, limits?: SheetLimits, numRows = 3, numCols = 3) => {
const registry = createRegistry();
const cells = buildInitialCells({
matrices: {
A1: [
['a1', 'b1'],
['a2', 'b2'],
],
},
ensured: { numRows, numCols },
});
const sheet = new Sheet({ name: 'S', registry, autoExpand, limits });
sheet.initialize(cells);
registry.contextsBySheetId[sheet.id] = {
store: { sheetReactive: { current: sheet } },
dispatch: () => {},
} as any;
return sheet;
};
const value = (sheet: Sheet, y: number, x: number) => sheet.getCell({ y, x })?.value;
// A1:B2 pasted at C3 needs rows 3..4 and cols 3..4 on a 3x3 sheet.
const pasteA1B2AtC3 = (sheet: Sheet) =>
sheet.copy({ src: { top: 1, left: 1, bottom: 2, right: 2 }, dst: { top: 3, left: 3, bottom: 4, right: 4 } });

it("clips the overflow with 'none' (default behavior)", () => {
const sheet = make('none');
pasteA1B2AtC3(sheet);
expect([sheet.numRows, sheet.numCols]).toEqual([3, 3]);
expect(value(sheet, 3, 3)).toBe('a1');
});

it.each<[AutoExpandType, number, number]>([
['vertical', 4, 3],
['horizontal', 3, 4],
['both', 4, 4],
])("grows the sheet per direction with '%s'", (mode, rows, cols) => {
const sheet = make(mode);
pasteA1B2AtC3(sheet);
expect([sheet.numRows, sheet.numCols]).toEqual([rows, cols]);
expect(value(sheet, 3, 3)).toBe('a1');
if (mode === 'both') {
expect(value(sheet, 4, 4)).toBe('b2');
}
});

it('undoes the growth and the paste in one step, and redoes both', () => {
const sheet = make('both');
const historyCount = sheet.registry.histories.length;
pasteA1B2AtC3(sheet);
expect(sheet.registry.histories.length).toBe(historyCount + 1);
expect(sheet.registry.histories.at(-1)?.operation).toBe('BATCH');

sheet.undo();
expect([sheet.numRows, sheet.numCols]).toEqual([3, 3]);
expect(value(sheet, 3, 3)).toBeFalsy();

sheet.redo();
expect([sheet.numRows, sheet.numCols]).toEqual([4, 4]);
expect(value(sheet, 3, 3)).toBe('a1');
expect(value(sheet, 4, 4)).toBe('b2');
});

it('records a plain history (no BATCH) when nothing overflows', () => {
const sheet = make('both');
sheet.copy({ src: { top: 1, left: 1, bottom: 1, right: 1 }, dst: { top: 3, left: 3, bottom: 3, right: 3 } });
expect(sheet.registry.histories.at(-1)?.operation).toBe('UPDATE');
});

it('stops at maxRows / maxCols and clips the rest', () => {
const sheet = make('both', { maxRows: 4, maxCols: 3 });
sheet.copy({ src: { top: 1, left: 1, bottom: 2, right: 2 }, dst: { top: 3, left: 2, bottom: 6, right: 3 } });
expect([sheet.numRows, sheet.numCols]).toEqual([4, 3]);
expect(value(sheet, 4, 3)).toBe('b2');
});

it('grows for an external matrix write (writeRawCellMatrix)', () => {
const sheet = make('vertical');
sheet.writeRawCellMatrix({
point: { y: 3, x: 1 },
matrix: [[{ value: 'x' }], [{ value: 'y' }], [{ value: 'z' }]],
});
expect(sheet.numRows).toBe(5);
expect(value(sheet, 5, 1)).toBe('z');
sheet.undo();
expect(sheet.numRows).toBe(3);
});

it('grows for a cut-paste (move)', () => {
const sheet = make('vertical');
sheet.move({ src: { top: 1, left: 1, bottom: 2, right: 1 }, dst: { top: 3, left: 1, bottom: 4, right: 1 } });
expect(sheet.numRows).toBe(4);
expect(value(sheet, 4, 1)).toBe('a2');
sheet.undo();
expect(sheet.numRows).toBe(3);
expect(value(sheet, 2, 1)).toBe('a2');
});

it('takes the growth back when the operation is refused (move cutting through a merge)', () => {
const sheet = make('vertical');
sheet.merge({ area: { top: 1, left: 1, bottom: 2, right: 2 } });
const historyCount = sheet.registry.histories.length;
// The source cuts through the A1:B2 merge, so the move is refused after the sheet grew.
sheet.move({ src: { top: 2, left: 1, bottom: 2, right: 1 }, dst: { top: 3, left: 1, bottom: 5, right: 1 } });
expect(sheet.numRows).toBe(3);
expect(sheet.registry.histories.length).toBe(historyCount);
});

it('grows for an autofill dragged past the last row, clamping to the limit', () => {
const sheet = make('vertical', { maxRows: 6 });
const store = {
sheetReactive: { current: sheet },
choosing: { y: 1, x: 1 },
selectingZone: { startY: 1, startX: 1, endY: 2, endX: 1 },
};
new Autofill(store, { y: 10, x: 1 }).applied;
expect(sheet.numRows).toBe(6);
expect(value(sheet, 6, 1)).toBe('a6'); // the series continues a1, a2 → a3..a6
sheet.undo();
expect(sheet.numRows).toBe(3);
});

it('ignores the column direction for a vertical-only sheet when filling right', () => {
const sheet = make('vertical');
const store = {
sheetReactive: { current: sheet },
choosing: { y: 1, x: 1 },
selectingZone: { startY: 1, startX: 1, endY: 1, endX: 1 },
};
new Autofill(store, { y: 1, x: 8 }).applied;
expect(sheet.numCols).toBe(3);
});
});
2 changes: 2 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,11 @@ export type {
HistoryInsertColsType,
HistoryRemoveColsType,
HistorySortRowsType,
HistoryBatchType,
HistoryType,
Virtualization,
OperatorType,
AutoExpandType,
OperationType,
Dispatcher,
StoreDispatchType,
Expand Down
16 changes: 14 additions & 2 deletions packages/engine/src/lib/autofill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export class Autofill {
throw new Error('Sheet is not available');
}
this.src = complementSelectingArea(zoneToArea(selectingZone), choosing);
// A drag may point past the sheet's edge (into the auto-expand ghost area). Clamp it to
// what the sheet can actually grow to, so the fill never targets cells that won't exist.
const { numRows, numCols } = sheet.getAutoExpansion({ bottom: draggingTo.y, right: draggingTo.x });
draggingTo = {
y: Math.min(draggingTo.y, sheet.numRows + numRows),
x: Math.min(draggingTo.x, sheet.numCols + numCols),
};
this.direction = this.suggestDirection(draggingTo);
this.dst = this.getDestinationArea(draggingTo);
this.sheet = sheet;
Expand Down Expand Up @@ -106,15 +113,20 @@ export class Autofill {
}

public get applied(): Sheet {
return this.sheet.update({ diff: this.buildDiff(), operator: 'USER', ...this.reflections });
// Grow the sheet first (per sheet.autoExpand) when the fill was dragged past its edge.
return this.sheet.withAutoExpand(this.dst, () =>
this.sheet.update({ diff: this.buildDiff(), operator: 'USER', ...this.reflections }),
);
}

/** Async, progress-reporting apply. Same result as `applied` but time-sliced. */
public appliedAsync(opts: {
onProgress?: (progress: { done: number; total: number }) => void;
yieldControl?: () => Promise<void> | void;
}): Promise<Sheet> {
return this.sheet.updateAsync({ diff: this.buildDiff(), operator: 'USER', ...this.reflections, ...opts });
return this.sheet.withAutoExpandAsync(this.dst, () =>
this.sheet.updateAsync({ diff: this.buildDiff(), operator: 'USER', ...this.reflections, ...opts }),
);
}

public get wholeArea() {
Expand Down
2 changes: 2 additions & 0 deletions packages/engine/src/lib/book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export class Registry {
historyLimit: number = DEFAULT_HISTORY_LIMIT;
lastHistory?: HistoryType;
currentHistory?: HistoryType;
/** While non-null, pushed histories are collected here to be recorded as one BATCH. @internal */
_historyBatch: HistoryType[] | null = null;
/** StrictMode guard: Map from action object (identity) to cached reducer result.
* Using a Map instead of a single slot allows multiple batched dispatches to coexist. */
_strictModeCache?: Map<unknown, unknown>;
Expand Down
Loading
Loading