@aiquants/spreadsheet 0.8.0 → 0.10.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.
@@ -21,6 +21,13 @@
21
21
  * cell binds —) and the
22
22
  * IME guard the layer applies to every popover keydown (`isCompositionKeydown`). No DOM is
23
23
  * rendered here — PopoverLayer.tsx renders from `context` / `tipContext` (lazy mount).
24
+ * v0.10 read-only (design ADR-30, refusals via the required `rejectReadOnly` funnel): S3 = ONE
25
+ * interactive-context builder site for the declaration-gated `cellReadOnly` key (note / comment /
26
+ * link builds and the G14a re-present all share it); G14a re-checks live at `commitOrReject`'s
27
+ * head (refusal re-presents with `cellReadOnly` recomputed, `rejected` kept false, draft
28
+ * preserved); G14b is the list commit's defense-in-depth; G14c refuses a list open PRE-open —
29
+ * after the RangeError wiring check, BEFORE the commit gate (doomed-open: no force-commit).
30
+ * Capability undeclared = contexts and conduct byte-identical to v0.9.
24
31
  *
25
32
  * ポップオーバー状態機械 (v0.8 §1.8 / §4.1.15 — ADR-15 対話層)。6 種 (note エディタ / ホバー
26
33
  * note tip / コメントスレッド / ハイパーリンクエディタ / 検証リスト / 検証メッセージ) を overlay が
@@ -39,7 +46,12 @@
39
46
  * 変化 (root の active 効果が呼ぶ) のたびに再評価し、離れれば外し、ホバー中セルへ乗れば結ぶ —)、
40
47
  * 層が全 popover keydown に掛ける
41
48
  * IME ガード (`isCompositionKeydown`)。DOM は描かない — PopoverLayer.tsx が `context` /
42
- * `tipContext` から遅延マウントする。
49
+ * `tipContext` から遅延マウントする。v0.10 読み取り専用 (設計 ADR-30、拒否は必須 `rejectReadOnly`
50
+ * 漏斗): S3 = 宣言ゲートキー `cellReadOnly` の単一対話 context ビルダ (note / comment / link の
51
+ * 構築と G14a 再提示が共有)、G14a = commitOrReject 頭の live 再検査 (拒否は cellReadOnly 再計算 +
52
+ * rejected false のまま + 下書き保持で再提示)、G14b = list commit の縦深防御、G14c = list の
53
+ * pre-open 拒否 (RangeError 結線検査の後・commit ゲートの前 — doomed-open で force-commit なし)。
54
+ * 能力未宣言は context も品行も v0.9 とバイト恒等。
43
55
  */
44
56
 
45
57
  import type { VirtualGridHandle } from "@aiquants/virtualscroll"
@@ -48,7 +60,7 @@ import { isCompositionKeydown } from "../keyboard/compositionGuard.ts"
48
60
  import type { AxisModel } from "../model/axisModel.ts"
49
61
  import { type CellComment, type CellCommentInput, normalizeAdapterHyperlink, normalizeHyperlink } from "../model/cellAnnotations.ts"
50
62
  import { type CellCoord, clampCoord, coordEquals } from "../model/cellCoord.ts"
