From 26a3552dd82743ce2e7d733c2226326621785ed8 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Fri, 9 Oct 2026 00:13:24 +0700 Subject: [PATCH 1/7] fix(core): centralize interaction sessions and canonical editor targets --- .changeset/interaction-sessions.md | 5 + docs/src/content/docs/guides/interaction.md | 24 + .../core/src/geometry-layout-controller.ts | 40 +- packages/core/src/grid.ts | 336 ++------------ ...-controller.ts => interaction-sessions.ts} | 419 ++++++++++++++++-- packages/core/test/input.test.ts | 129 ++++++ packages/core/test/semantic-grid.test.ts | 272 +++++++++++- 7 files changed, 884 insertions(+), 341 deletions(-) create mode 100644 .changeset/interaction-sessions.md rename packages/core/src/{input-controller.ts => interaction-sessions.ts} (66%) diff --git a/.changeset/interaction-sessions.md b/.changeset/interaction-sessions.md new file mode 100644 index 00000000..782c13b4 --- /dev/null +++ b/.changeset/interaction-sessions.md @@ -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. diff --git a/docs/src/content/docs/guides/interaction.md b/docs/src/content/docs/guides/interaction.md index d9abae3d..55b36115 100644 --- a/docs/src/content/docs/guides/interaction.md +++ b/docs/src/content/docs/guides/interaction.md @@ -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: @@ -117,6 +129,18 @@ 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` 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 diff --git a/packages/core/src/geometry-layout-controller.ts b/packages/core/src/geometry-layout-controller.ts index ee812e62..0d883067 100644 --- a/packages/core/src/geometry-layout-controller.ts +++ b/packages/core/src/geometry-layout-controller.ts @@ -57,6 +57,35 @@ export class GeometryLayoutController { private rowTopsView = this.rowTopsScratch; private rowHeightsView = this.rowHeightsScratch; private rowGeometryLength = 0; + private columnPreview: { column: number; width: number } | null = null; + private hasRowPreview = false; + + previewColumnWidth(column: number, width: number): void { + this.columnPreview = { column, width }; + this.rebuildColumns(); + } + + columnWidth(column: number): number { + return this.columnPreview?.column === column + ? this.columnPreview.width + : (this.options.sheet().columns[column]?.width ?? 0); + } + + previewRowHeight(row: number, height: number): void { + this.hasRowPreview = true; + this.rowIndex.setHeight(row, height * this.options.zoom()); + } + + clearResizePreview(): void { + if (this.columnPreview) { + this.columnPreview = null; + this.rebuildColumns(); + } + if (this.hasRowPreview) { + this.hasRowPreview = false; + this.rebuildRows(this.rowIndex.count); + } + } constructor( private readonly options: GeometryLayoutOptions, @@ -288,7 +317,7 @@ export class GeometryLayoutController { rowHeights: Float64Array; } | null { const sheet = this.options.sheet(); - if (!sheet.rowHeights || sheet.rowHeights.size === 0) return null; + if (!this.hasRowPreview && (!sheet.rowHeights || sheet.rowHeights.size === 0)) return null; const count = Math.max(0, window.end - window.start); if (this.rowTopsScratch.length < count) { @@ -315,7 +344,7 @@ export class GeometryLayoutController { rowTops: Float64Array; rowHeights: Float64Array; } | null { - if (count <= 0 || !this.options.sheet().rowHeights?.size) return null; + if (count <= 0 || (!this.hasRowPreview && !this.options.sheet().rowHeights?.size)) return null; assertGeometryDimensions(count, 0); let rowTops: Float64Array; let rowHeights: Float64Array; @@ -351,7 +380,12 @@ export class GeometryLayoutController { const sheet = this.options.sheet(); assertGeometryDimensions(sheet.rowCount, sheet.columns.length); this.visibleColumnIndices = visibleColumns(sheet); - this.columnIndex = buildColumnIndex(sheet, this.visibleColumnIndices, this.options.zoom()); + const widths = new Float64Array(this.visibleColumnIndices.length); + for (let i = 0; i < this.visibleColumnIndices.length; i++) { + const column = this.visibleColumnIndices[i]; + if (column !== undefined) widths[i] = this.columnWidth(column) * this.options.zoom(); + } + this.columnIndex = new ColumnIndex(this.visibleColumnIndices, widths); } layoutSize(viewportHeight: number): { width: number; height: number } { diff --git a/packages/core/src/grid.ts b/packages/core/src/grid.ts index 599b5909..2b56f5fc 100644 --- a/packages/core/src/grid.ts +++ b/packages/core/src/grid.ts @@ -8,10 +8,8 @@ import { prepareMergeIndex, } from "./canvas-paint.js"; import { CanvasRenderer } from "./canvas-renderer.js"; -import { cellScalarToText, parseCellInput } from "./cell-input.js"; +import { cellScalarToText } from "./cell-input.js"; import { ClipboardController } from "./clipboard-controller.js"; -import { ContextMenu } from "./context-menu.js"; -import { CustomEditorController } from "./custom-editor.js"; import { DatasourceController } from "./datasource-controller.js"; import { DocumentController } from "./document-controller.js"; import { @@ -21,14 +19,13 @@ import { validateTransactionResources, } from "./document-protocol.js"; import { DomOverlay } from "./dom-overlay.js"; -import { EditController, type EditNavigate } from "./editor.js"; import { isEngineLoaded as isLoaded, loadEngine, type SheetwriteEngine } from "./engine.js"; import { normalizeSheetwriteError, SheetwriteError } from "./errors.js"; import { downloadBytes, toCsv, toXlsxTable } from "./export.js"; import { FindBar } from "./find-bar.js"; import { GeometryLayoutController } from "./geometry-layout-controller.js"; import { hyperlinkAt, resolveHyperlinkTarget, sanitizeCellHyperlink } from "./hyperlink.js"; -import { InputController } from "./input-controller.js"; +import { InteractionSessions } from "./interaction-sessions.js"; import { MutationRevisionIndex, type MutationRevisionStats } from "./mutation-revision-index.js"; import { OverlayPainter } from "./overlay-painter.js"; import { RenderCoordinator } from "./render-coordinator.js"; @@ -68,7 +65,6 @@ import type { AggregateOp } from "./types/data.js"; import type { AddSheetInput, ColumnFilter, - CommitReason, DataValidationRule, DocumentOp, MutationPolicyMode, @@ -83,7 +79,6 @@ import type { } from "./types/document.js"; import type { CellEditor, - CellEditorRect, CellInputSnapshot, Grid, GridActions, @@ -106,7 +101,6 @@ import type { RemoteOperationOptions, TransactionResourceLimits, } from "./types/transaction.js"; -import { ValidationEditor } from "./validation-editor.js"; import { seedWidgetTheme } from "./widget-theme.js"; import { WorkerRenderer } from "./worker-renderer.js"; @@ -370,10 +364,7 @@ export class GridImpl implements Grid { private readonly scroller: HTMLDivElement; private readonly sizer: HTMLDivElement; private renderer: Renderer; - private readonly editor: EditController; - private readonly validationEditor: ValidationEditor; - private readonly customEditor: CustomEditorController; - private readonly input: InputController; + private readonly interactions: InteractionSessions; private readonly ariaMirror: AriaMirror; private readonly searchController: SearchController; private readonly clipboard: ClipboardController; @@ -396,30 +387,12 @@ export class GridImpl implements Grid { private zoom = 1; private baseTheme: Theme; private toolbar: Toolbar | null = null; - private contextMenu: ContextMenu | null = null; private findBar: FindBar | null = null; private config: GridConfig | undefined; private readonly presentation: GridPresentation; private readonly hyperlinkActivation: "event-only" | "internal-navigation" | "disabled"; private toolbarHeight = 0; private readonly viewportEl: HTMLDivElement; - private readonly onContextMenu = (e: MouseEvent): void => { - if (!this.contextMenu) return; - e.preventDefault(); - - const addr = this.getCellAtPoint(e.clientX, e.clientY); - if (addr && !this.selection.contains(addr.row, addr.col)) { - this.selection.selectCell(addr.row, addr.col); - this.emitSelection(); - this.scheduleRender(); - } - - this.contextMenu.open({ - cell: addr, - clientX: e.clientX, - clientY: e.clientY, - }); - }; private readonly customRenderers = new Map(); private readonly customEditors = new Map(); private readonly listeners: { [K in keyof GridEvents]: Set<(e: GridEvents[K]) => void> } = { @@ -688,10 +661,6 @@ export class GridImpl implements Grid { this.toolbarHeight = Toolbar.height; } - if (config?.contextMenu !== false) { - this.contextMenu = new ContextMenu(host, config ?? {}, this.theme, this.actions, this); - } - if (config?.find !== false) { this.findBar = new FindBar(host, this.theme, this, this.readOnly); } @@ -712,19 +681,18 @@ export class GridImpl implements Grid { this.renderer = this.createRenderer(opts, this.viewportEl); this.renderer.setRenderers(this.customRenderers); - this.editor = new EditController(this.viewportEl, { - highlightCells: (ranges) => this.highlightCells(ranges), - sheet: () => this.activeSheet, - }); - this.validationEditor = new ValidationEditor(this.viewportEl); - this.customEditor = new CustomEditorController(this.viewportEl); - this.input = new InputController({ + this.interactions = new InteractionSessions({ host, scroller: this.scroller, viewportEl: this.viewportEl, - editor: this.editor, - isEditing: () => - this.editor.isEditing || this.validationEditor.isEditing || this.customEditor.isEditing, + grid: this, + customEditors: this.customEditors, + highlightCells: (ranges) => this.highlightCells(ranges), + columnHeader: (col) => this.columnHeader(col), + toViewRow: (row) => this.toViewRow(row), + notify: (event, payload) => { + for (const listener of this.listeners[event]) listener(payload); + }, findBar: () => this.findBar, store: this.store, loadable: this.loadable, @@ -752,14 +720,21 @@ export class GridImpl implements Grid { this.overscan, ), previewColumnWidth: (col, width) => { - const column = this.sheet().columns[col]; - if (!column) return; - column.width = width; - this.rebuildColumnIndex(); + this.geometry.previewColumnWidth(col, width); + this.applyLayout(); + this.scheduleRender(); + }, + previewRowHeight: (row, height) => { + this.geometry.previewRowHeight(row, height); + this.syncSizer(); + this.renderCoordinator.invalidate(); + this.scheduleRender(); + }, + clearResizePreview: () => { + this.geometry.clearResizePreview(); this.applyLayout(); this.scheduleRender(); }, - setRowHeight: (row, height) => this.setRowHeight(row, height / this.zoom), dataEdge: (row, col, dRow, dCol) => this.dataEdge(row, col, dRow, dCol), keyboard: () => this.keyboardPolicy, contentXAt: (viewportX) => this.contentXAt(viewportX), @@ -769,7 +744,6 @@ export class GridImpl implements Grid { this.screenRect(row, col, contentTop, scrollLeft), anchorCell: (row, col) => this.anchorCell(row, col), toDataRow: (viewRow) => this.toDataRow(viewRow), - beginEdit: (row, col, initial, selectAll) => this.beginEdit(row, col, initial, selectAll), clearSelection: () => this.clearSelection(), emitSelection: () => this.emitSelection(), scrollToCell: (addr) => this.scrollToCell(addr), @@ -790,6 +764,7 @@ export class GridImpl implements Grid { commit: (patches, reason) => this.document.commit(patches, reason), readOnly: () => this.readOnly, }); + this.interactions.configureMenu(config, this.theme); this.overlayPainter = new OverlayPainter(this.viewportEl, { theme: () => this.theme, @@ -808,11 +783,10 @@ export class GridImpl implements Grid { screenRect: (row, col, contentTop, scrollLeft) => this.screenRect(row, col, contentTop, scrollLeft), toViewRow: (dataRow) => this.toViewRow(dataRow), - isEditing: () => - this.editor.isEditing || this.validationEditor.isEditing || this.customEditor.isEditing, - fillTarget: () => this.input.fillPreview, + isEditing: () => this.interactions.isEditing, + fillTarget: () => this.interactions.fillPreview, fillHandleScreen: (contentTop, scrollLeft) => - this.input.fillHandleScreen(contentTop, scrollLeft), + this.interactions.fillHandleScreen(contentTop, scrollLeft), searchMatches: () => this.searchController.matches, searchActive: () => this.searchController.active, searchVersion: () => this.searchController.version, @@ -966,12 +940,17 @@ export class GridImpl implements Grid { this.datasourceController.reset(this.sheet().rowCount); this.mutationRevisions.clear(); } + this.interactions.reconcile( + shouldResetDatasource + ? "structure" + : shouldRebuildRows || shouldRebuildColumns || shouldApplyLayout + ? "view" + : "value", + ); if (shouldRebuildRows) this.rebuildIndex(); if (shouldRebuildColumns) this.rebuildColumnIndex(); if (shouldRebuildColumns || shouldApplyLayout) this.applyLayout(); if (sheetsChanged) this.renderTabs(); - if (shouldResetDatasource) this.customEditor.cancel(); - else this.refreshCustomEditor(); this.ariaMirror.bumpVersion(); this.scheduleRender(); for (const fn of this.listeners.change) fn(event); @@ -980,7 +959,6 @@ export class GridImpl implements Grid { this.applyLayout(); this.scroller.addEventListener("scroll", this.onScroll, { passive: true }); - this.scroller.addEventListener("contextmenu", this.onContextMenu); if (typeof ResizeObserver !== "undefined") { this.resizeObserver = new ResizeObserver(() => this.onResize()); this.resizeObserver.observe(host); @@ -1202,20 +1180,6 @@ export class GridImpl implements Grid { ); } - private editorLabel(row: number, col: number): string { - return `Edit ${this.columnHeader(col)}, row ${row + 1}`; - } - - private editorRect( - row: number, - col: number, - contentTop: number, - scrollLeft: number, - ): CellEditorRect { - const rect = this.screenRect(row, col, contentTop, scrollLeft); - return { x: rect.x, y: rect.y, width: rect.w, height: rect.h }; - } - private applyLayout(): void { this.renderCoordinator.invalidate(); const sheet = this.sheet(); @@ -1224,7 +1188,7 @@ export class GridImpl implements Grid { header: column.visible === false ? "" : this.columnHeader(c), // Paint geometry is zoomed to match the column index; base widths stay // untouched on the workbook. - width: column.visible === false ? 0 : column.width * this.zoom, + width: column.visible === false ? 0 : this.geometry.columnWidth(c) * this.zoom, })); const domRendererColumns = new Uint8Array(columns.length); for (let col = 0; col < columns.length; col++) { @@ -1248,25 +1212,6 @@ export class GridImpl implements Grid { this.selection.setBounds(sheet.rowCount, this.firstCol(), this.lastCol()); this.syncSizer(); } - private refreshCustomEditor(): void { - const address = this.customEditor.editingAddress; - if (!address || address.sheet !== this.activeSheet) return; - const viewRow = this.toViewRow(address.row); - const column = this.sheet().columns[address.col]; - if (viewRow === null || !column) { - this.customEditor.cancel(); - return; - } - const value = this.store.getCell(address).resolved; - const formula = this.loadable?.getFormula(address) ?? this.store.getFormula(address); - this.customEditor.update({ - viewAddress: { sheet: this.activeSheet, row: viewRow, col: address.col }, - column, - value, - text: formula ?? cellScalarToText(value), - label: this.editorLabel(viewRow, address.col), - }); - } private syncSizer(): void { // Browser zoom moves the layout clamp; a stale cap truncates tall documents. @@ -1336,189 +1281,11 @@ export class GridImpl implements Grid { } private repositionEditor(contentTop: number, scrollLeft: number): void { - const editorCell = this.editor.editingCell; - if (editorCell) { - this.editor.position(this.screenRect(editorCell.row, editorCell.col, contentTop, scrollLeft)); - } - const validationCell = this.validationEditor.editingCell; - if (validationCell) { - this.validationEditor.position( - this.screenRect(validationCell.row, validationCell.col, contentTop, scrollLeft), - ); - } - const customCell = this.customEditor.editingCell; - if (customCell) { - this.customEditor.position( - this.editorRect(customCell.row, customCell.col, contentTop, scrollLeft), - ); - } + this.interactions.reposition(contentTop, scrollLeft); } - // ── editing ──────────────────────────────────────────────────────────────-- - beginEdit(row: number, col: number, initial?: string, selectAll = false): void { - if (this.readOnly) return; - - const editCell = this.anchorCell(row, col); - const sheet = this.sheet(); - const column = sheet.columns[editCell.col]; - if (!column) return; - - const dataAddr = { - sheet: this.activeSheet, - row: this.toDataRow(editCell.row), - col: editCell.col, - }; - const formula = this.loadable?.getFormula(dataAddr) ?? null; - const current = this.store.getCell(dataAddr).resolved; - const text = initial ?? formula ?? (current === null ? "" : String(current)); - const contentTop = this.geometry.toContent(this.scroller.scrollTop); - - this.selection.selectCell(editCell.row, editCell.col); - this.scheduleRender(); - - for (const fn of this.listeners["edit-begin"]) { - fn({ addr: { sheet: this.activeSheet, row: editCell.row, col: editCell.col } }); - } - - const validationRule = sheet.validationRules?.find( - (rule) => - rule.range.sheet === dataAddr.sheet && - dataAddr.row >= Math.min(rule.range.start.row, rule.range.end.row) && - dataAddr.row <= Math.max(rule.range.start.row, rule.range.end.row) && - dataAddr.col >= Math.min(rule.range.start.col, rule.range.end.col) && - dataAddr.col <= Math.max(rule.range.start.col, rule.range.end.col) && - (rule.condition.kind === "list" || rule.condition.kind === "checkbox"), - ); - const custom = column.editor ? this.customEditors.get(column.editor) : undefined; - if (custom) { - this.editor.cancel(); - this.validationEditor.cancel(false); - this.customEditor.begin({ - editor: custom, - grid: this, - address: dataAddr, - viewAddress: { sheet: this.activeSheet, row: editCell.row, col: editCell.col }, - column, - value: current, - text, - initialInput: initial, - selectAll: selectAll || initial === undefined, - label: this.editorLabel(editCell.row, editCell.col), - rect: this.editorRect(editCell.row, editCell.col, contentTop, this.scroller.scrollLeft), - onCommit: (value, navigate) => this.commitDataEdit(dataAddr, value, navigate), - onCancel: () => { - this.host.focus(); - this.scheduleRender(); - }, - }); - return; - } - this.customEditor.cancel(false); - if (initial === undefined && validationRule) { - this.editor.cancel(); - this.validationEditor.begin({ - row: editCell.row, - col: editCell.col, - rule: validationRule, - current, - rect: this.screenRect(editCell.row, editCell.col, contentTop, this.scroller.scrollLeft), - theme: this.theme, - onCommit: (value, navigate) => - this.commitCellEdit(editCell.row, editCell.col, { kind: "literal", value }, navigate), - onCancel: () => { - this.host.focus(); - this.scheduleRender(); - }, - }); - return; - } - this.validationEditor.cancel(false); - this.editor.begin({ - row: editCell.row, - col: editCell.col, - type: column.type, - initial: text, - selectAll: selectAll || initial === undefined, - rect: this.screenRect(editCell.row, editCell.col, contentTop, this.scroller.scrollLeft), - label: this.editorLabel(editCell.row, editCell.col), - theme: this.theme, - onCommit: (value, navigate) => this.commitEdit(editCell.row, editCell.col, value, navigate), - onCancel: () => { - this.host.focus(); - this.scheduleRender(); - }, - }); - } - - private commitEdit(row: number, col: number, raw: string, navigate: EditNavigate): void { - const column = this.sheet().columns[col]; - this.commitCellEdit(row, col, parseCellInput(raw, column?.type ?? "text"), navigate); - } - - private commitDataEdit( - address: Readonly, - raw: string, - navigate: EditNavigate, - ): void { - if (address.sheet !== this.activeSheet) return; - const viewRow = this.toViewRow(address.row); - if (viewRow === null) { - this.host.focus(); - this.scheduleRender(); - return; - } - const column = this.sheet().columns[address.col]; - this.commitCellEditAt(address, viewRow, parseCellInput(raw, column?.type ?? "text"), navigate); - } - - private commitCellEdit(row: number, col: number, value: CellValue, navigate: EditNavigate): void { - this.commitCellEditAt( - { sheet: this.activeSheet, row: this.toDataRow(row), col }, - row, - value, - navigate, - ); - } - - private commitCellEditAt( - address: Readonly, - viewRow: number, - value: CellValue, - navigate: EditNavigate, - ): void { - const reason: CommitReason = - navigate === "down" ? "edit-enter" : navigate === "none" ? "edit-blur" : "edit-tab"; - const outcome = this.document.commit( - [ - { - op: "set", - addr: { ...address }, - value, - }, - ], - reason, - ); - - if (outcome.status === "applied") { - for (const fn of this.listeners["edit-commit"]) { - fn({ addr: { sheet: address.sheet, row: viewRow, col: address.col }, value }); - } - this.moveAfterCommit(viewRow, address.col, navigate); - } - this.host.focus(); - this.scheduleRender(); - } - - private moveAfterCommit(row: number, col: number, navigate: EditNavigate): void { - const sheet = this.sheet(); - if (navigate === "down") this.selection.selectCell(Math.min(sheet.rowCount - 1, row + 1), col); - else if (navigate === "right") this.selection.selectCell(row, this.nextVisibleCol(col, 1)); - else if (navigate === "left") this.selection.selectCell(row, this.nextVisibleCol(col, -1)); - else this.selection.selectCell(row, col); - this.emitSelection(); - const f = this.selection.focusCell; - if (f) this.scrollToCell({ sheet: this.activeSheet, row: f.row, col: f.col }); + this.interactions.beginEdit(row, col, initial, selectAll); } private clearSelection(): void { @@ -2052,9 +1819,7 @@ export class GridImpl implements Grid { if (!target || (target.visibility ?? "visible") !== "visible" || id === this.activeSheet) return; - this.editor.cancel(); - this.validationEditor.cancel(); - this.customEditor.cancel(); + this.interactions.reconcile("sheet"); this.cancelAutoFit(); this.domOverlay.reset(); this.mutationRevisions.clear(); @@ -2081,7 +1846,7 @@ export class GridImpl implements Grid { } getCellAtPoint(clientX: number, clientY: number): CellAddress | null { - const cell = this.input.cellAtPointer(clientX, clientY); + const cell = this.interactions.cellAtPointer(clientX, clientY); return cell ? { sheet: this.activeSheet, row: cell.row, col: cell.col } : null; } @@ -2188,7 +1953,7 @@ export class GridImpl implements Grid { * repaints the widgets too, not only the canvas. */ private reseedWidgetTheme(): void { - if (!this.toolbar && !this.contextMenu && !this.findBar) return; + if (!this.toolbar && !this.findBar && this.config?.contextMenu === false) return; seedWidgetTheme(this.host, this.baseTheme); } @@ -2209,9 +1974,7 @@ export class GridImpl implements Grid { this.sheetTabs?.setReadOnly(readOnly); if (readOnly) { this.cancelAutoFit(); - this.editor.cancel(); - this.validationEditor.cancel(); - this.customEditor.cancel(); + this.interactions.reconcile("read-only"); } if (readOnly) this.host.setAttribute("aria-readonly", "true"); else this.host.removeAttribute("aria-readonly"); @@ -2248,11 +2011,7 @@ export class GridImpl implements Grid { this.toolbarHeight = Toolbar.height; } - this.contextMenu?.destroy(); - this.contextMenu = - config?.contextMenu === false - ? null - : new ContextMenu(this.host, config ?? {}, this.baseTheme, this.actions, this); + this.interactions.configureMenu(config, this.baseTheme); this.findBar?.destroy(); this.findBar = @@ -3097,8 +2856,8 @@ export class GridImpl implements Grid { const count = this.loadable ? this.loadable.viewRowCount(this.activeSheet) : this.sheet().rowCount; + this.interactions.reconcile("view"); this.geometry.rebuildRows(count); - this.refreshCustomEditor(); this.selection.clear(); this.selection.setBounds(count, this.firstCol(), this.lastCol()); this.emitSelection(); @@ -3138,12 +2897,8 @@ export class GridImpl implements Grid { this.datasourceController.destroy(); this.mutationRevisions.clear(); this.renderCoordinator.destroy(); - this.editor.destroy(); - this.validationEditor.destroy(); - this.customEditor.destroy(); - this.input.destroy(); + this.interactions.destroy(); this.scroller.removeEventListener("scroll", this.onScroll); - this.scroller.removeEventListener("contextmenu", this.onContextMenu); this.resizeObserver?.disconnect(); this.resolutionMediaQuery?.removeEventListener("change", this.onResolutionChange); this.resolutionMediaQuery = null; @@ -3171,7 +2926,6 @@ export class GridImpl implements Grid { cleanup(() => this.sheetTabs?.destroy()); cleanup(() => this.tabBar?.remove()); cleanup(() => this.toolbar?.destroy()); - cleanup(() => this.contextMenu?.destroy()); cleanup(() => this.findBar?.destroy()); cleanup(() => this.viewportEl.remove()); cleanup(() => this.ariaMirror.destroy()); diff --git a/packages/core/src/input-controller.ts b/packages/core/src/interaction-sessions.ts similarity index 66% rename from packages/core/src/input-controller.ts rename to packages/core/src/interaction-sessions.ts index 1701ea9b..51caf08c 100644 --- a/packages/core/src/input-controller.ts +++ b/packages/core/src/interaction-sessions.ts @@ -1,5 +1,8 @@ import { cellA1, rangeA1, shiftA1Refs } from "./a1.js"; -import type { EditController } from "./editor.js"; +import { cellScalarToText, parseCellInput } from "./cell-input.js"; +import { ContextMenu } from "./context-menu.js"; +import { CustomEditorController } from "./custom-editor.js"; +import { EditController, type EditNavigate } from "./editor.js"; import { detectFillSeries, type FillSeries, type FillSourceCell } from "./fill-series.js"; import type { FindBar } from "./find-bar.js"; import { formatNumber } from "./number-format.js"; @@ -7,20 +10,26 @@ import { autofitColumnWidth, MIN_COLUMN_WIDTH, MIN_ROW_HEIGHT, resizeTargetAt } import type { CellRef, SelectionModel, SelRect } from "./selection.js"; import type { SheetwriteStore } from "./store.js"; import type { CellValue } from "./types/cell.js"; -import type { CellAddress, SheetId } from "./types/coordinates.js"; +import type { CellAddress, HighlightRange, SheetId } from "./types/coordinates.js"; import type { CommitReason, DocumentOp, Sheet } from "./types/document.js"; +import type { CellEditor, CellEditorRect, Grid, GridConfig, GridEvents } from "./types/grid.js"; import type { Theme } from "./types/render.js"; import type { Store } from "./types/store.js"; +import type { ApplyTransactionResult } from "./types/transaction.js"; +import { ValidationEditor } from "./validation-editor.js"; const PRINTABLE = /^.$/u; -export interface InputControllerDeps { +interface InteractionDependencies { host: HTMLElement; scroller: HTMLDivElement; viewportEl: HTMLDivElement; - editor: EditController; - /** True for the stock, validation, or a host-supplied editor. */ - isEditing: () => boolean; + grid: Grid; + customEditors: ReadonlyMap; + highlightCells: (ranges: HighlightRange[] | null) => void; + columnHeader: (col: number) => string; + toViewRow: (dataRow: number) => number | null; + notify: (event: K, payload: GridEvents[K]) => void; findBar: () => FindBar | null; store: Store; loadable: SheetwriteStore | null; @@ -57,8 +66,8 @@ export interface InputControllerDeps { visibleRowWindow: () => { start: number; end: number }; /** Live column-resize preview: set the width and re-lay-out without committing. */ previewColumnWidth: (col: number, width: number) => void; - /** Apply a row height directly (sheet metadata, not undoable). */ - setRowHeight: (row: number, height: number) => void; + previewRowHeight: (row: number, height: number) => void; + clearResizePreview: () => void; /** * Ctrl+Arrow data-edge target for the moved axis (row for vertical, col for * horizontal), or null when unsupported (non-columnar store or an active @@ -78,7 +87,6 @@ export interface InputControllerDeps { ) => { x: number; y: number; w: number; h: number }; anchorCell: (row: number, col: number) => CellRef; toDataRow: (viewRow: number) => number; - beginEdit: (row: number, col: number, initial: string | undefined, selectAll: boolean) => void; clearSelection: () => void; emitSelection: () => void; scrollToCell: (addr: CellAddress) => void; @@ -91,7 +99,7 @@ export interface InputControllerDeps { cut: () => void; paste: () => void; pasteValues: () => void; - commit: (patches: DocumentOp[], reason: CommitReason) => void; + commit: (patches: DocumentOp[], reason: CommitReason) => ApplyTransactionResult; readOnly: () => boolean; } @@ -99,8 +107,15 @@ export interface InputControllerDeps { * Owns grid pointer/keyboard input, including formula point-mode and fill-drag * interactions, while delegating mutations back through the grid shell. */ -export class InputController { - private readonly deps: InputControllerDeps; +export class InteractionSessions { + private readonly deps: InteractionDependencies; + private readonly editor: EditController; + private readonly validationEditor: ValidationEditor; + private readonly customEditor: CustomEditorController; + private contextMenu: ContextMenu | null = null; + private editingAddress: Readonly | null = null; + private activeEditor: EditController | ValidationEditor | CustomEditorController | null = null; + private generation = 0; private fillTarget: SelRect | null = null; private dragMove: ((ev: PointerEvent) => void) | null = null; private dragUp: ((ev: PointerEvent) => void) | null = null; @@ -110,14 +125,305 @@ export class InputController { /** Detached 2D context for autofit text measurement; lazily created. */ private measureCtx: CanvasRenderingContext2D | null = null; - constructor(deps: InputControllerDeps) { + constructor(deps: InteractionDependencies) { this.deps = deps; + this.editor = new EditController(deps.viewportEl, { + highlightCells: deps.highlightCells, + sheet: deps.activeSheet, + }); + this.validationEditor = new ValidationEditor(deps.viewportEl); + this.customEditor = new CustomEditorController(deps.viewportEl); + deps.scroller.addEventListener("contextmenu", this.onContextMenu); deps.scroller.addEventListener("pointerdown", this.onPointerDown); deps.scroller.addEventListener("dblclick", this.onDblClick); deps.scroller.addEventListener("pointermove", this.onHover); deps.host.addEventListener("keydown", this.onKeyDown); } + get isEditing(): boolean { + return this.activeEditor !== null; + } + + configureMenu(config: GridConfig | undefined, theme: Theme): void { + this.contextMenu?.destroy(); + this.contextMenu = + config?.contextMenu === false + ? null + : new ContextMenu( + this.deps.host, + config ?? {}, + theme, + this.deps.grid.actions, + this.deps.grid, + ); + } + + private readonly onContextMenu = (e: MouseEvent): void => { + if (!this.contextMenu) return; + e.preventDefault(); + const cell = this.cellAtPointer(e.clientX, e.clientY); + const address = cell ? { sheet: this.deps.activeSheet(), ...cell } : null; + const selection = this.deps.selection(); + if (cell && !selection.contains(cell.row, cell.col)) { + selection.selectCell(cell.row, cell.col); + this.deps.emitSelection(); + this.deps.scheduleRender(); + } + this.contextMenu.open({ cell: address, clientX: e.clientX, clientY: e.clientY }); + }; + + reconcile(change: "view" | "sheet" | "structure" | "read-only" | "value"): void { + if (change !== "value") this.cancelPointer(); + if (change === "sheet" || change === "structure" || change === "read-only") { + this.cancelEditor(); + return; + } + const address = this.editingAddress; + if (!address) return; + const viewRow = this.deps.toViewRow(address.row); + const column = this.deps.sheet().columns[address.col]; + if (address.sheet !== this.deps.activeSheet() || viewRow === null || !column) { + this.cancelEditor(); + return; + } + if (this.activeEditor === this.customEditor) { + const value = this.deps.store.getCell(address).resolved; + const formula = + this.deps.loadable?.getFormula(address) ?? this.deps.store.getFormula(address); + this.customEditor.update({ + viewAddress: { sheet: address.sheet, row: viewRow, col: address.col }, + column, + value, + text: formula ?? cellScalarToText(value), + label: this.editorLabel(viewRow, address.col), + }); + } + } + + reposition(contentTop: number, scrollLeft: number): void { + const address = this.editingAddress; + if (!address) return; + const row = this.deps.toViewRow(address.row); + if (row === null) return; + const rect = this.deps.screenRect(row, address.col, contentTop, scrollLeft); + const editor = this.activeEditor; + if (editor instanceof CustomEditorController) { + editor.position({ x: rect.x, y: rect.y, width: rect.w, height: rect.h }); + } else { + editor?.position(rect); + } + } + + private editorLabel(row: number, col: number): string { + return `Edit ${this.deps.columnHeader(col)}, row ${row + 1}`; + } + + private editorRect( + row: number, + col: number, + contentTop: number, + scrollLeft: number, + ): CellEditorRect { + const rect = this.deps.screenRect(row, col, contentTop, scrollLeft); + return { x: rect.x, y: rect.y, width: rect.w, height: rect.h }; + } + + private endEditor(): void { + this.editingAddress = null; + this.activeEditor = null; + this.deps.host.focus(); + this.deps.scheduleRender(); + } + + private cancelEditor(): void { + ++this.generation; + this.editingAddress = null; + const editor = this.activeEditor; + this.activeEditor = null; + editor?.cancel(); + } + + private finishEdit( + address: Readonly, + input: string | CellValue, + navigate: EditNavigate, + ): void { + if (this.editingAddress !== address) return; + this.editingAddress = null; + this.activeEditor = null; + if (address.sheet !== this.deps.activeSheet()) return; + const viewRow = this.deps.toViewRow(address.row); + if (viewRow === null) return; + const column = this.deps.sheet().columns[address.col]; + const value = typeof input === "string" ? parseCellInput(input, column?.type ?? "text") : input; + this.commitCellEditAt(address, viewRow, value, navigate); + } + + private cancelPointer(): void { + if (this.activePointerId === null) return; + const cancel = this.dragCancel; + if (cancel) cancel(new PointerEvent("pointercancel", { pointerId: this.activePointerId })); + else this.detachDrag(); + } + + beginEdit(row: number, col: number, initial?: string, selectAll = false): void { + if (this.deps.readOnly()) return; + + const editCell = this.deps.anchorCell(row, col); + const sheet = this.deps.sheet(); + const column = sheet.columns[editCell.col]; + if (!column) return; + + const dataAddr = { + sheet: this.deps.activeSheet(), + row: this.deps.toDataRow(editCell.row), + col: editCell.col, + }; + const formula = this.deps.loadable?.getFormula(dataAddr) ?? null; + const current = this.deps.store.getCell(dataAddr).resolved; + const text = initial ?? formula ?? (current === null ? "" : String(current)); + const contentTop = this.deps.contentTop(); + + this.cancelPointer(); + this.cancelEditor(); + const generation = ++this.generation; + this.editingAddress = dataAddr; + const selection = this.deps.selection(); + const focus = selection.focusCell; + const selectionChanged = + !focus || + focus.row !== editCell.row || + focus.col !== editCell.col || + selection.toSelection(dataAddr.sheet)?.kind !== "cell"; + selection.selectCell(editCell.row, editCell.col); + this.deps.scheduleRender(); + if (selectionChanged) this.deps.emitSelection(); + if (generation !== this.generation || this.deps.readOnly()) return; + this.deps.notify("edit-begin", { + addr: { sheet: dataAddr.sheet, row: editCell.row, col: editCell.col }, + }); + // edit-begin remains a before-open notification. Reentrant callbacks can + // replace or invalidate this request, but cannot reopen its stale target. + if (generation !== this.generation || this.deps.readOnly()) return; + + const validationRule = sheet.validationRules?.find( + (rule) => + rule.range.sheet === dataAddr.sheet && + dataAddr.row >= Math.min(rule.range.start.row, rule.range.end.row) && + dataAddr.row <= Math.max(rule.range.start.row, rule.range.end.row) && + dataAddr.col >= Math.min(rule.range.start.col, rule.range.end.col) && + dataAddr.col <= Math.max(rule.range.start.col, rule.range.end.col) && + (rule.condition.kind === "list" || rule.condition.kind === "checkbox"), + ); + const custom = column.editor ? this.deps.customEditors.get(column.editor) : undefined; + if (custom) { + this.activeEditor = this.customEditor; + this.customEditor.begin({ + editor: custom, + grid: this.deps.grid, + address: dataAddr, + viewAddress: { sheet: this.deps.activeSheet(), row: editCell.row, col: editCell.col }, + column, + value: current, + text, + initialInput: initial, + selectAll: selectAll || initial === undefined, + label: this.editorLabel(editCell.row, editCell.col), + rect: this.editorRect( + editCell.row, + editCell.col, + contentTop, + this.deps.scroller.scrollLeft, + ), + onCommit: (value, navigate) => this.finishEdit(dataAddr, value, navigate), + onCancel: () => this.endEditor(), + }); + return; + } + if (initial === undefined && validationRule) { + this.activeEditor = this.validationEditor; + this.validationEditor.begin({ + row: editCell.row, + col: editCell.col, + rule: validationRule, + current, + rect: this.deps.screenRect( + editCell.row, + editCell.col, + contentTop, + this.deps.scroller.scrollLeft, + ), + theme: this.deps.theme(), + onCommit: (value, navigate) => + this.finishEdit(dataAddr, { kind: "literal", value }, navigate), + onCancel: () => this.endEditor(), + }); + return; + } + this.activeEditor = this.editor; + this.editor.begin({ + row: editCell.row, + col: editCell.col, + type: column.type, + initial: text, + selectAll: selectAll || initial === undefined, + rect: this.deps.screenRect( + editCell.row, + editCell.col, + contentTop, + this.deps.scroller.scrollLeft, + ), + label: this.editorLabel(editCell.row, editCell.col), + theme: this.deps.theme(), + onCommit: (value, navigate) => this.finishEdit(dataAddr, value, navigate), + onCancel: () => this.endEditor(), + }); + } + + private commitCellEditAt( + address: Readonly, + viewRow: number, + value: CellValue, + navigate: EditNavigate, + ): void { + const reason: CommitReason = + navigate === "down" ? "edit-enter" : navigate === "none" ? "edit-blur" : "edit-tab"; + const outcome = this.deps.commit( + [ + { + op: "set", + addr: { ...address }, + value, + }, + ], + reason, + ); + + if (outcome.status === "applied") { + this.deps.notify("edit-commit", { + addr: { sheet: address.sheet, row: viewRow, col: address.col }, + value, + }); + this.moveAfterCommit(viewRow, address.col, navigate); + } + this.deps.host.focus(); + this.deps.scheduleRender(); + } + + private moveAfterCommit(row: number, col: number, navigate: EditNavigate): void { + const sheet = this.deps.sheet(); + if (navigate === "down") + this.deps.selection().selectCell(Math.min(sheet.rowCount - 1, row + 1), col); + else if (navigate === "right") + this.deps.selection().selectCell(row, this.deps.nextVisibleCol(col, 1)); + else if (navigate === "left") + this.deps.selection().selectCell(row, this.deps.nextVisibleCol(col, -1)); + else this.deps.selection().selectCell(row, col); + this.deps.emitSelection(); + const f = this.deps.selection().focusCell; + if (f) this.deps.scrollToCell({ sheet: this.deps.activeSheet(), row: f.row, col: f.col }); + } + get fillPreview(): SelRect | null { return this.fillTarget; } @@ -149,7 +455,13 @@ export class InputController { } destroy(): void { - this.detachDrag(); + this.cancelPointer(); + this.cancelEditor(); + this.editor.destroy(); + this.validationEditor.destroy(); + this.customEditor.destroy(); + this.contextMenu?.destroy(); + this.deps.scroller.removeEventListener("contextmenu", this.onContextMenu); this.deps.scroller.removeEventListener("pointerdown", this.onPointerDown); this.deps.scroller.removeEventListener("dblclick", this.onDblClick); this.deps.scroller.removeEventListener("pointermove", this.onHover); @@ -157,12 +469,13 @@ export class InputController { } private readonly onPointerDown = (e: PointerEvent): void => { + if (this.activePointerId !== null) return; // Primary button only. Touch contacts always report button 0; a pen barrel // button (non-zero) must not select, so the guard covers mouse AND pen. if (e.pointerType !== "touch" && e.button !== 0) return; const isTouch = e.pointerType === "touch"; - const editor = this.deps.editor; + const editor = this.editor; // Formula point mode: while editing a "=" formula, clicks/drags pick A1 // references into the editor instead of moving the grid selection. if (editor.isEditing && editor.value.startsWith("=")) { @@ -178,16 +491,16 @@ export class InputController { editor.endReference(); this.detachDrag(); }; - this.attachDrag(e, move, up); + this.attachDrag(e, move, up, up); return; } // A selected cell must own subsequent keyboard input across browsers. // Keep touch and active-editor gestures focused where they already are. - if (!isTouch && !this.deps.isEditing()) this.deps.host.focus({ preventScroll: true }); + if (!isTouch && !this.isEditing) this.deps.host.focus({ preventScroll: true }); const viewportRect = this.deps.viewportEl.getBoundingClientRect(); const fillHandle = this.fillHandleScreen(this.deps.contentTop(), this.deps.scroller.scrollLeft); - if (fillHandle && !this.deps.isEditing()) { + if (fillHandle && !this.isEditing) { const hx = e.clientX - viewportRect.left; const hy = e.clientY - viewportRect.top; if (Math.abs(hx - fillHandle.x) <= 5 && Math.abs(hy - fillHandle.y) <= 5) { @@ -199,7 +512,7 @@ export class InputController { // Resize gesture: near a column boundary in the top header, or a row boundary // in the left gutter. Takes priority over selection; disabled while editing. - if (!this.deps.isEditing() && !this.deps.readOnly()) { + if (!this.isEditing && !this.deps.readOnly()) { const resize = this.resizeAt(e); if (resize) { e.preventDefault(); @@ -314,7 +627,7 @@ export class InputController { } const cell = this.cellAtPointer(e.clientX, e.clientY); if (!cell) return; - this.deps.beginEdit(cell.row, cell.col, undefined, true); + this.beginEdit(cell.row, cell.col, undefined, true); }; private readonly onHover = (e: PointerEvent): void => { @@ -372,6 +685,7 @@ export class InputController { }; const up = (): void => { this.detachDrag(); + this.deps.clearResizePreview(); this.deps.commit( [ { @@ -384,16 +698,37 @@ export class InputController { "structure", ); }; - this.attachDrag(e, move, up); + const cancel = (): void => { + this.detachDrag(); + this.deps.clearResizePreview(); + }; + this.attachDrag(e, move, up, cancel); } private startRowResize(e: PointerEvent, row: number, startY: number): void { - const startHeight = this.deps.rowHeight(row); + const sheet = this.deps.activeSheet(); + const dataRow = this.deps.toDataRow(row); + const startHeight = this.deps.rowHeight(row) / this.deps.zoom(); + let finalHeight = startHeight; const move = (ev: PointerEvent): void => { - const height = Math.max(MIN_ROW_HEIGHT, Math.round(startHeight + ev.clientY - startY)); - this.deps.setRowHeight(row, height); + finalHeight = Math.max( + MIN_ROW_HEIGHT, + Math.round(startHeight + (ev.clientY - startY) / this.deps.zoom()), + ); + this.deps.previewRowHeight(row, finalHeight); }; - this.attachDrag(e, move, () => this.detachDrag()); + const cancel = (): void => { + this.detachDrag(); + this.deps.clearResizePreview(); + }; + const up = (): void => { + cancel(); + this.deps.commit( + [{ op: "setRowMeta", sheet, row: dataRow, meta: { height: finalHeight } }], + "structure", + ); + }; + this.attachDrag(e, move, up, cancel); } private autofitColumn(col: number): void { @@ -441,7 +776,7 @@ export class InputController { } private readonly onKeyDown = (e: KeyboardEvent): void => { - if (this.deps.isEditing()) return; + if (this.isEditing) return; // Keys typed into an editable widget inside the host (find bar, custom // toolbar fields) belong to that widget. Without this guard the grid's // type-to-edit default steals focus mid-keystroke and Backspace becomes a @@ -568,7 +903,7 @@ export class InputController { break; case "Enter": case "F2": - if (focus) this.deps.beginEdit(focus.row, focus.col, undefined, e.key === "F2"); + if (focus) this.beginEdit(focus.row, focus.col, undefined, e.key === "F2"); break; case "Delete": case "Backspace": @@ -576,7 +911,7 @@ export class InputController { break; default: if (!mod && !e.altKey && focus && PRINTABLE.test(e.key)) { - this.deps.beginEdit(focus.row, focus.col, e.key, false); + this.beginEdit(focus.row, focus.col, e.key, false); } else { return; } @@ -655,6 +990,7 @@ export class InputController { private startFillDrag(e: PointerEvent): void { const source = this.fillSourceRect(); if (!source) return; + this.fillTarget = null; const move = (ev: PointerEvent): void => { const c = this.fillCellAt(ev.clientX, ev.clientY); this.fillTarget = this.extendFill(source, c); @@ -664,8 +1000,7 @@ export class InputController { this.detachDrag(); const target = this.fillTarget; this.fillTarget = null; - if (target) { - this.commitFill(source, target); + if (target && this.commitFill(source, target)) { const selection = this.deps.selection(); selection.selectCell(target.r0, target.c0); selection.extendTo(target.r1, target.c1); @@ -698,8 +1033,8 @@ export class InputController { return { r0: source.r0, c0: c.col, r1: source.r1, c1: source.c1 }; } - private commitFill(source: SelRect, target: SelRect): void { - if (this.deps.readOnly()) return; + private commitFill(source: SelRect, target: SelRect): boolean { + if (this.deps.readOnly()) return false; const srcCols = source.c1 - source.c0 + 1; const series = new Map(); const patches: DocumentOp[] = []; @@ -714,7 +1049,9 @@ export class InputController { if (r >= source.r0 && r <= source.r1 && c >= source.c0 && c <= source.c1) continue; const sc = source.c0 + ((((c - source.c0) % srcCols) + srcCols) % srcCols); - const step = series.get(sc)!.stepAt(r - source.r0); + const columnSeries = series.get(sc); + if (!columnSeries) throw new Error(`Missing fill series for column ${sc}`); + const step = columnSeries.stepAt(r - source.r0); const targetDataRow = this.deps.toDataRow(r); patches.push({ op: "set", @@ -731,7 +1068,7 @@ export class InputController { }); } } - this.deps.commit(patches, "fill"); + return this.deps.commit(patches, "fill").status === "applied"; } private seriesForColumn(source: SelRect, col: number): FillSeries { @@ -764,7 +1101,7 @@ export class InputController { up: (ev: PointerEvent) => void, cancel?: (ev: PointerEvent) => void, ): void { - this.detachDrag(); + this.cancelPointer(); const scroller = this.deps.scroller; this.activePointerId = e.pointerId; @@ -784,11 +1121,14 @@ export class InputController { scroller.addEventListener("pointermove", this.dragMove); scroller.addEventListener("pointerup", this.dragUp); scroller.addEventListener("pointercancel", this.dragCancel); + scroller.addEventListener("lostpointercapture", this.dragCancel); if (typeof scroller.setPointerCapture === "function") { try { scroller.setPointerCapture(e.pointerId); - } catch { - // happy-dom / detached elements: capture is a UA nicety, not required. + } catch (error) { + if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error; + // Detached grids cannot capture; scoped listeners still own termination. + console.warn("Pointer capture unavailable for detached grid", error); } } } @@ -797,7 +1137,10 @@ export class InputController { const scroller = this.deps.scroller; if (this.dragMove) scroller.removeEventListener("pointermove", this.dragMove); if (this.dragUp) scroller.removeEventListener("pointerup", this.dragUp); - if (this.dragCancel) scroller.removeEventListener("pointercancel", this.dragCancel); + if (this.dragCancel) { + scroller.removeEventListener("pointercancel", this.dragCancel); + scroller.removeEventListener("lostpointercapture", this.dragCancel); + } this.dragMove = null; this.dragUp = null; this.dragCancel = null; diff --git a/packages/core/test/input.test.ts b/packages/core/test/input.test.ts index d79098a4..72dd0381 100644 --- a/packages/core/test/input.test.ts +++ b/packages/core/test/input.test.ts @@ -216,6 +216,135 @@ describe("pointer input: drag lifecycle", () => { scroller.dispatchEvent(pointer("pointerup", { ...cellPoint(3, 1, workbook) })); expect(store.getCell({ sheet: "s1", row: 1, col: 1 }).resolved).toBe(before); }); + for (const axis of ["column", "row"] as const) { + it(`${axis} resize previews without mutations and commits one undoable change`, () => { + const { grid, store, scroller } = makeGrid(); + const original = axis === "column" ? 160 : DEFAULT_THEME.rowHeight; + const start = + axis === "column" + ? { clientX: DEFAULT_THEME.rowHeaderWidth + original, clientY: 10 } + : { clientX: 10, clientY: DEFAULT_THEME.headerHeight + original }; + const end = + axis === "column" + ? { ...start, clientX: start.clientX + 40 } + : { ...start, clientY: start.clientY + 40 }; + const dimension = () => + axis === "column" + ? store.getWorkbook().sheets[0]?.columns[0]?.width + : (store.getWorkbook().sheets[0]?.rowHeights?.get(0) ?? DEFAULT_THEME.rowHeight); + const changes: string[] = []; + grid.on("change", (event) => changes.push(event.commitReason)); + scroller.dispatchEvent(pointer("pointerdown", start)); + scroller.dispatchEvent(pointer("pointermove", end)); + scroller.dispatchEvent(pointer("pointermove", end)); + expect(dimension()).toBe(original); + expect(changes).toEqual([]); + scroller.dispatchEvent(pointer("pointerup", end)); + expect(dimension()).toBe(original + 40); + expect(changes).toEqual(["structure"]); + grid.undo(); + expect(dimension()).toBe(original); + grid.destroy(); + store.dispose(); + }); + + for (const termination of ["pointercancel", "lostpointercapture", "read-only"] as const) { + it(`${axis} resize restores geometry on ${termination}`, () => { + const { grid, store, scroller, capture } = makeGrid(); + const original = axis === "column" ? 160 : DEFAULT_THEME.rowHeight; + const start = + axis === "column" + ? { clientX: DEFAULT_THEME.rowHeaderWidth + original, clientY: 10 } + : { clientX: 10, clientY: DEFAULT_THEME.headerHeight + original }; + const end = + axis === "column" + ? { ...start, clientX: start.clientX + 40 } + : { ...start, clientY: start.clientY + 40 }; + const changes: string[] = []; + grid.on("change", (event) => changes.push(event.commitReason)); + scroller.dispatchEvent(pointer("pointerdown", start)); + scroller.dispatchEvent(pointer("pointermove", end)); + if (termination === "read-only") grid.setReadOnly(true); + else scroller.dispatchEvent(pointer(termination, end)); + scroller.dispatchEvent(pointer("pointerup", end)); + expect(changes).toEqual([]); + expect(capture.released).toEqual([1]); + const address = grid.getCellAtPoint( + axis === "column" ? start.clientX + 10 : DEFAULT_THEME.rowHeaderWidth + 20, + axis === "row" ? start.clientY + 10 : DEFAULT_THEME.headerHeight + 10, + ); + expect(axis === "column" ? address?.col : address?.row).toBe(1); + grid.destroy(); + store.dispose(); + }); + } + } + + it("ignores another pointerdown before it can replace selection or capture", () => { + const { grid, store, scroller, workbook, capture } = makeGrid(); + scroller.dispatchEvent(pointer("pointerdown", cellPoint(0, 0, workbook))); + scroller.dispatchEvent(pointer("pointerdown", { ...cellPoint(4, 2, workbook), pointerId: 2 })); + scroller.dispatchEvent(pointer("pointermove", cellPoint(2, 1, workbook))); + scroller.dispatchEvent(pointer("pointerup", cellPoint(2, 1, workbook))); + expect(grid.getSelection()).toMatchObject({ + kind: "range", + range: { start: { row: 0, col: 0 }, end: { row: 2, col: 1 } }, + }); + expect(capture.set).toEqual([1]); + expect(capture.released).toEqual([1]); + grid.destroy(); + store.dispose(); + }); + + it("ends a cancelled formula reference span before the next pick", () => { + const { grid, store, host, scroller, workbook } = makeGrid(); + grid.beginEdit(0, 0, "="); + const editor = host.querySelector("textarea.sheetwrite-editor"); + if (!editor) throw new Error("formula editor missing"); + scroller.dispatchEvent(pointer("pointerdown", cellPoint(1, 1, workbook))); + scroller.dispatchEvent(pointer("pointercancel", cellPoint(1, 1, workbook))); + editor.value += "+"; + editor.setSelectionRange(editor.value.length, editor.value.length); + scroller.dispatchEvent(pointer("pointerdown", cellPoint(2, 2, workbook))); + scroller.dispatchEvent(pointer("pointerup", cellPoint(2, 2, workbook))); + expect(editor.value).toBe("=B2+C3"); + grid.destroy(); + store.dispose(); + }); + + it("abandons a fill when structure changes before release", () => { + const { grid, store, scroller, workbook, capture } = makeGrid(); + grid.setSelection({ kind: "cell", addr: { sheet: "s1", row: 0, col: 1 } }); + const before = store.getCell({ sheet: "s1", row: 1, col: 1 }).resolved; + scroller.dispatchEvent(pointer("pointerdown", fillHandlePoint(0, 1, workbook))); + scroller.dispatchEvent(pointer("pointermove", cellPoint(2, 1, workbook))); + grid.setColumnWidth(0, 200); + scroller.dispatchEvent(pointer("pointerup", cellPoint(2, 1, workbook))); + expect(store.getCell({ sheet: "s1", row: 1, col: 1 }).resolved).toBe(before); + expect(capture.released).toEqual([1]); + grid.destroy(); + store.dispose(); + }); + + it("keeps the source selection when fill admission rejects its values", () => { + const { grid, store, scroller, workbook } = makeGrid(); + grid.setValidationRule({ + id: "deny-fill", + range: { sheet: "s1", start: { row: 1, col: 0 }, end: { row: 2, col: 0 } }, + condition: { kind: "list", values: ["Allowed"] }, + policy: "reject", + }); + const source = { kind: "cell" as const, addr: { sheet: "s1", row: 0, col: 0 } }; + grid.setSelection(source); + const before = store.getCell({ sheet: "s1", row: 1, col: 0 }).resolved; + scroller.dispatchEvent(pointer("pointerdown", fillHandlePoint(0, 0, workbook))); + scroller.dispatchEvent(pointer("pointermove", cellPoint(2, 0, workbook))); + scroller.dispatchEvent(pointer("pointerup", cellPoint(2, 0, workbook))); + expect(store.getCell({ sheet: "s1", row: 1, col: 0 }).resolved).toBe(before); + expect(grid.getSelection()).toEqual(source); + grid.destroy(); + store.dispose(); + }); }); describe("validation through input mutation paths", () => { it("rejects invalid external paste at the shared commit boundary", async () => { diff --git a/packages/core/test/semantic-grid.test.ts b/packages/core/test/semantic-grid.test.ts index 7e5888d0..8c7a468e 100644 --- a/packages/core/test/semantic-grid.test.ts +++ b/packages/core/test/semantic-grid.test.ts @@ -307,8 +307,8 @@ describe("custom editor canonical lifecycle", () => { store.dispose(); }); - it("keeps async commits bound to their canonical row through sort and clearView", async () => { - for (const resetView of [false, true]) { + it("keeps async commits bound to their canonical row through sort, clearView, and filter", async () => { + for (const viewChange of ["sort", "clearView", "filter"] as const) { const workbook = makeWorkbook(3); workbook.sheets[0]!.columns[0]!.editor = "pending"; const store = new SheetwriteStore(workbook, makeColumnarData(3)); @@ -332,10 +332,10 @@ describe("custom editor canonical lifecycle", () => { }, }; const grid = new GridImpl(host, { workbook, editors: { pending: editor } }, store); - if (resetView) grid.sortBy(1, false); + if (viewChange === "clearView") grid.sortBy(1, false); grid.beginEdit(0, 0); - const originalDataRow = resetView ? 2 : 0; - const otherDataRow = resetView ? 0 : 2; + const originalDataRow = viewChange === "clearView" ? 2 : 0; + const otherDataRow = viewChange === "clearView" ? 0 : 2; const committed: CellEditorContext["address"][] = []; grid.on("edit-commit", ({ addr }) => { committed.push(addr); @@ -344,9 +344,10 @@ describe("custom editor canonical lifecycle", () => { new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), ); - if (resetView) grid.clearView(); + if (viewChange === "clearView") grid.clearView(); + else if (viewChange === "filter") grid.filterBy(0, "Customer 0"); else grid.sortBy(1, false); - const expectedViewRow = resetView ? 2 : 2; + const expectedViewRow = viewChange === "filter" ? 0 : 2; expect(contexts.at(-1)?.address).toEqual({ sheet: "s1", row: originalDataRow, @@ -354,11 +355,11 @@ describe("custom editor canonical lifecycle", () => { }); expect(contexts.at(-1)?.viewAddress.row).toBe(expectedViewRow); - result.resolve(resetView ? "ClearView target" : "Sort target"); + result.resolve(`${viewChange} target`); await result.promise; await Promise.resolve(); expect(store.getCell({ sheet: "s1", row: originalDataRow, col: 0 }).resolved).toBe( - resetView ? "ClearView target" : "Sort target", + `${viewChange} target`, ); expect(store.getCell({ sheet: "s1", row: otherDataRow, col: 0 }).resolved).toBe( `Customer ${otherDataRow}`, @@ -371,6 +372,259 @@ describe("custom editor canonical lifecycle", () => { } }); + for (const editorKind of ["stock", "list", "checkbox"] as const) { + for (const viewChange of ["sort", "clearView", "filter"] as const) { + it(`commits the ${editorKind} editor to its canonical row after ${viewChange}`, () => { + const workbook = makeWorkbook(3); + const sheet = workbook.sheets[0]; + if (!sheet) throw new Error("editor workbook sheet missing"); + if (editorKind !== "stock") { + sheet.validationRules = [ + { + id: "choice", + range: { sheet: "s1", start: { row: 0, col: 0 }, end: { row: 2, col: 0 } }, + condition: + editorKind === "list" + ? { kind: "list", values: ["Edited choice"] } + : { + kind: "checkbox", + checkedValue: "Edited choice", + uncheckedValue: "Unchecked", + }, + policy: "warn", + }, + ]; + } + const store = new SheetwriteStore(workbook, makeColumnarData(3)); + const host = mountHost(); + const grid = new GridImpl(host, { workbook }, store); + if (viewChange !== "sort") grid.sortBy(1, false); + grid.beginEdit(0, 0); + const selector = + editorKind === "stock" + ? "textarea.sheetwrite-editor" + : editorKind === "list" + ? '[role="listbox"]' + : '[role="checkbox"]'; + const editor = host.querySelector(selector); + if (!editor) throw new Error(`${editorKind} editor missing`); + if (editor instanceof HTMLTextAreaElement) editor.value = "Edited choice"; + const committed: CellEditorContext["address"][] = []; + grid.on("edit-commit", ({ addr }) => committed.push(addr)); + + if (viewChange === "clearView") grid.clearView(); + else if (viewChange === "filter") grid.filterBy(0, "Customer 2"); + else grid.sortBy(1, false); + expect(host.querySelector(selector)).toBe(editor); + editor.dispatchEvent( + new KeyboardEvent("keydown", { + key: editorKind === "checkbox" ? " " : "Enter", + bubbles: true, + cancelable: true, + }), + ); + + const canonicalRow = viewChange === "sort" ? 0 : 2; + const otherRow = viewChange === "sort" ? 2 : 0; + const viewRow = viewChange === "filter" ? 0 : 2; + expect(store.getCell({ sheet: "s1", row: canonicalRow, col: 0 }).resolved).toBe( + "Edited choice", + ); + expect(store.getCell({ sheet: "s1", row: otherRow, col: 0 }).resolved).toBe( + `Customer ${otherRow}`, + ); + expect(committed).toEqual([{ sheet: "s1", row: viewRow, col: 0 }]); + expect(host.querySelector(selector)).toBeNull(); + grid.undo(); + expect(store.getCell({ sheet: "s1", row: canonicalRow, col: 0 }).resolved).toBe( + `Customer ${canonicalRow}`, + ); + grid.destroy(); + store.dispose(); + host.remove(); + }); + } + } + + it("cancels every editor when filtering removes its canonical row, including pending custom commits", async () => { + for (const editorKind of ["stock", "list", "checkbox", "custom"] as const) { + const workbook = makeWorkbook(3); + const sheet = workbook.sheets[0]; + const column = sheet?.columns[0]; + if (!sheet || !column) throw new Error("editor workbook column missing"); + if (editorKind === "list" || editorKind === "checkbox") { + sheet.validationRules = [ + { + id: "choice", + range: { sheet: "s1", start: { row: 0, col: 0 }, end: { row: 2, col: 0 } }, + condition: + editorKind === "list" + ? { kind: "list", values: ["Stale choice"] } + : { kind: "checkbox", checkedValue: "Stale choice", uncheckedValue: "Unchecked" }, + policy: "reject", + }, + ]; + } + const stats = editorStats(); + const pending = Promise.withResolvers(); + const custom = inputEditor(stats); + if (editorKind === "custom") column.editor = "pending"; + const store = new SheetwriteStore(workbook, makeColumnarData(3)); + const host = mountHost(); + const grid = new GridImpl( + host, + { + workbook, + editors: { + pending: { + mount(root, context) { + const session = custom.mount(root, context); + return { ...session, commit: () => pending.promise }; + }, + }, + }, + }, + store, + ); + const committed: unknown[] = []; + grid.on("edit-commit", (event) => committed.push(event)); + grid.beginEdit(0, 0); + const selector = + editorKind === "stock" + ? "textarea.sheetwrite-editor" + : editorKind === "list" + ? '[role="listbox"]' + : editorKind === "checkbox" + ? '[role="checkbox"]' + : ".sheetwrite-custom-editor input"; + const editor = host.querySelector(selector); + if (!editor) throw new Error(`${editorKind} editor missing`); + if (editor instanceof HTMLTextAreaElement) editor.value = "Stale choice"; + if (editorKind === "custom") { + editor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }), + ); + } + + grid.filterBy(0, "Customer 2"); + expect(host.querySelector(selector)).toBeNull(); + editor.dispatchEvent( + new KeyboardEvent("keydown", { + key: editorKind === "checkbox" ? " " : "Enter", + bubbles: true, + cancelable: true, + }), + ); + pending.resolve("Stale choice"); + await pending.promise; + await Promise.resolve(); + expect(committed).toEqual([]); + expect(store.getCell({ sheet: "s1", row: 0, col: 0 }).resolved).toBe("Customer 0"); + expect(store.getCell({ sheet: "s1", row: 2, col: 0 }).resolved).toBe("Customer 2"); + if (editorKind === "custom") { + expect(stats.contexts[0]?.signal.aborted).toBe(true); + expect({ cancels: stats.cancels, destroys: stats.destroys }).toEqual({ + cancels: 1, + destroys: 1, + }); + } + grid.destroy(); + store.dispose(); + host.remove(); + } + }); + + it("notifies beginEdit selection changes before the before-open edit-begin event", () => { + const workbook = makeWorkbook(3); + const store = new SheetwriteStore(workbook, makeColumnarData(3)); + const host = mountHost(); + const grid = new GridImpl(host, { workbook }, store); + grid.setSelection({ + kind: "range", + range: { sheet: "s1", start: { row: 0, col: 0 }, end: { row: 1, col: 1 } }, + }); + const events: string[] = []; + grid.on("selection", ({ selection }) => { + events.push("selection"); + expect(selection).toEqual({ kind: "cell", addr: { sheet: "s1", row: 1, col: 1 } }); + expect(grid.getSelection()).toEqual(selection); + expect(host.querySelector("textarea.sheetwrite-editor")).toBeNull(); + }); + grid.on("edit-begin", ({ addr }) => { + events.push("edit-begin"); + expect(addr).toEqual({ sheet: "s1", row: 1, col: 1 }); + expect(host.querySelector("textarea.sheetwrite-editor")).toBeNull(); + }); + + grid.beginEdit(1, 1); + expect(events).toEqual(["selection", "edit-begin"]); + expect(host.querySelector("textarea.sheetwrite-editor")).not.toBeNull(); + grid.beginEdit(1, 1); + expect(events).toEqual(["selection", "edit-begin", "edit-begin"]); + grid.destroy(); + store.dispose(); + }); + + for (const replacement of ["sheet", "editor"] as const) { + it(`does not mount an obsolete editor after edit-begin replaces the ${replacement}`, () => { + const workbook = makeWorkbook(3); + const sheet = workbook.sheets[0]; + const otherSheet = makeWorkbook(3).sheets[0]; + if (!sheet || !otherSheet) throw new Error("editor workbook sheets missing"); + for (const column of sheet.columns) column.editor = "input"; + otherSheet.id = "s2"; + otherSheet.name = "Other sheet"; + workbook.sheets.push(otherSheet); + const store = new SheetwriteStore(workbook, makeColumnarData(3)); + const host = mountHost(); + const stats = editorStats(); + const grid = new GridImpl(host, { workbook, editors: { input: inputEditor(stats) } }, store); + const begins: CellEditorContext["address"][] = []; + grid.on("edit-begin", ({ addr }) => { + begins.push(addr); + expect(host.querySelector(".sheetwrite-custom-editor")).toBeNull(); + if (addr.row !== 0) return; + if (replacement === "sheet") grid.setActiveSheet("s2"); + else grid.beginEdit(1, 0); + }); + + grid.beginEdit(0, 0); + expect(begins).toEqual( + replacement === "sheet" + ? [{ sheet: "s1", row: 0, col: 0 }] + : [ + { sheet: "s1", row: 0, col: 0 }, + { sheet: "s1", row: 1, col: 0 }, + ], + ); + expect(stats.mounts).toBe(replacement === "sheet" ? 0 : 1); + if (replacement === "sheet") { + expect(host.querySelector(".sheetwrite-custom-editor")).toBeNull(); + expect(grid.getCellInput(0, 0)?.address.sheet).toBe("s2"); + } else { + expect(stats.contexts[0]?.address).toEqual({ sheet: "s1", row: 1, col: 0 }); + const input = activeEditorInput(host); + input.value = "Replacement edit"; + input.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }), + ); + expect(store.getCell({ sheet: "s1", row: 0, col: 0 }).resolved).toBe("Customer 0"); + expect(store.getCell({ sheet: "s1", row: 1, col: 0 }).resolved).toBe("Replacement edit"); + } + grid.destroy(); + store.dispose(); + host.remove(); + }); + } + it("finishes cancel and Grid teardown when an editor destroy hook throws", () => { const reported: unknown[] = []; Object.defineProperty(globalThis, "reportError", { From bdd10072f309eb7f9c685db5ca77e42db61b1d99 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Fri, 9 Oct 2026 00:34:12 +0700 Subject: [PATCH 2/7] test(core): prove interaction cancellation and resize history contracts --- bench/package.json | 1 + bench/src/interaction-sessions-bench.ts | 106 ++++++++++++++++++ docs/public/llms-full.txt | 25 +++++ docs/src/content/docs/api/core/create-grid.md | 2 +- .../content/docs/api/core/default-theme.md | 2 +- .../content/docs/api/core/init-sheetwrite.md | 2 +- .../docs/api/core/is-sheetwrite-ready.md | 2 +- .../docs/api/core/resolve-theme-from-css.md | 2 +- docs/src/content/docs/guides/interaction.md | 7 +- docs/src/generated/docs-contract.json | 2 +- docs/src/generated/public-api.json | 10 +- packages/core/test/input.test.ts | 60 ++++++++++ scripts/public-api-baseline.json | 11 +- test/browser/examples.spec.ts | 61 ++++++++++ 14 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 bench/src/interaction-sessions-bench.ts diff --git a/bench/package.json b/bench/package.json index 33a7a05d..8d0e7f96 100644 --- a/bench/package.json +++ b/bench/package.json @@ -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", diff --git a/bench/src/interaction-sessions-bench.ts b/bench/src/interaction-sessions-bench.ts new file mode 100644 index 00000000..669f4ad7 --- /dev/null +++ b/bench/src/interaction-sessions-bench.ts @@ -0,0 +1,106 @@ +import "./dom-setup.js"; +import { createGrid, DEFAULT_THEME, initSheetwrite, type Workbook } from "@sheetwrite/core"; +import { installCanvasTestStubs } from "@sheetwrite/core/testing"; + +const ROWS = 100; +const ITERATIONS = 100; +const SAMPLES = 9; +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 = {}; +try { + for (const workload of [ + "column-resize", + "row-resize", + "edit", + "selection", + "get-cell", + "aggregate", + ] as const) { + const timings: number[] = []; + for (let sample = -1; sample < SAMPLES; sample++) { + 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", samples })}\n`); +} finally { + grid.destroy(); + host.remove(); + restoreCanvas(); +} diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 688ed0de..fc2280df 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -2862,6 +2862,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: @@ -2931,6 +2943,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 diff --git a/docs/src/content/docs/api/core/create-grid.md b/docs/src/content/docs/api/core/create-grid.md index ed0ff8de..f5729379 100644 --- a/docs/src/content/docs/api/core/create-grid.md +++ b/docs/src/content/docs/api/core/create-grid.md @@ -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. ## Declaration diff --git a/docs/src/content/docs/api/core/default-theme.md b/docs/src/content/docs/api/core/default-theme.md index 09611886..0b742430 100644 --- a/docs/src/content/docs/api/core/default-theme.md +++ b/docs/src/content/docs/api/core/default-theme.md @@ -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. ## Declaration diff --git a/docs/src/content/docs/api/core/init-sheetwrite.md b/docs/src/content/docs/api/core/init-sheetwrite.md index 14f23cde..8c8e0e1f 100644 --- a/docs/src/content/docs/api/core/init-sheetwrite.md +++ b/docs/src/content/docs/api/core/init-sheetwrite.md @@ -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. ## Declaration diff --git a/docs/src/content/docs/api/core/is-sheetwrite-ready.md b/docs/src/content/docs/api/core/is-sheetwrite-ready.md index 7de99d87..21fed0ce 100644 --- a/docs/src/content/docs/api/core/is-sheetwrite-ready.md +++ b/docs/src/content/docs/api/core/is-sheetwrite-ready.md @@ -8,7 +8,7 @@ description: "Whether initSheetwrite has completed — the single readiness sour Whether `initSheetwrite` has completed — the single readiness source. ## Declaration diff --git a/docs/src/content/docs/api/core/resolve-theme-from-css.md b/docs/src/content/docs/api/core/resolve-theme-from-css.md index 21778cc1..af360f96 100644 --- a/docs/src/content/docs/api/core/resolve-theme-from-css.md +++ b/docs/src/content/docs/api/core/resolve-theme-from-css.md @@ -8,7 +8,7 @@ description: "Read --sheetwrite- CSS custom properties into a partial theme." Read `--sheetwrite-*` CSS custom properties into a partial theme. ## Declaration diff --git a/docs/src/content/docs/guides/interaction.md b/docs/src/content/docs/guides/interaction.md index 55b36115..9bf0991a 100644 --- a/docs/src/content/docs/guides/interaction.md +++ b/docs/src/content/docs/guides/interaction.md @@ -129,9 +129,10 @@ 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` 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. +`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. diff --git a/docs/src/generated/docs-contract.json b/docs/src/generated/docs-contract.json index 1b0b7b22..a0ab0dbf 100644 --- a/docs/src/generated/docs-contract.json +++ b/docs/src/generated/docs-contract.json @@ -25,7 +25,7 @@ "entryPointCount": 20, "exportCount": 484, "symbolPageCount": 484, - "apiSha256": "b830162c29ff8e7b08ca0d184c0e03d49074ed44ae687f65ec6254bcfbcac5c1", + "apiSha256": "a5fc7ac2011cb4d7842a6f686f28a547a792d38e8e5bfd0a2bfddf0cafdfc7c7", "compatibilitySha256": "0e2d1e8db45a097ab437bdcb85591c8fddabd60cd6dcfcf2bc2098588e2966f6", "formulaContractSha256": "daec72c46c939824c564fe3fe0758858f67c298a7c888f18fd4a060f467bd3b0" } diff --git a/docs/src/generated/public-api.json b/docs/src/generated/public-api.json index 95636c4e..f97a6e04 100644 --- a/docs/src/generated/public-api.json +++ b/docs/src/generated/public-api.json @@ -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": [] @@ -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": [] @@ -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": [] @@ -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": [] @@ -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": [] diff --git a/packages/core/test/input.test.ts b/packages/core/test/input.test.ts index 72dd0381..f2949b98 100644 --- a/packages/core/test/input.test.ts +++ b/packages/core/test/input.test.ts @@ -325,6 +325,66 @@ describe("pointer input: drag lifecycle", () => { grid.destroy(); store.dispose(); }); + it("abandons a captured selection when switching sheets", () => { + const { grid, store, scroller, workbook, capture } = makeGrid(); + const sheet = makeWorkbook(10).sheets[0]; + if (!sheet) throw new Error("second sheet missing"); + sheet.id = "s2"; + sheet.name = "Other sheet"; + grid.applyTransaction({ + patches: [{ op: "addSheet", sheet: { ...sheet, order: 1, cells: [] } }], + }); + scroller.dispatchEvent(pointer("pointerdown", cellPoint(0, 0, workbook))); + grid.setActiveSheet("s2"); + const selection = grid.getSelection(); + scroller.dispatchEvent(pointer("pointermove", cellPoint(2, 1, workbook))); + scroller.dispatchEvent(pointer("pointerup", cellPoint(2, 1, workbook))); + expect(grid.getSelection()).toEqual(selection); + expect(capture.released).toEqual([1]); + grid.destroy(); + store.dispose(); + }); + + for (const axis of ["column", "row"] as const) { + it(`restores ${axis} preview geometry when structural admission rejects release`, () => { + const { grid, store, scroller } = makeGrid(); + grid.setProtectedRange({ + id: "deny-resize", + range: { sheet: "s1", start: { row: 0, col: 0 }, end: { row: 9, col: 2 } }, + }); + grid.setProtectionResolver(() => "deny"); + const original = axis === "column" ? 160 : DEFAULT_THEME.rowHeight; + const start = + axis === "column" + ? { clientX: DEFAULT_THEME.rowHeaderWidth + original, clientY: 10 } + : { clientX: 10, clientY: DEFAULT_THEME.headerHeight + original }; + const end = + axis === "column" + ? { ...start, clientX: start.clientX + 40 } + : { ...start, clientY: start.clientY + 40 }; + const rejections: string[] = []; + grid.on("mutation-rejected", ({ issues }) => + rejections.push(...issues.map((issue) => issue.kind)), + ); + scroller.dispatchEvent(pointer("pointerdown", start)); + scroller.dispatchEvent(pointer("pointermove", end)); + scroller.dispatchEvent(pointer("pointerup", end)); + expect(rejections).toEqual(["protection"]); + const sheet = store.getWorkbook().sheets[0]; + expect( + axis === "column" + ? sheet?.columns[0]?.width + : (sheet?.rowHeights?.get(0) ?? DEFAULT_THEME.rowHeight), + ).toBe(original); + const address = grid.getCellAtPoint( + axis === "column" ? start.clientX + 10 : DEFAULT_THEME.rowHeaderWidth + 20, + axis === "row" ? start.clientY + 10 : DEFAULT_THEME.headerHeight + 10, + ); + expect(axis === "column" ? address?.col : address?.row).toBe(1); + grid.destroy(); + store.dispose(); + }); + } it("keeps the source selection when fill admission rejects its values", () => { const { grid, store, scroller, workbook } = makeGrid(); diff --git a/scripts/public-api-baseline.json b/scripts/public-api-baseline.json index c6c7f621..5178aa47 100644 --- a/scripts/public-api-baseline.json +++ b/scripts/public-api-baseline.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "manifestFormatVersion": 2, - "sha256": "b830162c29ff8e7b08ca0d184c0e03d49074ed44ae687f65ec6254bcfbcac5c1", + "sha256": "a5fc7ac2011cb4d7842a6f686f28a547a792d38e8e5bfd0a2bfddf0cafdfc7c7", "intentionalExports": [ { "package": "@sheetwrite/core", @@ -423,7 +423,10 @@ { "package": "@sheetwrite/core", "entryPoint": "./worker", - "exports": ["WorkerAcknowledgement", "createWorkerMessageHandler"] + "exports": [ + "WorkerAcknowledgement", + "createWorkerMessageHandler" + ] }, { "package": "@sheetwrite/formulas", @@ -556,7 +559,9 @@ { "package": "@sheetwrite/xlsx", "entryPoint": "./register", - "exports": ["registerXlsxBackends"] + "exports": [ + "registerXlsxBackends" + ] } ] } diff --git a/test/browser/examples.spec.ts b/test/browser/examples.spec.ts index e2ebe6b9..75987318 100644 --- a/test/browser/examples.spec.ts +++ b/test/browser/examples.spec.ts @@ -323,3 +323,64 @@ test("validation dropdown and checkbox editors are keyboard and ARIA operable", ) .toBe(true); }); + +test("pointer selection and row/column resize keep preview out of history until release", async ({ + page, +}) => { + await page.goto(urlOf("vue")); + await page.waitForSelector(".sheetwrite canvas", { state: "attached", timeout: 15_000 }); + await page.evaluate(() => { + const grid = window.__sheetwriteVueWorkbench?.grid; + if (!grid) throw new Error("Vue grid unavailable"); + grid.setActiveSheet("orders"); + // This scenario isolates pointer history; policy rejection has its own + // interface regressions and the workbench starts in a restricted role. + grid.setProtectionResolver(() => "allow"); + grid.scrollToCell({ sheet: "orders", row: 0, col: 0 }); + }); + const scroller = page.locator(".sw-demo-grid .sheetwrite-scroller"); + const bounds = await scroller.boundingBox(); + if (!bounds) throw new Error("Grid scroller bounds unavailable"); + const gutter = 48; + const header = 32; + const rowHeight = 30; + await page.mouse.move(bounds.x + gutter + 20, bounds.y + header + 10); + await page.mouse.down(); + await page.mouse.move(bounds.x + gutter + 20, bounds.y + header + rowHeight * 2 + 10); + await page.mouse.up(); + expect( + await page.evaluate(() => window.__sheetwriteVueWorkbench?.grid.getSelection()), + ).toMatchObject({ + kind: "range", + range: { start: { row: 0, col: 0 }, end: { row: 2, col: 0 } }, + }); + + for (const axis of ["column", "row"] as const) { + const original = await page.evaluate(() => { + const grid = window.__sheetwriteVueWorkbench?.grid; + if (!grid) throw new Error("Vue grid unavailable"); + return grid.exportSnapshot(); + }); + const sheet = original.sheets.find((candidate) => candidate.id === "orders"); + const column = sheet?.columns[0]; + if (!column) throw new Error("Orders column unavailable"); + const x = bounds.x + (axis === "column" ? gutter + column.width : 10); + const y = bounds.y + (axis === "row" ? header + rowHeight : 10); + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + (axis === "column" ? 40 : 0), y + (axis === "row" ? 40 : 0), { + steps: 4, + }); + expect( + await page.evaluate(() => window.__sheetwriteVueWorkbench?.grid.exportSnapshot()), + ).toEqual(original); + await page.mouse.up(); + expect( + await page.evaluate(() => window.__sheetwriteVueWorkbench?.grid.exportSnapshot()), + ).not.toEqual(original); + await page.evaluate(() => window.__sheetwriteVueWorkbench?.grid.undo()); + expect( + await page.evaluate(() => window.__sheetwriteVueWorkbench?.grid.exportSnapshot()), + ).toEqual(original); + } +}); From a488f5f5676b32f0d60c72d9294508b2a37b1f44 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Fri, 9 Oct 2026 00:36:08 +0700 Subject: [PATCH 3/7] docs(bench): document interaction session comparison protocol --- bench/README.md | 15 +++++++++++++++ scripts/public-api-baseline.json | 9 ++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/bench/README.md b/bench/README.md index e87a367b..8f8c0396 100644 --- a/bench/README.md +++ b/bench/README.md @@ -273,6 +273,21 @@ 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 nine raw samples in milliseconds per operation after one warmup, +with one hundred operations per sample. Fixture restoration is outside the +timed interval; resize includes admission and history, but not undo. +These are imperative-path timings, not browser paint or input-to-frame latency. +For comparisons, build each checkout's packages first, run an A/A control, then +alternate five ABBA rounds on the same CPU under an exclusive 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, diff --git a/scripts/public-api-baseline.json b/scripts/public-api-baseline.json index 5178aa47..6f41d528 100644 --- a/scripts/public-api-baseline.json +++ b/scripts/public-api-baseline.json @@ -423,10 +423,7 @@ { "package": "@sheetwrite/core", "entryPoint": "./worker", - "exports": [ - "WorkerAcknowledgement", - "createWorkerMessageHandler" - ] + "exports": ["WorkerAcknowledgement", "createWorkerMessageHandler"] }, { "package": "@sheetwrite/formulas", @@ -559,9 +556,7 @@ { "package": "@sheetwrite/xlsx", "entryPoint": "./register", - "exports": [ - "registerXlsxBackends" - ] + "exports": ["registerXlsxBackends"] } ] } From 1de5121310c9efffe536f701e1af47eef1d7ebf0 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Fri, 9 Oct 2026 01:02:51 +0700 Subject: [PATCH 4/7] perf(core): reuse resize geometry and avoid unobserved selection snapshots --- bench/README.md | 9 ++++--- bench/src/interaction-sessions-bench.ts | 24 ++++++++++++++----- packages/core/src/column-index.ts | 15 +++++++++++- .../core/src/geometry-layout-controller.ts | 19 ++++++++------- packages/core/src/grid.ts | 6 +++-- packages/core/src/interaction-sessions.ts | 7 +----- packages/core/src/selection.ts | 9 +++++++ 7 files changed, 63 insertions(+), 26 deletions(-) diff --git a/bench/README.md b/bench/README.md index 8f8c0396..39d30bdf 100644 --- a/bench/README.md +++ b/bench/README.md @@ -277,9 +277,12 @@ baseline build. `bench:release` refreshes both captures, and 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 nine raw samples in milliseconds per operation after one warmup, -with one hundred operations per sample. Fixture restoration is outside the -timed interval; resize includes admission and history, but not undo. +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 an A/A control, then alternate five ABBA rounds on the same CPU under an exclusive lock: diff --git a/bench/src/interaction-sessions-bench.ts b/bench/src/interaction-sessions-bench.ts index 669f4ad7..8964b8fb 100644 --- a/bench/src/interaction-sessions-bench.ts +++ b/bench/src/interaction-sessions-bench.ts @@ -3,8 +3,11 @@ import { createGrid, DEFAULT_THEME, initSheetwrite, type Workbook } from "@sheet import { installCanvasTestStubs } from "@sheetwrite/core/testing"; const ROWS = 100; -const ITERATIONS = 100; -const SAMPLES = 9; +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(); @@ -53,9 +56,16 @@ try { "aggregate", ] as const) { const timings: number[] = []; - for (let sample = -1; sample < SAMPLES; sample++) { + 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++) { + for (let iteration = 0; iteration < iterations; iteration++) { const started = performance.now(); if (workload === "column-resize" || workload === "row-resize") { const isColumn = workload === "column-resize"; @@ -94,11 +104,13 @@ try { else if (workload === "row-resize") grid.setRowHeight(0, DEFAULT_THEME.rowHeight); else if (workload === "edit") grid.undo(); } - if (sample >= 0) timings.push(elapsed / ITERATIONS); + if (sample >= 0) timings.push(elapsed / iterations); } samples[workload] = timings; } - process.stdout.write(`${JSON.stringify({ unit: "ms/operation", samples })}\n`); + 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(); diff --git a/packages/core/src/column-index.ts b/packages/core/src/column-index.ts index fc0a8d58..cb094c77 100644 --- a/packages/core/src/column-index.ts +++ b/packages/core/src/column-index.ts @@ -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) { const count = columns.length; @@ -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); diff --git a/packages/core/src/geometry-layout-controller.ts b/packages/core/src/geometry-layout-controller.ts index 0d883067..12374a00 100644 --- a/packages/core/src/geometry-layout-controller.ts +++ b/packages/core/src/geometry-layout-controller.ts @@ -57,17 +57,19 @@ export class GeometryLayoutController { private rowTopsView = this.rowTopsScratch; private rowHeightsView = this.rowHeightsScratch; private rowGeometryLength = 0; - private columnPreview: { column: number; width: number } | null = null; + private previewColumn = -1; + private previewWidth = 0; private hasRowPreview = false; previewColumnWidth(column: number, width: number): void { - this.columnPreview = { column, width }; - this.rebuildColumns(); + this.previewColumn = column; + this.previewWidth = width; + this.columnIndex.setWidth(column, width * this.options.zoom()); } columnWidth(column: number): number { - return this.columnPreview?.column === column - ? this.columnPreview.width + return this.previewColumn === column + ? this.previewWidth : (this.options.sheet().columns[column]?.width ?? 0); } @@ -77,9 +79,10 @@ export class GeometryLayoutController { } clearResizePreview(): void { - if (this.columnPreview) { - this.columnPreview = null; - this.rebuildColumns(); + if (this.previewColumn !== -1) { + const column = this.previewColumn; + this.previewColumn = -1; + this.columnIndex.setWidth(column, this.columnWidth(column) * this.options.zoom()); } if (this.hasRowPreview) { this.hasRowPreview = false; diff --git a/packages/core/src/grid.ts b/packages/core/src/grid.ts index 2b56f5fc..edb0ca83 100644 --- a/packages/core/src/grid.ts +++ b/packages/core/src/grid.ts @@ -1350,8 +1350,10 @@ export class GridImpl implements Grid { private emitSelection(): void { this.ariaMirror.bumpVersion(); - const sel = this.getSelection(); - for (const fn of this.listeners.selection) fn({ selection: sel }); + if (this.listeners.selection.size > 0) { + const selection = this.getSelection(); + for (const listener of this.listeners.selection) listener({ selection }); + } this.emitCommandStateChange(); } diff --git a/packages/core/src/interaction-sessions.ts b/packages/core/src/interaction-sessions.ts index 51caf08c..1bac8584 100644 --- a/packages/core/src/interaction-sessions.ts +++ b/packages/core/src/interaction-sessions.ts @@ -289,12 +289,7 @@ export class InteractionSessions { const generation = ++this.generation; this.editingAddress = dataAddr; const selection = this.deps.selection(); - const focus = selection.focusCell; - const selectionChanged = - !focus || - focus.row !== editCell.row || - focus.col !== editCell.col || - selection.toSelection(dataAddr.sheet)?.kind !== "cell"; + const selectionChanged = !selection.isCell(editCell.row, editCell.col); selection.selectCell(editCell.row, editCell.col); this.deps.scheduleRender(); if (selectionChanged) this.deps.emitSelection(); diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index c4cd2bd9..8fe67963 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -71,6 +71,15 @@ export class SelectionModel { return this.focus; } + /** Whether the complete selection is exactly this cell, without a snapshot allocation. */ + isCell(row: number, col: number): boolean { + if (this.regions.length !== 1) return false; + const region = this.regions[0]; + if (!region || region.type !== "range") return false; + const { rect } = region; + return rect.r0 === row && rect.r1 === row && rect.c0 === col && rect.c1 === col; + } + selectCell(row: number, col: number, additive = false): void { const region: Region = { rect: { r0: row, c0: col, r1: row, c1: col }, type: "range" }; if (additive) this.regions.push(region); From ddef7ac6c174c22fea429dd2bdd384da97b86105 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Fri, 9 Oct 2026 01:15:44 +0700 Subject: [PATCH 5/7] style(core): simplify the single-cell selection guard --- packages/core/src/selection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index 8fe67963..fb4b5fe3 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -75,7 +75,7 @@ export class SelectionModel { isCell(row: number, col: number): boolean { if (this.regions.length !== 1) return false; const region = this.regions[0]; - if (!region || region.type !== "range") return false; + if (region?.type !== "range") return false; const { rect } = region; return rect.r0 === row && rect.r1 === row && rect.c0 === col && rect.c1 === col; } From 1a47014fd73a465b72afc32ed0a90c4a53ed5e42 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Fri, 9 Oct 2026 01:17:05 +0700 Subject: [PATCH 6/7] docs(bench): require tight paired interaction noise controls --- bench/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bench/README.md b/bench/README.md index 39d30bdf..3991779b 100644 --- a/bench/README.md +++ b/bench/README.md @@ -284,8 +284,12 @@ and fixture restoration are outside the timed interval; resize includes admissio 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 an A/A control, then -alternate five ABBA rounds on the same CPU under an exclusive lock: +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 From 876ff8d680f6d0fbfd850acd82671c9876f333c7 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Fri, 9 Oct 2026 01:37:02 +0700 Subject: [PATCH 7/7] perf(core): allocate edit notifications only for subscribed consumers --- packages/core/src/grid.ts | 4 +--- packages/core/src/interaction-sessions.ts | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/core/src/grid.ts b/packages/core/src/grid.ts index edb0ca83..1b436004 100644 --- a/packages/core/src/grid.ts +++ b/packages/core/src/grid.ts @@ -690,9 +690,7 @@ export class GridImpl implements Grid { highlightCells: (ranges) => this.highlightCells(ranges), columnHeader: (col) => this.columnHeader(col), toViewRow: (row) => this.toViewRow(row), - notify: (event, payload) => { - for (const listener of this.listeners[event]) listener(payload); - }, + editListeners: this.listeners, findBar: () => this.findBar, store: this.store, loadable: this.loadable, diff --git a/packages/core/src/interaction-sessions.ts b/packages/core/src/interaction-sessions.ts index 1bac8584..75709288 100644 --- a/packages/core/src/interaction-sessions.ts +++ b/packages/core/src/interaction-sessions.ts @@ -29,7 +29,9 @@ interface InteractionDependencies { highlightCells: (ranges: HighlightRange[] | null) => void; columnHeader: (col: number) => string; toViewRow: (dataRow: number) => number | null; - notify: (event: K, payload: GridEvents[K]) => void; + editListeners: { + readonly [K in "edit-begin" | "edit-commit"]: ReadonlySet<(payload: GridEvents[K]) => void>; + }; findBar: () => FindBar | null; store: Store; loadable: SheetwriteStore | null; @@ -294,9 +296,9 @@ export class InteractionSessions { this.deps.scheduleRender(); if (selectionChanged) this.deps.emitSelection(); if (generation !== this.generation || this.deps.readOnly()) return; - this.deps.notify("edit-begin", { - addr: { sheet: dataAddr.sheet, row: editCell.row, col: editCell.col }, - }); + for (const listener of this.deps.editListeners["edit-begin"]) { + listener({ addr: { sheet: dataAddr.sheet, row: editCell.row, col: editCell.col } }); + } // edit-begin remains a before-open notification. Reentrant callbacks can // replace or invalidate this request, but cannot reopen its stale target. if (generation !== this.generation || this.deps.readOnly()) return; @@ -395,10 +397,12 @@ export class InteractionSessions { ); if (outcome.status === "applied") { - this.deps.notify("edit-commit", { - addr: { sheet: address.sheet, row: viewRow, col: address.col }, - value, - }); + for (const listener of this.deps.editListeners["edit-commit"]) { + listener({ + addr: { sheet: address.sheet, row: viewRow, col: address.col }, + value, + }); + } this.moveAfterCommit(viewRow, address.col, navigate); } this.deps.host.focus();