Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/interaction-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sheetwrite/core": patch
---

Keep row and column resize previews out of the document until release, then record one undoable change. Cancel active pointer gestures on sheet, view, structure, or read-only changes and ignore other pointers during a drag. Keep all editors bound to their original document cell through sorting and filtering. Notify selection changes before edit-begin, prevent reentrant handlers from reopening stale editors, and leave selection unchanged when a fill is rejected.
22 changes: 22 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,28 @@ artifact was a one-off before/after pair, and a release cannot re-measure a
baseline build. `bench:release` refreshes both captures, and
`bench:interaction:smoke` rehearses the interaction one.

`bench:interaction-sessions` supplements that browser capture with synchronous
Grid-interface timings under happy-dom: row and column resize with twenty move
events followed by release, editor open/commit, pointer selection, and two
untouched controls (one hundred cell-input reads and a column aggregate).
It reports eleven raw samples in milliseconds per operation after three discarded
warmups. Each sample runs two thousand resize/edit operations, ten thousand
selection/read operations, or two hundred thousand aggregates. Garbage collection
and fixture restoration are outside the timed interval; resize includes admission
and history, but not undo. Longer samples keep sub-millisecond paths from turning
process startup and scheduling noise into an apparent regression.
These are imperative-path timings, not browser paint or input-to-frame latency.
For comparisons, build each checkout's packages first. Run five A/A ABBA control
rounds with the same baseline in both slots, then five actual ABBA rounds on the
same CPU under an exclusive lock. Compare medians of the per-round medians and
retain every raw round. A broad noise band makes a changed-path comparison
inconclusive; it does not excuse a slower result. Leave fifteen seconds between
locked commands so other checkouts can acquire the shared lock.

```sh
flock -x /home/vvin/learn/quiet.lock taskset -c 4 bun run bench:interaction-sessions
```

