@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.
@@ -39,6 +39,15 @@
39
39
  * - Return value: true = committed (or nothing needed writing), false = gate rejection / adapter
40
40
  * rejection / command unavailable / commit-gate refusal. Every callback is identity-stable
41
41
  * (params are read through `paramsRef`).
42
+ * - v0.10 read-only walls (design ADR-28/30, all through the dataAdapter helpers, refusals via the
43
+ * required `rejectReadOnly` funnel): G8 `runFill` two-phase collect → walk → materialise (the
44
+ * walk covers the producer's own targets — a read-only SOURCE stays legal), G9 editing
45
+ * `fillSelection`, G10 `writeFormat` (walks the invoked target ranges, never the selection),
46
+ * G11 `runCalc`, G12 idle insertNow, G13 the four annotate setters (commit-gate-first kept),
47
+ * and G15 — ONE shared pre-check for the four popover-open command ids between the availability
48
+ * bail and the dispatch-level `requestCommit` (content-present annotate chords fall through and
49
+ * open the viewer; content-absent and the list chord consume + refuse without force-committing).
50
+ * Capability undeclared = every wall compiles to the previous behaviour byte-identically.
42
51
  *
43
52
  * コマンド実行器 (v0.8 §4.1.13 — ADR-17)。70 の SpreadsheetCommand 全件の executeCommand、handle の
44
53
  * 書込群 (applyFormat / setBorders / setHyperlink / setNote / addComment / setValidation)、能力
@@ -67,7 +76,13 @@
67
76
  * トグルは setHighlight。formatCommandAnnouncement (opt-in) は書式コマンド成功後に announce へ
68
77
  * (state はトグルの適用後状態、非トグルは null)。戻り値: true = commit 成功 (書くものが無い場合も)、
69
78
  * false = ゲート拒否 / アダプタ拒否 / 不可用 / commit ゲート拒否。全コールバックは identity 安定
70
- * (パラメータは paramsRef 経由で読む)。
79
+ * (パラメータは paramsRef 経由で読む)。v0.10 の読み取り専用壁 (設計 ADR-28/30、全て dataAdapter
80
+ * ヘルパ経由、拒否は必須 `rejectReadOnly` 漏斗): G8 runFill の 2 相化 (収集 → walk → 実体化 —
81
+ * walk はプロデューサ自身の targets、読み取り専用ソースは合法のまま)、G9 編集中 fillSelection、
82
+ * G10 writeFormat (呼出しパラメータを walk — 選択ではない)、G11 runCalc、G12 idle insertNow、
83
+ * G13 注釈 setter ×4 (commit-gate-first 維持)、G15 = popover 系 4 コマンド id の単一 pre-check
84
+ * (availability bail と dispatch 水準 requestCommit の間 — 内容有の注釈チョードはビューア開通、
85
+ * 内容無と list は force-commit なしの消費拒否)。能力未宣言 = 全壁が従来挙動と byte 恒等。
71
86
  */
72
87
 
73
88
  import { useCallback, useMemo, useRef } from "react"
@@ -80,7 +95,7 @@ import { type CellCommentInput, normalizeAdapterHyperlink, normalizeHyperlink }
80
95
  import { type CellCoord, type CellRange, countRangeCells, enumerateRangeCells, makeRange, type SpreadsheetOperation } from "../model/cellCoord.ts"
81
96
  import { BORDER_DEFAULT_COLOR, type CellBorder, type CellBorders, type CellFormat, type CellFormatPatch, type CellValueFormat, mergeFormat } from "../model/cellFormat.ts"
82
97
  import { normalizeColor } from "../model/cellPaint.ts"
83
- import type { CellEdit, EditRejection, SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
98
+ import { type CellEdit, type EditRejection, isReadOnlyCell, type ReadOnlyRejection, rangesContainReadOnly, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
84
99
  import type { DataValidationRule, ValidationVerdict } from "../model/dataValidation.ts"
85
100
  import type { PopoverKind, PopoverOverrides } from "../popover/usePopovers.ts"
86
101
  import type { SelectionState } from "../selection/selectionState.ts"
@@ -118,6 +133,15 @@ export type UseCommandsParams = {
118
133
  refreshEditPaint: () => void
119
134
  maxOperationCells: number
120
135
  onOperationRejected?: (op: SpreadsheetOperation, cellCount: number, reason: "limit" | "bounds") => void
136
+ /**
137
+ * v0.10 read-only refusal funnel (required seam — the root always wires it; identity-stable,
138
+ * read through paramsRef): the producer walls G8–G13 and the popover-chord pre-check G15
139
+ * fire it once per refused gesture. Capability absent ⇒ never called (byte identity).
140
+ * v0.10 読み取り専用拒否漏斗 (必須 seam — root が常に渡す。identity 安定、paramsRef 読み):
141
+ * プロデューサ壁 G8–G13 と popover チョード pre-check G15 が拒否ジェスチャ毎 1 回発火する。
142
+ * 能力未宣言なら一度も呼ばれない (バイト恒等)。
143
+ */
144
+ rejectReadOnly: (rejection: ReadOnlyRejection) => void
121
145
  /** Custom colour slots (5 each) for fillColorCustomN / fontColorCustomN. / fillColorCustomN / fontColorCustomN の色スロット (各 5)。 */
122
146
  colors: { fill: readonly string[]; font: readonly string[] }
123
147
  /** The formatCurrency currency / rendering locale; undefined ⇒ formatCurrency is unavailable (passes through). / formatCurrency の通貨 / locale。未宣言なら formatCurrency は行不在 (素通し)。 */
@@ -395,6 +419,13 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
395
419
  if (!gateSelectionWrite("format", ranges, p.maxOperationCells, p.onOperationRejected)) {
396
420
  return false
397
421
  }
422
+ // G10 (v0.10 §6.9): 読み取り専用壁 — walk は**呼出しパラメータ** (targetRanges) であって選択では
423
+ // ない (編集列トグルはセッションセル 1×1 を渡す — 古い混在選択で拒否しない。設計 rule 3)。
424
+ // 限度ゲートの後 = O(1) 算術が O(cells) walk を守る既存拒否ランク
425
+ if (rangesContainReadOnly(p.adapter, ranges)) {
426
+ p.rejectReadOnly({ kind: "operation", op: "format", cellCount: countRangeCells(ranges) })
427
+ return false
428
+ }
398
429
  // memo: バケット (パッチの同一性) × base identity — パッチはジェスチャ内で定数なので
399
430
  // intern された base 1 つにつき mergeFormat は 1 回 (applyEdits 引数の format identity は 1 つ)
400
431
  const memo = new Map<number, Map<CellFormat | undefined, CellFormat | undefined>>()
@@ -478,7 +509,16 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
478
509
  }
479
510
  }, [])
480
511
 
481
- /** fillDown / fillRight / fillSelection (idle). / fillDown / fillRight / fillSelection (idle)。 */
512
+ /**
513
+ * fillDown / fillRight / fillSelection (idle) — two phases since v0.10 (§0.2 R2): phase 1
514
+ * collects the (source, targets) pairs with coordinate arithmetic only (no reads, no edits),
515
+ * the G8 read-only walk runs over the collected targets, and only a passing gesture
516
+ * materialises (readFillSource + fillSourceEdit) and commits.
517
+ * fillDown / fillRight / fillSelection (idle) — v0.10 から 2 相 (§0.2 R2): 第 1 相は座標算術
518
+ * だけで (source, targets) 対を収集 (読みも edits 生成もしない)、G8 の読み取り専用 walk を
519
+ * 収集済み targets に掛け、通過したジェスチャだけが実体化 (readFillSource + fillSourceEdit)
520
+ * して commit する。
521
+ */
482
522
  const runFill = useCallback(
483
523
  (direction: "down" | "right" | "selection"): boolean => {
484
524
  const p = paramsRef.current
@@ -486,7 +526,8 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
486
526
  if (!gateSelectionWrite("fill", selection.ranges, p.maxOperationCells, p.onOperationRejected)) {
487
527
  return false
488
528
  }
489
- const edits: CellEdit[] = []
529
+ // 第 1 相: (source, targets) 対の収集のみ — 対の順序は従来の fillEdits 呼出し順と同一 (byte 恒等)
530
+ const pairs: { source: CellCoord; targets: CellCoord[] }[] = []
490
531
  if (direction === "selection") {
491
532
  // active の内容を active 以外の全選択セルへ
492
533
  const active = selection.active
@@ -496,7 +537,7 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
496
537
  targets.push(cell)
497
538
  }
498
539
  })
499
- fillEdits(active, targets, edits)
540
+ pairs.push({ source: active, targets })
500
541
  } else {
501
542
  for (const range of selection.ranges) {
502
543
  if (direction === "down") {
@@ -511,7 +552,7 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
511
552
  for (let row = single ? range.startRow : range.startRow + 1; row <= range.endRow; row += 1) {
512
553
  targets.push({ row, col })
513
554
  }
514
- fillEdits({ row: sourceRow, col }, targets, edits)
555
+ pairs.push({ source: { row: sourceRow, col }, targets })
515
556
  }
516
557
  } else {
517
558
  const single = range.startCol === range.endCol
@@ -525,11 +566,31 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
525
566
  for (let col = single ? range.startCol : range.startCol + 1; col <= range.endCol; col += 1) {
526
567
  targets.push({ row, col })
527
568
  }
528
- fillEdits({ row, col: sourceCol }, targets, edits)
569
+ pairs.push({ source: { row, col: sourceCol }, targets })
570
+ }
571
+ }
572
+ }
573
+ }
574
+ // G8 (v0.10 §6.9): 収集済み対の全 targets を 1 サイトで walk (最初の hit で短絡)。読み取り
575
+ // 専用**ソース**は walk 外 = 合法のまま (Excel 同等 — RC-G8)。拒否 cellCount = Σ targets.length
576
+ // (overlap-naive — ONE 語彙)。実体化 (ソース読み) より前に立つ
577
+ for (const pair of pairs) {
578
+ for (const target of pair.targets) {
579
+ if (isReadOnlyCell(p.adapter, target.row, target.col)) {
580
+ let cellCount = 0
581
+ for (const counted of pairs) {
582
+ cellCount += counted.targets.length
529
583
  }
584
+ p.rejectReadOnly({ kind: "operation", op: "fill", cellCount })
585
+ return false
530
586
  }
531
587
  }
532
588
  }
