@aiquants/spreadsheet 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/spreadsheet",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Excel-grade spreadsheet grid for React: multi-cell selection, TSV/HTML clipboard, in-place editing on top of @aiquants/virtualscroll",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -73,7 +73,7 @@ import { DEFAULT_HIGHLIGHT_COLORS, type DragFrameMode, type HighlightColors, typ
73
73
  import { useFillHandle } from "./overlay/useFillHandle.ts"
74
74
  import { PopoverLayer } from "./popover/PopoverLayer.tsx"
75
75
  import { type PopoverAnchorState, type PopoverContext, type PopoverController, type PopoverKind, type PopoverOpenOptions, type PopoverOverrides, usePopovers } from "./popover/usePopovers.ts"
76
- import { reduceActiveCycle, reduceSelectionExtend, reduceSelectionPress, type SelectionState, selectionStateEquals } from "./selection/selectionState.ts"
76
+ import { clampSelectionToGrid, reduceActiveCycle, reduceSelectionExtend, reduceSelectionPress, type SelectionState, selectionStateEquals } from "./selection/selectionState.ts"
77
77
  import { useAutoScroller } from "./selection/useAutoScroller.ts"
78
78
  import { type PointDragOwner, usePointDrag } from "./selection/usePointDrag.ts"
79
79
  import { useSelectionEngine } from "./selection/useSelectionEngine.ts"
@@ -217,10 +217,92 @@ export type SpreadsheetProps = {
217
217
  * 宣言の不在信号 (エンジン既定) で、本パッケージでの値フォールバックなし。
218
218
  */
219
219
  tapScrollCircleOptions?: ScrollBarTapCircleOptions
220
+ /**
221
+ * Row-axis overscan pass-through (the VirtualGrid `overscanRows` knob; LIVE prop): rows
222
+ * rendered beyond each vertical viewport edge. This slack is the only thing absorbing the
223
+ * engine's one-frame rendering-window lag during fast scrolling (the blank-band remedy).
224
+ * Overscan counts ROWS — hidden 0-height rows consume slots too — so the px slack per edge
225
+ * is overscan × row height only where rows are uniform and visible. Undefined ⇒ the engine
226
+ * default (3). The engine does not validate overscan (a negative value silently shrinks the
227
+ * rendering window), so THIS surface owns the fail-fast: a non-integer or negative value
228
+ * throws a RangeError at render. Cost multiplies with the column axis, and the engine
229
+ * TRUNCATES rather than bounds: a window beyond its per-axis row budget or its total cell
230
+ * cap keeps the LEADING side and drops the trailing side — the row-budget case has no
231
+ * callback (the engine only logs a warning), the cell-cap case (columns dropped) reports
232
+ * through `onRenderTruncated`. Values of
233
+ * hundreds of rows per side approach that budget on tall viewports; keep overscan to the
234
+ * slack you need.
235
+ * 行軸 overscan の素通し (VirtualGrid `overscanRows`。ライブ prop): 縦 viewport 端の外側へ
236
+ * 描画する行数。高速スクロール時の描画窓 1 フレーム遅れを吸収する唯一の余裕 (白帯対策)。
237
+ * overscan は**行数**で数える (非表示の 0px 行も枠を消費) ため、片端の余裕 px が overscan ×
238
+ * 行高になるのは行が一様に可視のときだけ。undefined はエンジン既定 (3)。エンジンは overscan を
239
+ * 検証しない (負値は描画窓を無言で縮める) ため fail-fast は本層所有 — 非整数・負値は render 時
240
+ * RangeError。費用は列軸との積で増幅し、エンジンは有界化でなく**打切り**で応じる: 軸毎の行予算
241
+ * や総セル上限を超えた窓は先頭側を残し末尾側を落とす — 行予算超過はコールバック無し (エンジンは
242
+ * warn ログのみ)、セル上限超過 (列の脱落) は `onRenderTruncated` で通知。片側数百行の値は高い viewport で予算に迫るため、必要な
243
+ * 余裕にとどめる。
244
+ */
245
+ overscanRows?: number
246
+ /**
247
+ * Column-axis overscan pass-through (the VirtualGrid `overscanCols` knob; LIVE prop) — the
248
+ * column twin of `overscanRows`, same validation contract (non-negative integer or
249
+ * RangeError; undefined ⇒ engine default 3).
250
+ * 列軸 overscan の素通し (VirtualGrid `overscanCols`。ライブ prop) — `overscanRows` の
251
+ * 列双子。検証契約は同文 (非負整数、それ以外 RangeError。undefined ⇒ エンジン既定 3)。
252
+ */
253
+ overscanCols?: number
254
+ /**
255
+ * Background layer rendered behind the scroll-band rows, inside the engine content element
256
+ * (the VirtualGrid `background` pass-through; LIVE prop). VIEWPORT-PINNED: the engine
257
+ * translates only its row wrapper, so this layer does NOT follow the scroll offset —
258
+ * periodic row ruling placed here slides against the real rows while scrolling. Suited to
259
+ * a static texture that reads as "content still arriving" in the overscan-exhausted band,
260
+ * not to row-aligned gridlines. The layer spans the full width of the scroll rows — the
261
+ * frozen-COLUMN region included (frozen columns live inside the scroll rows) — but not the
262
+ * frozen row bands. Identity-stable contract (same as `renderCell`): an inline element here
263
+ * pierces the GridBody memo boundary on every host re-render (the engine's render bodies
264
+ * re-run; cells stay memoized) — hoist the node or `useMemo` it.
265
+ * スクロール帯行の背後・エンジンコンテンツ要素内に描画する背景レイヤー (VirtualGrid
266
+ * `background` の素通し。ライブ prop)。viewport 固定: エンジンが translate するのは行
267
+ * ラッパーだけで、この層はスクロール量に追従しない — 周期的な行罫線を置くとスクロール中に
268
+ * 実際の行へ対して滑る。overscan が尽きた帯で「まだ到着中」と読める静的テクスチャ向きで、
269
+ * 行整合の罫線には不向き。層はスクロール行の全幅 — 凍結**列**域を含む (凍結列はスクロール行の
270
+ * 内側に居る) — に広がり、凍結行帯には広がらない。identity 安定契約 (`renderCell` と同じ):
271
+ * インライン要素はホスト再レンダー毎に GridBody の memo 境界を貫通する (エンジンの render 本体が
272
+ * 再実行、セルは memo 維持) — ノードは hoist するか `useMemo` で保つ。
273
+ */
274
+ background?: ReactNode
275
+ /**
276
+ * Rendering-truncation signal pass-through (the VirtualGrid `onRenderTruncated` callback):
277
+ * fires when the engine drops trailing columns to stay under its total cell cap
278
+ * (`MAX_RENDERED_CELLS`) — the cells actually rendered and the columns dropped. The engine's
279
+ * doctrine is that truncation is never silent; an oversized `overscanCols` × row span is the
280
+ * typical cause. Undefined ⇒ no callback (engine default).
281
+ * 描画打切り通知の素通し (VirtualGrid `onRenderTruncated`): エンジンが総セル上限
282
+ * (`MAX_RENDERED_CELLS`) を守るため末尾列を落としたときに、実描画セル数と脱落列数で発火する。
283
+ * 「打切りは無言にしない」がエンジン教義で、過大な `overscanCols` × 行スパンが典型原因。
284
+ * undefined = 通知なし (エンジン既定)。
285
+ */
286
+ onRenderTruncated?: (info: { renderedCells: number; droppedCols: number }) => void
220
287
  /**
221
288
  * Identity-stable contract (same as adapter): an inline arrow here defeats the CellView memo
222
289
  * and turns every host re-render into a full visible re-slice (~800 cells at 16k columns).
223
- * identity 安定契約 — インライン関数は memo を無効化し毎レンダー全再スライスになる。
290
+ * Per-cell opt-out (v0.12): returning `undefined` from one cell lands that cell on the
291
+ * package default rendering (value-format engine included) — "custom where needed, default
292
+ * elsewhere" without wrapper elements. ONLY `undefined` opts out — `null`, `false` (the
293
+ * `cond && <X/>` idiom) and every other ReactNode are custom output, i.e. an empty custom
294
+ * cell. `ctx.text` is the RAW canonical string; `ctx.displayText` (v0.12) is the value-format
295
+ * display — the exact string the default rendering, the AT announcement, the HTML clipboard
296
+ * and autofit share, so a custom cell that renders it can never drift from what gets copied.
297
+ * Autofit stays a whole-grid no-op while `renderCell` is set (custom DOM width is unknowable).
298
+ * identity 安定契約 — インライン関数は memo を無効化し毎レンダー全再スライスになる。セル単位
299
+ * オプトアウト (v0.12): `undefined` を返したセルはパッケージ既定描画 (値書式エンジン込み) へ
300
+ * 降りる — 「要る列だけカスタム、残りは既定」を器なしで構成できる。オプトアウトは
301
+ * `undefined` **だけ** — `null` / `false` (`cond && <X/>` 慣用) / 他の ReactNode はカスタム
302
+ * 出力 (= 空のカスタムセル)。`ctx.text` は素の正準文字列、`ctx.displayText` (v0.12) は値書式の
303
+ * 表示文字列 — 既定描画・AT 読み上げ・HTML クリップボード・autofit と同一なので、これを描く
304
+ * カスタムセルはコピー結果と食い違わない。autofit は `renderCell` 指定中はグリッド全体
305
+ * no-op のまま (カスタム DOM の幅は測れない)。
224
306
  */
225
307
  renderCell?: (ctx: RenderCellContext) => ReactNode
226
308
  /**
@@ -228,11 +310,16 @@ export type SpreadsheetProps = {
228
310
  * paste replace, Ctrl+A, ...). Never during a drag (state frozen — one fire at pointerup),
229
311
  * with ONE documented in-drag firing point: the header subtract reservation converts to a
230
312
  * real press on the first movement, firing the deferred press replace mid-drag (total fire
231
- * count matches v0.1 — conversion 1 + up 1). Never for the initial A1 mount. Independent
232
- * of the live region's 200ms debounce (firing point: the §6.9-1 setSelection wrapper).
313
+ * count matches v0.1 — conversion 1 + up 1). Never for the initial A1 mount. ONE host-driven
314
+ * fire point outside gestures (ADR-33): a same-adapter rowCount / colCount shrink that moved
315
+ * the selection fires once from a layout effect of that commit — not a user gesture.
316
+ * Independent of the live region's 200ms debounce (firing point: the §6.9-1 setSelection
317
+ * wrapper).
233
318
  * 確定 SelectionState が変わるたびに発火。ドラッグ中と初期マウントでは発火しない。明文の
234
319
  * ドラッグ中発火点が 1 つ: ヘッダー減算予約の実 press 変換 (初動 move) は遅延 press 置換を
235
- * ドラッグ中に発火する (発火総数は v0.1 と同一 — 変換 1 + up 1)。
320
+ * ドラッグ中に発火する (発火総数は v0.1 と同一 — 変換 1 + up 1)。ジェスチャ外のホスト起点発火点が
321
+ * 1 つ (ADR-33): 同一 adapter の rowCount / colCount 縮小で選択が動いたとき、その commit の
322
+ * layout effect から 1 回発火する (ユーザー操作ではない)。
236
323
  */
237
324
  onSelectionChange?: (selection: SelectionState) => void
238
325
  /** Consumer context menu (§6.4.3-4 / §6.6.1 Shift+F10); keyboard origin carries the active cell viewport rect. / consumer コンテキストメニュー。 */
@@ -325,9 +412,11 @@ export type SpreadsheetProps = {
325
412
  enableResizeUI?: boolean
326
413
  /**
327
414
  * AT announcement text for the active cell; omit for silence (no built-in strings). `text`
328
- * is the RAW canonical getCellText; `displayText` is what a sighted user sees (the
415
+ * is the RAW canonical getCellText; `displayText` is the value-format display string (the
329
416
  * getCellFormat-formatted string — equals `text` when the cell has no format), so AT can
330
- * hear "87.0%" for a cell whose raw value is "0.87". `readOnly` (v0.10) is
417
+ * hear "87.0%" for a cell whose raw value is "0.87". It is the same string a custom
418
+ * `renderCell` cell receives as `ctx.displayText` (v0.12) — what a sighted user sees unless
419
+ * the host deliberately renders something else. `readOnly` (v0.10) is
331
420
  * declaration-gated three-valued: the key is ABSENT when the adapter does not declare
332
421
  * `isCellReadOnly` (payload byte-identical to v0.9), `false` = declared editable, `true` =
333
422
  * declared read-only — the navigation-time read-only channel (DOM focus stays on the sink,
@@ -490,6 +579,25 @@ const clampZoom = (value: number): number => {
490
579
  */
491
580
  const stepZoom = (value: number, direction: 1 | -1): number => (direction > 0 ? (Math.floor(value * 10 + 1e-6) + 1) / 10 : (Math.ceil(value * 10 - 1e-6) - 1) / 10)
492
581
 
582
+ /**
583
+ * Validates one overscan prop and returns it unchanged (fail-fast — the engine does NOT
584
+ * validate overscan: a negative value silently shrinks the rendering window past the visible
585
+ * band on the normal path and is clamped to 0 only on the huge-index path, so rejection is owned
586
+ * by this surface). Undefined is the declared absence signal (engine default) and is exempt.
587
+ * Throws RangeError on a non-integer or negative value (the message echoes the raw input —
588
+ * numbers verbatim incl. Infinity / NaN, strings quoted so a JS host passing "5" sees the quotes).
589
+ * overscan prop 1 つの検証 (値はそのまま返す。fail-fast — エンジンは overscan を検証しない: 負値は
590
+ * 通常経路で描画窓を可視帯の内側へ無言で縮め、巨大 index 経路でだけ 0 へクランプされるため、拒否は
591
+ * 本層所有)。undefined は宣言不在信号 (エンジン既定) で対象外。非整数・負値は RangeError (メッセージは
592
+ * 生の入力を映す — 数値は Infinity / NaN 込みでそのまま、文字列は引用符つき — JS ホストの "5" は引用符で映る)。
593
+ */
594
+ const validateOverscan = (name: "overscanRows" | "overscanCols", value: number | undefined): number | undefined => {
595
+ if (value !== undefined && !(Number.isInteger(value) && value >= 0)) {
596
+ throw new RangeError(`[spreadsheet] ${name} must be a non-negative integer (got ${typeof value === "string" ? JSON.stringify(value) : String(value)})`)
597
+ }
598
+ return value
599
+ }
600
+
493
601
  /**
494
602
  * Test-only seam props (NOT part of the public v0.1.0 contract — §8.2 counter injection).
495
603
  * テスト専用シーム props (公開 v0.1.0 契約外 — §8.2 カウンタ注入)。
@@ -508,6 +616,15 @@ type SpreadsheetTestProps = {
508
616
  * では渡さない (未指定 = 呼出しゼロ — `__onCellRender` と同じ品行)。
509
617
  */
510
618
  __onVirtualKeyboardRaise?: () => void
619
+ /**
620
+ * Test-only rendered-cell cap forwarded to the engine's `__maxRenderedCells` seam so the
621
+ * `onRenderTruncated` pass-through can be pinned without materializing 32,768 cells. Never
622
+ * passed in production (unset = the engine's MAX_RENDERED_CELLS — the `__onCellRender` conduct).
623
+ * テスト専用の描画セル上限 (エンジンの `__maxRenderedCells` シームへ素通し) — `onRenderTruncated`
624
+ * 素通しを 32,768 セルの実体化なしでピンするため。production では渡さない (未指定 = エンジンの
625
+ * MAX_RENDERED_CELLS — `__onCellRender` と同じ品行)。
626
+ */
627
+ __maxRenderedCells?: number
511
628
  }
512
629
 
513
630
  const DEFAULT_ROW_HEIGHT = 24
@@ -619,6 +736,10 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
619
736
  frozenTrailingColumns = 0,
620
737
  frozenTrailingRows = 0,
621
738
  tapScrollCircleOptions,
739
+ overscanRows,
740
+ overscanCols,
741
+ background,
742
+ onRenderTruncated,
622
743
  renderCell,
623
744
  onSelectionChange,
624
745
  onCellContextMenu,
@@ -660,6 +781,7 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
660
781
  className,
661
782
  __onCellRender,
662
783
  __onVirtualKeyboardRaise,
784
+ __maxRenderedCells,
663
785
  }: SpreadsheetProps & SpreadsheetTestProps,
664
786
  ref,
665
787
  ): ReactNode {
@@ -2347,6 +2469,68 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2347
2469
  }