51
- import type { CellEdit, EditRejection, SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
63
+ import { type CellEdit, type EditRejection, isReadOnlyCell, type ReadOnlyRejection, readOnlyDeclared, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
52
64
  import { colToViewportX, rowToViewportY, viewportXToCol, viewportYToRow } from "../model/frozenPartition.ts"
53
65
  import { resolveLabel, type SpreadsheetLabels } from "../model/labels.ts"
54
66
  import { NOTE_TIP_LEAVE_GRACE_MS, type PopoverRect, shouldShowNoteTip } from "./placement.ts"
@@ -62,17 +74,28 @@ export const POPOVER_KINDS: readonly PopoverKind[] = ["note", "note-tip", "comme
62
74
  /** The `popovers` prop: `false` disables a kind (its commands pass through, `openPopover` throws). / `popovers` prop: `false` はその kind を無効化 (コマンドは素通し、`openPopover` は RangeError)。 */
63
75
  export type PopoverOverrides = { readonly [K in PopoverKind]?: boolean }
64
76
 
65
- /** Note editor context (v0.8 §1.8). `current` undefined = no note yet; `commit(null)` clears. / note エディタ文脈。current undefined = ノートなし、commit(null) は消去。 */
66
- export type NotePopoverContext = { kind: "note"; cell: CellCoord; anchorRect: PopoverRect; current: string | undefined; commit: (next: string | null) => void; close: () => void; rejected: boolean }
77
+ /** Note editor context (v0.8 §1.8). `current` undefined = no note yet; `commit(null)` clears. `cellReadOnly` (v0.10, declaration-gated): key ABSENT = the `isCellReadOnly` capability is undeclared (byte-identical context), else the live predicate answer — `true` disables the default component's commit affordance (G14a walls the commit regardless). / note エディタ文脈。current undefined = ノートなし、commit(null) は消去。cellReadOnly (v0.10、宣言ゲート): キー不在 = isCellReadOnly 能力未宣言 (context はバイト恒等)、あれば live 述語の答え — true は既定コンポーネントの確定操作を disabled にする (壁は G14a)。 */
78
+ export type NotePopoverContext = { kind: "note"; cell: CellCoord; anchorRect: PopoverRect; current: string | undefined; commit: (next: string | null) => void; close: () => void; rejected: boolean; cellReadOnly?: boolean }
67
79
 
68
80
  /** Hover note tip context (read-only). / ホバー note tip 文脈 (読み取り専用)。 */
69
81
  export type NoteTipContext = { kind: "note-tip"; cell: CellCoord; anchorRect: PopoverRect; current: string; close: () => void }
70
82
 
71
- /** Comment thread context: the reply UI commits `[...current (with ids), { text }]`; `null` clears the thread. / コメントスレッド文脈: 返信 UI は `[...current (id つき), { text }]` を commit、null はスレッド消去。 */
72
- export type CommentPopoverContext = { kind: "comment"; cell: CellCoord; anchorRect: PopoverRect; current: readonly CellComment[]; commit: (next: readonly CellCommentInput[] | null) => void; close: () => void; rejected: boolean }
83
+ /** Comment thread context: the reply UI commits `[...current (with ids), { text }]`; `null` clears the thread. `cellReadOnly` (v0.10): same declaration-gated key as the note context. / コメントスレッド文脈: 返信 UI は `[...current (id つき), { text }]` を commit、null はスレッド消去。cellReadOnly (v0.10) は note 文脈と同じ宣言ゲートキー。 */
84
+ export type CommentPopoverContext = { kind: "comment"; cell: CellCoord; anchorRect: PopoverRect; current: readonly CellComment[]; commit: (next: readonly CellCommentInput[] | null) => void; close: () => void; rejected: boolean; cellReadOnly?: boolean }
73
85
 
74
- /** Hyperlink editor context: `url` on commit is a normalised href or null (clear); `canOpen` = onHyperlinkOpen declared. / ハイパーリンクエディタ文脈: commit の url は正規化済み href か null (消去)、canOpen = onHyperlinkOpen 宣言。 */
75
- export type LinkPopoverContext = { kind: "link"; cell: CellCoord; anchorRect: PopoverRect; current: { text: string; url: string | undefined }; commit: (next: { text: string; url: string | null }) => void; close: () => void; canOpen: boolean; open: () => void; rejected: boolean }
86
+ /** Hyperlink editor context: `url` on commit is a normalised href or null (clear); `canOpen` = onHyperlinkOpen declared. `cellReadOnly` (v0.10): same declaration-gated key as the note context (the Open button stays live — navigation is a read). / ハイパーリンクエディタ文脈: commit の url は正規化済み href か null (消去)、canOpen = onHyperlinkOpen 宣言。cellReadOnly (v0.10) は note 文脈と同じ宣言ゲートキー (Open は読みなので生きたまま)。 */
87
+ export type LinkPopoverContext = {
88
+ kind: "link"
89
+ cell: CellCoord
90
+ anchorRect: PopoverRect
91
+ current: { text: string; url: string | undefined }
92
+ commit: (next: { text: string; url: string | null }) => void
93
+ close: () => void
94
+ canOpen: boolean
95
+ open: () => void
96
+ rejected: boolean
97
+ cellReadOnly?: boolean
98
+ }
76
99
 
77
100
  /** Validation list picker context: `current` = the list rule's values, `typed` = the prefix that opened it. / 検証リスト文脈: current = list 規則の値、typed = 開いたときの入力プレフィックス。 */
78
101
  export type ListPopoverContext = { kind: "list"; cell: CellCoord; anchorRect: PopoverRect; current: readonly string[]; typed: string; commit: (value: string) => void; close: () => void }
@@ -144,6 +167,15 @@ export type UsePopoversParams = {
144
167
  /** Composite drag probe incl. the reference drag — the tip is never shown mid-drag. / 参照ドラッグ含む合成ドラッグ判定 — ドラッグ中は tip を出さない。 */
145
168
  isDraggingAny: () => boolean
146
169
  commitEdits: (edits: readonly CellEdit[], rejection: EditRejection) => number | null
170
+ /**
171
+ * v0.10 read-only refusal funnel (required seam — the root always wires it; identity-stable,
172
+ * read through paramsRef): G14a (interactive commit), G14b (list commit) and G14c (the list
173
+ * pre-open refusal) fire it once per refused gesture. Capability absent ⇒ never called.
174
+ * v0.10 読み取り専用拒否漏斗 (必須 seam — root が常に渡す。identity 安定、paramsRef 読み):
175
+ * G14a (対話 commit)・G14b (list commit)・G14c (list の pre-open 拒否) が拒否ジェスチャ毎
176
+ * 1 回発火する。能力未宣言なら一度も呼ばれない。
177
+ */
178
+ rejectReadOnly: (rejection: ReadOnlyRejection) => void
147
179
  labels: SpreadsheetLabels
148
180
  onHyperlinkOpen?: (url: string, cell: CellCoord) => void
149
181
  /** Root-owned anchor state the overlay positions. / root 所有のアンカー状態 (overlay が配置)。 */
@@ -207,16 +239,28 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
207
239
  const compositionEndedAtRef = useRef<number | null>(null)
208
240
  const tipId = useId()
209
241
 
210
- /** The active cell's rect in layer px (the overlay's forward map — the same recipe as the keyboard cellRect). / active セルの層 px 矩形 (overlay の順写像 — keyboard cellRect と同じ式)。 */
242
+ /**
243
+ * The active cell's rect in layer px (the overlay's three-way forward map — the same recipe
244
+ * as the keyboard cellRect; v0.9 plan §4.6, census #7): the AxisForwardCtx pair is built
245
+ * INLINE from one handle snapshot per judgment, `extent` from a judgment-time
246
+ * `getViewportSize()` read (plan §2.1 rule 1) — a trailing cell's anchor is extent-anchored
247
+ * and scroll-static, exactly like the engine's trailing band.
248
+ * active セルの層 px 矩形 (overlay の 3 分岐順写像 — keyboard cellRect と同じ式。v0.9 計画
249
+ * §4.6、census #7)。AxisForwardCtx の対は判断毎に 1 ハンドルスナップショットからインライン
250
+ * 構築し、`extent` は判断時 `getViewportSize()` 読み (計画 §2.1 規則 1) — 末尾セルのアンカーは
251
+ * extent アンカーでスクロール静的 (エンジンの末尾帯と同じ)。
252
+ */
211
253
  const anchorRectOf = useCallback((cell: CellCoord): PopoverRect => {
212
254
  const p = paramsRef.current
213
255
  const handle = p.handleRef.current
214
- // detached ハンドルは原点扱い (番兵クランプは keyboard と同じ)
256
+ // detached ハンドルは原点扱い (番兵クランプは keyboard と同じ)。件数 0 / extent 0 の縮退で
257
+ // 末尾分岐は不活性 — 未装着ホストの矩形は v0.8 と byte 一致
215
258
  const position = handle === null ? { x: 0, y: 0 } : handle.getScrollPosition()
216
- const frozen = handle === null ? { rows: 0, cols: 0, width: 0, height: 0 } : handle.getFrozenSize()
259
+ const frozen = handle === null ? { rows: 0, cols: 0, trailingRows: 0, trailingCols: 0 } : handle.getFrozenSize()
260
+ const viewport = handle === null ? { width: 0, height: 0 } : handle.getViewportSize()
217
261
  return {
218
- top: rowToViewportY(p.rowAxis, cell.row, Math.max(0, position.y), frozen.rows),
219
- left: colToViewportX(p.colAxis, cell.col, Math.max(0, position.x), frozen.cols),
262
+ top: rowToViewportY(p.rowAxis, cell.row, { scroll: Math.max(0, position.y), frozenCount: frozen.rows, trailingCount: frozen.trailingRows, extent: viewport.height }),
263
+ left: colToViewportX(p.colAxis, cell.col, { scroll: Math.max(0, position.x), frozenCount: frozen.cols, trailingCount: frozen.trailingCols, extent: viewport.width }),
220
264
  width: p.colAxis.sizeOf(cell.col),
221
265
  height: p.rowAxis.sizeOf(cell.row),
222
266
  }
@@ -348,7 +392,18 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
348
392
  [showTip, cancelGrace, startGrace],
349
393
  )
350
394
 
351
- /** Hit-tests a client position to a cell (the fill machine's recipe). / client 座標をセルへヒットテスト (fill 機械と同じ式)。 */
395
+ /**
396
+ * Hit-tests a client position to a cell — the fill machine's recipe, three-way since v0.9
397
+ * (plan §4.6, census #8): the AxisInverseCtx pair is built INLINE per judgment,
398
+ * `trailingSize` from the TREE fields and `extent` from a NEW judgment-time
399
+ * `getViewportSize()` read (plan §2.1 rules 1/2 — the pre-existing dimension-probe read in
400
+ * `getLayerSize` is unrelated; extent 0 keeps the trailing branch inert, byte-matching v0.8).
401
+ * client 座標をセルへヒットテスト — fill 機械と同じ式、v0.9 から 3 分岐 (計画 §4.6、census
402
+ * #8)。AxisInverseCtx の対は判断毎にインライン構築し、`trailingSize` は TREE フィールド、
403
+ * `extent` は**新規**の判断時 `getViewportSize()` 読み (計画 §2.1 規則 1/2 — 既存の
404
+ * `getLayerSize` の寸法プローブ読みは無関係。extent 0 は末尾分岐を不活性に保ち v0.8 と byte
405
+ * 一致)。
406
+ */
352
407
  const hitTest = useCallback((clientX: number, clientY: number): CellCoord | null => {
353
408
  const p = paramsRef.current
354
409
  const body = p.bodyRef.current
@@ -360,7 +415,16 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
360
415
  const z = p.zoomRef.current
361
416
  const position = handle.getScrollPosition()
362
417
  const frozen = handle.getFrozenSize()
363
- return clampCoord({ row: viewportYToRow(p.rowAxis, (clientY - rect.top) / z, Math.max(0, position.y), frozen.height), col: viewportXToCol(p.colAxis, (clientX - rect.left) / z, Math.max(0, position.x), frozen.width) }, p.rowAxis.count, p.colAxis.count)
418
+ // 判断時の viewport 読み (§2.1 規則 1 — census #8 の新規読み。キャッシュ / ストア由来の extent は不可)
419
+ const viewport = handle.getViewportSize()
420
+ return clampCoord(
421
+ {
422
+ row: viewportYToRow(p.rowAxis, (clientY - rect.top) / z, { scroll: Math.max(0, position.y), frozenSize: frozen.height, trailingSize: frozen.trailingHeight, extent: viewport.height }),
423
+ col: viewportXToCol(p.colAxis, (clientX - rect.left) / z, { scroll: Math.max(0, position.x), frozenSize: frozen.width, trailingSize: frozen.trailingWidth, extent: viewport.width }),
424
+ },
425
+ p.rowAxis.count,
426
+ p.colAxis.count,
427
+ )
364
428
  }, [])
365
429
 
366
430
  /**
@@ -419,10 +483,34 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
419
483
  [writeAnchors],
420
484
  )
421
485
 
422
- /** Runs an annotate commit; a rejection re-presents the context with `rejected: true`. / annotate の commit を実行し、拒否なら rejected: true で再提示する。 */
486
+ /**
487
+ * S3 (v0.10): the SINGLE interactive-context builder site for the declaration-gated
488
+ * `cellReadOnly` key — the three build paths (note / comment / link) and the G14a re-present
489
+ * all spread this result, so the census stays at one `readOnlyDeclared` + one `isReadOnlyCell`
490
+ * call. Key absent = capability undeclared (context byte-identical), else the live answer.
491
+ * S3 (v0.10): 宣言ゲートキー `cellReadOnly` の**単一**対話 context ビルダサイト — 3 つの構築
492
+ * 経路 (note / comment / link) と G14a の再提示が全てこの戻り値を spread するため、census は
493
+ * readOnlyDeclared 1 + isReadOnlyCell 1 のまま。キー不在 = 能力未宣言 (context バイト恒等)、
494
+ * あれば live の答え。
495
+ */
496
+ const readOnlyKeyOf = useCallback((cell: CellCoord): { cellReadOnly?: boolean } => {
497
+ const p = paramsRef.current
498
+ // 宣言時だけキーを出す (undefined キーも出さない — v0.9 形ホストの context はバイト恒等)
499
+ return readOnlyDeclared(p.adapter) ? { cellReadOnly: isReadOnlyCell(p.adapter, cell.row, cell.col) } : {}
500
+ }, [])
501
+
502
+ /** Runs an annotate commit; a G14a read-only refusal re-presents with `cellReadOnly` recomputed (`rejected` stays false), an adapter rejection re-presents with `rejected: true`. / annotate の commit を実行。G14a の読み取り専用拒否は cellReadOnly を再計算して再提示 (rejected は false のまま)、アダプタ拒否は rejected: true で再提示。 */
423
503
  const commitOrReject = useCallback(
424
504
  (ctx: NotePopoverContext | CommentPopoverContext | LinkPopoverContext, edits: readonly CellEdit[]) => {
425
505
  const p = paramsRef.current
506
+ // G14a (v0.10 §6.10): commit 頭の live 再検査 — adapter 不到達。再提示は cellReadOnly 再計算
507
+ // (今は true — 既定コンポーネントが確定操作を disabled に)、rejected は false のまま
508
+ // (true は「adapter が投げた、再試行の余地あり」の意味 — ここでは嘘になる)、入力値は保持
509
+ if (isReadOnlyCell(p.adapter, ctx.cell.row, ctx.cell.col)) {
510
+ p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: 1 })
511
+ present({ ...ctx, ...readOnlyKeyOf(ctx.cell) })
512
+ return
513
+ }
426
514
  if (p.commitEdits(edits, { source: "annotate" }) === null) {
427
515
  // 拒否: 開いたまま rejected 状態へ (既定コンポーネントが Ctrl+Enter 再試行 / Escape 破棄を担う)
428
516
  present({ ...ctx, rejected: true })
@@ -430,7 +518,7 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
430
518
  }
431
519
  close()
432
520
  },
433
- [present, close],
521
+ [present, close, readOnlyKeyOf],
434
522
  )
435
523
 
436
524
  const open = useCallback(
@@ -440,6 +528,14 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
440
528
  throw new RangeError(`[spreadsheet] openPopover("${kind}") is unavailable: the kind is disabled by the popovers prop or its adapter capability is undeclared`)
441
529
  }
442
530
  const cell = p.getActive()
531
+ // G14c (v0.10 §6.10 — doomed-open 原則): list の pre-open 拒否。座席は RangeError 結線検査の
532
+ // 後 (結線バグはデータ状態に優先 — fail-fast 序列保存)・open 経路 commit ゲートの**前**
533
+ // (拒否される open が無関係セッションを force-commit しない)。picker の対話 = 書込そのもの
534
+ // なので popover は開かない。RangeError にはしない (読み取り専用はデータ状態で結線バグではない)
535
+ if (kind === "list" && isReadOnlyCell(p.adapter, cell.row, cell.col)) {
536
+ p.rejectReadOnly({ kind: "edit", cell, editSession: "none" })
537
+ return
538
+ }
443
539
  if (kind === "note-tip") {
444
540
  // 手動表示: active セルにノートがあるときだけ (ホバー述語とは独立)
445
541
  showTip(cell)
@@ -483,6 +579,14 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
483
579
  current: rule.values,
484
580
  typed,
485
581
  commit: (value: string) => {
582
+ // G14b (v0.10 §6.10): list commit の縦深防御 — 開いている間の読み取り専用化を
583
+ // commitEdits 前に live 再検査 (G14c が正門、ここは第二壁)。拒否は無書込で閉じる
584
+ // (picker に他の対話は無い — Escape 相当の閉鎖)
585
+ if (isReadOnlyCell(paramsRef.current.adapter, cell.row, cell.col)) {
586
+ paramsRef.current.rejectReadOnly({ kind: "edit", cell, editSession: "none" })
587
+ close()
588
+ return
589
+ }
486
590
  // リスト確定はセル編集 (source "edit"、セッションなし = discarded)
487
591
  paramsRef.current.commitEdits([{ row: cell.row, col: cell.col, text: value }], { source: "edit", editSession: "discarded" })
488
592
  close()
@@ -505,6 +609,8 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
505
609
  },
506
610
  close,
507
611
  rejected: false,
612
+ // S3: 宣言ゲートキー (単一ビルダ経由 — 未宣言はキー不在で context バイト恒等)
613
+ ...readOnlyKeyOf(cell),
508
614
  }
509
615
  present(ctx)
510
616
  return
@@ -522,6 +628,8 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
522
628
  },
523
629
  close,
524
630
  rejected: false,
631
+ // S3: 宣言ゲートキー (単一ビルダ経由)
632
+ ...readOnlyKeyOf(cell),
525
633
  }
526
634
  present(ctx)
527
635
  return
@@ -553,10 +661,12 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
553
661
  }