589
+ // 第 2 相: 実体化 — ソース毎に 1 回読み、対象へ複製 (従来と同じ呼出し順)
590
+ const edits: CellEdit[] = []
591
+ for (const pair of pairs) {
592
+ fillEdits(pair.source, pair.targets, edits)
593
+ }
533
594
  if (edits.length === 0) {
534
595
  return true
535
596
  }
@@ -538,6 +599,60 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
538
599
  [fillEdits],
539
600
  )
540
601
 
602
+ /**
603
+ * Editing-column Ctrl+Enter (`fillSelection` with an open session): writes the session text
604
+ * to every selected cell as ONE applyEdits and closes the session. Order: limit gate → the
605
+ * G9 read-only wall (v0.10 — whole refusal, session kept, `validateCommit` unreached) → the
606
+ * per-target validation walk (the first strict refusal aborts before the adapter) → commit →
607
+ * `cancelEdit` (no double write).
608
+ * 編集列 Ctrl+Enter (セッション中の fillSelection): セッション文字列を全選択セルへ 1 applyEdits
609
+ * で書き、セッションを閉じる。順序: 限度ゲート → G9 読み取り専用壁 (v0.10 — 全体拒否・
610
+ * セッション保持・validateCommit 不到達) → 対象セル毎の検証 walk (最初の strict 拒否で adapter
611
+ * 前に中断) → commit → cancelEdit (二重書込なし)。
612
+ */
613
+ const runFillSelectionEditing = useCallback((): boolean => {
614
+ const p = paramsRef.current
615
+ // 編集列: セッション文字列を全選択セルへ 1 applyEdits、セッション閉鎖
616
+ const text = p.getSessionText()
617
+ if (text === null) {
618
+ return false
619
+ }
620
+ const selection = p.getSelection()
621
+ if (!gateSelectionWrite("fill", selection.ranges, p.maxOperationCells, p.onOperationRejected)) {
622
+ return false
623
+ }
624
+ // G9 (v0.10 §6.9): 読み取り専用壁 — validateCommit walk より前・セッション保持で
625
+ // 全体拒否 (書込集合 = 選択そのもの、意図した粗さ — 設計 rule 3)
626
+ if (rangesContainReadOnly(p.adapter, selection.ranges)) {
627
+ p.rejectReadOnly({ kind: "operation", op: "fill", cellCount: countRangeCells(selection.ranges) })
628
+ return false
629
+ }
630
+ // 検証ゲート (分冊 08 §4.2) は対象セル毎 — 規則はセル毎に違い得る。最初の strict 拒否で
631
+ // 走査を打ち切り applyEdits へ届けない (通知と message ポップオーバーは root の validateCommit が担う)
632
+ const edits: CellEdit[] = []
633
+ let rejected = false
634
+ enumerateRangeCells(selection.ranges, (cell) => {
635
+ if (rejected) {
636
+ return
637
+ }
638
+ if (!p.validateCommit(cell, text).ok) {
639
+ rejected = true
640
+ return
641
+ }
642
+ edits.push({ row: cell.row, col: cell.col, text })
643
+ })
644
+ // 拒否はセッション維持のまま false (Enter の keep 経路と同じ — Escape で取消 / 修正して再試行)
645
+ if (rejected) {
646
+ return false
647
+ }
648
+ if (p.commitEdits(edits, { source: "fill" }) === null) {
649
+ return false
650
+ }
651
+ // 値は全セルへ書かれた — セッションは破棄で閉じる (二重書込なし)
652
+ p.cancelEdit()
653
+ return true
654
+ }, [])
655
+
541
656
  /** Self-calc over the selection (source "calc"). / 選択の自己計算 (source "calc")。 */