2348
2470
  }, [adapter, isEmpty, setSelection, engine, colHeaderMachine, rowHeaderMachine, headerResizeMachine, fillMachine, pointDrag, viewStore, editor.cancelEdit])
2349
2471
 
2472
+ // ---- 行数 / 列数の縮小 (同一 adapter — 絞り込み / カテゴリ / 期間の変更で明細が減る) は保持中の
2473
+ // 座標を新グリッドへ畳む (ADR-33) ----
2474
+ // 旧座標のレンジ / セッション / アンカー / クリップは新軸で範囲外になり得て、repaintOverlay の
2475
+ // sizeOf が例外を投げる (Fenwick は範囲外 index で fail-fast — 測る側は緩めない)。シート切替と違い
2476
+ // 座標系は同一グリッドなので全リセットではなく**クランプ**: 範囲内に残る選択 / セッション /
2477
+ // ポップオーバーは保持し、範囲外に出たものだけ端へ畳む (選択) か取り消す (セッション / ポップオーバー /
2478
+ // 自シートのクリップ)。成長 (行が増える) は座標を無効化しないので何もしない。同一性切替の effect より
2479
+ // 後に置き、エッジ ref に adapter を同乗させて「同一 adapter の縮小」だけを見る (切替は前段が A1 へ
2480
+ // 全リセット済み)。順序: **選択の畳込みを最初に**置く — 畳んだ選択は旧軸でも新軸でも範囲内なので、
2481
+ // 以降の取消 / 閉鎖 / 破棄が同期 repaint を呼んでも (現配線では repaintOverlay は passive effect 側の
2482
+ // 旧クロージャ = 旧軸、将来 layout effect 化しても新軸) どちらのクロージャでも安全 — 切替 effect の
2483
+ // 「防御的順序」を一段強めた形
2484
+ const prevCountsRef = useRef({ adapter, rows: rowAxis.count, cols: colAxis.count })
2485
+ const { getSessionCell, cancelEdit: cancelEditSession } = editor
2486
+ useLayoutEffect(() => {
2487
+ const prev = prevCountsRef.current
2488
+ const rows = rowAxis.count
2489
+ const cols = colAxis.count
2490
+ prevCountsRef.current = { adapter, rows, cols }
2491
+ if (prev.adapter !== adapter || (rows >= prev.rows && cols >= prev.cols)) {
2492
+ return
2493
+ }
2494
+ if (isEmpty) {
2495
+ // 空グリッドへの縮小: 0 行に A1 は存在せず、通知も読み上げも rearm も流さない (切替と同文)
2496
+ selectionRef.current = INITIAL_SELECTION
2497
+ setSelectionState(INITIAL_SELECTION)
2498
+ } else {
2499
+ // 選択は端へ畳む — 範囲外が無ければ同一 object が返り、ラッパーの Object.is bail で通知なし
2500
+ setSelection(clampSelectionToGrid(selectionRef.current, rows, cols))
2501
+ }
2502
+ // 飛行中ドラッグは復元なしで取り消す (各機械の dragBeforeRef は旧グリッド座標 — 切替シームと同文)
2503
+ engine.cancelDrag()
2504
+ colHeaderMachine.cancelDrag()
2505
+ rowHeaderMachine.cancelDrag()
2506
+ headerResizeMachine.cancelDrag()
2507
+ fillMachine.cancelDrag()
2508
+ pointDrag.cancelDrag()
2509
+ const outside = (cell: CellCoord): boolean => isEmpty || cell.row >= rows || cell.col >= cols
2510
+ // 範囲外セルの編集セッションは値破棄で終了 (commit すると消えた行へ誤書込) — 範囲内は保持
2511
+ const sessionCell = getSessionCell()
2512
+ if (sessionCell !== null && outside(sessionCell)) {
2513
+ cancelEditSession()
2514
+ }
2515
+ // ポップオーバーはアンカーが範囲外のものだけ畳む (repaintOverlay がアンカーを測る)
2516
+ const anchors = popoverAnchorsRef.current
2517
+ if (anchors.tip !== null && outside(anchors.tip)) {
2518
+ popoversRef.current?.hideTip()
2519
+ }
2520
+ if (anchors.interactive !== null && outside(anchors.interactive)) {
2521
+ // ホスト起点の閉鎖: フォーカスがグリッド外 (絞り込みの入力欄など) にある間は奪わない — 通常の
2522
+ // 閉鎖 (Escape / ボタン) と違い、ユーザーの打鍵先がここで変わってはならない
2523
+ const focusInsideGrid = rootRef.current?.contains(document.activeElement) === true
2524
+ popoversRef.current?.close({ restoreFocus: focusInsideGrid })
2525
+ }
2526
+ // 自シートのクリップは範囲外なら破棄 (元セルが消えた cut / copy — マーキーも範囲外を描かない)。
2527
+ // foreign (別アダプタのレンジ) は別座標系なので触れない
2528
+ const clip = getClip()
2529
+ if (clip !== null && !clip.foreign && outside({ row: clip.range.endRow, col: clip.range.endCol })) {
2530
+ discardClip()
2531
+ }
2532
+ }, [adapter, rowAxis.count, colAxis.count, isEmpty, setSelection, engine, colHeaderMachine, rowHeaderMachine, headerResizeMachine, fillMachine, pointDrag, getSessionCell, cancelEditSession, getClip, discardClip])
2533
+
2350
2534
  // ---- 凍結列数の変化 (v0.5 ライブ prop) は飛行中ジェスチャを復元なしで取り消す ----