A change to a harness source file, to the sampling flags, or to the protocol
version invalidates the committed render baseline: `bench:check` compares the
harness hashes, the declared sampling, and the runner (OS, CPU, Bun, Node,
Expand Down
1 change: 1 addition & 0 deletions bench/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"bench:paged:smoke": "bun run src/paged-bench.ts --smoke",
"bench:interaction": "bun run src/interaction-bench.ts",
"bench:interaction:smoke": "bun run src/interaction-bench.ts --smoke",
"bench:interaction-sessions": "bun run src/interaction-sessions-bench.ts",
"bench:core-paths": "bun run src/core-paths-bench.ts --output results/core-paths-results.json",
"bench:core-paths:smoke": "bun run src/core-paths-bench.ts --smoke",
"bench:release": "bun run src/release-capture.ts",
Expand Down
118 changes: 118 additions & 0 deletions bench/src/interaction-sessions-bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import "./dom-setup.js";
import { createGrid, DEFAULT_THEME, initSheetwrite, type Workbook } from "@sheetwrite/core";
import { installCanvasTestStubs } from "@sheetwrite/core/testing";

const ROWS = 100;
const INTERACTION_ITERATIONS = 2_000;
const CONTROL_ITERATIONS = 10_000;
const AGGREGATE_ITERATIONS = 200_000;
const WARMUPS = 3;
const SAMPLES = 11;
const DRAG_STEPS = 20;
await initSheetwrite();
const restoreCanvas = installCanvasTestStubs();
const workbook: Workbook = {
activeSheet: "s1",
sheets: [
{
id: "s1",
name: "Timing",
rowCount: ROWS,
columns: [
{ key: "name", header: "Name", type: "text", width: 160 },
{ key: "amount", header: "Amount", type: "number", width: 120 },
],
},
],
};
const host = document.createElement("div");
Object.defineProperties(host, { clientWidth: { value: 800 }, clientHeight: { value: 400 } });
document.body.appendChild(host);
const grid = createGrid(host, {
workbook,
data: {
rowCount: ROWS,
columns: {
name: Array.from({ length: ROWS }, (_, row) => `Row ${row}`),
amount: Float64Array.from({ length: ROWS }, (_, row) => row),
},
},
config: { toolbar: false, tabs: false, find: false, contextMenu: false },
});
const scroller = host.querySelector(".sheetwrite-scroller");
if (!(scroller instanceof HTMLElement)) throw new Error("Timing grid scroller missing");
const pointer = (type: string, x: number, y: number) =>
scroller.dispatchEvent(
new PointerEvent(type, { clientX: x, clientY: y, pointerId: 1, button: 0, bubbles: true }),
);
const samples: Record<string, number[]> = {};
try {
for (const workload of [
"column-resize",
"row-resize",
"edit",
"selection",
"get-cell",
"aggregate",
] as const) {
const timings: number[] = [];
const iterations =
workload === "aggregate"
? AGGREGATE_ITERATIONS
: workload === "selection" || workload === "get-cell"
? CONTROL_ITERATIONS
: INTERACTION_ITERATIONS;
for (let sample = -WARMUPS; sample < SAMPLES; sample++) {
Bun.gc(true);
let elapsed = 0;
for (let iteration = 0; iteration < iterations; iteration++) {
const started = performance.now();
if (workload === "column-resize" || workload === "row-resize") {
const isColumn = workload === "column-resize";
const x = isColumn ? DEFAULT_THEME.rowHeaderWidth + 160 : 10;
const y = isColumn ? 10 : DEFAULT_THEME.headerHeight + DEFAULT_THEME.rowHeight;
pointer("pointerdown", x, y);
for (let step = 1; step <= DRAG_STEPS; step++)
pointer("pointermove", x + (isColumn ? step : 0), y + (isColumn ? 0 : step));
pointer("pointerup", x + (isColumn ? DRAG_STEPS : 0), y + (isColumn ? 0 : DRAG_STEPS));
} else if (workload === "edit") {
grid.beginEdit(0, 0, "Timing edit");
const editor = host.querySelector("textarea.sheetwrite-editor");
if (!(editor instanceof HTMLTextAreaElement)) throw new Error("Timing editor missing");
editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
} else if (workload === "selection") {
pointer(
"pointerdown",
DEFAULT_THEME.rowHeaderWidth + 40,
DEFAULT_THEME.headerHeight + 10,
);
pointer(
"pointermove",
DEFAULT_THEME.rowHeaderWidth + 200,
DEFAULT_THEME.headerHeight + 60,
);
pointer("pointerup", DEFAULT_THEME.rowHeaderWidth + 200, DEFAULT_THEME.headerHeight + 60);
} else if (workload === "get-cell") {
for (let row = 0; row < ROWS; row++) grid.getCellInput(row, 1);
} else {
grid.aggregate(1, "sum");
}
elapsed += performance.now() - started;
// Reset outside timing: baseline row drags have multiple history entries
// and baseline column undo does not restore its pre-preview width.
if (workload === "column-resize") grid.setColumnWidth(0, 160);
else if (workload === "row-resize") grid.setRowHeight(0, DEFAULT_THEME.rowHeight);
else if (workload === "edit") grid.undo();
}
if (sample >= 0) timings.push(elapsed / iterations);
}
samples[workload] = timings;
}
process.stdout.write(
`${JSON.stringify({ unit: "ms/operation", warmups: WARMUPS, interactionIterations: INTERACTION_ITERATIONS, controlIterations: CONTROL_ITERATIONS, aggregateIterations: AGGREGATE_ITERATIONS, samples })}\n`,
);
} finally {
grid.destroy();
host.remove();
restoreCanvas();
}
25 changes: 25 additions & 0 deletions docs/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2878,6 +2878,18 @@ Row and full-column selections are part of the model. Column selection is wired
the top header; **row selection is programmatic only** — there is no row-header
click gesture, so set it via `setSelection({ kind: "row", sheet, row })`.

Only the pointer that began a drag can change it. Switching sheets, changing
sheet structure or the sorted/filtered view, or losing pointer capture ends the
gesture; a later pointer release cannot apply it to a different sheet or view.

## Resizing

Drag a column-header boundary or a row-header boundary to preview its size.
The preview changes geometry only: the document and undo history remain intact
until release. Release submits one undoable size change. Cancellation, capture
loss, or a rejected change restores the document's size. Turning read-only on
ends an active resize without committing.

## Keyboard navigation

When the grid is focused and not editing:
Expand Down Expand Up @@ -2947,6 +2959,19 @@ Commit and navigation keys:
While the IME is composing, keystrokes belong to the composition and are not
treated as navigation. Editing emits `edit-begin` and `edit-commit` events.

`beginEdit` closes the previous editor, selects the requested cell, and emits
`selection` when that changes the selection, then emits `edit-begin` before
opening the editor. A synchronous handler can replace or cancel the request
without the old editor reopening.
Successful completion emits `change`, then `edit-commit`, then the navigation's
`selection` event; rejected completion does not emit `edit-commit` or navigate.
Event addresses remain view coordinates.

Stock, validation, and custom editors retain the underlying document cell while
sorting or filtering changes its visible position. Completion writes that same
cell, not whichever row now occupies the old position. If the cell leaves the
view, or sheet structure changes, the editor is cancelled.

## Clipboard interoperability

Copy and cut always produce injection-hardened TSV and, where the browser exposes
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/api/core/create-grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: "Creates and mounts an imperative Grid in the supplied host element
Creates and mounts an imperative Grid in the supplied host element.

<dl class="api-metadata" data-pagefind-ignore>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/grid.ts#L347"><code>packages/core/src/grid.ts#L347</code></a></dd></div>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/grid.ts#L341"><code>packages/core/src/grid.ts#L341</code></a></dd></div>
</dl>

## Declaration
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/api/core/default-theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: "Default canvas theme used before CSS and explicit theme overrides.
Default canvas theme used before CSS and explicit theme overrides.

<dl class="api-metadata" data-pagefind-ignore>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/grid.ts#L256"><code>packages/core/src/grid.ts#L256</code></a></dd></div>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/grid.ts#L250"><code>packages/core/src/grid.ts#L250</code></a></dd></div>
</dl>

## Declaration
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/api/core/init-sheetwrite.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Load one WASM engine before `createGrid`. The default is `@sheetwrite/wasm`.
Pass the `@sheetwrite/formulas` module as the second argument to select the full engine.

<dl class="api-metadata" data-pagefind-ignore>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/grid.ts#L290"><code>packages/core/src/grid.ts#L290</code></a></dd></div>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/grid.ts#L284"><code>packages/core/src/grid.ts#L284</code></a></dd></div>
</dl>

## Declaration
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/api/core/is-sheetwrite-ready.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: "Whether initSheetwrite has completed — the single readiness sour
Whether `initSheetwrite` has completed — the single readiness source.

<dl class="api-metadata" data-pagefind-ignore>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/grid.ts#L302"><code>packages/core/src/grid.ts#L302</code></a></dd></div>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/grid.ts#L296"><code>packages/core/src/grid.ts#L296</code></a></dd></div>
</dl>

## Declaration
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/api/core/resolve-theme-from-css.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: "Read --sheetwrite- CSS custom properties into a partial theme."
Read `--sheetwrite-*` CSS custom properties into a partial theme.

<dl class="api-metadata" data-pagefind-ignore>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/grid.ts#L307"><code>packages/core/src/grid.ts#L307</code></a></dd></div>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/grid.ts#L301"><code>packages/core/src/grid.ts#L301</code></a></dd></div>
</dl>

## Declaration
Expand Down
25 changes: 25 additions & 0 deletions docs/src/content/docs/guides/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ Row and full-column selections are part of the model. Column selection is wired
the top header; **row selection is programmatic only** — there is no row-header
click gesture, so set it via `setSelection({ kind: "row", sheet, row })`.

Only the pointer that began a drag can change it. Switching sheets, changing
sheet structure or the sorted/filtered view, or losing pointer capture ends the
gesture; a later pointer release cannot apply it to a different sheet or view.

## Resizing

Drag a column-header boundary or a row-header boundary to preview its size.
The preview changes geometry only: the document and undo history remain intact
until release. Release submits one undoable size change. Cancellation, capture
loss, or a rejected change restores the document's size. Turning read-only on
ends an active resize without committing.

## Keyboard navigation

When the grid is focused and not editing:
Expand Down Expand Up @@ -117,6 +129,19 @@ Commit and navigation keys:
While the IME is composing, keystrokes belong to the composition and are not
treated as navigation. Editing emits `edit-begin` and `edit-commit` events.

`beginEdit` closes the previous editor, selects the requested cell, and emits
`selection` when that changes the selection, then emits `edit-begin` before
opening the editor. A synchronous handler can replace or cancel the request
without the old editor reopening.
Successful completion emits `change`, then `edit-commit`, then the navigation's
`selection` event; rejected completion does not emit `edit-commit` or navigate.
Event addresses remain view coordinates.

Stock, validation, and custom editors retain the underlying document cell while
sorting or filtering changes its visible position. Completion writes that same
cell, not whichever row now occupies the old position. If the cell leaves the
view, or sheet structure changes, the editor is cancelled.

## Clipboard interoperability

Copy and cut always produce injection-hardened TSV and, where the browser exposes
Expand Down
2 changes: 1 addition & 1 deletion docs/src/generated/docs-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"entryPointCount": 20,
"exportCount": 484,
"symbolPageCount": 484,
"apiSha256": "3d8e4e61e382a25d648d082e3a7285b986cbf7257c1c66562c34c652408697cd",
"apiSha256": "9ab557ec2487315a0942b87c4b2a50f3468980980c3bb6f1c04267a3bd03b386",
"compatibilitySha256": "0e2d1e8db45a097ab437bdcb85591c8fddabd60cd6dcfcf2bc2098588e2966f6",
"formulaContractSha256": "daec72c46c939824c564fe3fe0758858f67c298a7c888f18fd4a060f467bd3b0"
}
10 changes: 5 additions & 5 deletions docs/src/generated/public-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -864,7 +864,7 @@
"owners": [
"src/grid.ts"
],
"source": "src/grid.ts#L347",
"source": "src/grid.ts#L341",
"jsDocTags": [],
"documentation": "Creates and mounts an imperative Grid in the supplied host element.",
"memberDocs": []
Expand Down Expand Up @@ -1157,7 +1157,7 @@
"owners": [
"src/grid.ts"
],
"source": "src/grid.ts#L256",
"source": "src/grid.ts#L250",
"jsDocTags": [],
"documentation": "Default canvas theme used before CSS and explicit theme overrides.",
"memberDocs": []
Expand Down Expand Up @@ -2037,7 +2037,7 @@
"owners": [
"src/grid.ts"
],
"source": "src/grid.ts#L290",
"source": "src/grid.ts#L284",
"jsDocTags": [],
"documentation": "Load one WASM engine before `createGrid`. The default is `@sheetwrite/wasm`.\nPass the `@sheetwrite/formulas` module as the second argument to select the full engine.",
"memberDocs": []
Expand Down Expand Up @@ -2073,7 +2073,7 @@
"owners": [
"src/grid.ts"
],
"source": "src/grid.ts#L302",
"source": "src/grid.ts#L296",
"jsDocTags": [],
"documentation": "Whether `initSheetwrite` has completed — the single readiness source.",
"memberDocs": []
Expand Down Expand Up @@ -2877,7 +2877,7 @@
"owners": [
"src/grid.ts"
],
"source": "src/grid.ts#L307",
"source": "src/grid.ts#L301",
"jsDocTags": [],
"documentation": "Read `--sheetwrite-*` CSS custom properties into a partial theme.",
"memberDocs": []
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/column-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export class ColumnIndex {
private readonly columns: Int32Array;
private readonly offsets: Float64Array;
private readonly positions: Int32Array;
private readonly total: number;
private total: number;

constructor(columns: readonly number[], widths: ArrayLike<number>) {
const count = columns.length;
Expand Down Expand Up @@ -41,6 +41,19 @@ export class ColumnIndex {
return this.total;
}

/** Update a visible span without reallocating the reverse map or prefix storage. */
setWidth(absoluteCol: number, width: number): void {
const position = this.positionOf(absoluteCol);
if (position === -1) return;
const previous = (this.offsets[position + 1] ?? 0) - (this.offsets[position] ?? 0);
const delta = width - previous;
if (delta === 0) return;
for (let i = position + 1; i < this.offsets.length; i++) {
this.offsets[i] = (this.offsets[i] ?? 0) + delta;
}
this.total += delta;
}

/** Sum of widths of visible columns before `absoluteCol`. */
leftOf(absoluteCol: number): number {
const position = this.positionOf(absoluteCol);
Expand Down
Loading
Loading