542
657
  const runCalc = useCallback((transform: { text: (text: string) => string | null; formula: (formula: string) => string }): boolean => {
543
658
  const p = paramsRef.current
@@ -545,6 +660,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
545
660
  if (!gateSelectionWrite("calc", selection.ranges, p.maxOperationCells, p.onOperationRejected)) {
546
661
  return false
547
662
  }
663
+ // G11 (v0.10 §6.9): 読み取り専用壁 — 限度ゲート後・列挙 (アダプタ読み) 前の全体拒否
664
+ if (rangesContainReadOnly(p.adapter, selection.ranges)) {
665
+ p.rejectReadOnly({ kind: "operation", op: "calc", cellCount: countRangeCells(selection.ranges) })
666
+ return false
667
+ }
548
668
  const edits: CellEdit[] = []
549
669
  const adapter = p.adapter
550
670
  enumerateRangeCells(selection.ranges, (cell) => {
@@ -660,6 +780,68 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
660
780
  [activeHyperlink],
661
781
  )
662
782
 
783
+ /**
784
+ * G15 (v0.10 — design ADR-30): the SINGLE read-only pre-check for the popover-opening
785
+ * dispatches — the four popover-open command ids (`spec.kind === "popover"`) plus the
786
+ * `openHyperlink` host-undeclared fallback, whose execute arm opens the SAME link popover
787
+ * (Ctrl+K equivalent) and therefore walks the link arm of this gate. Returns false when the
788
+ * chord was consumed AND refused (the dispatcher aborts BEFORE its session `requestCommit` —
789
+ * doomed-open), true when dispatch may proceed: non-popover-opening commands (including
790
+ * `openHyperlink` with a declared host — a pure read), editable cells, and the
791
+ * content-PRESENT annotate chords (the normal open path then serves the viewer — S3 carries
792
+ * `cellReadOnly`, G14a is the wall; opening is a success, no callback). Content presence
793
+ * reads ONLY the command's own capability member (availability already guarantees the
794
+ * declaration); an empty note string / empty comment thread counts as ABSENT (design §8).
795
+ * `openValidationList` is always refused (the list surface has no view value).
796
+ * G15 (v0.10 — 設計 ADR-30): popover を開くディスパッチの**単一** pre-check — popover 系
797
+ * 4 コマンド id (`spec.kind === "popover"`) に加え、execute 腕が**同じ** link ポップオーバーを
798
+ * 開く `openHyperlink` の host 未宣言 fallback (Ctrl+K 同等) も link 腕を通る。false =
799
+ * チョード消費 + 拒否 (dispatcher はセッション requestCommit の**前**に中断 — doomed-open)、
800
+ * true = 続行 (popover を開かないコマンド / host 宣言時の `openHyperlink` (純粋な読み) /
801
+ * 編集可能セル / 内容**有**の注釈チョード — 通常 open 経路がビューアを開く。S3 が
802
+ * cellReadOnly を運び G14a が壁。開通は成功で callback なし)。内容有無は当該コマンドの能力
803
+ * メンバーだけを読む (availability が宣言を保証済み)。空文字ノート / 空スレッドは「無」扱い
804
+ * (設計 §8)。openValidationList は常に拒否 (list 面に閲覧価値は無い)。
805
+ */
806
+ const gateReadOnlyChord = useCallback(
807
+ (spec: CommandSpec): boolean => {
808
+ const p = paramsRef.current
809
+ // 壁の対象 = execute 腕が開く popover 種。openHyperlink の host 未宣言 fallback は
810
+ // Ctrl+K 同等の link open (宣言時は読み = 対象外。リンク無しの行不在は availability が先に落とす)
811
+ const popover: PopoverKind | undefined = spec.kind === "popover" ? spec.popover : spec.kind === "openHyperlink" && p.onHyperlinkOpen === undefined ? "link" : undefined
812
+ if (popover === undefined) {
813
+ return true
814
+ }
815
+ const active = p.getActive()
816
+ if (!isReadOnlyCell(p.adapter, active.row, active.col)) {
817
+ return true
818
+ }
819
+ if (popover === "list") {
820
+ // list 面に閲覧価値は無い (対話 = 書込そのもの) — 常に消費拒否
821
+ p.rejectReadOnly({ kind: "edit", cell: active, editSession: "none" })
822
+ return false
823
+ }
824
+ // 注釈 3 種は内容の有無で分岐 (このパッケージに独立ビューアは無い — WCAG 2.1.1)
825
+ let present: boolean
826
+ if (popover === "link") {
827
+ present = activeHyperlink() !== undefined
828
+ } else if (popover === "note") {
829
+ const note = p.adapter.getCellNote === undefined ? undefined : p.adapter.getCellNote(active.row, active.col)
830
+ present = note !== undefined && note !== ""
831
+ } else {
832
+ const thread = p.adapter.getCellComments === undefined ? undefined : p.adapter.getCellComments(active.row, active.col)
833
+ present = thread !== undefined && thread.length > 0
834
+ }
835
+ if (!present) {
836
+ // 内容無 = 純粋な追加意図 (doomed empty editor) — 消費拒否、requestCommit 不実行
837
+ p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: 1 })
838
+ return false
839
+ }
840
+ return true
841
+ },
842
+ [activeHyperlink],
843
+ )
844
+
663
845
  const executeCommand = useCallback(
664
846
  (command: SpreadsheetCommand): boolean => {
665
847
  const p = paramsRef.current
@@ -667,6 +849,14 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
667
849
  return false
668
850
  }
669
851
  const spec = COMMAND_SPECS[command]
852
+ // G15 (v0.10 §6.9 / 設計 ADR-30): popover を開くディスパッチ (popover 系 4 コマンド id +
853
+ // openHyperlink の host 未宣言 fallback) の**単一** pre-check — 座席は availability bail と
854
+ // dispatch 水準 requestCommit の**間** (doomed-open 原則: 拒否腕は無関係セッションを
855
+ // force-commit しない)。チョードは keymap が execute 前に消費済み。
856
+ // imperative 経路 (handle.executeCommand / commandDispatch.execute) も同関数で自動継承
857
+ if (!gateReadOnlyChord(spec)) {
858
+ return false
859
+ }
670
860
  const editing = p.isEditing()
671
861
  // 編集列に無いコマンドは先にセッションを確定する (拒否なら中断)
672
862
  if (editing && !EDITING_COLUMN_COMMANDS.has(command) && !p.requestCommit()) {
@@ -733,39 +923,7 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
733
923
  return runCalc({ text: reverseSignText, formula: reverseSignFormula })
734
924
  case "fill": {
735
925
  if (editing && spec.direction === "selection") {
736
- // 編集列: セッション文字列を全選択セルへ 1 applyEdits、セッション閉鎖
737
- const text = p.getSessionText()
738
- if (text === null) {
739
- return false
740
- }
741
- const selection = p.getSelection()
742
- if (!gateSelectionWrite("fill", selection.ranges, p.maxOperationCells, p.onOperationRejected)) {
743
- return false
744
- }
745
- // 検証ゲート (分冊 08 §4.2) は対象セル毎 — 規則はセル毎に違い得る。最初の strict 拒否で
746
- // 走査を打ち切り applyEdits へ届けない (通知と message ポップオーバーは root の validateCommit が担う)
747
- const edits: CellEdit[] = []
748
- let rejected = false
749
- enumerateRangeCells(selection.ranges, (cell) => {
750
- if (rejected) {
751
- return
752
- }
753
- if (!p.validateCommit(cell, text).ok) {
754
- rejected = true
755
- return
756
- }
757
- edits.push({ row: cell.row, col: cell.col, text })
758
- })
759
- // 拒否はセッション維持のまま false (Enter の keep 経路と同じ — Escape で取消 / 修正して再試行)
760
- if (rejected) {
761
- return false
762
- }
763
- if (p.commitEdits(edits, { source: "fill" }) === null) {
764
- return false
765
- }
766
- // 値は全セルへ書かれた — セッションは破棄で閉じる (二重書込なし)
767
- p.cancelEdit()
768
- return true
926
+ return runFillSelectionEditing()
769
927
  }
770
928
  return runFill(spec.direction)
771
929
  }
@@ -782,7 +940,8 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
782
940
  p.onHyperlinkOpen(url, p.getActive())
783
941
  return true
784
942
  }
785
- // 未宣言: link ポップオーバー (Open ボタンなし)
943
+ // 未宣言: link ポップオーバー (Open ボタンなし)。読み取り専用 × リンク無しの
944
+ // doomed empty editor は G15 の link 腕が拒否済み (ここに届くのは開通できる open のみ)
786
945
  p.openPopover("link")
787
946
  return true
788
947
  }
@@ -806,6 +965,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
806
965
  }