2351
2535
  // 押下時の grab オフセット / アンカーは旧パーティション座標系で確立済み — 変更後の鮮度
2352
2536
  // ヒットテスト (新座標系) と混ざると、ユーザーが狙っていないセルへ fill が確定し得る
@@ -2577,6 +2761,10 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2577
2761
  frozenTrailingColumns={frozenTrailingColumns}
2578
2762
  frozenTrailingRows={frozenTrailingRows}
2579
2763
  tapScrollCircleOptions={tapScrollCircleOptions}
2764
+ overscanRows={validateOverscan("overscanRows", overscanRows)}
2765
+ overscanCols={validateOverscan("overscanCols", overscanCols)}
2766
+ background={background}
2767
+ onRenderTruncated={onRenderTruncated}
2580
2768
  renderCell={renderCell}
2581
2769
  onScroll={onScroll}
2582
2770
  onRangeChange={onRangeChange}
@@ -2585,6 +2773,7 @@ export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & Spre
2585
2773
  requestCommit={requestCommit}
2586
2774
  testId={testId ? `${testId}-vs` : undefined}
2587
2775
  __onCellRender={__onCellRender}
2776
+ __maxRenderedCells={__maxRenderedCells}
2588
2777
  cfIndexRef={cfIndexRef}
2589
2778
  cfMemoRef={cfMemoRef}
