@aiquants/spreadsheet 0.9.0 → 0.11.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.
@@ -23,6 +23,11 @@
23
23
  * engine-validated pass-throughs onto VirtualGrid's `frozenTrailingCols` / `frozenTrailingRows`
24
24
  * — validation SSOT is the engine's fail-fast contract, no second layer here (§1.1).
25
25
  *
26
+ * v0.10 read-only cells (plan §6.5, S1): `getCellProps` conditionally stamps painted read-only
27
+ * cells with `aria-readonly="true"` + `data-aqsp-readonly=""` through the `isReadOnlyCell`
28
+ * helper (ADR-31 — no package CSS; capability absent ⇒ O(1) false ⇒ keys absent ⇒ the DOM
29
+ * stays byte-identical).
30
+ *
26
31
  * グリッド本体。VirtualGrid 結線 (両軸) とセルレンダラーを所有する。v0.4 から横軸は VirtualGrid
27
32
  * の所有 (量子化列アンカー + 残差 transform + 横バー) — v0.3 の列スタック (useColumnWindow /
28
33
  * --aqsp-hx CSS 変数 / 軸原点絶対 left / 自前 HScrollBar) は設計計画 §7-1 に従い削除。
@@ -41,16 +46,20 @@
41
46
  * v0.9 末尾凍結 (計画 §4.15): `frozenTrailingColumns` / `frozenTrailingRows` は VirtualGrid の
42
47
  * `frozenTrailingCols` / `frozenTrailingRows` へのエンジン検証素通し — 検証 SSOT はエンジンの
43
48
  * fail-fast 契約で、ここに第二検証層は置かない (§1.1)。
49
+ *
50
+ * v0.10 読み取り専用セル (計画 §6.5、S1): `getCellProps` が塗られた読み取り専用セルへ
51
+ * `aria-readonly="true"` + `data-aqsp-readonly=""` を `isReadOnlyCell` ヘルパ経由で条件スタンプ
52
+ * する (ADR-31 — パッケージ CSS なし。能力未宣言 = O(1) false = キー不在 = DOM バイト同一)。
44
53
  */
45
54
 
46
- import { VirtualGrid, type VirtualGridHandle, type VirtualGridRange } from "@aiquants/virtualscroll"
55
+ import { type ScrollBarTapCircleOptions, VirtualGrid, type VirtualGridHandle, type VirtualGridRange } from "@aiquants/virtualscroll"
47
56
  import { type HTMLAttributes, memo, type ProfilerOnRenderCallback, type ReactNode, type RefObject, useCallback, useMemo, useRef, useSyncExternalStore } from "react"
48
57
  import { type AxisModel, nextVisibleIndex } from "../model/axisModel.ts"
49
58
  import { normalizeAdapterHyperlink } from "../model/cellAnnotations.ts"
50
59
  import { alignToJustify, type CellAlign, type CellValueFormat, formatCellValue } from "../model/cellFormat.ts"
51
60
  import { type CellPaint, resolveCellPaint } from "../model/cellPaint.ts"
52
61
  import { type CfIndex, type CfMemo, createCfMemo, resolvePaintedFormat } from "../model/conditionalFormat.ts"
