@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.
@@ -10,7 +10,11 @@
10
10
  * the conditional-format translation moves rule ranges by these origins). `tileCells` returns
11
11
  * the tile origins it entered (a tile whose origin lies inside the grid, even when the grid edge
12
12
  * clips part of it). Grid-edge clipping is silent (the §6.5.3 deviation). estimatePasteCellCount
13
- * pre-checks WITHOUT materializing (ADR-12). The private expandCutPaste is the cut-move path:
13
+ * pre-checks WITHOUT materializing (ADR-12). v0.10 (ONE geometry, TWO readers — ADR-28 rule 5):
14
+ * computePasteTargetRect is the tiler's geometry judgment extracted pure (consumed by tileCells
15
+ * AND walked by the G5 / G6a read-only paste walls) and fillExtensionRects is the disjoint
16
+ * ≤2-rect decomposition of box ∖ source the G7 wall walks (bound to expandFill's emission set by
17
+ * the per-geometry set-equality spec, never by restructuring the filler). The private expandCutPaste is the cut-move path:
14
18
  * never tiles, and the CALLER rejects grid-edge clipping. Fill (§8.3, barrel-PRIVATE):
15
19
  * expandFill fills every box cell OUTSIDE the source with 2D source-start modular anchoring
16
20
  * (single-axis bands and diagonal L-shapes share one formula — v0.3), generic over the source
@@ -28,7 +32,10 @@
28
32
  * タイルの原点 (タイル毎に 1 つの共有 object — 条件付き書式のレンジ平行移動が使う) を
29
33
  * 受け取る。`tileCells` は入ったタイルの原点 (原点がグリッド内のタイル — 端で一部が切れても
30
34
  * 残る) を返す。グリッド外は静かに切り捨て (§6.5.3 の逸脱)。estimatePasteCellCount は