2590
2779
  isPointing={isPointing}
@@ -180,6 +180,8 @@ export type CellEditor = PointingSeams & {
180
180
  insertAtCaret: (text: string) => void
181
181
  /** The session's live text, or null when idle (editing-column fillSelection). / セッションの現在文字列 (idle は null — 編集列の fillSelection)。 */
182
182
  getSessionText: () => string | null
183
+ /** The session's cell, or null when idle — the count-shrink clamp cancels a session whose cell left the grid. / セッションのセル (idle は null) — 行数 / 列数縮小の畳込みが、グリッドから消えたセルのセッションを取り消す判定に使う。 */
184
+ getSessionCell: () => CellCoord | null
183
185
  /** Recomputes and re-applies the edit paint after an editing-column format toggle, then repaints the overlay. / 編集列の書式トグル後に塗りを再計算・再適用し overlay を再描画。 */
184
186
  refreshPaint: () => void
185
187
  /** React state mirror for the overlay's editing prop (§6.7.2-4). / overlay の editing prop 用ミラー。 */
@@ -655,6 +657,8 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
655
657
  return sessionRef.current === null || sink === null ? null : sink.value
656
658
  }, [])
657
659
 
660
+ const getSessionCell = useCallback((): CellCoord | null => (sessionRef.current === null ? null : sessionRef.current.cell), [])
661
+
658
662
  const refreshPaint = useCallback((): void => {
659
663
  const p = paramsRef.current
660
664
  const session = sessionRef.current
@@ -749,9 +753,10 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
749
753
  cycleAnchorAtCaret,
750
754
  insertAtCaret,
751
755
  getSessionText,
756
+ getSessionCell,
752
757
  refreshPaint,
753
758
  editing,
754
759
  }),