53
- import { lazyCellText, resolveCellAlign, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
62
+ import { isReadOnlyCell, lazyCellText, resolveCellAlign, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
54
63
  import { isVisiblyInvalid } from "../model/dataValidation.ts"
55
64
  import { SCROLLBAR_WIDTH, type ViewStore } from "../model/viewStore.ts"
56
65
  import { CellView, type RenderCellContext } from "./CellView.tsx"
@@ -299,6 +308,32 @@ type GridBodyProps = {
299
308
  * 焦点回収 — §4.2.11)。未指定 ⇒ v0.7 のゲート。
300
309
  */
301
310
  isPointing?: () => boolean
311
+ /**
312
+ * Tap-scroll circle tuning (v0.10 — the engine `scrollBarOptions.tapScrollCircleOptions`
313
+ * engine-validated pass-through; the engine's `resolveTapScrollCircleOptions` is the
314
+ * validation SSOT). Undefined rides the memo as an explicit key = the engine's declared
315
+ * absence signal (engine defaults), never a value fallback here.
316
+ * タップサークル調整 (v0.10 — エンジン `scrollBarOptions.tapScrollCircleOptions` の
317
+ * エンジン検証素通し。検証の SSOT はエンジンの `resolveTapScrollCircleOptions`)。undefined は
318
+ * キー同乗のままエンジン宣言の不在信号 (エンジン既定) — この層での値フォールバックなし。
319
+ */
320
+ tapScrollCircleOptions?: ScrollBarTapCircleOptions
321
+ /**
322
+ * Row-axis overscan (the engine `overscanRows` pass-through; the Spreadsheet surface owns
323
+ * the fail-fast validation — the engine clamps silently). Undefined ⇒ engine default (3).
324
+ * 行軸 overscan (エンジン `overscanRows` の素通し。fail-fast 検証は Spreadsheet 面所有 —
325
+ * エンジンは無言クランプ)。undefined ⇒ エンジン既定 (3)。
326
+ */
327
+ overscanRows?: number
328
+ /** Column-axis overscan — the column twin of `overscanRows`, same contract. / 列軸 overscan — `overscanRows` の列双子 (同契約)。 */
329
+ overscanCols?: number
330
+ /**
331
+ * Background layer behind the scroll-band rows (the engine `background` pass-through;
332
+ * viewport-pinned — the engine translates only its row wrapper).
333
+ * スクロール帯行背後の背景レイヤー (エンジン `background` の素通し。viewport 固定 —
334
+ * エンジンが translate するのは行ラッパーのみ)。
335
+ */
336
+ background?: ReactNode
302
337
  }
303
338
 
304
339
  /**
@@ -328,6 +363,10 @@ const GridBodyInner = ({
328
363
  cfIndexRef,
329
364
  cfMemoRef,
330
365
  isPointing,
366
+ tapScrollCircleOptions,
367
+ overscanRows,
368
+ overscanCols,
369
+ background,
331
370
  }: GridBodyProps): ReactNode => {
332
371
  const internalBodyRef = useRef<HTMLDivElement | null>(null)
333
372
  // 外部 ref (エンジン結線用) と内部 ref の合成
@@ -357,7 +396,8 @@ const GridBodyInner = ({
357
396
  // (§7-8 — サンプリング省略の Fenwick baseValue)。pointerDragInputs はペン / タッチのみ
358
397
  // (マウスドラッグは選択エンジンの所有)
359
398
  const behaviorOptions = useMemo(() => ({ enableKeyboardNavigation: false, pointerDragInputs: ["pen", "touch"] as const, resetOnGetItemHeightChange: true, resetOnGetColWidthChange: true, defaultColWidth: defaultColumnWidth }), [defaultColumnWidth])
360
- const scrollBarOptions = useMemo(() => ({ width: SCROLLBAR_WIDTH }), [])
399
+ // タップサークル素通し (v0.10 §2.1): 未指定は undefined キー同乗 = エンジン宣言の不在信号
400
+ const scrollBarOptions = useMemo(() => ({ width: SCROLLBAR_WIDTH, tapScrollCircleOptions }), [tapScrollCircleOptions])
361
401
  // 表示値書式の掛け金 (getCellProps が書き、同じセルの children が直後に消費 — 番兵は届かない座標)
362
402
  const displayLatchRef = useRef<DisplayLatch>({ row: -1, col: -1, value: undefined })
363
403
  /**
@@ -381,6 +421,11 @@ const GridBodyInner = ({
381
421
  "data-row": row,
382
422
  "data-col": col,
383
423
  className: "aqsp-cell",
424
+ // S1 (v0.10 §6.5 / 設計 ADR-31): 読み取り専用スタンプ — 閲覧モード / a11y ツリー用の
425
+ // aria-readonly と data-* フック (パッケージ CSS なし)。ヘルパ経由が規範 (member 名の
426
+ // 単一居住 — layer-2 census)。未宣言 = O(1) false = キー不在 = v0.9 と DOM バイト同一、
427
+ // 宣言ホストは塗りセル毎 1 述語読み (ADR-31 の登記コスト)
428
+ ...(isReadOnlyCell(adapter, row, col) ? { "aria-readonly": "true", "data-aqsp-readonly": "" } : {}),
384
429
  }
385
430
  const rich = resolveRichCellProps(adapter, rowAxis, colAxis, cf, row, col)
386
431
  // 掛け金は毎セル更新 (v0.1 恒等の null でも座標を進める — children が消費するのは書式能力下だけ)
@@ -491,6 +536,9 @@ const GridBodyInner = ({
491
536
  contentProps={contentProps}
492
537
  behaviorOptions={behaviorOptions}
493
538
  scrollBarOptions={scrollBarOptions}
539
+ overscanRows={overscanRows}
540
+ overscanCols={overscanCols}
541
+ background={background}
494
542
  callbackThrottleMs={0}
495
543
  onScroll={onScroll}
496
544
  onRangeChange={onRangeChange}
package/src/index.ts CHANGED
@@ -36,6 +36,10 @@
36
36
  * (分冊 00 §5.2)。
37
37
  */
38
38
 
39
+ // 統合タップサークル (エンジン v3.6.0 — ADR-33 縮小形): tapScrollCircleOptions prop (v0.10 H2) と
40
+ // renderVisual をエンジン直接依存なしで型付ける型のみ再 export。値面 (tapScrollCircleSampleVisual) は
41
+ // 意図的に非再 export — エンジン所有のまま
42
+ export type { ScrollBarTapCircleOptions, TapScrollCircleRenderProps } from "@aiquants/virtualscroll"
39
43
  export { type ClipCell, type ClipChannel, type ClipComment, type ClipFlavours, type ClipPayload, type ClipSource, detectClipSource, parseClipPayload, serializeClipPayload } from "./clipboard/clipPayload.ts"
40
44
  export { type HtmlTableKind, type HtmlTableResult, parseHtmlTable, serializeHtmlTable } from "./clipboard/htmlInterop.ts"
41
45
  // parseHtmlTable の第 3 引数型 — 復号器のホスト宣言 (codec 本体は非公開のまま、引数型だけ名指しできるようにする)
@@ -87,7 +91,7 @@ export type {
87
91
  ScalePoint,
88
92
  TextCondition,
89
93
  } from "./model/conditionalFormat.ts"
90
- export type { CellEdit, CellPatch, EditContext, EditRejection, EditSource, PasteEditContext, PlainEditContext, SpreadsheetDataAdapter } from "./model/dataAdapter.ts"
94
+ export type { CellEdit, CellPatch, EditContext, EditRejection, EditSource, PasteEditContext, PlainEditContext, ReadOnlyRejection, SpreadsheetDataAdapter } from "./model/dataAdapter.ts"
91
95
  // データ検証の語彙 — 規則 union / 判定に加え、共通オプション・7 つの腕・op 語彙・validateCellText の評価器型も名指しできる
92
96
  export {
93
97
  type CheckboxValidationRule,
@@ -29,7 +29,9 @@
29
29
  * Latin letter, else the physical `Key*` code), so they fire on non-Latin layouts (RU / EL …)
30
30
  * exactly like Excel / Sheets and like every command row; on Latin layouts the match is
31
31
  * identical to the produced-character test. Every addition is a capability param — absent =
32
- * the v0.7 keymap.
32
+ * the v0.7 keymap. v0.10 read-only cells (design §4.1, via the required `rejectReadOnly`
33
+ * funnel): G3 refuses the whole Delete / Backspace when the selection contains a read-only
34
+ * cell — after the limit gate, before enumeration; an undeclared capability is byte-identical.
33
35
  *
34
36
  * シンク keydown 上のキーマップ実装 (§6.6.1 の契約表 — idle 列。編集列は Phase 3 の編集
35
37
  * セッションへ委譲)。v0.2 で findDataEdge ナビゲーション + ディスパッチ前 fail-fast 検証 (§2)・
@@ -54,7 +56,9 @@
54
56
  * Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y / Ctrl+Shift+V) は表と同じ VK 規則 (`matchChord` — 生成文字がラテン
55
57
  * 文字ならその文字、非ラテンは物理 `Key*` code) で照合し、非ラテン配列 (RU / EL …) でも Excel /
56
58
  * Sheets やコマンド行と同じく成立する (ラテン配列では生成文字照合と同一)。追加は全て能力 param —
57
- * 不在 = v0.7 キーマップ。
59
+ * 不在 = v0.7 キーマップ。v0.10 読み取り専用セル (設計 §4.1、必須 `rejectReadOnly` 漏斗経由):
60
+ * G3 は選択に読み取り専用セルを含む Delete / Backspace を全体拒否する — 限度ゲート後・列挙前。
61
+ * 能力未宣言はバイト恒等。
58
62
  */
59
63
 
60
64
  import type { VirtualGridHandle } from "@aiquants/virtualscroll"
@@ -63,7 +67,7 @@ import type { PasteOptions } from "../clipboard/pasteSpecial.ts"
63
67
  import type { PointingSeams } from "../editor/useCellEditor.ts"