807
966
  // idle: active セルだけへ 1 applyEdits (選択全体には書かない)、直接確定 (セッションは無い = discarded)
808
967
  const active = p.getActive()
968
+ // G12 (v0.10 §6.9): 読み取り専用壁 — セッションレス "edit" 書込も validateCommit より前で拒否
969
+ if (isReadOnlyCell(p.adapter, active.row, active.col)) {
970
+ p.rejectReadOnly({ kind: "edit", cell: active, editSession: "none" })
971
+ return false
972
+ }
809
973
  // Excel の Ctrl+; は編集モード経由で Enter 確定が検証される (strict ⇒ 拒否) — 分冊 08 §4.2 の
810
974
  // fill / 貼り付け非検証免除はここへ及ばないため、strict 拒否は書込前に判定する
811
975
  // (通知と message ポップオーバーは root の validateCommit が担う)
@@ -838,7 +1002,7 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
838
1002
  }
839
1003
  }
840
1004
  },
841
- [isCommandAvailable, activeHyperlink, activeBase, writeConstantPatch, announceFormat, setBorders, runCalc, runFill, runHide],
1005
+ [isCommandAvailable, gateReadOnlyChord, activeHyperlink, activeBase, writeConstantPatch, announceFormat, setBorders, runCalc, runFill, runFillSelectionEditing, runHide],
842
1006
  )