31
- * 展開せず事前見積もる (ADR-12)。expandCutPaste は cut-move 専用 (タイルしない — グリッド外
35
+ * 展開せず事前見積もる (ADR-12)。v0.10 (1 幾何 2 読者 — ADR-28 rule 5): computePasteTargetRect
36
+ * はタイラーの幾何判断の純関数抽出 (tileCells が消費し、G5 / G6a の読み取り専用 paste 壁が
37
+ * walk する)、fillExtensionRects は G7 壁が walk する box ∖ source の非交 ≤2 矩形分解
38
+ * (expandFill の発行集合とは幾何毎の集合等値 spec で束ね、filler は再構成しない)。expandCutPaste は cut-move 専用 (タイルしない — グリッド外
32
39
  * クリップの拒否は呼び出し側 §6.5.4-4)。fill (§8.3 — barrel 非公開): expandFill はソース
33
40
  * 開始端 anchoring の 2D 剰余式で「バウンディングボックス − ソース」へ展開 (単軸帯も斜め
34
41
  * L 字も同一式 — v0.3 の斜めフィル) し、ソースセル型に generic で対象毎の emitter を受ける
@@ -39,7 +46,7 @@
39
46
  */
40
47
 
41
48
  import { normalizeAdapterHyperlink } from "../model/cellAnnotations.ts"
42
- import { type CellCoord, type CellRange, rangeSize } from "../model/cellCoord.ts"
49
+ import { type CellCoord, type CellRange, makeRange, rangeSize } from "../model/cellCoord.ts"
43
50
  import { type CellFormat, canonicalizeFormat } from "../model/cellFormat.ts"
44
51
  import { type CellEdit, resolveEditText, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
45
52
  import type { DataValidationRule } from "../model/dataValidation.ts"
@@ -57,6 +64,45 @@ export const estimatePasteCellCount = (source: readonly (readonly string[])[], t
57
64
  return isTileTarget ? size.rows * size.cols : srcRows * srcCols
58
65
  }
59
66
 
67
+ /**
68
+ * The grid-clamped rectangle a paste occupies, from dimensions alone (v0.10 — ONE geometry,
69
+ * TWO readers, ADR-28 rule 5): the §6.5.3 table's judgment verbatim — a non-1×1 target whose
70
+ * both extents are exact multiples of the dims tiles (output = target extents), anything else
71
+ * expands once (output = the dims); the origin is `active` for a 1×1 target, else the target's
72
+ * top-left; the output rectangle is clamped to the grid. Null when the origin lies outside the
73
+ * grid or the dims are empty — exactly the case `tileCells` visits nothing. `tileCells` consumes
74
+ * THIS function for its geometry and the G5 / G6a read-only walls walk THIS rect, so the walled
75
+ * rectangle and the written rectangle cannot drift.
76
+ * 寸法だけから貼り付けが占めるグリッド clamp 済み矩形 (v0.10 — 1 幾何 2 読者、ADR-28 rule 5)。
77
+ * §6.5.3 の表の判断そのまま — 非 1×1 かつ両軸が寸法の整数倍のターゲットはタイル (出力 =
78
+ * ターゲット寸法)、それ以外は 1 回展開 (出力 = 寸法)。基点は 1×1 ターゲットなら `active`、
79
+ * さもなくばターゲット左上。出力矩形はグリッドへ clamp。基点がグリッド外か寸法が空なら null —
80
+ * `tileCells` が何も訪問しないケースと厳密一致。`tileCells` はこの関数を幾何として消費し、
81
+ * G5 / G6a の読み取り専用壁もこの矩形を walk する — 壁の矩形と書込矩形は乖離し得ない。
82
+ */
83
+ export const computePasteTargetRect = (dims: { rows: number; cols: number }, target: CellRange, active: CellCoord, rowCount: number, colCount: number): CellRange | null => {
84
+ // 空寸法は占有矩形なし (tileCells の空ソース早期 return と同じ意味論)
85
+ if (dims.rows === 0 || dims.cols === 0) {
86
+ return null
87
+ }
88
+ const size = rangeSize(target)
89
+ const isSingleTarget = size.rows === 1 && size.cols === 1
90
+ // 整数倍レンジ (1×1 を除く) はタイル、それ以外は 1 回展開 (§6.5.3 の表)
91
+ const isTileTarget = !isSingleTarget && size.rows % dims.rows === 0 && size.cols % dims.cols === 0
92
+ const outRows = isTileTarget ? size.rows : dims.rows
93
+ const outCols = isTileTarget ? size.cols : dims.cols
94
+ // 基点: 単一セル選択なら active、それ以外は現在レンジの左上 (表の規則そのもの)
95
+ const origin = isSingleTarget ? active : { row: target.startRow, col: target.startCol }
96
+ // グリッド外は切り捨て (逸脱 — Excel は拒否。§6.5.3 の表参照): 出力矩形をグリッド境界でクランプ
97
+ const rowEnd = Math.min(outRows, rowCount - origin.row)
98
+ const colEnd = Math.min(outCols, colCount - origin.col)
99
+ // 基点自体がグリッド外 = 訪問セルゼロ (タイラーは何も書かない)
100
+ if (rowEnd <= 0 || colEnd <= 0) {
101
+ return null
102
+ }
103
+ return makeRange(origin, { row: origin.row + rowEnd - 1, col: origin.col + colEnd - 1 })
104
+ }
105
+
60
106
  /**
61
107
  * Generic tiler — the §6.5.3 table over any cell type (spec 03 §4, v0.8): the one construction
62
108
  * point the string paste and the rich paste both delegate to. Visits every output cell in
@@ -67,7 +113,9 @@ export const estimatePasteCellCount = (source: readonly (readonly string[])[], t
67
113
  * deviation): the output rectangle is clamped to the grid before visiting, so a tile whose
68
114
  * origin is inside the grid is entered (and returned) even when only part of it fits, and a
69
115
  * tile entirely outside is never entered. Empty source (no rows or no columns) visits nothing
70
- * and returns no tiles. Returns the tile origins in the same row-major order.
116
+ * and returns no tiles. Returns the tile origins in the same row-major order. The occupied
117
+ * rectangle itself comes from `computePasteTargetRect` (v0.10 — the shared geometry the G5 wall
118
+ * also walks; ADR-28 rule 5).
71
119
  * generic タイラー — §6.5.3 の表を任意セル型で (spec 03 §4、v0.8)。文字列貼り付けとリッチ
72
120
  * 貼り付けが委譲する唯一の構築点。出力矩形全体の行優先 (v0.7 のフラットループ順 — タイル毎
73
121
  * ではない) で全セルを `(row, col, cell, tile)` で訪問: `cell` はソース要素そのもの (同一参照、
@@ -75,24 +123,21 @@ export const estimatePasteCellCount = (source: readonly (readonly string[])[], t
75
123
  * 戻り値にも載せる。グリッド端は静かに切り捨て (copy 経路の逸脱): 出力矩形を訪問前に
76
124
  * グリッドへクランプするため、原点がグリッド内のタイルは一部しか収まらなくても入り (戻り値
77
125
  * にも載り)、全体が外のタイルには入らない。空ソース (行なし / 列なし) は訪問なし・タイル
78
- * なし。戻り値は入ったタイルの原点 (同じ行優先順)。
126
+ * なし。戻り値は入ったタイルの原点 (同じ行優先順)。占有矩形そのものは `computePasteTargetRect`
127
+ * (v0.10 — G5 壁も walk する共有幾何、ADR-28 rule 5) から得る。
79
128
  */
80
129
  export const tileCells = <T>(source: readonly (readonly T[])[], target: CellRange, active: CellCoord, rowCount: number, colCount: number, visit: (row: number, col: number, cell: T, tile: CellCoord) => void): CellCoord[] => {
81
130
  const srcRows = source.length
82
131
  const srcCols = source[0]?.length ?? 0
83
- if (srcRows === 0 || srcCols === 0) {
132
+ // 幾何判断 (タイル vs 1 回展開、基点、クランプ) は共有関数 1 箇所 (1 幾何 2 読者 — ADR-28 rule 5)
133
+ const rect = computePasteTargetRect({ rows: srcRows, cols: srcCols }, target, active, rowCount, colCount)
134
+ // null = 空ソースか基点グリッド外 — 訪問なし・タイルなし (従来の早期 return / 空ループと同一挙動)
135
+ if (rect === null) {
84
136
  return []
85
137
  }
86
- const size = rangeSize(target)
87
- const isSingleTarget = size.rows === 1 && size.cols === 1
88
- const isTileTarget = !isSingleTarget && size.rows % srcRows === 0 && size.cols % srcCols === 0
89
- const outRows = isTileTarget ? size.rows : srcRows
90
- const outCols = isTileTarget ? size.cols : srcCols
91
- // 基点: 単一セル選択なら active、それ以外は現在レンジの左上 (表の規則そのもの)
92
- const origin = isSingleTarget ? active : { row: target.startRow, col: target.startCol }
93
- // グリッド外は切り捨て (逸脱 — Excel は拒否。§6.5.3 の表参照): 出力矩形をグリッド境界でクランプ
94
- const rowEnd = Math.min(outRows, rowCount - origin.row)
95
- const colEnd = Math.min(outCols, colCount - origin.col)
138
+ const origin: CellCoord = { row: rect.startRow, col: rect.startCol }
139
+ const rowEnd = rect.endRow - rect.startRow + 1
140
+ const colEnd = rect.endCol - rect.startCol + 1
96
141
  const tiles: CellCoord[] = []
97
142
  for (let r0 = 0; r0 < rowEnd; r0 += srcRows) {
98
143
  // このタイル行の原点をタイル毎に 1 object だけ作る (同一タイルの全セルへ同じ参照を渡す)
@@ -212,6 +257,51 @@ export const estimateFillCellCount = (sourceRange: CellRange, box: CellRange): n
212
257
  return boxSize.rows * boxSize.cols - srcSize.rows * srcSize.cols + rowsRead * colsRead
213
258
  }
214
259
 
260
+ /**
261
+ * The disjoint decomposition of box ∖ source into edge strips (v0.10 — ONE geometry, TWO
262
+ * readers, ADR-28 rules 3/5): one row strip per extended row side (the new rows × the full box
263
+ * width) plus one column strip per extended column side (the new columns × the source's row
264
+ * span). A fill box is the bounding rect of source ∪ ONE hover point, so extension is at most
265
+ * one side per axis and the result is ≤2 disjoint rects (an L on a diagonal drag); the
266
+ * decomposition stays total for any containing box. The G7 read-only wall walks these rects;
267
+ * `expandFill` is NOT restructured to consume them (its row-major emission order is pinned
268
+ * shipped behaviour) — the per-geometry set-equality spec binds the two readers instead. Zero
269
+ * extension (box == source) = empty array; a box not containing the source is a caller bug
270
+ * (RangeError, fail-fast — `expandFill`'s assert family).
271
+ * box ∖ source の非交な縁ストリップ分解 (v0.10 — 1 幾何 2 読者、ADR-28 rules 3/5): 拡張された
272
+ * 行側毎に行ストリップ (新規行 × box 全幅) + 拡張された列側毎に列ストリップ (新規列 × source
273
+ * 行スパン)。fill の box は source ∪ hover 1 点の外接矩形のため拡張は軸毎に片側のみ = 結果は
274
+ * 非交 ≤2 矩形 (斜めドラッグは L 字)。分解自体は包含する任意の box に対して全域。G7 の
275
+ * 読み取り専用壁がこの矩形群を walk する。`expandFill` は再構成しない (行優先の発行順は
276
+ * 出荷ピン) — 両読者は幾何毎の集合等値 spec が束ねる。拡張ゼロ (box == source) = 空配列、
277
+ * source を包含しない box は呼び出し側バグ (RangeError で fail-fast — `expandFill` と同じ
278
+ * assert 系)。
279
+ */
280
+ export const fillExtensionRects = (sourceRange: CellRange, box: CellRange): readonly CellRange[] => {
281
+ // 包含だけを検査 (assertFillBox と違い box == source は合法 — 拡張ゼロ = 空配列。部分重なりは
282
+ // 呼び出し側バグとして fail-fast)
283
+ const contains = box.startRow <= sourceRange.startRow && box.endRow >= sourceRange.endRow && box.startCol <= sourceRange.startCol && box.endCol >= sourceRange.endCol
284
+ if (!contains) {
285
+ throw new RangeError(`[spreadsheet] fillExtensionRects requires a box containing the source (source ${JSON.stringify(sourceRange)}, box ${JSON.stringify(box)})`)
286
+ }
287
+ const rects: CellRange[] = []
288
+ // 行ストリップ: 新規行 × box 全幅 (source 行スパン外の全セルを被覆)
289
+ if (box.startRow < sourceRange.startRow) {
290
+ rects.push(makeRange({ row: box.startRow, col: box.startCol }, { row: sourceRange.startRow - 1, col: box.endCol }))
291
+ }
292
+ if (box.endRow > sourceRange.endRow) {
293
+ rects.push(makeRange({ row: sourceRange.endRow + 1, col: box.startCol }, { row: box.endRow, col: box.endCol }))
294
+ }
295
+ // 列ストリップ: 新規列 × source 行スパン (行ストリップと非交 — 行は source の行スパンに限る)
296
+ if (box.startCol < sourceRange.startCol) {
297
+ rects.push(makeRange({ row: sourceRange.startRow, col: box.startCol }, { row: sourceRange.endRow, col: sourceRange.startCol - 1 }))
298
+ }
299
+ if (box.endCol > sourceRange.endCol) {
300
+ rects.push(makeRange({ row: sourceRange.startRow, col: sourceRange.endCol + 1 }, { row: sourceRange.endRow, col: box.endCol }))
301
+ }
302
+ return rects
303
+ }
304
+
215
305
  /**
216
306
  * Expands a fill box into cell edits for every cell of the box OUTSIDE the source (§8.3 —
217
307
  * copy fill only), generic over the source cell type (v0.8): `readCell` resolves one source
@@ -18,7 +18,18 @@
18
18
  * `pasteSpecial.ts`, tiling in the generic `expandRichPaste`, sizes through `gateResizeEntries`
19
19
  * (non-undo geometry). Every v0.8 parameter is a capability declaration — absent ⇒ the v0.7 branch
20
20
  * byte-for-byte (TSV-only adapters — the commit seam `commitPaste` is the one required member). rearm stays with the setSelection
21
- * wrapper (§6.9-1 no-double-actor rule).
21
+ * wrapper (§6.9-1 no-double-actor rule). v0.10 read-only walls (whole-operation, before any
22
+ * commit, through the `rejectReadOnly` funnel): G4 refuses ARMING a cut whose source range
23
+ * contains a read-only cell (preventDefault, zero setData, armed clip unchanged, before the
24
+ * multi-range advisory); G5 refuses a paste whose shared target rect (`computePasteTargetRect`,
25
+ * transpose-aware) contains one — only for modes writing a cell aspect (CF-only / columnWidths-only
26
+ * pass); G6a/G6b re-check the cut-move target block and the CURRENT source adapter state before
27
+ * EITHER commit (the cross-sheet source has no dataVersion channel — the move-time re-read is its
28
+ * only wall). Capability absent ⇒ all four compile to the previous path byte-identically.
29
+ * v0.10 paste feedback (plan §3.1): `onPasteResolved(outcome, channel)` — the menu channel is
30
+ * the promise's 1:1 mirror at the single `paste` wrap site, the event channel fires once per
31
+ * `runPaste`-reaching gesture; the drag-freeze and null-`clipboardData` pre-reach branches stay
32
+ * deliberately silent. Prop absent ⇒ never called.
22
33
  *
23
34
  * シンク上のクリップボード結線 (§6.5.4、v0.8 §4.2.5 / ADR-14)。copy / cut は 3 フレーバー —
24
35
  * `text/plain` (素の TSV)、`text/html` (lossless payload を先頭属性 `data-aqsp-clip` に運ぶ相互運用
@@ -35,7 +46,16 @@
35
46
  * `navigator.clipboard.read({ unsanitized: ["text/html"] })`) から、変換は `pasteSpecial.ts`、タイル
36
47
  * は generic `expandRichPaste`、寸法は `gateResizeEntries` (非 undo 幾何)。v0.8 のパラメータは全て
37
48
  * 能力宣言 — 不在なら v0.7 分岐そのまま (TSV のみのアダプタ — commit シーム `commitPaste` だけが必須メンバー)。rearm は
38
- * setSelection ラッパーの専有 (§6.9-1)。
49
+ * setSelection ラッパーの専有 (§6.9-1)。v0.10 の読み取り専用壁 (操作全体・両 commit より前・
50
+ * `rejectReadOnly` 漏斗経由): G4 は元レンジに読み取り専用セルを含む cut の**武装**を拒否
51
+ * (preventDefault・setData 0 回・装填クリップ不変・複数レンジ通知の前)、G5 は共有ターゲット矩形
52
+ * (`computePasteTargetRect` — 転置対応) に含む貼り付けを拒否 (セル側面を書くモードだけ — CF-only /
53
+ * columnWidths-only は素通し)、G6a/G6b は cut-move の移動先ブロックと元アダプタの**今の**状態を
54
+ * 両 commit より前に再検査 (跨ぎ元シートは dataVersion チャネルが無く移動時再読が唯一の壁)。
55
+ * 能力未宣言なら 4 壁とも従来経路とバイト同一。v0.10 貼り付けフィードバック (計画 §3.1):
56
+ * `onPasteResolved(outcome, channel)` — menu チャネルは単一ラップサイト `paste` での promise
57
+ * 1:1 鏡、event チャネルは `runPaste` 到達ゲスチャ毎 1 回。ドラッグ凍結と `clipboardData` null
58
+ * の到達前 2 分岐は意図的無言。prop 未指定なら一度も呼ばれない。
39
59
  */
40
60
 
41
61
  import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"
@@ -45,7 +65,7 @@ import { isWholeCols, isWholeRows, normalizeAdapterHyperlink, normalizeHyperlink
45
65
  import { type CellCoord, type CellRange, countRangeCells, enumerateRangeCells, makeRange, rangeSize, type SpreadsheetOperation } from "../model/cellCoord.ts"
46
66
  import type { CellFormat } from "../model/cellFormat.ts"
47
67
  import type { ConditionalFormatRule, ConditionalFormatRuleInput } from "../model/conditionalFormat.ts"
48
- import { type AspectCapabilities, aspectCapabilities, type CellEdit, type EditRejection, type EditSource, resolveEditText, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
68
+ import { type AspectCapabilities, aspectCapabilities, type CellEdit, type EditRejection, type EditSource, type ReadOnlyRejection, rectContainsReadOnly, resolveEditText, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
49
69
  import { toR1C1 } from "../model/formulaRefs.ts"
50
70
  import type { SelectionState } from "../selection/selectionState.ts"
51
71
  import {
@@ -67,7 +87,7 @@ import {
67
87
  } from "./clipPayload.ts"
68
88
  import { parseHtmlTable, serializeHtmlTable } from "./htmlInterop.ts"
69
89
  import { DEFAULT_PASTE_OPTIONS, MODE_ASPECTS, type PasteAspects, type PasteOptions, patchFromClipCell, stripUndeclaredAspects, translateConditionalFormats, transposePayload } from "./pasteSpecial.ts"
70
- import { expandRichPaste } from "./tiling.ts"
90
+ import { computePasteTargetRect, expandRichPaste } from "./tiling.ts"
71
91
  import { measureTsv, parseTsv, serializeTsv } from "./tsv.ts"
72
92
 
73
93
  /** The armed clip surface a consumer sees (`onClipChange` / `getClip`): `foreign` = the record belongs to another adapter (sheet switch kept a cut). / consumer が見る装填クリップ (`onClipChange` / `getClip`): `foreign` = 記録が別アダプタのもの (シート切替が cut を保持)。 */
@@ -136,6 +156,32 @@ export type UseClipboardParams = {
136
156
  onClipboardUnavailable?: (cause: ClipboardUnavailableCause) => void
137
157
  /** Source-side rejection of a cross-sheet cut (the target commit already stands). / 跨ぎ cut の元側拒否 (対象 commit は立っている)。 */
138
158
  onEditRejected?: (error: unknown, rejection: EditRejection) => void
159
+ /**
160
+ * v0.10 read-only refusal funnel (required seam — the root always wires it; identity-stable,
161
+ * read through paramsRef): G4 (a refused cut arming, `{op:"cut"}`), G5 (a refused paste over
162
+ * the shared target rect, `{op:"paste"}`) and G6a/G6b (the cut-move target / source walls,
163
+ * `{op:"paste"}` / `{op:"cut"}`) fire it once per refused gesture. Capability absent ⇒ never
164
+ * called (byte identity).
165
+ * v0.10 読み取り専用拒否漏斗 (必須 seam — root が常に渡す。identity 安定、paramsRef 読み):
166
+ * G4 (cut 武装拒否 — `{op:"cut"}`)、G5 (共有ターゲット矩形への貼付拒否 — `{op:"paste"}`)、
167
+ * G6a/G6b (cut-move の移動先 / 元側の両壁 — `{op:"paste"}` / `{op:"cut"}`) が拒否ジェスチャ毎
168
+ * 1 回発火する。能力未宣言なら一度も呼ばれない (バイト恒等)。
169
+ */
170
+ rejectReadOnly: (rejection: ReadOnlyRejection) => void
171
+ /**
172
+ * v0.10 paste feedback (plan §3.1): the menu channel mirrors `paste(options)`'s resolve value
173
+ * 1:1 from the single wrap site (read refusals and the write-time re-gate included); the
174
+ * event channel fires once per gesture that REACHES `runPaste` — the two pre-reach gate
175
+ * consumptions (drag-freeze, null `clipboardData`) stay deliberately silent. Cause-specific
176
+ * callbacks (`onClipboardUnavailable` / `onOperationRejected` / `onReadOnlyRejected`) fire
177
+ * FIRST; the outcome is the trailing summary. Absent ⇒ never called (byte identity).
178
+ * v0.10 貼り付けフィードバック (計画 §3.1): menu チャネルは `paste(options)` の resolve 値の
179
+ * 1:1 鏡 (単一ラップサイト — 読み拒否・書込時再ゲートも含む)、event チャネルは `runPaste` に
180
+ * **到達した**ゲスチャ毎 1 回 (到達前 2 分岐 — ドラッグ凍結消費と `clipboardData` null — は
181
+ * 意図的無言)。原因特定系 callback が先、outcome は後追いの要約。未指定なら一度も呼ばれない
182
+ * (バイト恒等)。
183
+ */
184
+ onPasteResolved?: (outcome: PasteOutcome, channel: ClipChannel) => void
139
185
  }
140
186
 
141
187
  /** The clip surface (§6.5.4-4 — copy/cut unified in v0.3.2, widened in v0.8). / クリップ状態の公開面 (v0.3.2 で copy / cut 統一、v0.8 で拡張)。 */
@@ -192,6 +238,18 @@ export const estimateTiledCells = (rows: number, cols: number, target: CellRange
192
238
  /** True for the plain Ctrl+V options — the only paste that may move a cut (transforms are copy semantics). / 素の Ctrl+V オプションだけ真 — cut を move できる唯一の貼り付け (変換は copy 意味論)。 */
193
239
  const isPlainPaste = (options: PasteOptions): boolean => options.mode === "all" && options.operation === undefined && options.transpose !== true && options.skipBlanks !== true
194
240
 
241
+ /**
242
+ * Whether a paste mode writes any CELL aspect (v0.10 G5 wall condition): text / formula /
243
+ * format / borders / note / comments / hyperlink / validation. `cf` and `sizes` are the
244
+ * NON-cell channels — a CF-only or columnWidths-only mode never walks the read-only wall
245
+ * (sheet-level rules and geometry are not cell content, ADR-28 out-of-scope registration).
246
+ * 貼り付けモードがセル側面 (text / formula / format / borders / note / comments / hyperlink /
247
+ * validation のいずれか) を書くか (v0.10 G5 の壁条件)。`cf` と `sizes` は非セルチャネル —
248
+ * CF だけ / 列幅だけのモードは読み取り専用壁を walk しない (シート水準の規則と幾何はセル内容
249
+ * ではない — ADR-28 のスコープ外登記)。
250
+ */
251
+ const hasCellAspect = (aspects: PasteAspects): boolean => aspects.text || aspects.formula || aspects.format || aspects.borders || aspects.note || aspects.comments || aspects.hyperlink || aspects.validation
252
+
195
253
  /**
196
254
  * The event channel's flavours: the DataTransfer types and a `getData` memoised per type for the
197
255
  * gesture (a paste event's `getData` re-materialises the string on every call — 91 ms for 15.8 MB).
@@ -633,6 +691,14 @@ export const useClipboard = (params: UseClipboardParams): ClipboardController =>
633
691
  p.onOperationRejected?.(op, count, "limit")
634
692
  return null
635
693
  }
694
+ // G4 (v0.10 §3-6): cut の武装壁 — 上限ゲート直後・複数レンジ通知の前 (拒否 cut に advisory を
695
+ // 出さない)。preventDefault はネイティブ cut がシンクの " " を OS クリップボードへ書くのも防ぎ、
696
+ // setData 0 回 = OS クリップボードも装填クリップも不変。copy は無条件 ALLOW (§3-9)
697
+ if (op === "cut" && rectContainsReadOnly(p.adapter, range)) {
698
+ event.preventDefault()
699
+ p.rejectReadOnly({ kind: "operation", op: "cut", cellCount: count })
700
+ return null
701
+ }
636
702
  // 複数レンジ選択は末尾レンジのみ + 通知 (明示逸脱 — Excel の整列連結コピーは非対応 §6.5.4-3)
637
703
  if (selection.ranges.length > 1) {
638
704
  p.onCopyMultiRange?.()
@@ -746,6 +812,20 @@ export const useClipboard = (params: UseClipboardParams): ClipboardController =>
746
812
  p.onOperationRejected?.("paste", cellCount, "bounds")
747
813
  return "rejected"
748
814
  }
815
+ // G6a (v0.10 §3-7): 移動先の読み取り専用壁 — staleness bounds 拒否の後・遅延読みの前 (両 commit
816
+ // より前)。cut はタイルしない: 矩形は共有幾何へ 1×1 ターゲット (= 原点 1 回展開) を渡した装填
817
+ // ブロックそのもの (ADR-28 rule 5 — bounds 通過済みなので clamp は恒等で null は構造的に不能)
818
+ const targetRect = computePasteTargetRect({ rows: snapshot.rows, cols: snapshot.cols }, makeRange(origin, origin), origin, adapter.rowCount, adapter.colCount)
819
+ if (targetRect !== null && rectContainsReadOnly(adapter, targetRect)) {
820
+ p.rejectReadOnly({ kind: "operation", op: "paste", cellCount: snapshot.rows * snapshot.cols })
821
+ return "rejected" // クリップは維持 (bounds 拒否と同じ品行)
822
+ }
823
+ // G6b (v0.10 §3-8): 元レンジの移動時再検査 — 装填後の flip と跨ぎ元シート (dataVersion チャネルの
824
+ // 無い唯一の面 — ADR-28 rule 4) に対する壁。offending セルは元 = payload は {op:"cut"} (G4 と同文言)
825
+ if (rectContainsReadOnly(armed.sourceAdapter, armed.range)) {
826
+ p.rejectReadOnly({ kind: "operation", op: "cut", cellCount: countRangeCells([armed.range]) })
827
+ return "rejected"
828
+ }
749
829
  // 遅延読み: Ctrl+X 後の書式 / 注釈 commit を運ぶ (ゲートは snapshot 寸法で済んでいる)
750
830
  const payload = armedPayload(armed)
751
831
  const caps = aspectCapabilities(adapter)
@@ -925,6 +1005,18 @@ export const useClipboard = (params: UseClipboardParams): ClipboardController =>
925
1005
  const caps = aspectCapabilities(adapter)
926
1006
  const shaped = stripUndeclaredAspects(transpose ? transposePayload(payload) : payload, caps)
927
1007
  const aspects = MODE_ASPECTS[options.mode]
1008
+ // G5 (v0.10 §3-4): 読み取り専用壁 — cut ルーティング / 転置 / 側面剥離の後・実体化
1009
+ // (expandRichPaste) の前。壁はセル側面を書くモードだけ (CF-only / columnWidths-only は
1010
+ // walk なしで ALLOW — §3-5)。矩形はタイラーと同一の共有幾何 (computePasteTargetRect —
1011
+ // ADR-28 rule 5、寸法は転置済み shaped のもの)。null (基点グリッド外) は walk せず従来の
1012
+ // "empty" 経路へ落とす。拒否はクリップ / 選択 / CF / サイズ全チャネル不動 (whole gesture)
1013
+ if (hasCellAspect(aspects)) {
1014
+ const wallRect = computePasteTargetRect({ rows: shaped.rows, cols: shaped.cols }, target, selection.active, adapter.rowCount, adapter.colCount)
1015
+ if (wallRect !== null && rectContainsReadOnly(adapter, wallRect)) {
1016
+ p.rejectReadOnly({ kind: "operation", op: "paste", cellCount: countRangeCells([wallRect]) })
1017
+ return "rejected"
1018
+ }
1019
+ }
928
1020
  const cursor: PasteCursor = {
929
1021
  row: 0,
930
1022
  col: 0,
@@ -995,7 +1087,7 @@ export const useClipboard = (params: UseClipboardParams): ClipboardController =>
995
1087
  * `onClipboardUnavailable` を鳴らして `"rejected"` (promise は決して reject しない)。イベント
996
1088
  * 経路の凍結 / 編集中 / 無効ゲートは書込時点で適用。
997
1089
  */
998
- const paste = useCallback(
1090
+ const pasteInner = useCallback(
999
1091
  async (options: PasteOptions): Promise<PasteOutcome> => {
1000
1092
  const unavailable = (cause: ClipboardUnavailableCause): PasteOutcome => {
1001
1093
  paramsRef.current.onClipboardUnavailable?.(cause)
@@ -1051,6 +1143,24 @@ export const useClipboard = (params: UseClipboardParams): ClipboardController =>
1051
1143
  [runPaste],
1052
1144
  )
1053
1145
 
1146
+ /**
1147
+ * The public menu paste: `pasteInner`'s resolve value mirrored 1:1 to `onPasteResolved`
1148
+ * from this SINGLE wrap site (v0.10 plan §3.1) — `handle.paste` and the command dispatch's
1149
+ * `void paste(...)` both route here, so every menu gesture is mirrored exactly once.
1150
+ * 公開メニュー貼り付け: `pasteInner` の resolve 値をこの**単一**ラップサイトから
1151
+ * `onPasteResolved` へ 1:1 で鏡映 (v0.10 計画 §3.1)。`handle.paste` もコマンド発行の
1152
+ * `void paste(...)` もここを通るため、menu ゲスチャ毎ちょうど 1 回。
1153
+ */
1154
+ const paste = useCallback(
1155
+ async (options: PasteOptions): Promise<PasteOutcome> => {
1156
+ const outcome = await pasteInner(options)
1157
+ // 原因特定系 callback (pasteInner 内で発火済み) が先、outcome の要約が後 (§3.1 の登記順)
1158
+ paramsRef.current.onPasteResolved?.(outcome, "menu")
1159
+ return outcome
1160
+ },
1161
+ [pasteInner],
1162
+ )
1163
+
1054
1164
  const { enabled, sinkRef } = params
1055
1165
  useEffect(() => {
1056
1166
  const sink = sinkRef.current
@@ -1121,11 +1231,14 @@ export const useClipboard = (params: UseClipboardParams): ClipboardController =>
1121
1231
  armedRef.current = null
1122
1232
  }
1123
1233
  const data = clipboardDataOf(event)
1124
- // clipboardData の無い paste イベントは読むものが無い (v0.7 の空文字 no-op と同じ)
1234
+ // clipboardData の無い paste イベントは読むものが無い (v0.7 の空文字 no-op と同じ —
1235
+ // 到達前分岐は意図的無言、onPasteResolved は鳴らさない)
1125
1236
  if (data === null) {
1126
1237
  return
1127
1238
  }
1128
- runPaste(flavoursOf(data), "event", options)
1239
+ // event チャネルの発火点は runPaste 到達ゲスチャ毎にここ 1 箇所 (v0.10 計画 §3.1)
1240
+ const outcome = runPaste(flavoursOf(data), "event", options)
1241
+ p.onPasteResolved?.(outcome, "event")
1129
1242
  }
1130
1243
 
1131
1244
  sink.addEventListener("copy", onCopy)