diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d2f459c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Performance benchmark suite with `BenchmarkRunner`, `measureMultiRun`, `computeStats`, `measureThroughput` utilities +- `npm run benchmark` script for running performance benchmarks +- Baseline metrics for 6 categories across 1K/10K/100K row datasets +- `TextMeasureCache.measureEmHeight()` for font em-height measurement +- `TextMeasureCache.getWrappedLines()` for word-boundary text wrapping with character-level fallback +- `TextMeasureCache.countWrappedLines()` and `measureWrappedHeight()` for wrapped text height computation +- `ColumnDef.wrapText` option to enable word-wrap rendering per column +- Multi-line wrapped text rendering in `CellTextLayer` when `wrapText` is enabled +- E2E Playwright test for word-wrap visual verification (`tests/e2e/word-wrap.test.ts`) +- `LINE_HEIGHT_MULTIPLIER` shared constant (1.2) for consistent line-height across text rendering +- `RowStore` auto/manual height separation: `setAutoHeight()`, `setAutoHeightsBatch()`, `clearAutoHeight()`, `clearAllAutoHeights()`, `isManual()`, `isAuto()` +- `LayoutEngine.setRowHeightsBatch()` for O(n) batch row height updates (vs O(n²) for individual calls) +- `CellTypeRenderer.measureHeight()` optional method for custom cell type height measurement +- `RenderLayer.measureHeights()` optional method for bulk row height measurement by render layers +- `CellTextLayer.measureHeights()` implementation for measuring wrapped text row heights +- `SpreadsheetEngine.setAutoRowHeights()` for batch auto-measured height updates with manual-always-wins priority +- `AutoRowSizeManager` class: orchestrates automatic row height measurement with viewport-first sync strategy and off-screen async measurement via `requestIdleCallback` +- `SpreadsheetEngineConfig.autoRowHeight` option to enable auto row height (`boolean` or `AutoRowSizeConfig` with `batchSize`, `minRowHeight`, `cellPadding`) +- `SpreadsheetEngine.getAutoRowSizeManager()` accessor for the auto row size manager instance +- `AutoRowSizeManager` dirty tracking: `markDirtyRows()`, `markAllDirty()`, `clearDirty()`, `hasDirtyRows`, `isRowDirty()`, `isAllDirty`, `dirtyRowCount` +- `AutoRowSizeManager.startDirtyMeasurement()` for efficient re-measurement of only dirty rows +- Scroll compensation in `setAutoRowHeights()`: adjusts scroll position when row heights change above viewport to prevent visual jumping +- `SpreadsheetEngine.markAutoRowHeightDirty()` and `markAllAutoRowHeightDirty()` public API for triggering dirty re-measurement +- Auto row height integration with `setCell()`: cell value changes mark the row dirty for re-measurement +- Auto row height integration with column resize: resizing a wrap-enabled column triggers full re-measurement +- `ColumnStretchManager` class: distributes available container width across columns with two modes ('all' and 'last') +- `SpreadsheetEngineConfig.stretchColumns` option: `'all'` distributes extra space evenly among stretchable columns, `'last'` gives remaining space to the last visible column +- `LayoutEngine.setColumnWidthsBatch()` for efficient batch column width updates (single recomputation pass) +- Column stretch recalculation on container resize via existing `ResizeObserver` +- Manual column resize exclusion: manually resized columns are excluded from stretch distribution in 'all' mode +- Frozen column handling: frozen columns are excluded from stretch distribution +- `DatePickerOverlay` class: pure-DOM calendar widget for date-type cell editing, positioned below target cell +- `CellEditor` interface and `CellEditorRegistry` for extensible cell editing with type-based editor resolution +- `DatePickerEditor` adapter wrapping `DatePickerOverlay` as a `CellEditor` implementation +- `DateTimeEditor` class: combined date+time picker (calendar + hour/minute spin controls) for `datetime` columns, commits ISO `YYYY-MM-DDTHH:mm` format +- `'datetime'` added to `CellType` union type +- `dateTimePicker` section added to `SpreadsheetLocale` interface (hour, minute, now, ariaLabel) with EN and RU translations +- Date picker opens on double-click, F2, or type-to-edit for columns with `type: 'date'` +- Calendar month/year navigation, day grid with keyboard navigation (arrows, Enter, Escape, Tab) +- Date selection commits value in YYYY-MM-DD format via command system (undo/redo supported) +- Grid scroll and outside click close the date picker +- Today button for quick date selection +- ARIA attributes on date picker overlay (role=dialog, aria-label) +- `ContextMenuItem.submenu` optional field for recursive nested submenus +- Submenu chevron indicator (`▸`) on items with submenus +- Submenu opens on hover (200ms delay) or ArrowRight key, closes on ArrowLeft or Escape +- Nested submenus supported recursively to arbitrary depth +- Empty menu prevention: parent items with all invisible submenu children are hidden +- Keyboard navigation within submenus (ArrowUp/Down), Escape closes one level at a time +- Interactive demo page with sidebar layout showcasing all library features +- Demo sidebar sections: Display (theme, stretch, auto row height), Data (1M rows, progressive load, streaming), Views (grouping, pivot), Import/Export (Excel, print), Collaboration +- Demo feature badges showing active engine configuration +- Demo context menu items with Insert and Format submenus for submenu showcase diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68da451..37485ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,7 @@ npm run typecheck # TypeScript check npm run lint # ESLint npm run test # Unit tests (vitest) npm run test:e2e # E2E tests (playwright) +npm run benchmark # Performance benchmarks (6 metrics × 3 datasets) ``` ## Project Structure diff --git a/README.md b/README.md index 984ff3d..5c64275 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,13 @@ Official wrappers for **React**, **Vue 3**, and **Angular**. Embeddable widget b | Category | Features | |----------|----------| -| **Rendering** | Canvas 2D multi-layer pipeline, 100K+ rows at 60fps, progressive loading | -| **Editing** | Inline editor, undo/redo (100 steps), clipboard (TSV + HTML), autofill | -| **Data** | Sort (multi-column), filter (14 operators), frozen panes, merged cells | -| **Plugins** | Formulas, conditional formatting, collaboration (OT), Excel I/O, context menu | +| **Rendering** | Canvas 2D multi-layer pipeline, 100K+ rows at 60fps, progressive loading, auto row height with text wrapping | +| **Editing** | Inline editor, date picker, datetime picker, custom cell editors via CellEditorRegistry, undo/redo (100 steps), clipboard (TSV + HTML), autofill | +| **Layout** | Column stretch (`'all'` / `'last'`), frozen panes, auto row sizing, container resize observation | +| **Data** | Sort (multi-column), filter (14 operators), merged cells | +| **Plugins** | Formulas, conditional formatting, collaboration (OT), Excel I/O, context menu with submenus | | **Theming** | Light/dark built-in, fully customizable via `WitTheme` | +| **Localization** | Built-in English and Russian locales, custom locale packs, runtime switching | | **Accessibility** | WCAG 2.1 AA: role=grid, aria-live, keyboard-only, print support | | **Frameworks** | React, Vue 3, Angular, vanilla JS widget (<36KB gzip) | @@ -96,6 +98,48 @@ Full documentation with interactive demos at **[spreadsheet.witqq.dev](https://s - [API Reference](https://spreadsheet.witqq.dev/api/wit-engine/) — WitEngine, types, cell types - [Migration from Handsontable](https://spreadsheet.witqq.dev/guides/migration-from-handsontable/) — Side-by-side API mapping +## 🌐 Localization + +Built-in English and Russian locale packs. Create custom locales for any language: + +```tsx +import { WitTable } from '@witqq/spreadsheet-react'; +import { ruLocale } from '@witqq/spreadsheet'; + + +``` + +Custom partial locales are merged over English defaults: + +```ts +import { resolveLocale } from '@witqq/spreadsheet'; + +const myLocale = resolveLocale({ + contextMenu: { cut: 'Cortar', copy: 'Copiar', paste: 'Pegar' }, + formatLocale: 'es-ES', +}); +``` + +Locale covers: context menu labels, date picker, filter panel, accessibility announcements, aggregate labels, print notices, and number/date formatting. + +## 🔌 Custom Cell Editors + +Register custom overlay editors for specific column types via `CellEditorRegistry`: + +```ts +import type { CellEditor } from '@witqq/spreadsheet'; + +class ColorPickerEditor implements CellEditor { + readonly id = 'color-picker'; + // ... implement open(), close(), setTheme(), setLocale(), destroy() +} + +const engine = new SpreadsheetEngine({ columns, data }); +engine.registerCellEditor(new ColorPickerEditor(), 'color'); +``` + +Built-in editors: `DatePickerEditor` (registered for `type: 'date'`), `DateTimeEditor` (registered for `type: 'datetime'` — calendar + hour/minute controls, commits ISO `YYYY-MM-DDTHH:mm`), `InlineEditor` (textarea fallback). The registry resolves editors by priority — higher priority wins when multiple editors match. + ## 🛠 Development ```bash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..73db203 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,688 @@ +# Architecture + +## Overview + +`@witqq/spreadsheet` is a canvas-based spreadsheet engine. It renders everything on a single `` element using a layered rendering pipeline. The engine is framework-agnostic — framework wrappers (React, Vue, Angular) are thin adapters. + +## Monorepo Packages + +| Package | npm name | Purpose | +|---------|----------|---------| +| `packages/core` | `@witqq/spreadsheet` | Engine: rendering, data, editing, selection, commands. Zero external deps. | +| `packages/react` | `@witqq/spreadsheet-react` | React wrapper (`` component) | +| `packages/vue` | `@witqq/spreadsheet-vue` | Vue wrapper | +| `packages/angular` | `@witqq/spreadsheet-angular` | Angular wrapper | +| `packages/widget` | `@witqq/spreadsheet-widget` | Single-file IIFE/UMD bundle for embedding | +| `packages/plugins` | `@witqq/spreadsheet-plugins` | Official plugins (formulas, collaboration, context menu, etc.) | +| `packages/demo` | `@witqq/spreadsheet-demo` | Demo app (React + Vite, private) | +| `packages/server` | — | Collaboration WebSocket server | +| `packages/site` | — | Documentation site (Astro + Starlight) | + +## Core Engine Architecture + +### Entry Point + +`SpreadsheetEngine` (`packages/core/src/engine/spreadsheet-engine.ts`) is the main class. It orchestrates ~30 subsystems and uses two-phase initialization: + +- **Constructor** (headless) — creates CellStore, ColStore, RowStore, EventBus, CommandManager, LayoutEngine, and other non-DOM subsystems +- **mount()** — creates DOM-dependent subsystems: CanvasManager, ScrollManager, RenderPipeline, InlineEditor, SelectionManager + +--- + +## Rendering Pipeline + +*Source: `packages/core/src/renderer/`* + +### RenderScheduler + +*Source: `render-scheduler.ts`* + +Classic RAF coalescing with a boolean dirty flag. When `requestRender()` is called: + +1. If `dirty` is already `true`, return immediately (coalescing — any number of calls per frame collapse to one) +2. Set `dirty = true`, schedule a single `requestAnimationFrame` +3. Inside the RAF callback: reset `dirty = false`, invoke `renderCallback()` + +The coalescing is O(1) — the first `requestRender()` in a frame wins the RAF slot, all subsequent calls are no-ops. No queue, no debounce timer, no priority system. + +### RenderPipeline + +*Source: `render-pipeline.ts`* + +Orchestrates layer rendering. Layers are stored in an ordered array. + +**Non-frozen render cycle:** +1. `ctx.clearRect(0, 0, canvasWidth, canvasHeight)` — wipe entire canvas +2. Iterate layers in order, call `layer.render(renderContext)` with viewport/scroll/theme/geometry + +**Default layer order** (from GridRenderer, `grid-renderer.ts`): +1. BackgroundLayer — cell backgrounds +2. CellTextLayer — cell values, formatted text +3. CellStatusLayer — validation icons, status indicators +4. EmptyStateLayer — placeholder when no data +5. GridLinesLayer — grid borders (if enabled) +6. HeaderLayer — column/row headers +7. RowNumberLayer — row numbers (if enabled) +8. SelectionOverlayLayer — selection highlight, active cell (always last) + +Plugin layers are inserted before GridLinesLayer (or before SelectionOverlayLayer if no grid lines), ensuring plugins render above cell text but below the grid overlay. + +### Frozen Pane Rendering + +When frozen rows or columns are configured, the pipeline switches to a 4-region compositing model: + +| Region | Clip Area | Scrolls With | Cache Strategy | +|--------|-----------|-------------|----------------| +| **corner** | Top-left intersection | Neither axis | Rendered once, cached as ImageData | +| **frozenRow** | Top strip, scrollable columns | Horizontal scroll only | Cached, re-rendered when scrollX changes | +| **frozenCol** | Left strip, scrollable rows | Vertical scroll only | Cached, re-rendered when scrollY changes | +| **main** | Remaining area | Both axes | Re-rendered every frame | + +For each layer × each region: `ctx.save()` → clip to region rect → `layer.render()` → `ctx.restore()`. + +**ImageData caching:** After rendering, regions are captured via `ctx.getImageData()` at DPR-scaled coordinates. On subsequent frames, unchanged regions are restored via `ctx.putImageData()` instead of re-rendering all layers. Five caches are maintained: corner, frozenRow, frozenCol, frozenRowHeaders, frozenColRowNumbers. + +Cache invalidation occurs on theme change, data change, or structural change (column/row add/remove). + +### Key Layer Implementations + +**BackgroundLayer** (`background-layer.ts`) — fills entire canvas with `theme.colors.background`. Always the first layer. + +**CellTextLayer** (`cell-text-layer.ts`) — the most complex layer: +1. Clips to cell area (excluding headers and row number gutter) +2. Collects visible cells, handling merge anchors that may be off-screen +3. For each cell, resolves the cell type from column definition, cell data, or auto-detection +4. Custom renderer path: delegates to `renderer.render()` if the cell type provides one +5. Wrap text path: uses `TextMeasureCache.getWrappedLines()` for word breaking, draws each line with proper alignment and vertical centering +6. Single-line path: uses `TextMeasureCache.truncateText()` with ellipsis, draws via `ctx.fillText()` +7. Also provides `measureHeights()` for the auto row-size system + +**SelectionOverlayLayer** (`selection-overlay-layer.ts`) — draws selection fills (translucent rectangles) and borders for each range, plus the active cell border with merge-awareness. + +### CanvasManager + +*Source: `canvas-manager.ts`* + +Manages the `` element with DPI-aware scaling: +- Sets physical canvas size to `cssWidth × dpr` by `cssHeight × dpr` +- Sets CSS display size to match container +- Applies `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` so all drawing uses CSS pixel coordinates while rendering at native resolution + +Detects browser zoom (DPR changes) via `matchMedia(\`(resolution: ${dpr}dppx)\`)` and re-syncs canvas size on change. + +--- + +## Data Model + +*Source: `packages/core/src/model/`* + +### CellStore + +*Source: `cell-store.ts`* + +Sparse map storage: `Map` keyed by `"row:col"` string (e.g. `"5:3"`). Only non-empty cells are stored. + +**Version tracking:** `_version` counter incremented on every `set()`, `delete()`, `clear()`. Bulk methods (`bulkLoadChunk`, `bulkGenerate`) increment version once at the end for efficiency. + +**CellData interface** (`types/interfaces.ts`): +- `value: CellValue` — raw value (`string | number | boolean | Date | null`) +- `displayValue?: string` — formatted override +- `formula?: string` — e.g. `"=SUM(A1:A10)"` +- `style?: CellStyleRef` — `{ ref: string, style: CellStyle }` +- `type?: CellType` — rendering/editing behavior +- `metadata?: CellMetadata` — status, errors, links, comments + +All CellData fields are `readonly`. + +**CellType union:** `'string' | 'number' | 'boolean' | 'date' | 'datetime' | 'select' | 'dynamicSelect' | 'formula' | 'link' | 'image' | 'progressBar' | 'rating' | 'badge' | 'custom'` + +**Key methods:** `get/set/has/delete` (O(1)), `setValue()` (merges with existing data), `setMetadata()`, `iterateRange()` (generator over bounds), `bulkLoad()` / `bulkGenerate()` (progressive loading). + +CellStore has no row-shift logic — row insertion/deletion is handled externally by RowStore. + +### ColStore + +*Source: `col-store.ts`* + +Ordered array of `ColumnDef[]` plus `Set` for hidden columns. Each ColumnDef contains: `key`, `title`, `width`, `minWidth?`, `maxWidth?`, `type?: CellType`, `frozen?`, `sortable?`, `filterable?`, `editable?`, `resizable?`, `hidden?`, `wrapText?`, `validation?`. + +Version-tracked like CellStore. + +### RowStore + +*Source: `row-store.ts`* + +Three sparse collections: +- `manualHeightOverrides: Map` — user drag-resized heights +- `autoHeightOverrides: Map` — auto-measured heights +- `hiddenRows: Set` + +**Height resolution priority:** hidden → 0, manual override → auto override → defaultHeight. + +`setAutoHeightsBatch()` only updates rows without manual overrides and uses epsilon comparison (0.01) to avoid unnecessary version bumps. + +`shiftRowsUp(deletedRow)` rebuilds all three collections, shifting indices down by 1. + +### DataView + +*Source: `dataview/data-view.ts`* + +Provides logical (visible/sorted/filtered) ↔ physical (CellStore) row index mapping. + +**Passthrough mode:** When no sort or filter is active, mapping is `null` — all lookups return identity. Zero overhead. + +**Active mapping:** `_mapping: number[]` where `mapping[logicalRow] = physicalRow`, plus `_reverseMapping: Map` for physical → logical lookups. + +`recompute(physicalIndices[])` rebuilds mapping from sorted/filtered index array. `reset()` returns to passthrough. + +--- + +## Layout Engine + +*Source: `packages/core/src/renderer/layout-engine.ts`* + +All position data stored in `Float64Array` typed arrays for cache-friendly memory access and 64-bit precision: + +| Array | Size | Purpose | +|-------|------|---------| +| `colPositions` | visibleCols + 1 | Cumulative x-offsets (prefix sum) | +| `colWidths` | visibleCols | Per-column widths | +| `rowPositions` | rowCount + 1 | Cumulative y-offsets (prefix sum) | +| `rowHeights` | rowCount | Per-row heights | + +### Construction + +Filters hidden columns, then builds prefix-sum arrays in a single linear pass. The "+1" entry stores the total dimension (e.g. `colPositions[n] = totalWidth`). + +### getCellRect — O(1) + +Pure array index lookups: +``` +x = rowNumberWidth + colPositions[colIndex] +y = headerHeight + rowPositions[rowIndex] +width = colWidths[colIndex] +height = rowHeights[rowIndex] +``` + +No computation beyond array indexing. Returns `{0,0,0,0}` for out-of-bounds inputs. + +### Hit-testing — O(log n) binary search + +`getRowAtY(y)` and `getColAtX(x)` both use binary search on the cumulative positions array to find which cell a pixel coordinate falls within. Returns -1 if outside content bounds. + +### Mutation + +- `setRowHeight(row, h)` — updates height, recomputes cumulative positions from that row onward: O(n−row) +- `setRowHeightsBatch(Map)` — applies all changes, single recompute pass from minimum changed index: much faster than N individual calls +- `setRowCount(count)` — if exceeding current capacity, reallocates new Float64Array instances and copies old data via `.set(subarray)` (memcpy-speed) + +### Frozen pane helpers + +`getFrozenRowsHeight(count)` and `getFrozenColsWidth(count)` are O(1) prefix-sum lookups. + +### ViewportManager + +*Source: `viewport-manager.ts`* + +Determines visible cells by binary-searching scroll position against LayoutEngine's cumulative arrays: + +1. `layout.getRowAtY(scrollY)` → first visible row +2. `layout.getRowAtY(scrollY + viewportHeight)` → last visible row +3. Same for columns + +Applies render buffers: 10 extra rows and 5 extra columns beyond the visible area for smooth scrolling. + +For frozen panes, computes 4 separate viewport ranges (corner, frozenRow, frozenCol, main), each with appropriate scroll offsets. + +### ScrollManager + +*Source: `scroll-manager.ts`* + +Uses a transparent absolutely-positioned `
` with `overflow: auto` overlaying the canvas. A spacer div inside is sized to the total content dimensions, creating native scrollbars without rendering content. The scroll listener uses `{ passive: true }` for performance and syncs `scrollLeft`/`scrollTop` to the rendering pipeline. + +--- + +## Editing + +*Source: `packages/core/src/editing/`* + +### Two-Tier Architecture + +The editing system has a two-tier design: + +1. **InlineEditor** — built-in textarea fallback for free-text editing (text, number) +2. **CellEditorRegistry** + **CellEditor** interface — dispatches to specialized overlay editors (date pickers, etc.) for specific column types + +The engine method `openCellEditor()` is the central dispatch point: it queries the registry first, falls back to InlineEditor if no match. + +### InlineEditor + +*Source: `editing/inline-editor.ts`* + +A `