843
1007
 
844
1008
  /** Commit gate shared by the annotation setters. / 注釈 setter 共通の commit ゲート。 */
@@ -861,6 +1025,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
861
1025
  return false
862
1026
  }
863
1027
  const active = p.getActive()
1028
+ // G13 (v0.10 §6.9): 読み取り専用壁 — commit-gate-first は登記済み品行 (§3 preamble)、壁は commit 前
1029
+ if (isReadOnlyCell(p.adapter, active.row, active.col)) {
1030
+ p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: 1 })
1031
+ return false
1032
+ }
864
1033
  return p.commitEdits([{ row: active.row, col: active.col, hyperlink: normalized }], { source: "annotate" }) !== null
865
1034
  },
866
1035
  [commitGate],
@@ -876,6 +1045,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
876
1045
  return false
877
1046
  }
878
1047
  const active = p.getActive()
1048
+ // G13 (v0.10 §6.9): 読み取り専用壁 — commit 前の単一ターゲット walk
1049
+ if (isReadOnlyCell(p.adapter, active.row, active.col)) {
1050
+ p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: 1 })
1051
+ return false
1052
+ }
879
1053
  return p.commitEdits([{ row: active.row, col: active.col, note: text }], { source: "annotate" }) !== null
880
1054
  },
881
1055
  [commitGate],