64
68
  import { type AxisModel, nextVisibleIndex } from "../model/axisModel.ts"
65
69
  import { type CellCoord, type CellRange, countRangeCells, enumerateRangeCells, makeRange, type SpreadsheetDirection, type SpreadsheetOperation } from "../model/cellCoord.ts"
66
- import { aspectCapabilities, type CellEdit, type EditRejection, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
70
+ import { aspectCapabilities, type CellEdit, type EditRejection, type ReadOnlyRejection, rangesContainReadOnly, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
67
71
  import { colToViewportX, rowToViewportY } from "../model/frozenPartition.ts"
68
72
  import { reduceActiveCycle, type SelectionState } from "../selection/selectionState.ts"
69
73
  import { COMPOSITION_CLEAR_MS, isCompositionKeydown } from "./compositionGuard.ts"
@@ -155,6 +159,17 @@ type UseGridKeyboardParams = {
155
159
  pendingPasteOptionsRef?: RefObject<PasteOptions | null>
156
160
  /** v0.8 capability (§2.7): the keyboard-pointing reference-rect writer — paints the pointed cell / range on the overlay's resident drag-rect node with the fixed "point" mode (the same node the reference drag paints; null hides it). Undefined = no rect (the pointing rows rewrite the reference only). / v0.8 能力 (§2.7): キーボードポインティングの参照矩形 writer — 指し先セル / レンジを overlay の常設ドラッグ矩形ノードへ固定 "point" モードで描く (参照ドラッグと同一ノード。null は消灯)。undefined = 矩形なし (ポインティング行は参照書換のみ)。 */
157
161
  onPointFrame?: (range: CellRange | null) => void
162
+ /**
163
+ * v0.10 read-only refusal funnel (required seam — the root always wires it; identity-stable,
164
+ * read through paramsRef): G3 (the Delete / Backspace whole-refusal when the selection
165
+ * contains a read-only cell, `{op:"delete"}`) fires it once per refused keystroke with the
166
+ * limit channel's own overlap-naive count. Capability absent ⇒ never called (byte identity).
167
+ * v0.10 読み取り専用拒否漏斗 (必須 seam — root が常に渡す。identity 安定、paramsRef 読み):
168
+ * G3 (選択に読み取り専用セルを含む Delete / Backspace の全体拒否 — `{op:"delete"}`) が拒否
169
+ * キーストローク毎 1 回、限度チャネルと同じ overlap-naive の数で発火する。能力未宣言なら
170
+ * 一度も呼ばれない (バイト恒等)。
171
+ */
172
+ rejectReadOnly: (rejection: ReadOnlyRejection) => void
158
173
  }
159
174
 
160
175
  /**
@@ -691,6 +706,12 @@ export const useGridKeyboard = (params: UseGridKeyboardParams): void => {
691
706
  p.onOperationRejected?.("delete", count, "limit")
692
707
  return
693
708
  }
709
+ // G3 (v0.10 設計 §4.1): 限度ゲート通過後・enumerateRangeCells 前の読み取り専用 walk —
710
+ // 混在選択は全体拒否 (消費形は限度拒否と同一、cellCount は既計算の overlap-naive 値を再利用)
711
+ if (p.adapter !== undefined && rangesContainReadOnly(p.adapter, state.ranges)) {
712
+ p.rejectReadOnly({ kind: "operation", op: "delete", cellCount: count })
713
+ return
714
+ }
694
715
  const edits: CellEdit[] = []
695
716
  // Delete はパッチ (v0.8 §4.2.7): text を消し、getCellHyperlink 宣言時だけ hyperlink も消す
696
717
  // (Excel: 内容の削除はリンクも外す)。未宣言側面のキーは出さない — 能力分岐 (adapter 未配線 = text のみ)
@@ -10,8 +10,12 @@
10
10
  * PAINTED format = base ⊕ conditional formats), lazyCellText (the once-only text thunk = the
11
11
  * eighth read of a cell), resolveEditText (the editor seed — formula source or canonical text,
12
12
  * fail-fast on a formula that does not start with "=") and aspectCapabilities (the write gate's
13
- * single reader of the declared aspects). Every capability is a DECLARATION: absence is a code
14
- * branch that reproduces the v0.7 behaviour exactly, never a value fallback.
13
+ * single reader of the declared aspects). v0.10 adds the 11th optional member `isCellReadOnly`
14
+ * (a NON-aspect capability), the refusal context `ReadOnlyRejection` and the read-only helper
15
+ * quartet (readOnlyDeclared / isReadOnlyCell / rangesContainReadOnly / rectContainsReadOnly) —
16
+ * the ONLY production module where the member name appears (layer-2 census, ADR-27). Every
17
+ * capability is a DECLARATION: absence is a code branch that reproduces the previous behaviour
18
+ * exactly, never a value fallback.
15
19
  * グリッドと consumer の唯一のデータ境界 (v0.8 — ADR-13 リッチセル / ADR-16 書込語彙): 8 つの
16
20
  * 任意リッチセル能力読みを持つアダプタ型、グリッドが書くセル単位パッチ (CellPatch / CellEdit —
17
21
  * 未指定キーは「触らない」、`null` は「消す」)、全 applyEdits が受け取る判別可能な書込文脈
@@ -158,6 +162,41 @@ void plainContextNeverPaste
158
162
  // 混入いずれも即赤)
159
163
  export type EditRejection = { source: "edit"; editSession: "kept" | "discarded" } | { source: "paste" | "fill" | "delete" | "format" | "calc" | "annotate" }
160
164
 
165
+ /**
166
+ * Context of a READ-ONLY refusal (v0.10, delivered by onReadOnlyRejected): fired once per
167
+ * refused gesture — the adapter was never called, so this is neither an {@link EditRejection}
168
+ * (applyEdits never threw) nor an operation limit. `kind: "edit"` is a refused single-cell
169
+ * text-write entry or commit and carries the cell; `kind: "operation"` is a refused whole
170
+ * operation with its producer's own overlap-naive `cellCount` arithmetic (the same numbers the
171
+ * limit channel reports — never a dedup count).
172
+ * 読み取り専用拒否の文脈 (v0.10 — onReadOnlyRejected が届ける): 拒否されたジェスチャ毎に 1 回
173
+ * 発火。adapter は呼ばれていないため {@link EditRejection} (applyEdits の throw) でも操作上限
174
+ * でもない。`kind: "edit"` はセル単位テキスト書込の入口 / 確定の拒否でセル座標を運び、
175
+ * `kind: "operation"` は操作全体の拒否で、`cellCount` はプロデューサ自身の overlap-naive 算術
176
+ * (上限チャネルと同じ数 — dedup 数ではない)。
177
+ */
178
+ // 明示リテラル union で宣言する (api-extractor は Exclude<SpreadsheetOperation, ...> 等の lib 汎用型を
179
+ // d.ts ロールアップできない — EditRejection と同じ制約)。op の 7 リテラル (copy / cut / resize を除いた
180
+ // 集合 + "cut") と editSession の 3 リテラルは dataAdapter.spec.ts の型パリティガードが双方向に機械封印する
181
+ export type ReadOnlyRejection =
182
+ | {
183
+ readonly kind: "edit"
184
+ readonly cell: CellCoord
185
+ /**
186
+ * Fate of the edit session THIS refusal adjudicated: "none" = no session commit was
187
+ * adjudicated (no session existed at the refusing gate, or the gate does not commit
188
+ * sessions — an untouched session may still exist, e.g. the G15 list chord during a
189
+ * flipped session); "kept" / "discarded" = G16's caller-declared fate.
190
+ * この拒否が裁いた編集セッションの帰結。none = セッション確定を裁いていない。
191
+ */
192
+ readonly editSession: "none" | "kept" | "discarded"
193
+ }
194
+ | {
195
+ readonly kind: "operation"
196
+ readonly op: "cut" | "delete" | "paste" | "fill" | "format" | "calc" | "annotate"
197
+ readonly cellCount: number
198
+ }
199
+
161
200
  /**
162
201
  * The single data boundary between the grid and the consumer. Reads via getCellText (renderCell
163
202
  * — a SpreadsheetProps prop, not an adapter member — customizes only the presentation of that
@@ -344,6 +383,21 @@ export type SpreadsheetDataAdapter = {
344
383
  * PasteEditContext.conditionalFormats で届く)。未宣言 = base 書式をそのまま塗る。
345
384
  */
346
385
  getConditionalFormats?: () => readonly ConditionalFormatRule[]
386
+ /**
387
+ * Read-only declaration for one cell (v0.10 read-only cells). `true` = the cell can never be
388
+ * written by this grid: edit-mode entry is refused, an already-open session's commit re-checks
389
+ * this predicate live, and every write operation whose target set contains the cell is rejected
390
+ * WHOLE before the adapter is called. Consulted live per gesture (never memoised); publish
391
+ * changes via `dataVersion`. The grid only ever calls this with in-grid coordinates. Host cost
392
+ * budget: O(1) and allocation-free — the same budget as `getCellText` (it also runs once per
393
+ * painted read-only-capable cell). Absence = every cell editable = the previous version's
394
+ * behaviour byte-identically.
395
+ * 読み取り専用宣言 (v0.10)。true のセルは編集モードに入れず、開いているセッションの確定も
396
+ * ライブ再検査され、対象に含む書込操作は adapter 到達前に全体拒否。ジェスチャ毎に生読み、
397
+ * 変更は dataVersion で公開。座標は常にグリッド内。ホスト側コストは O(1)・割当なし
398
+ * (getCellText と同格、描画パスでも呼ばれるため)。未宣言 = 全セル編集可 = 従来挙動とバイト同一。
399
+ */
400
+ isCellReadOnly?: (row: number, col: number) => boolean
347
401
  /**
348
402
  * OPTIONAL capability (v0.8): the numeric minimum / maximum over a range, for colorScale
349
403
  * rules whose end points omit `value`; null = the range holds no numeric value. Memoised by
@@ -476,3 +530,68 @@ export const aspectCapabilities = (adapter: SpreadsheetDataAdapter): AspectCapab
476
530
  cf: adapter.getConditionalFormats !== undefined,
477
531
  formula: adapter.getCellFormula !== undefined,
478
532
  })
533
+
534
+ /**
535
+ * Whether the read-only capability is declared (v0.10 ADR-27). Used only where
536
+ * declaration-presence itself is the semantic — the announcement payload key and the popover
537
+ * context key: a declared host gets an explicit `false` for editable cells, an undeclared host
538
+ * gets no key at all (byte-identical payloads).
539
+ * 読み取り専用能力が宣言されているか (v0.10 ADR-27)。宣言の有無そのものが意味になる場所
540
+ * (announcement payload キー / ポップオーバー context キー) 専用 — 宣言ホストは編集可セルに
541
+ * 明示 `false`、未宣言ホストはキー自体なし (payload はバイト同一)。
542
+ */
543
+ export const readOnlyDeclared = (adapter: SpreadsheetDataAdapter): boolean => adapter.isCellReadOnly !== undefined
544
+
545
+ /**
546
+ * Live read-only answer for one cell: `false` in O(1) without calling anything when the
547
+ * capability is absent (the byte-identity lever — every gate and stamp goes through this
548
+ * helper, never the member; v0.10 ADR-27 / ADR-31). A throw from the host predicate is a host
549
+ * bug and propagates (no try/catch — Strict No-Fallback).
550
+ * セル 1 つの読み取り専用ライブ判定: 能力未宣言なら何も呼ばず O(1) で `false` (バイト恒等の
551
+ * 支点 — 全ゲート / スタンプはメンバー直接読みでなく必ず本ヘルパを通る。v0.10 ADR-27 /
552
+ * ADR-31)。ホスト述語の throw はホストのバグとして素通し (try/catch しない — Strict No-Fallback)。
553
+ */
554
+ export const isReadOnlyCell = (adapter: SpreadsheetDataAdapter, row: number, col: number): boolean => adapter.isCellReadOnly?.(row, col) === true
555
+
556
+ /**
557
+ * Whether a grid-clamped rect contains at least one read-only cell: rectangular walk with
558
+ * short-circuit on first hit; `false` in O(1) when the capability is absent. For the
559
+ * paste / cut / cut-move rect walls (v0.10 ADR-27).
560
+ * グリッド内 clamp 済み矩形に読み取り専用セルが 1 つでも含まれるか: 最初のヒットで短絡する
561
+ * 矩形 walk。能力未宣言は O(1) で `false`。paste / cut / cut-move の矩形壁用 (v0.10 ADR-27)。
562
+ */
563
+ export const rectContainsReadOnly = (adapter: SpreadsheetDataAdapter, rect: CellRange): boolean => {
564
+ // 能力分岐: 未宣言は述語 0 呼びの O(1) false (v0.9 挙動とバイト同一)
565
+ const predicate = adapter.isCellReadOnly
566
+ if (predicate === undefined) {
567
+ return false
568
+ }
569
+ // 矩形 walk — 最初のヒットで短絡 (割当なし。上限ゲートが先に走るため walk は有界)
570
+ for (let row = rect.startRow; row <= rect.endRow; row += 1) {
571
+ for (let col = rect.startCol; col <= rect.endCol; col += 1) {
572
+ if (predicate(row, col)) {
573
+ return true
574
+ }
575
+ }
576
+ }
577
+ return false
578
+ }
579
+
580
+ /**
581
+ * Whether a range set contains at least one read-only cell: plain nested-loop enumeration with
582
+ * short-circuit on first hit and NO dedup (re-testing an overlapped cell of a pure predicate is
583
+ * harmless; a dedup Set would add O(cells) allocation inside a gate that must be
584
+ * allocation-free); `false` in O(1) when the capability is absent (v0.10 ADR-27).
585
+ * レンジ集合に読み取り専用セルが 1 つでも含まれるか: 素朴 2 重ループ列挙 + 最初のヒットで短絡、
586
+ * dedup なし (純述語の重複再検査は無害。dedup Set は割当ゼロであるべきゲートに O(セル数) の
587
+ * 割当を持ち込む)。能力未宣言は O(1) で `false` (v0.10 ADR-27)。
588
+ */
589
+ export const rangesContainReadOnly = (adapter: SpreadsheetDataAdapter, ranges: readonly CellRange[]): boolean => {
590
+ // 矩形 walk は rectContainsReadOnly と単一実装を共有 (SSOT — 能力分岐も同関数の冒頭 1 箇所)
591
+ for (const range of ranges) {
592
+ if (rectContainsReadOnly(adapter, range)) {
593
+ return true
594
+ }
595
+ }
596
+ return false
597
+ }
@@ -45,6 +45,8 @@ export type SpreadsheetLabels = {
45
45
  noteEscapeHint: string
46
46
  /** Note popover hint in the rejected state (Ctrl+Enter retries, Escape closes without saving). / 拒否状態のノートヒント。 */
47
47
  noteRejectedHint: string
48
+ /** Note popover hint in the v0.10 read-only viewer mode (Escape closes without saving — the commit affordance is disabled, so the hint must not promise a save). / 読み取り専用ビューアモード (v0.10) のノートヒント (Escape は保存せず閉じる — 確定操作は disabled のため保存を約束しない)。 */
49
+ noteViewerHint: string
48
50
  /** Comment popover heading. / コメントポップオーバーの見出し。 */
49
51
  commentTitle: string
50
52
  /** Comment reply field label. / 返信入力欄のラベル。 */
@@ -103,6 +105,7 @@ export const LABEL_KEYS = [
103
105
  "noteSave",
104
106
  "noteEscapeHint",
105
107
  "noteRejectedHint",
108
+ "noteViewerHint",
106
109
  "commentTitle",
107
110
  "commentReply",
108
111
  "commentPost",
@@ -129,10 +132,12 @@ void labelKeysCoverEveryKey
129
132
 
130
133
  /**
131
134
  * English defaults for every key. `sinkAriaLabel` / `rootAriaLabel` are byte-identical to the
132
- * v0.7 prop defaults (the fold is DOM-identical); `noteEscapeHint` = "Escape saves and closes";
135
+ * v0.7 prop defaults (the fold is DOM-identical); `noteEscapeHint` = "Escape saves and closes"
136
+ * and its v0.10 viewer counterpart `noteViewerHint` = "Escape closes without saving";
133
137
  * `commentCount` and `listMore` pluralise ("1 comment" / "n comments", "1 more value" / "n more values"). Frozen.
134
138
  * 全キーの英語既定。`sinkAriaLabel` / `rootAriaLabel` は v0.7 の prop 既定と byte 同一 (畳込みは
135
- * DOM 恒等)、`noteEscapeHint` = "Escape saves and closes"、`commentCount` / `listMore` は単複を区別。凍結済み。
139
+ * DOM 恒等)、`noteEscapeHint` = "Escape saves and closes" とその v0.10 ビューア対応
140
+ * `noteViewerHint` = "Escape closes without saving"、`commentCount` / `listMore` は単複を区別。凍結済み。
136
141
  */
137
142
  export const DEFAULT_LABELS: SpreadsheetLabels = Object.freeze({
138
143
  sinkAriaLabel: "Spreadsheet grid. Press Escape to move focus out of the grid.",
@@ -141,6 +146,7 @@ export const DEFAULT_LABELS: SpreadsheetLabels = Object.freeze({
141
146
  noteSave: "Save",
142
147
  noteEscapeHint: "Escape saves and closes",
143
148
  noteRejectedHint: "The note was rejected. Press Ctrl+Enter to retry or Escape to close without saving",
149
+ noteViewerHint: "Escape closes without saving",
144
150
  commentTitle: "Comments",
145
151
  commentReply: "Reply",
146
152
  commentPost: "Post",
@@ -15,6 +15,13 @@
15
15
  * plus every declared aspect (format / validation / hyperlink / note), comments never; a v0.7
16
16
  * adapter yields exactly `{ row, col, text }`.
17
17
  *
18
+ * v0.10 read-only wall (G7 — design ADR-28 rule 5): `confirmFill` walks the ≤2-rect disjoint
19
+ * decomposition of box ∖ source (`fillExtensionRects` — diagonal L legs included) after the limit
20
+ * gate and before `expandFill`; a hit refuses the WHOLE fill through the required
21
+ * `rejectReadOnly` funnel with `cellCount` = box area − source area (exact — deliberately not the
22
+ * limit gate's estimate) and the limit-refusal cleanup (no selection growth, no bumpEdit). Fill
23
+ * FROM a read-only source stays legal; capability undeclared = byte-identical behaviour.
24
+ *
18
25
  * v0.9 trailing freeze (plan §2.1 / §4.5 — census #5 / #6): the hover hit test builds BOTH
19
26
  * inverse ctx literals inline from one handle snapshot (a NEW judgment-time `getViewportSize()`
20
27
  * read supplies `extent`; `trailingSize` cites the TREE fields); the press-time grab arithmetic
@@ -38,6 +45,12 @@
38
45
  * 宣言済み側面 (format / validation / hyperlink / note) の全部、comments は決して運ばず、v0.7
39
46
  * アダプタは `{ row, col, text }` ちょうど。
40
47
  *
48
+ * v0.10 読み取り専用壁 (G7 — 設計 ADR-28 rule 5): `confirmFill` は限度ゲート後・`expandFill` 前に
49
+ * box ∖ source の ≤2 非交矩形分解 (`fillExtensionRects` — 斜めの L 字 2 レッグ込み) を walk し、
50
+ * hit は必須 `rejectReadOnly` 漏斗経由で fill 全体を拒否する (cellCount = box 面積 − source 面積 —
51
+ * 限度ゲートの見積りとは意図的に別の数)。後始末は限度拒否と同一 (選択不伸長・bumpEdit なし)。
52
+ * 読み取り専用ソースからの fill は合法のまま。能力未宣言は挙動バイト恒等。
53
+ *
41
54
  * v0.9 末尾凍結 (計画 §2.1 / §4.5 — census #5 / #6): hover ヒットテストは 1 ハンドル快照から
42
55
  * 両軸の逆写像 ctx をインライン構築 (新規の判断時 `getViewportSize()` 読みが `extent` を供給、
43
56
  * `trailingSize` は TREE フィールドを引用)。押下時の grab 算術は順写像 ctx 対を構築し、隅を
@@ -48,10 +61,10 @@
48
61
 
49
62
  import type { VirtualGridHandle } from "@aiquants/virtualscroll"
50
63
  import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"
51
- import { estimateFillCellCount, expandFill, fillSourceEdit, readFillSource } from "../clipboard/tiling.ts"
64
+ import { estimateFillCellCount, expandFill, fillExtensionRects, fillSourceEdit, readFillSource } from "../clipboard/tiling.ts"
52
65
  import type { AxisModel } from "../model/axisModel.ts"
53
66
  import { type CellCoord, type CellRange, clampCoord, makeRange, type SpreadsheetOperation } from "../model/cellCoord.ts"
54
- import type { CellEdit, EditRejection, SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
67
+ import { type CellEdit, type EditRejection, type ReadOnlyRejection, rangesContainReadOnly, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
55
68
  import { type AxisForwardCtx, colToViewportX, rowToViewportY, viewportXToCol, viewportYToRow } from "../model/frozenPartition.ts"
56
69
  import type { SelectionState } from "../selection/selectionState.ts"
57
70
  import type { AutoScroller } from "../selection/useAutoScroller.ts"
@@ -101,6 +114,14 @@ type UseFillHandleParams = {
101
114
  commitEdits: (edits: readonly CellEdit[], rejection: EditRejection) => number | null
102
115
  maxOperationCells: number
103
116
  onOperationRejected?: (op: SpreadsheetOperation, cellCount: number, reason: "limit" | "bounds") => void
117
+ /**
118
+ * v0.10 read-only refusal funnel (required seam — the root always wires it; identity-stable,
119
+ * read through paramsRef): G7 fires it once per refused confirm. Capability absent ⇒ never
120
+ * called (byte identity).
121
+ * v0.10 読み取り専用拒否漏斗 (必須 seam — root が常に渡す。identity 安定、paramsRef 読み):
122
+ * G7 が拒否された確定毎に 1 回発火する。能力未宣言なら一度も呼ばれない (バイト恒等)。
123
+ */
124
+ rejectReadOnly: (rejection: ReadOnlyRejection) => void
104
125
  /** Preview writer relay (overlay-owned positioner + liveFillPreviewRef). / プレビュー writer リレー。 */
105
126
  writeFillPreview: (range: CellRange | null) => void
106
127
  /** §8.2 repaint edges (press after flag + cleanup end). / §8.2 の repaint エッジ。 */
@@ -265,6 +286,18 @@ export const useFillHandle = (params: UseFillHandleParams): SelectionEngine => {
265
286
  p.onOperationRejected?.("fill", count, "limit")
266
287
  return
267
288
  }
289
+ // G7 (v0.10 §6.8 / 設計 ADR-28 rule 5): 読み取り専用壁 — walk は拡張域 (box ∖ source) の
290
+ // ≤2 非交矩形分解 fillExtensionRects (斜めドラッグの L 字 2 レッグ対応)。ソース行 / 列は
291
+ // 書込対象ではないため walk 外 = 読み取り専用ソースからの fill は合法 (Excel 同等)。座席は
292
+ // 限度ゲート後・expandFill (実体化) 前。cellCount = box 面積 − source 面積 (正確 — 限度
293
+ // チャネルの見積り estimateFillCellCount とは別の数、登記済み非対称)。後始末は限度拒否と
294
+ // 同一 (cleanupDrag 済み — 選択不伸長・bumpEdit なし)
295
+ if (rangesContainReadOnly(p.adapter, fillExtensionRects(source, box))) {
296
+ const boxArea = (box.endRow - box.startRow + 1) * (box.endCol - box.startCol + 1)
297
+ const sourceArea = (source.endRow - source.startRow + 1) * (source.endCol - source.startCol + 1)
298
+ p.rejectReadOnly({ kind: "operation", op: "fill", cellCount: boxArea - sourceArea })
299
+ return
300
+ }
268
301
  // 展開は fillDown / fillRight と同じソース毎規則 (分冊 08 §4 の fill 行): 種 (数式ソースか正準
269
302
  // 文字列) + 宣言済み側面を 1 回読み、対象毎に数式を相対シフト — v0.7 アダプタは { row, col, text } ちょうど
270
303
  const edits = expandFill((row, col) => readFillSource(p.adapter, row, col), source, box, fillSourceEdit)
@@ -6,12 +6,17 @@
6
6
  * Ctrl+Enter posts (retries after a rejection), Escape closes. Leaving by focus closes without
7
7
  * posting. DOM hooks: `data-aqsp-popover="comment"`, `data-aqsp-popover-title`,
8
8
  * `data-aqsp-comment-thread`, `data-aqsp-comment-id`, `data-aqsp-comment-input`,
9
- * `data-aqsp-popover-post`, `data-aqsp-popover-close`, `data-aqsp-popover-rejected`.
9
+ * `data-aqsp-popover-post`, `data-aqsp-popover-close`, `data-aqsp-popover-rejected`. v0.10 viewer
10
+ * mode (`ctx.cellReadOnly === true`): the Post button is `disabled` + `aria-disabled` and
11
+ * Ctrl+Enter does nothing — the thread stays readable (the wall is G14a; key absent =
12
+ * v0.9-identical conduct).
10
13
  *
11
14
  * 既定のコメントスレッド (v0.8 §4.1.16): 既存スレッド (著者 / 時刻 / 本文 — 各件は consumer の id を
12
15
  * 保つ) + 返信 textarea + 投稿ボタン。投稿はスレッド全体 `[...current (id つき), { text }]` を
13
16
  * commit する — id / 著者 / 時計はパッケージが採番しない (設計 v5「Comments」)。空の返信は投稿
14
17
  * しない。キー: Ctrl+Enter 投稿 (拒否後は再試行)、Escape で閉じる。フォーカス離脱は投稿せず閉じる。
18
+ * v0.10 ビューアモード (`ctx.cellReadOnly === true`): 投稿ボタンは `disabled` + `aria-disabled`、
19
+ * Ctrl+Enter は何もしない — スレッドは読めるまま (壁は G14a。キー不在 = v0.9 恒等)。
15
20
  */
16
21
 
17
22
  import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"
@@ -26,6 +31,9 @@ export type CommentPopoverProps = { ctx: CommentPopoverContext; labels: Spreadsh
26
31
  */
27
32
  export const CommentPopover = ({ ctx, labels }: CommentPopoverProps): ReactNode => {
28
33
  const [reply, setReply] = useState("")
34
+ // v0.10 (設計 ADR-30): 宣言ゲートキーが true のときだけ確定操作を disabled に (キー不在 = v0.9 恒等)。
35
+ // 壁は G14a — ここは表示であって防壁ではない
36
+ const readOnly = ctx.cellReadOnly === true
29
37
  const titleId = useId()
30
38
  const settlingRef = useRef(false)
31
39
 
@@ -38,6 +46,10 @@ export const CommentPopover = ({ ctx, labels }: CommentPopoverProps): ReactNode
38
46
  }
39
47
  }
40
48
  const post = (): void => {
49
+ // 読み取り専用ビューア: 投稿は不可 (disabled ボタンと同じ品行 — Ctrl+Enter も何もしない)
50
+ if (readOnly) {
51
+ return
52
+ }
41
53
  // 空 (空白のみ) の返信は投稿しない
42
54
  if (reply.trim() === "") {
43
55
  return
@@ -97,7 +109,7 @@ export const CommentPopover = ({ ctx, labels }: CommentPopoverProps): ReactNode
97
109
  onKeyDown={onKeyDown}
98
110
  />
99
111
  <div className="aqsp-popover-actions">
100
- <button type="button" className="aqsp-popover-button" data-aqsp-popover-post="" onClick={post}>
112
+ <button type="button" className="aqsp-popover-button" data-aqsp-popover-post="" disabled={readOnly} aria-disabled={readOnly ? "true" : undefined} onClick={post}>
101
113
  {resolveLabel(labels, "commentPost")}
102
114
  </button>
103
115
  <button type="button" className="aqsp-popover-button" data-aqsp-popover-close="" aria-label={resolveLabel(labels, "popoverClose")} onClick={() => settle(ctx.close)}>
@@ -10,14 +10,19 @@
10
10
  * without saving. DOM hooks: `data-aqsp-popover="link"`, `data-aqsp-popover-title`,
11
11
  * `data-aqsp-link-text`, `data-aqsp-link-url`, `data-aqsp-popover-save`, `data-aqsp-link-open`
12
12
  * (the Open button — distinct from the layer's anchor open-state `data-aqsp-popover-open`),
13
- * `data-aqsp-popover-close`, `data-aqsp-popover-rejected`.
13
+ * `data-aqsp-popover-close`, `data-aqsp-popover-rejected`. v0.10 viewer mode
14
+ * (`ctx.cellReadOnly === true`): Save is `disabled` + `aria-disabled` and Enter does nothing,
15
+ * while the URL stays readable and Open stays live (navigation is a read — the wall is G14a; key
16
+ * absent = v0.9-identical conduct).
14
17
  *
15
18
  * 既定のハイパーリンクエディタ (v0.8 §4.1.16): text + URL 欄、保存、Open (ホストが onHyperlinkOpen を
16
19
  * 宣言し (`ctx.canOpen`) セルにリンクがあるときだけ)、閉じる。URL は打鍵毎に normalizeHyperlink で
17
20
  * 検証し、正規化不能な非空値は不正表示 (`data-aqsp-link-invalid` / `aria-invalid`) で保存を拒む —
18
21
  * commit は常に正規化済み href か null (空欄 = リンク消去) だけを運ぶ。空の text 下書き + 有効 URL の
19
22
  * 保存は href を表示テキストとして commit する (Sheets / Excel 同等 — 空セルのリンクを見えないままに
20
- * しない)。キー: Enter 保存、Escape で閉じる。フォーカス離脱は保存せず閉じる。
23
+ * しない)。キー: Enter 保存、Escape で閉じる。フォーカス離脱は保存せず閉じる。v0.10 ビューアモード
24
+ * (`ctx.cellReadOnly === true`): 保存は `disabled` + `aria-disabled` で Enter も何もしないが、URL は
25
+ * 読めたまま Open も生きている (ナビゲーションは読み — 壁は G14a。キー不在 = v0.9 恒等)。
21
26
  */
22
27
 
23
28
  import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"
@@ -34,6 +39,9 @@ export type LinkPopoverProps = { ctx: LinkPopoverContext; labels: SpreadsheetLab
34
39
  export const LinkPopover = ({ ctx, labels }: LinkPopoverProps): ReactNode => {
35
40
  const [text, setText] = useState(ctx.current.text)
36
41
  const [url, setUrl] = useState(ctx.current.url === undefined ? "" : ctx.current.url)
42
+ // v0.10 (設計 ADR-30): 宣言ゲートキーが true のときだけ確定操作を disabled に (キー不在 = v0.9 恒等)。
43
+ // Open は読み (ナビゲーション) なので生きたまま。壁は G14a — ここは表示であって防壁ではない
44
+ const readOnly = ctx.cellReadOnly === true
37
45
  const titleId = useId()
38
46
  const settlingRef = useRef(false)
39
47
  // 空欄はリンク消去、それ以外は正規化 (null = 不正)
@@ -49,6 +57,10 @@ export const LinkPopover = ({ ctx, labels }: LinkPopoverProps): ReactNode => {
49
57
  }
50
58
  }
51
59
  const save = (): void => {
60
+ // 読み取り専用ビューア: 保存は不可 (disabled ボタンと同じ品行 — Enter も何もしない)
61
+ if (readOnly) {
62
+ return
63
+ }
52
64
  // 不正 URL は保存しない (表示で知らせる — 代替値は作らない)
53
65
  if (invalid) {
54
66
  return
@@ -91,7 +103,7 @@ export const LinkPopover = ({ ctx, labels }: LinkPopoverProps): ReactNode => {
91
103
  <input className="aqsp-popover-input" data-aqsp-link-url="" data-aqsp-popover-autofocus="" type="url" value={url} aria-invalid={invalid || ctx.rejected ? true : undefined} onChange={(event) => setUrl(event.currentTarget.value)} onKeyDown={onKeyDown} />
92
104
  </label>
93
105
  <div className="aqsp-popover-actions">
94
- <button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" disabled={invalid} onClick={save}>
106
+ <button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" disabled={invalid || readOnly} aria-disabled={readOnly ? "true" : undefined} onClick={save}>
95
107
  {resolveLabel(labels, "linkSave")}
96
108
  </button>
97
109
  {ctx.canOpen && ctx.current.url !== undefined ? (
@@ -5,17 +5,26 @@
5
5
  * focus saves-if-changed — an anchor scrolled under a frozen band is clipped, not un-rendered
6
6
  * (SelectionOverlay `placeAnchor`), so the textarea keeps focus and that is NOT a leave: the
7
7
  * editor hides and comes back with its draft. The textarea's `aria-describedby` points at the hint paragraph, which
8
- * swaps between `labels.noteEscapeHint` and `labels.noteRejectedHint` with the rejected state.
8
+ * swaps among `labels.noteEscapeHint` (normal), `labels.noteRejectedHint` (rejected) and
9
+ * `labels.noteViewerHint` (viewer mode — precedence over the rejected wording, since neither save
10
+ * nor retry is possible there and the hint must not promise either).
9
11
  * DOM hooks: `data-aqsp-popover="note"`, `data-aqsp-popover-title`, `data-aqsp-note-input`,
10
12
  * `data-aqsp-popover-save`, `data-aqsp-popover-close`, `data-aqsp-popover-hint`,
11
- * `data-aqsp-popover-rejected`.
13
+ * `data-aqsp-popover-rejected`. v0.10 viewer mode (`ctx.cellReadOnly === true`): the Save button
14
+ * is `disabled` + `aria-disabled` (native only — no new attribute, no package CSS, design ADR-31),
15
+ * Ctrl+Enter does nothing, Escape / focus-leave close WITHOUT saving (the wall is G14a; key
16
+ * absent = v0.9-identical conduct), and the hint swaps to `noteViewerHint`.
12
17
  *
13
18
  * 既定の note エディタ (v0.8 §4.1.16): textarea の下書き + 保存 / 閉じるボタン。キー: Escape は
14
19
  * 通常状態で「変更があれば保存して閉じる」、拒否状態では保存せず閉じる。Ctrl+Enter は保存 (拒否後は
15
20
  * 再試行)。フォーカスがポップオーバーを離れたら変更があれば保存 — 凍結帯の下へ潜ったアンカーは
16
21
  * clip されるだけで描画から外れない (SelectionOverlay の placeAnchor) ので textarea はフォーカスを保ち、
17
22
  * 離脱には当たらない: エディタは隠れて下書きごと戻る。textarea の aria-describedby は
18
- * ヒント段落を指し、拒否状態で `labels.noteEscapeHint` ↔ `labels.noteRejectedHint` が差し替わる。
23
+ * ヒント段落を指し、`labels.noteEscapeHint` (通常) ↔ `labels.noteRejectedHint` (拒否状態) ↔
24
+ * `labels.noteViewerHint` (ビューアモード — 保存も再試行も不能のため拒否文言より優先) が差し替わる。
25
+ * v0.10 ビューアモード (`ctx.cellReadOnly === true`): 保存ボタンは `disabled` + `aria-disabled`
26
+ * (ネイティブのみ — 新属性 / パッケージ CSS なし、設計 ADR-31)、Ctrl+Enter は何もせず、Escape /
27
+ * フォーカス離脱は保存せず閉じ、ヒントは `noteViewerHint` に差し替わる (壁は G14a。キー不在 = v0.9 恒等)。
19
28
  */
20
29
 
21
30
  import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"
@@ -31,6 +40,9 @@ export type NotePopoverProps = { ctx: NotePopoverContext; labels: SpreadsheetLab
31
40
  export const NotePopover = ({ ctx, labels }: NotePopoverProps): ReactNode => {
32
41
  const initial = ctx.current === undefined ? "" : ctx.current
33
42
  const [draft, setDraft] = useState(initial)
43
+ // v0.10 (設計 ADR-30): 宣言ゲートキーが true のときだけ確定操作を disabled に (キー不在 = v0.9 恒等)。
44
+ // 壁は G14a — ここは表示であって防壁ではない
45
+ const readOnly = ctx.cellReadOnly === true
34
46
  const titleId = useId()
35
47
  const hintId = useId()
36
48
  /** True while THIS component drives a commit / close (its synchronous focusout must not re-fire the save). / 自分が commit / close を駆動している間 true (同期 focusout の二重保存を防ぐ)。 */
@@ -46,11 +58,16 @@ export const NotePopover = ({ ctx, labels }: NotePopoverProps): ReactNode => {
46
58
  }
47
59
  }
48
60
  const save = (): void => {
61
+ // 読み取り専用ビューア: 確定操作は不可 (disabled ボタンと同じ品行 — Ctrl+Enter も何もしない)
62
+ if (readOnly) {
63
+ return
64
+ }
49
65
  // 空の下書きはノート消去
50
66
  settle(() => ctx.commit(draft === "" ? null : draft))
51
67
  }
52
68
  const saveIfChanged = (): void => {
53
- if (draft !== initial) {
69
+ // 読み取り専用ビューア: Escape / フォーカス離脱は保存せず閉じる (下書きは commit できない)
70
+ if (draft !== initial && !readOnly) {
54
71
  save()
55
72
  } else {
56
73
  settle(ctx.close)
@@ -91,10 +108,11 @@ export const NotePopover = ({ ctx, labels }: NotePopoverProps): ReactNode => {
91
108
  </div>
92
109
  <textarea className="aqsp-popover-textarea" data-aqsp-note-input="" data-aqsp-popover-autofocus="" value={draft} aria-describedby={hintId} aria-invalid={ctx.rejected ? true : undefined} onChange={(event) => setDraft(event.currentTarget.value)} onKeyDown={onKeyDown} />
93
110
  <p className="aqsp-popover-hint" id={hintId} data-aqsp-popover-hint="">
94
- {resolveLabel(labels, ctx.rejected ? "noteRejectedHint" : "noteEscapeHint")}
111
+ {/* ビューアは保存 (noteEscapeHint) も再試行 (noteRejectedHint) も約束できない — Escape は保存せず閉じるため noteViewerHint を最優先 */}
112
+ {resolveLabel(labels, readOnly ? "noteViewerHint" : ctx.rejected ? "noteRejectedHint" : "noteEscapeHint")}
95
113
  </p>
96
114
  <div className="aqsp-popover-actions">
97
- <button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" onClick={save}>
115
+ <button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" disabled={readOnly} aria-disabled={readOnly ? "true" : undefined} onClick={save}>
98
116
  {resolveLabel(labels, "noteSave")}
99
117
  </button>
100
118
  <button type="button" className="aqsp-popover-button" data-aqsp-popover-close="" aria-label={resolveLabel(labels, "popoverClose")} onClick={() => settle(ctx.close)}>