755
- [requestCommit, cancelEdit, startEdit, getPointing, togglePointingArm, getReferenceCursor, insertReference, captureSink, restoreSink, cycleAnchorAtCaret, insertAtCaret, getSessionText, refreshPaint, editing],
760
+ [requestCommit, cancelEdit, startEdit, getPointing, togglePointingArm, getReferenceCursor, insertReference, captureSink, restoreSink, cycleAnchorAtCaret, insertAtCaret, getSessionText, getSessionCell, refreshPaint, editing],
756
761
  )
757
762
  }
@@ -13,15 +13,27 @@
13
13
 
14
14
  import { memo, type ProfilerOnRenderCallback, type ReactNode, useEffect, useRef } from "react"
15
15
 
16
- /** Public custom cell renderer context (ADR-8). / 公開セルレンダラーの ctx (ADR-8)。 */
17
- export type RenderCellContext = { row: number; col: number; text: string }
16
+ /**
17
+ * Public custom cell renderer context (ADR-8). `text` is the RAW canonical `getCellText`
18
+ * string; `displayText` (v0.12) is the value-format display string — the exact string the
19
+ * default rendering, the AT announcement, the HTML clipboard and autofit all share (equals
20
+ * `text` when the format capability is absent). A custom cell that should look like the
21
+ * default renders `displayText`.
22
+ * 公開セルレンダラーの ctx (ADR-8)。`text` は素の正準 `getCellText` 文字列。`displayText`
23
+ * (v0.12) は値書式の表示文字列 — 既定描画・AT 読み上げ・HTML クリップボード・autofit が共有
24
+ * する同一文字列 (書式能力なしは `text` と同値)。既定と同じ見た目にしたいカスタムセルは
25
+ * `displayText` を描く。
26
+ */
27
+ export type RenderCellContext = { row: number; col: number; text: string; displayText: string }
18
28
 