554
662
  },
555
663
  rejected: false,
664
+ // S3: 宣言ゲートキー (単一ビルダ経由)
665
+ ...readOnlyKeyOf(cell),
556
666
  }
557
667
  present(ctx)
558
668
  },
559
- [anchorRectOf, close, commitOrReject, hideTipNow, present, showTip],
669
+ [anchorRectOf, close, commitOrReject, hideTipNow, present, showTip, readOnlyKeyOf],
560
670
  )
561
671
 
562
672
  const { enabled, bodyRef } = params
@@ -2,11 +2,22 @@
2
2
  * @fileoverview Edge-zone rAF auto-scroller for drag selection (§6.4.4). Driven by the
3
3
  * selection engine via start/stop; hit tests use the 2-axis scrollBy synchronous return
4
4
  * (freshness hierarchy §7-4: scrollBy return > getScrollPosition() — the store carries no
5
- * positions since v0.4).
5
+ * positions since v0.4). v0.9 (plan §4.4, census #3/#4): the far zones (right / bottom) are
6
+ * band-based — `p > pane − trailingVisible* − EDGE_ZONE` — with the trailing home-territory
7
+ * suppressor (anchor inside the trailing band AND T_px > 0 AND pointer inside the band ⇒ no
8
+ * drive, the exact far twin of the v0.5 R5 leading rule), and the hover resolution builds
9
+ * its AxisInverseCtx inline per tick, reusing the tick-top `getViewportSize()` snapshot as
10
+ * `extent` (same-tick freshness — plan §0.4 R1). T = 0 leaves every zone and ctx expression
11
+ * byte-identical to the v0.8 arithmetic.
6
12
  *
7
13
  * ドラッグ選択の端ゾーン rAF オートスクロール (§6.4.4)。エンジンが start/stop で駆動し、
8
14
  * ヒットテストは 2 軸 scrollBy の同期返り値を使う (鮮度序列 §7-4: scrollBy 返り値 >
9
- * getScrollPosition — v0.4 からストアは位置を運ばない)。
15
+ * getScrollPosition — v0.4 からストアは位置を運ばない)。v0.9 (計画 §4.4、census #3/#4): 遠
16
+ * ゾーン (右 / 下) は帯基準 `p > pane − trailingVisible* − EDGE_ZONE`、末尾ホームテリトリー
17
+ * (アンカー末尾帯 ∧ T_px > 0 ∧ ポインタ帯内 ⇒ 無駆動 — v0.5 R5 の leading 規則の遠端双子) を
18
+ * 併設。hover 解決は AxisInverseCtx を tick 内その場構築し、extent は tick 冒頭の
19
+ * `getViewportSize()` スナップショットを再利用 (同 tick 鮮度 — 計画 §0.4 R1)。T = 0 は全
20
+ * ゾーン / ctx 式が v0.8 算術と byte 一致。
10
21
  */
11
22
 
12
23
  import type { VirtualGridHandle } from "@aiquants/virtualscroll"
@@ -49,17 +60,22 @@ export type AutoScrollContext = {
49
60
  * Drag-anchor column for the frozen home-territory rule (v0.5 R5 fix): when the anchor is
50
61
  * a frozen column AND the pointer sits inside the frozen band, the band-based left zone
51
62
  * does NOT drive — operating within the frozen pane must not scroll the band (Excel-like).
63
+ * v0.9 (plan §4.4): the same anchor also feeds the TRAILING home territory — an anchor in
64
+ * the trailing band with the pointer inside that band suppresses the band-based right zone.
52
65
  * Omit for vertical-only drags.
53
66
  * 凍結ホームテリトリー規則 (v0.5 R5 是正) 用のドラッグアンカー列。アンカーが凍結列で
54
67
  * ポインタも凍結帯内なら帯基準左ゾーンは駆動しない — 凍結ペイン内の操作は可動帯を
55
- * スクロールさせない (Excel 同型)。縦専用ドラッグでは省略。
68
+ * スクロールさせない (Excel 同型)。v0.9 (計画 §4.4): 同じアンカーが末尾ホームテリトリーにも
69
+ * 供給され、末尾帯アンカー × ポインタ帯内は帯基準右ゾーンを抑止。縦専用ドラッグでは省略。
56
70
  */
57
71
  anchorCol?: number
58
72
  /**
59
73
  * Drag-anchor row for the vertical frozen home-territory rule (row twin of anchorCol — v0.6):
60
- * anchor frozen AND pointer inside the frozen band suppresses the band-based top zone.
74
+ * anchor frozen AND pointer inside the frozen band suppresses the band-based top zone; the
75
+ * trailing twin (v0.9 plan §4.4) suppresses the band-based bottom zone.
61
76
  * 縦の凍結ホームテリトリー規則用のドラッグアンカー行 (anchorCol の行双子 — v0.6)。アンカーが
62
- * 凍結行でポインタも凍結帯内なら帯基準上ゾーンは駆動しない。
77
+ * 凍結行でポインタも凍結帯内なら帯基準上ゾーンは駆動しない。末尾双子 (v0.9 計画 §4.4) は
78
+ * 帯基準下ゾーンを抑止。
63
79
  */
64
80
  anchorRow?: number
65
81
  }
@@ -143,10 +159,20 @@ export const useAutoScroller = ({ handleRef, rowAxis, colAxis, bodyRef, onDragFr
143
159
  let dx = 0
144
160
  if (axis !== "x") {
145
161
  // 上端ゾーンは帯基準 (v0.6 計画 §3 — 横の左端ゾーンと対称): スクロール帯の上端 = H_F。
146
- // 下端優先 + 凍結ホームテリトリー (アンカー凍結行 × ポインタ帯内は駆動しない、H_F = 0 除外)
162
+ // 下端優先 + 凍結ホームテリトリー (アンカー凍結行 × ポインタ帯内は駆動しない、H_F = 0 除外)。
163
+ // v0.9 (計画 §4.4): 下端ゾーンも帯基準 — スクロール帯の下端 = paneH − H_T_vis (vis =
164
+ // getFrozenSize().trailingVisibleHeight、tick スナップショット)。末尾ホームテリトリーは
165
+ // leading 双子と同形: アンカー末尾帯 (row >= count − trailingRows) ∧ T_px > 0 ∧ ポインタ
166
+ // 帯内 (py >= paneH − vis — 境界画素の >= は逆写像の末尾分類とミラー) ⇒ 駆動しない。
167
+ // T_px = 0 (末尾行が全て非表示) は可視帯が存在しない — ペイン外の深い py を帯内と誤認
168
+ // して抑止しない (leading の H_F = 0 除外の双子)
147
169
  const frozenV = handle.getFrozenSize()
148
- if (py > paneH - EDGE_ZONE) {
149
- dy = speedFor(py - (paneH - EDGE_ZONE))
170
+ const visH = frozenV.trailingVisibleHeight
171
+ if (py > paneH - visH - EDGE_ZONE) {
172
+ const anchorTrailingRow = context.anchorRow !== undefined && frozenV.trailingRows > 0 && context.anchorRow >= rowAxis.count - frozenV.trailingRows
173
+ if (!(anchorTrailingRow && visH > 0 && py >= paneH - visH)) {
174
+ dy = speedFor(py - (paneH - visH - EDGE_ZONE))
175
+ }
150
176
  } else if (py < frozenV.height + EDGE_ZONE) {
151
177
  const anchorFrozenRow = context.anchorRow !== undefined && context.anchorRow < frozenV.rows
152
178
  if (!(anchorFrozenRow && frozenV.height > 0 && py < frozenV.height)) {
@@ -158,12 +184,19 @@ export const useAutoScroller = ({ handleRef, rowAxis, colAxis, bodyRef, onDragFr
158
184
  // 左端ゾーンは帯基準 (v0.5 計画 §3-6): スクロール帯の左端 = W_F。スクロール帯アンカーの
159
185
  // ドラッグは凍結帯上の保持でも左スクロールが継続する (Excel 同型 — 帯を跨いだ引き寄せ)。
160
186
  // 判定は右端優先 (R2 是正): 帯幅 < 2×EDGE_ZONE で両ゾーンが重なると左優先は右端保持の
161
- // ドラッグを逆走させる — 重なりは右端が勝つ。凍結ホームテリトリー (R5 是正): アンカーが
162
- // 凍結列でポインタも凍結帯内なら駆動しない — 凍結ペイン内の選択操作が可動帯を勝手に
163
- // 左スクロールさせない (帯外 [W_F, W_F+EDGE_ZONE) の戻りドラッグは従来どおり駆動)
187
+ // ドラッグを逆走させる — 重なりは右端が勝つ (v0.9 でも far-edge-wins 形は不変)。凍結
188
+ // ホームテリトリー (R5 是正): アンカーが凍結列でポインタも凍結帯内なら駆動しない —
189
+ // 凍結ペイン内の選択操作が可動帯を勝手に左スクロールさせない (帯外 [W_F, W_F+EDGE_ZONE)
190
+ // の戻りドラッグは従来どおり駆動)。v0.9 (計画 §4.4): 右端ゾーンも帯基準 — スクロール帯の
191
+ // 右端 = paneW − W_T_vis。末尾ホームテリトリーは縦と同形 (アンカー末尾列 ∧ T_px > 0 ∧
192
+ // ポインタ帯内 ⇒ 無駆動、T_px = 0 除外)
164
193
  const frozen = handle.getFrozenSize()
165
- if (px > paneW - EDGE_ZONE) {
166
- dx = speedFor(px - (paneW - EDGE_ZONE))
194
+ const visW = frozen.trailingVisibleWidth
195
+ if (px > paneW - visW - EDGE_ZONE) {
196
+ const anchorTrailingCol = context.anchorCol !== undefined && frozen.trailingCols > 0 && context.anchorCol >= colAxis.count - frozen.trailingCols
197
+ if (!(anchorTrailingCol && visW > 0 && px >= paneW - visW)) {
198
+ dx = speedFor(px - (paneW - visW - EDGE_ZONE))
199
+ }
167
200
  } else if (px < frozen.width + EDGE_ZONE) {
168
201
  // W_F = 0 (凍結列が全て非表示) は可視の凍結帯が存在しない — 負 px を帯内と
169
202
  // 誤認して抑止しない (R5 是正)
@@ -183,9 +216,19 @@ export const useAutoScroller = ({ handleRef, rowAxis, colAxis, bodyRef, onDragFr
183
216
  // 静止 + 無スクロール + hover 同値の tick は書き込みをスキップ (同値スタイルの毎フレーム
184
217
  // 再書き込みを抑止 — スクロール追随の再配置は repaintOverlay 側が担う)
185
218
  if (vy >= 0 && hx >= 0 && rowAxis.totalSize() > 0 && colAxis.totalSize() > 0) {
186
- // 行 / 列とも凍結パーティション逆写像 (v0.6 / v0.5 計画 §2)
219
+ // 行 / 列とも凍結パーティション 3 分岐逆写像 (v0.6 / v0.5 計画 §2、末尾は v0.9 §2.1)。
220
+ // ctx は判断のこの場でインライン構築 (§2.1 規則 1 — census #4)。trailingSize は TREE
221
+ // フィールド (規則 2)、extent は tick 冒頭の viewport スナップショットを再利用 (同 tick
222
+ // 鮮度 — 計画 §0.4 R1。paneW/H <= 0 は上で次フレームへ抜けるため extent > 0 が保たれる)
187
223
  const frozenHover = handle.getFrozenSize()
188
- const hover = clampCoord({ row: viewportYToRow(rowAxis, py, vy, frozenHover.height), col: viewportXToCol(colAxis, px, hx, frozenHover.width) }, rowAxis.count, colAxis.count)
224
+ const hover = clampCoord(
225
+ {
226
+ row: viewportYToRow(rowAxis, py, { scroll: vy, frozenSize: frozenHover.height, trailingSize: frozenHover.trailingHeight, extent: viewport.height }),
227
+ col: viewportXToCol(colAxis, px, { scroll: hx, frozenSize: frozenHover.width, trailingSize: frozenHover.trailingWidth, extent: viewport.width }),
228
+ },
229
+ rowAxis.count,
230
+ colAxis.count,
231
+ )
189
232
  const last = lastHoverRef.current
190
233
  if (dy !== 0 || dx !== 0 || last === null || last.row !== hover.row || last.col !== hover.col) {
191
234
  lastHoverRef.current = hover
@@ -85,15 +85,22 @@ const caretIndexFromPoint = (clientX: number, clientY: number, cell: CellCoord):
85
85
  /**
86
86
  * Body-relative pointer → cell hit test with the §7-4 freshness hierarchy: the handle's
87
87
  * synchronous scroll position, the press-cached body rect, one zoom division at the boundary
88
- * and the frozen-partition inverse mapping on both axes, clamped into the grid. ONE mapping
89
- * shared by the cell engine and the reference-drag owner (usePointDrag — plan §4.1.18) so an
90
- * identical pointer resolves to an identical cell in both. null = unmeasurable (no handle /
91
- * rect, detached scroll sentinel, empty axis).
88
+ * and the three-way frozen-partition inverse mapping on both axes (leading > trailing >
89
+ * middle — v0.9 plan §4.2, census #1), clamped into the grid. The AxisInverseCtx pair is
90
+ * built INLINE from one handle snapshot per judgment (`trailingSize` = the TREE fields,
91
+ * `extent` = a judgment-time `getViewportSize()` read — plan §2.1 rules 1/2; extent 0 keeps
92
+ * the trailing branch inert, byte-matching v0.8). ONE mapping shared by the cell engine and
93
+ * the reference-drag owner (usePointDrag — plan §4.1.18) so an identical pointer resolves to
94
+ * an identical cell in both. null = unmeasurable (no handle / rect, detached scroll
95
+ * sentinel, empty axis).
92
96
  * body 基準のポインタ → セルのヒットテスト (§7-4 の鮮度序列): ハンドルの同期スクロール位置・
93
- * 押下時計測の body rect・境界で 1 回だけのズーム除算・両軸の凍結パーティション逆写像、グリッド内
94
- * へクランプ。セルエンジンと参照ドラッグ所有者 (usePointDrag — 計画 §4.1.18) が共有する単一写像
95
- * (同じポインタは両者で同じセルに解決する)。null = 計測不能 (ハンドル / rect 不在、detached 番兵、
96
- * 空軸)。
97
+ * 押下時計測の body rect・境界で 1 回だけのズーム除算・両軸の凍結パーティション 3 分岐逆写像
98
+ * (先頭 > 末尾 > 中央 — v0.9 計画 §4.2、census #1)、グリッド内へクランプ。AxisInverseCtx の対は
99
+ * 判断毎に 1 ハンドルスナップショットからインライン構築 (`trailingSize` は TREE フィールド、
100
+ * `extent` は判断時 `getViewportSize()` 読み — 計画 §2.1 規則 1/2。extent 0 は末尾分岐を不活性に
101
+ * 保ち v0.8 と byte 一致)。セルエンジンと参照ドラッグ所有者 (usePointDrag — 計画 §4.1.18) が
102
+ * 共有する単一写像 (同じポインタは両者で同じセルに解決する)。null = 計測不能 (ハンドル / rect
103
+ * 不在、detached 番兵、空軸)。
97
104
  */
98
105
  export const hitTestCell = (handle: VirtualGridHandle | null, rect: DOMRect | null, rowAxis: AxisModel, colAxis: AxisModel, zoom: number, clientX: number, clientY: number): CellCoord | null => {
99
106
  if (handle === null || rect === null) {
@@ -109,9 +116,19 @@ export const hitTestCell = (handle: VirtualGridHandle | null, rect: DOMRect | nu
109
116
  return null
110
117
  }
111
118
  // ズーム境界 (v0.3): rect / client は視覚 px、軸は論理 px — 差分を zoom で 1 回だけ除算。
112
- // 行 / 列とも凍結パーティション逆写像 (v0.6 / v0.5 計画 §2 — 帯サイズ未満は静的凍結帯、以上は + スクロール)
119
+ // 行 / 列とも凍結パーティション 3 分岐逆写像 (v0.6 / v0.5 計画 §2、末尾は v0.9 §2.1)。ctx は
120
+ // この判断のその場構築 (規則 1): trailingSize は TREE フィールド (規則 2)、extent は判断時の
121
+ // getViewportSize() 読み (未測定 {0,0} は末尾分岐が不活性のまま = v0.8 恒等の縮退)
113
122
  const frozen = handle.getFrozenSize()
114
- return clampCoord({ row: viewportYToRow(rowAxis, (clientY - rect.top) / zoom, vy, frozen.height), col: viewportXToCol(colAxis, (clientX - rect.left) / zoom, hx, frozen.width) }, rowAxis.count, colAxis.count)
123
+ const viewport = handle.getViewportSize()
124
+ return clampCoord(
125
+ {
126
+ row: viewportYToRow(rowAxis, (clientY - rect.top) / zoom, { scroll: vy, frozenSize: frozen.height, trailingSize: frozen.trailingHeight, extent: viewport.height }),
127
+ col: viewportXToCol(colAxis, (clientX - rect.left) / zoom, { scroll: hx, frozenSize: frozen.width, trailingSize: frozen.trailingWidth, extent: viewport.width }),
128
+ },
129
+ rowAxis.count,
130
+ colAxis.count,
131
+ )
115
132
  }
116
133
 
117
134
  type UseSelectionEngineParams = {
@@ -1027,3 +1027,35 @@
1027
1027
  width: 100%;
1028
1028
  height: 100%;
1029
1029
  }
1030
+
1031
+ /*
1032
+ * v0.9 凍結分割線 (計画 §2.4 — ADR-20 / ADR-24): 全凍結境界の強い Excel / Sheets 風の線。
1033
+ * consumer 面は CSS 変数 2 つだけ (--aqsp-divider-color / --aqsp-divider-width — パッケージは
1034
+ * 読むだけで書き手は consumer スタイルシート単独)。既定は 2px の #9ca3af (gray-400 — 既存全
1035
+ * 罫線より 1 段濃い)。width 0px は逃げ道 — 象限別 fill 断片の継ぎ目が再露出し forced-colors も
1036
+ * 無描画 (意図的、README / 分冊 05 §8 登記)。位置 (leading 縁 0 / trailing 縁 0) と長さは
1037
+ * 各セグメントの静的インライン幾何 + ホストクリップが供給し、このブロックは太さと色だけを持つ。
1038
+ * pointer-events: none — バーは不活性で、リサイズの operand は帯側隣接ストリップ (ADR-20)。
1039
+ */
1040
+ .aqsp-freeze-divider {
1041
+ position: absolute;
1042
+ background: var(--aqsp-divider-color, #9ca3af);
1043
+ pointer-events: none;
1044
+ }
1045
+
1046
+ /* 縦線 (x 境界) は太さ = width、横線 (y 境界) は太さ = height (共有変数 1 本) */
1047
+ .aqsp-freeze-divider-v {
1048
+ width: var(--aqsp-divider-width, 2px);
1049
+ }
1050
+
1051
+ .aqsp-freeze-divider-h {
1052
+ height: var(--aqsp-divider-width, 2px);
1053
+ }
1054
+
1055
+ /* forced-colors: 不透明な実線は再着色で生存する — CanvasText へ寄せるだけ (§2.4。凍結クリップの
1056
+ 無装飾免除とは別物で、分割線は自前の規則を持つ) */
1057
+ @media (forced-colors: active) {
1058
+ .aqsp-freeze-divider {
1059
+ background: CanvasText;
1060
+ }
1061
+ }