@@ -892,6 +1066,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
892
1066
  return false
893
1067
  }
894
1068
  const active = p.getActive()
1069
+ // G13 (v0.10 §6.9): 読み取り専用壁 — 既存スレッド読み (実体化) より前で拒否
1070
+ if (isReadOnlyCell(p.adapter, active.row, active.col)) {
1071
+ p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: 1 })
1072
+ return false
1073
+ }
895
1074
  // 既存スレッド (id つき) を verbatim に保ち input を末尾へ — 1 スレッドとして書く
896
1075
  const existing = read(active.row, active.col)
897
1076
  const thread: CellCommentInput[] = existing === undefined ? [input] : [...existing, input]
@@ -913,6 +1092,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
913
1092
  if (!gateSelectionWrite("annotate", ranges, p.maxOperationCells, p.onOperationRejected)) {
914
1093
  return false
915
1094
  }
1095
+ // G13 (v0.10 §6.9): 読み取り専用壁 — setValidation だけ選択レンジ walk (限度ゲート後・列挙前)
1096
+ if (rangesContainReadOnly(p.adapter, ranges)) {
1097
+ p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: countRangeCells(ranges) })
1098
+ return false
1099
+ }
916
1100
  const edits: CellEdit[] = []
917
1101
  enumerateRangeCells(ranges, (cell) => {
918
1102
  edits.push({ row: cell.row, col: cell.col, validation: rule })
@@ -18,7 +18,11 @@
18
18
  * F2 — plus the extension anchor of the pointed token (the corner a Shift+Arrow chain is
19
19
  * pinned to — Excel / Sheets keyboard pointing), and `data-aqsp-pointing` is a progressive
20
20
  * visual hint written after input / selectionchange and after every mutator here — never a
21
- * decision input.
21
+ * decision input. v0.10 read-only cells (plan §6.3): G1 refuses edit-mode entry for all six
22
+ * entry paths at the single `startEdit` funnel (between the sink-null bail and `discardClip` —
23
+ * ants kept, no scroll jump, before seeding) and G16 live-re-checks the session cell at
24
+ * `finishEdit` commit time (after the unchanged short-circuit, before `validateCommit`);
25
+ * both fire the required `rejectReadOnly` funnel, and an undeclared capability is byte-identical.
22
26
  *
23
27
  * セル編集セッション状態機械 (§6.7.1)。idle / editing(edit) / editing(replace) のセッション
24
28
  * 状態・composition 追跡・グリッド外 focusout commit・suppressFocusoutCommit を所有する。
@@ -34,13 +38,17 @@
34
38
  * Excel の Enter モード)、F2 でトグル — と指しトークンの伸長アンカー (Shift+Arrow 連鎖が固定
35
39
  * する角 — Excel / Sheets のキーボードポインティング)、`data-aqsp-pointing` は input /
36
40
  * selectionchange 後と当フックの各変更操作後に書く漸進的な視覚ヒントであり判断根拠にはしない。
41
+ * v0.10 読み取り専用セル (計画 §6.3): G1 は全 6 入口の単一漏斗 `startEdit` で編集モード進入を
42
+ * 拒否し (sink-null bail と `discardClip` の間 — アンツ保持・スクロール不跳び・シード前)、G16 は
43
+ * `finishEdit` の確定時にセッションセルを生再検査する (無変更短絡の後・`validateCommit` の前)。
44
+ * どちらも必須 `rejectReadOnly` 漏斗へ発火し、能力未宣言は従来挙動とバイト同一。
37
45
  */
38
46
 
39
47
  import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"
40
48
  import type { CellCoord, CellRange } from "../model/cellCoord.ts"
41
49
  import type { CellAlign } from "../model/cellFormat.ts"
42
50
  import { type EditPaint, IDLE_EDIT_PAINT } from "../model/cellPaint.ts"
43
- import { type CellEdit, type EditRejection, resolveEditText, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
51
+ import { type CellEdit, type EditRejection, isReadOnlyCell, type ReadOnlyRejection, resolveEditText, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
44
52
  import type { ValidationVerdict } from "../model/dataValidation.ts"
45
53
  import { cycleAnchorAt, findReferenceAt, formatRangeReference, isPointingPrefix, type PointingTarget, type ReferenceAxis } from "../model/formulaRefs.ts"
46
54
 
@@ -100,6 +108,17 @@ type UseCellEditorParams = {
100
108
  discardClip: () => void
101
109
  /** Drag/edit mutual exclusion probe (§6.7.1 entry guard). / ドラッグと編集セッションの併存禁止 (冒頭ガード)。 */
102
110
  isDragging: () => boolean
111
+ /**
112
+ * v0.10 read-only refusal funnel (required seam — the root always wires it; identity-stable,
113
+ * read through paramsRef): G1 (a refused entry, `editSession: "none"`) and G16 (a refused
114
+ * commit of a mid-session flipped cell, `editSession: "kept" | "discarded"` per the caller's
115
+ * declared fate) fire it once per attempt. Capability absent ⇒ never called (byte identity).
116
+ * v0.10 読み取り専用拒否漏斗 (必須 seam — root が常に渡す。identity 安定、paramsRef 読み):
117
+ * G1 (入口拒否 — `editSession: "none"`) と G16 (セッション途中で読み取り専用化されたセルの
118
+ * 確定拒否 — 呼び手宣言の帰結どおり `"kept" | "discarded"`) が試行毎 1 回発火する。能力
119
+ * 未宣言なら一度も呼ばれない (バイト恒等)。
120
+ */
121
+ rejectReadOnly: (rejection: ReadOnlyRejection) => void
103
122
  /**
104
123
  * The cell's effective alignment for the editing textarea (Excel keeps the cell alignment
105
124
  * while editing in place). null = the adapter has no getCellFormat capability — the sink's
@@ -285,8 +304,11 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
285
304
  }, [])
286
305
 
287
306
  /**
288
- * The single terminal transition (idempotent — no-op when idle). commit=true attempts
289
- * the validation gate then applyEdits; on rejection `onReject` decides the session's fate
307
+ * The single terminal transition (idempotent — no-op when idle). commit=true walks the
308
+ * pinned order: unchanged short-circuit → the v0.10 read-only live re-check (G16 — a
309
+ * mid-session flip refuses before `validateCommit`, firing `rejectReadOnly` with the
310
+ * caller-declared fate) → the validation gate → applyEdits; on rejection `onReject`
311
+ * decides the session's fate
290
312
  * and is DECLARED up front so the wrapper can relay it to onEditRejected: "keep" (in-grid
291
313
  * commits — the session stays open with the typed value) or "discard" (grid-exit focusout
292
314
  * — the session closes, the text-align / paint retire and the sink is re-armed immediately
@@ -295,7 +317,9 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
295
317
  * applyEdits: the session just ends (Sheets-like — opening an editor and leaving without a
296
318
  * change is not a write, so read-only hosts get a smooth focus-out instead of a rejection
297
319
  * loop).
298
- * 終端遷移の一本化 (冪等 — idle なら no-op)。commit=true は検証ゲート → applyEdits の順に
320
+ * 終端遷移の一本化 (冪等 — idle なら no-op)。commit=true は 無変更短絡 → v0.10 読み取り専用
321
+ * 生再検査 (G16 — セッション途中の読み取り専用化は `validateCommit` より前に拒否し、呼び手
322
+ * 宣言の帰結つきで `rejectReadOnly` へ発火) → 検証ゲート → applyEdits の順に
299
323
  * 試み、拒否時のセッションの行方は `onReject` が**事前に宣言**する (ラッパーが onEditRejected
300
324
  * へ中継できるように): "keep" (グリッド内確定 — 入力値ごと維持) / "discard" (グリッド外
301
325
  * focusout — セッションを閉じ、text-align / 塗りを退役し、フォーカスに触れずシンクを即再武装)。
@@ -333,13 +357,28 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
333
357
  // R4 是正)。replace モードで「打って全消し → 確定」は原文と異なるため従来どおり書込 (セルクリア)
334
358
  const canonical = resolveEditText(p.adapter, session.cell.row, session.cell.col).text.replace(/\r\n?/g, "\n")
335
359
  const unchanged = text === canonical
336
- // 拒否文脈は呼び手が事前宣言する (onEditRejected はラッパーが発火 — ここで重ねて発火しない):
337
- // keep = グリッド内確定 (セッション維持) / discard = グリッド外 focusout (破棄) の 2 値だけ
338
- const rejection: EditRejection = { source: "edit", editSession: options.onReject === "keep" ? "kept" : "discarded" }
339
- // 無変更は検証ゲートにも書込にも進まない (短絡)。検証ゲート (能力分岐) の拒否判定はアダプタへ
340
- // 届けず applyEdits 拒否と同じ keep / discard 経路へ。commitEdits は成功でトランザクション id、
341
- // 拒否で null (= 未確定)
342
- const committed = unchanged || ((p.validateCommit === undefined || p.validateCommit(session.cell, text).ok) && p.commitEdits([{ row: session.cell.row, col: session.cell.col, text }], rejection) !== null)
360
+ // 無変更は G16 にも検証ゲートにも書込にも進まない (短絡が先頭 — 開いて閉じただけは書込で
361
+ // ない: 読み取り専用化されたセルの focus-out も無音で閉じ、拒否ループに化けない)。
362
+ // 順序契約 unchanged → G16 → validateCommit → commitEdits (v0.10 §6.3 / RC-G16v)
363
+ let committed: boolean
364
+ if (unchanged) {
365
+ committed = true
366
+ } else if (isReadOnlyCell(p.adapter, session.cell.row, session.cell.col)) {
367
+ // G16 (v0.10 §6.3 / 設計 ADR-29): セッションと読み取り専用状態は両方ライブ —
368
+ // dataVersion 増分はセッションを取り消さないため、確定時にここで生再検査する。
369
+ // validateCommit より前 (退路の無い確定が strict 拒否メッセージポップオーバーを
370
+ // 開かない — RC-G16v)。帰結は呼び手宣言の keep / discard がそのまま payload の
371
+ // editSession になり、後段の既存 keep / discard 機構へ委譲する (strict 検証拒否と同形)
372
+ p.rejectReadOnly({ kind: "edit", cell: session.cell, editSession: options.onReject === "keep" ? "kept" : "discarded" })
373
+ committed = false
374
+ } else {
375
+ // 拒否文脈は呼び手が事前宣言する (onEditRejected はラッパーが発火 — ここで重ねて発火しない):
376
+ // keep = グリッド内確定 (セッション維持) / discard = グリッド外 focusout (破棄) の 2 値だけ
377
+ const rejection: EditRejection = { source: "edit", editSession: options.onReject === "keep" ? "kept" : "discarded" }
378
+ // 検証ゲート (能力分岐) の拒否判定はアダプタへ届けず applyEdits 拒否と同じ keep / discard
379
+ // 経路へ。commitEdits は成功でトランザクション id、拒否で null (= 未確定)
380
+ committed = (p.validateCommit === undefined || p.validateCommit(session.cell, text).ok) && p.commitEdits([{ row: session.cell.row, col: session.cell.col, text }], rejection) !== null
381
+ }
343
382
  if (!committed) {
344
383
  if (options.onReject === "discard") {
345
384
  // グリッド外 focusout の拒否 (§6.7.1): フォーカス無き宙吊りセッションを残さず、
@@ -380,9 +419,19 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
380
419
  if (sink === null) {
381
420
  return
382
421
  }
422
+ // G1 (v0.10 §6.3 / 設計 ADR-29): 全 6 入口の単一漏斗で読み取り専用を拒否する。座席は
423
+ // sink-null bail の直後・discardClip より前 — アンツ保持 (Excel 同様、拒否試行でコピー /
424
+ // カットのアンツを消さない)・スクロール不跳び・シード前の非破壊配置。既存 1 回の
425
+ // getActive 読みをゲート前へ巻上げ (§0.2 R3 — 追加読みゼロ、selection 読みは純粋で
426
+ // discardClip と独立)。E2/E3/E4 の打鍵 / 変換は武装シンクの自己修復が捨てる (isDragging
427
+ // 劣化と同じ検証済みの形)。発火は試行毎 1 回 (autorepeat = キーストローク毎)
428
+ const active = p.getActive()
429
+ if (isReadOnlyCell(p.adapter, active.row, active.col)) {
430
+ p.rejectReadOnly({ kind: "edit", cell: active, editSession: "none" })
431
+ return
432
+ }
383
433
  // 編集開始でクリップを破棄 (§6.5.4-4 — copy アンツも Excel 同様ここで消灯)
384
434
  p.discardClip()
385
- const active = p.getActive()
386
435
  // active はスクロールで画面外に出得る — 画面外セルの上にエディタ / IME 候補窓を開かない (§6.7.1)
387
436
  p.ensureCellVisible(active)
388
437
  if (mode === "edit") {