19
29
  type CellViewProps = {
20
30
  row: number
21
31
  col: number
22
32
  /** Canonical text the grid's getCell reads from adapter.getCellText (updated by re-slice). / グリッドの getCell が adapter.getCellText から読む正準文字列。 */
23
33
  text: string
24
- /** The public cell renderer (ADR-8); this is its execution point. / 公開レンダラーの実行点。 */
34
+ /** Value-format display string (equals `text` without the format capability) — the default rendering and the opt-out landing. / 値書式の表示文字列 (書式能力なしは `text` と同値) — 既定描画とオプトアウトの着地。 */
35
+ display: string
36
+ /** The public cell renderer (ADR-8); this is its execution point. ONLY an `undefined` return is the per-cell opt-out (v0.12) — `null` / `false` / any other ReactNode is custom output (an empty custom cell). / 公開レンダラーの実行点。セル単位オプトアウト (v0.12) は `undefined` 返し**だけ** — `null` / `false` / 他の ReactNode はカスタム出力 (空のカスタムセル)。 */
25
37
  renderCell?: (ctx: RenderCellContext) => ReactNode
26
38
  /** Test-only re-render counter injection point (§8.2); undefined in production. / テスト専用注入点。 */
27
39
  __onCellRender?: ProfilerOnRenderCallback
@@ -31,7 +43,7 @@ type CellViewProps = {
31
43
  * Renders one cell's content (memo target — no own positioned element since v0.4).
32
44
  * セル 1 枚の中身を描画 (memo 対象 — v0.4 から自前の配置要素は持たない)。
33
45
  */
34
- const CellViewInner = ({ row, col, text, renderCell, __onCellRender }: CellViewProps): ReactNode => {
46
+ const CellViewInner = ({ row, col, text, display, renderCell, __onCellRender }: CellViewProps): ReactNode => {
35
47
  // テスト専用カウンタ: Profiler ノードの常設を避けるコスト取引 — 代わりに常設の passive
36
48
  // effect 1 本が毎 commit 走る (注入なしなら本体は即 no-op)。フックは条件分岐できないため
37
49
  // この登録コスト自体は受容する (初回 = mount、以後 = update)
@@ -41,7 +53,12 @@ const CellViewInner = ({ row, col, text, renderCell, __onCellRender }: CellViewP
41
53
  mountedRef.current = true
42
54
  __onCellRender?.(`cell-${row}-${col}`, phase, 0, 0, 0, 0)
43
55
  })
44
- return renderCell ? renderCell({ row, col, text }) : text
56
+ if (renderCell !== undefined) {
57
+ const custom = renderCell({ row, col, text, displayText: display })
58
+ // undefined はセル単位オプトアウト (v0.12) — 既定描画 (値書式込み) へ降りる。「空を描く」の明示は null
59
+ return custom === undefined ? display : custom
60
+ }
61
+ return display
45
62
  }
46
63
 
47
64
  /**
@@ -59,7 +59,7 @@ import { normalizeAdapterHyperlink } from "../model/cellAnnotations.ts"
59
59
  import { alignToJustify, type CellAlign, type CellValueFormat, formatCellValue } from "../model/cellFormat.ts"
60
60
  import { type CellPaint, resolveCellPaint } from "../model/cellPaint.ts"
61
61
  import { type CfIndex, type CfMemo, createCfMemo, resolvePaintedFormat } from "../model/conditionalFormat.ts"
62
- import { isReadOnlyCell, lazyCellText, resolveCellAlign, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
62
+ import { isReadOnlyCell, lazyCellText, resolveCellAlign, resolveCellAttributes, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
63
63
  import { isVisiblyInvalid } from "../model/dataValidation.ts"
64
64
  import { SCROLLBAR_WIDTH, type ViewStore } from "../model/viewStore.ts"
65
65
  import { CellView, type RenderCellContext } from "./CellView.tsx"
@@ -291,6 +291,8 @@ type GridBodyProps = {
291
291
  testId?: string
292
292
  /** Test-only CellView re-render counter seam (§8.2). / テスト専用の再レンダー計数シーム。 */
293
293
  __onCellRender?: ProfilerOnRenderCallback
294
+ /** Test-only rendered-cell cap (engine `__maxRenderedCells` seam pass-through); undefined in production. / テスト専用の描画セル上限 (エンジン `__maxRenderedCells` シームの素通し)。production では undefined。 */
295
+ __maxRenderedCells?: number
294
296
  /**
295
297
  * v0.8 capability (with `cfMemoRef`): the root-built conditional-format index, rebuilt per
296
298
  * (rules identity, renderVersion) and read fresh at every slice. Absent ⇒ the painted format
@@ -318,6 +320,24 @@ type GridBodyProps = {
318
320
  * キー同乗のままエンジン宣言の不在信号 (エンジン既定) — この層での値フォールバックなし。
319
321
  */
320
322
  tapScrollCircleOptions?: ScrollBarTapCircleOptions
323
+ /**
324
+ * Row-axis overscan (the engine `overscanRows` pass-through; the Spreadsheet surface owns
325
+ * the fail-fast validation — the engine clamps silently). Undefined ⇒ engine default (3).
326
+ * 行軸 overscan (エンジン `overscanRows` の素通し。fail-fast 検証は Spreadsheet 面所有 —
327
+ * エンジンは無言クランプ)。undefined ⇒ エンジン既定 (3)。
328
+ */
329
+ overscanRows?: number
330
+ /** Column-axis overscan — the column twin of `overscanRows`, same contract. / 列軸 overscan — `overscanRows` の列双子 (同契約)。 */
331
+ overscanCols?: number
332
+ /**
333
+ * Background layer behind the scroll-band rows (the engine `background` pass-through;
334
+ * viewport-pinned — the engine translates only its row wrapper).
335
+ * スクロール帯行背後の背景レイヤー (エンジン `background` の素通し。viewport 固定 —
336
+ * エンジンが translate するのは行ラッパーのみ)。
337
+ */
338
+ background?: ReactNode
339
+ /** Rendering-truncation signal (engine `onRenderTruncated` pass-through — trailing columns dropped under the cell cap). / 描画打切り通知 (エンジン `onRenderTruncated` の素通し — セル上限下の末尾列脱落)。 */
340
+ onRenderTruncated?: (info: { renderedCells: number; droppedCols: number }) => void
321
341
  }
322
342
 
323
343
  /**
@@ -344,10 +364,15 @@ const GridBodyInner = ({
344
364
  requestCommit,
345
365
  testId,
346
366
  __onCellRender,
367
+ __maxRenderedCells,
347
368
  cfIndexRef,
348
369
  cfMemoRef,
349
370
  isPointing,
350
371
  tapScrollCircleOptions,
372
+ overscanRows,
373
+ overscanCols,
374
+ background,
375
+ onRenderTruncated,
351
376
  }: GridBodyProps): ReactNode => {
352
377
  const internalBodyRef = useRef<HTMLDivElement | null>(null)
353
378
  // 外部 ref (エンジン結線用) と内部 ref の合成
@@ -408,6 +433,11 @@ const GridBodyInner = ({
408
433
  // 宣言ホストは塗りセル毎 1 述語読み (ADR-31 の登記コスト)
409
434
  ...(isReadOnlyCell(adapter, row, col) ? { "aria-readonly": "true", "data-aqsp-readonly": "" } : {}),
410
435
  }
436
+ // v0.12 ホスト属性の継ぎ目: resolveCellAttributes が予約キー (data-aqsp* / data-row /
437
+ // data-col) と非 data-* を fail-fast 済みなのでパッケージ属性と衝突しない。未宣言は
438
+ // O(1) null = v0.11 のセル DOM とバイト同一
439
+ const hostAttrs = resolveCellAttributes(adapter, row, col)
440
+ const stamped = hostAttrs === null ? base : { ...base, ...hostAttrs }
411
441
  const rich = resolveRichCellProps(adapter, rowAxis, colAxis, cf, row, col)
412
442
  // 掛け金は毎セル更新 (v0.1 恒等の null でも座標を進める — children が消費するのは書式能力下だけ)
413
443
  const latch = displayLatchRef.current
@@ -415,9 +445,9 @@ const GridBodyInner = ({
415
445
  latch.col = col
416
446
  latch.value = rich === null ? undefined : rich.value
417
447
  if (rich === null) {
418
- return base as HTMLAttributes<HTMLDivElement>
448
+ return stamped as HTMLAttributes<HTMLDivElement>
419
449
  }
420
- return (rich.style === null ? { ...base, ...rich.attrs } : { ...base, ...rich.attrs, style: rich.style }) as HTMLAttributes<HTMLDivElement>
450
+ return (rich.style === null ? { ...stamped, ...rich.attrs } : { ...stamped, ...rich.attrs, style: rich.style }) as HTMLAttributes<HTMLDivElement>
421
451
  },
422
452
  [adapter, rowAxis, colAxis, cf],
423
453
  )
@@ -444,15 +474,17 @@ const GridBodyInner = ({
444
474
  (text: string, row: number, col: number): ReactNode => {
445
475
  // renderVersion は識別子として参照する (identity-bust — 値そのものは使わない)
446
476
  void renderVersion
447
- // v0.7 表示形式: renderCell 指定時は素の text を素通し (escape hatch・組み込み値書式に優先)、
448
- // 未指定時のみ値書式を表示文字列へ適用 (編集 / コピーは素の値のまま)。値書式は直前の
449
- // getCellProps が掛け金へ置いた塗り書式の value (= base の value — CF 語彙に value は無い) で、
450
- // アダプタは再読しない (書式能力なしは掛け金にも触れない v0.7 恒等)
477
+ // v0.7 表示形式 / v0.12 オプトアウト: 値書式は renderCell の有無に依らず書式能力下で
478
+ // 常に解決する — カスタムセルは ctx.displayText として受け取り (読み上げ / クリップ
479
+ // ボード / autofit と同一文字列)、undefined 返しのオプトアウトセルは既定描画がこれを
480
+ // 描く。値は直前の getCellProps が掛け金へ置いた塗り書式の value (= base の value —
481
+ // CF 語彙に value は無い) で、アダプタは再読しない (書式能力なしは掛け金にも触れない
482
+ // v0.7 恒等)
451
483
  let display = text
452
- if (renderCell === undefined && adapter.getCellFormat !== undefined) {
484
+ if (adapter.getCellFormat !== undefined) {
453
485
  display = formatCellValue(text, takeLatchedValue(displayLatchRef.current, row, col))
454
486
  }
455
- return <CellView row={row} col={col} text={display} renderCell={renderCell} __onCellRender={__onCellRender} />
487
+ return <CellView row={row} col={col} text={text} display={display} renderCell={renderCell} __onCellRender={__onCellRender} />
456
488
  },
457
489
  [renderVersion, renderCell, __onCellRender, adapter],
458
490
  )
@@ -517,6 +549,11 @@ const GridBodyInner = ({
517
549
  contentProps={contentProps}
518
550
  behaviorOptions={behaviorOptions}
519
551
  scrollBarOptions={scrollBarOptions}
552
+ overscanRows={overscanRows}
553
+ overscanCols={overscanCols}
554
+ background={background}
555
+ onRenderTruncated={onRenderTruncated}
556
+ __maxRenderedCells={__maxRenderedCells}
520
557
  callbackThrottleMs={0}
521
558
  onScroll={onScroll}
522
559
  onRangeChange={onRangeChange}
package/src/index.ts CHANGED
@@ -36,7 +36,7 @@
36
36
  * (分冊 00 §5.2)。
37
37
  */
38
38
 
39
- // 統合タップサークル (エンジン v3.6.0 — ADR-33 縮小形): tapScrollCircleOptions prop (v0.10 H2) と
39
+ // 統合タップサークル (エンジン v3.6.0 — エンジン ADR-33 縮小形): tapScrollCircleOptions prop (v0.10 H2) と
40
40
  // renderVisual をエンジン直接依存なしで型付ける型のみ再 export。値面 (tapScrollCircleSampleVisual) は
41
41
  // 意図的に非再 export — エンジン所有のまま
42
42
  export type { ScrollBarTapCircleOptions, TapScrollCircleRenderProps } from "@aiquants/virtualscroll"
@@ -66,7 +66,7 @@ import { type RefObject, useEffect, useRef } from "react"
66
66
  import type { PasteOptions } from "../clipboard/pasteSpecial.ts"
67
67
  import type { PointingSeams } from "../editor/useCellEditor.ts"
68
68
  import { type AxisModel, nextVisibleIndex } from "../model/axisModel.ts"
69
- import { type CellCoord, type CellRange, countRangeCells, enumerateRangeCells, makeRange, type SpreadsheetDirection, type SpreadsheetOperation } from "../model/cellCoord.ts"
69
+ import { type CellCoord, type CellRange, clampCoord, countRangeCells, enumerateRangeCells, makeRange, type SpreadsheetDirection, type SpreadsheetOperation } from "../model/cellCoord.ts"
70
70
  import { aspectCapabilities, type CellEdit, type EditRejection, type ReadOnlyRejection, rangesContainReadOnly, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
71
71
  import { colToViewportX, rowToViewportY } from "../model/frozenPartition.ts"
72
72
  import { reduceActiveCycle, type SelectionState } from "../selection/selectionState.ts"
@@ -450,16 +450,21 @@ export const useGridKeyboard = (params: UseGridKeyboardParams): void => {
450
450
  const reference = pointing.getReferenceCursor()
451
451
  if (reference !== null) {
452
452
  event.preventDefault()
453
+ // トークン由来の座標は生存セッションが行数 / 列数の縮小 (ADR-33) を跨いだあと範囲外
454
+ // になり得る (手打ち "=A999" も同じ) — moveActive の入力 clamp と同じ規律で消費点に
455
+ // 畳んでから歩く (軸の範囲外読みは Fenwick が fail-fast する)
456
+ const cursor = clampCoord(reference.cursor, p.rowAxis.count, p.colAxis.count)
457
+ const origin = clampCoord(reference.origin, p.rowAxis.count, p.colAxis.count)
453
458
  // Ctrl はデータ端 (idle の Ctrl+Arrow と同じ導出)、素は可視 1 歩。active は動かさない
454
- const next = ctrl ? dataEdgeTarget(reference.cursor, direction) : arrowStep(reference.cursor, direction)
459
+ const next = ctrl ? dataEdgeTarget(cursor, direction) : arrowStep(cursor, direction)
455
460
  p.ensureCellVisible(next)
456
461
  // Shift はトークン起点とのレンジへ置換、素はカーソル単独
457
- pointing.insertReference(event.shiftKey ? makeRange(reference.origin, next) : next, reference.axis)
462
+ pointing.insertReference(event.shiftKey ? makeRange(origin, next) : next, reference.axis)
458
463
  // 指し先の参照矩形 (§2.7): 挿入したトークンと同じ範囲を "point" 矩形で描く
459
464
  // (Excel / Sheets のキーボード指し枠)。帯参照 (A:C / 2:5) は軸全域へ展開 —
460
465
  // トークンの意味する範囲そのもの。能力分岐: writer 未配線 = 矩形なし (参照書換のみ)
461
466
  if (p.onPointFrame !== undefined) {
462
- const rect = event.shiftKey ? makeRange(reference.origin, next) : makeRange(next, next)
467
+ const rect = event.shiftKey ? makeRange(origin, next) : makeRange(next, next)
463
468
  p.onPointFrame(
464
469
  reference.axis === "columns"
465
470
  ? makeRange({ row: 0, col: rect.startCol }, { row: p.rowAxis.count - 1, col: rect.endCol })