@aiquants/spreadsheet 0.8.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +1103 -0
  2. package/LICENSE.md +155 -0
  3. package/README.md +817 -0
  4. package/dist/index.cjs +21 -0
  5. package/dist/index.d.cts +1960 -0
  6. package/dist/index.d.ts +1960 -0
  7. package/dist/index.js +7711 -0
  8. package/dist/styles/spreadsheet.css +2 -0
  9. package/dist/styles/spreadsheet.standalone.css +3 -0
  10. package/package.json +94 -0
  11. package/src/Spreadsheet.tsx +2313 -0
  12. package/src/clipboard/clipPayload.ts +1242 -0
  13. package/src/clipboard/fixtures/excel.fixture.ts +301 -0
  14. package/src/clipboard/fixtures/html.fixture.ts +90 -0
  15. package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
  16. package/src/clipboard/htmlInterop.ts +1694 -0
  17. package/src/clipboard/numberFormatCodec.ts +870 -0
  18. package/src/clipboard/pasteSpecial.ts +608 -0
  19. package/src/clipboard/tiling.ts +370 -0
  20. package/src/clipboard/tsv.ts +186 -0
  21. package/src/clipboard/useClipboard.ts +1154 -0
  22. package/src/commands/selfCalc.ts +279 -0
  23. package/src/commands/useCommands.ts +946 -0
  24. package/src/editor/useCellEditor.ts +708 -0
  25. package/src/grid/CellView.tsx +51 -0
  26. package/src/grid/GridBody.tsx +489 -0
  27. package/src/header/ColumnHeader.tsx +167 -0
  28. package/src/header/RowHeader.tsx +144 -0
  29. package/src/header/columnName.ts +31 -0
  30. package/src/header/resizeGates.ts +146 -0
  31. package/src/header/resizeStrips.ts +79 -0
  32. package/src/header/useHeaderResize.ts +598 -0
  33. package/src/header/useHeaderSelection.ts +454 -0
  34. package/src/index.ts +122 -0
  35. package/src/keyboard/compositionGuard.ts +53 -0
  36. package/src/keyboard/ensureCellVisible.ts +77 -0
  37. package/src/keyboard/shortcuts.ts +718 -0
  38. package/src/keyboard/useGridKeyboard.ts +940 -0
  39. package/src/model/axisModel.ts +186 -0
  40. package/src/model/cellAnnotations.ts +92 -0
  41. package/src/model/cellCoord.ts +114 -0
  42. package/src/model/cellFormat.ts +562 -0
  43. package/src/model/cellPaint.ts +537 -0
  44. package/src/model/conditionalFormat.ts +805 -0
  45. package/src/model/dataAdapter.ts +478 -0
  46. package/src/model/dataValidation.ts +542 -0
  47. package/src/model/formulaRefs.ts +984 -0
  48. package/src/model/frozenPartition.ts +100 -0
  49. package/src/model/labels.ts +228 -0
  50. package/src/model/viewStore.ts +124 -0
  51. package/src/overlay/SelectionOverlay.tsx +1425 -0
  52. package/src/overlay/useFillHandle.ts +432 -0
  53. package/src/popover/CommentPopover.tsx +109 -0
  54. package/src/popover/LinkPopover.tsx +108 -0
  55. package/src/popover/ListPopover.tsx +161 -0
  56. package/src/popover/MessagePopover.tsx +36 -0
  57. package/src/popover/NotePopover.tsx +106 -0
  58. package/src/popover/NoteTip.tsx +26 -0
  59. package/src/popover/PopoverLayer.tsx +210 -0
  60. package/src/popover/placement.ts +138 -0
  61. package/src/popover/usePopovers.ts +642 -0
  62. package/src/selection/selectionState.ts +239 -0
  63. package/src/selection/useAutoScroller.ts +226 -0
  64. package/src/selection/usePointDrag.ts +273 -0
  65. package/src/selection/useSelectionEngine.ts +687 -0
  66. package/src/styles/components.entry.css +10 -0
  67. package/src/styles/spreadsheet.css +1029 -0
  68. package/src/styles/standalone.entry.css +9 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @fileoverview Frozen-axis coordinate partition (v0.5 plan §2 = columns; v0.6 plan §2 = rows).
3
+ * The engine paints frozen leading cells at their raw tree coordinate and scroll-band cells at
4
+ * tree − scroll, so a viewport ↔ index mapping splits at the frozen prefix size F_px: the
5
+ * forward map is `i < F ? offsetOf(i) : offsetOf(i) − scroll`, the inverse map (guarded on
6
+ * F_px > 0) is `F_px > 0 && p < F_px ? indexAt(p) : indexAt(p + scroll)`. Pure, AXIS-NEUTRAL
7
+ * internals (frozenSizeOf / toViewportPos / viewportPosToIndex) with column- and row-named
8
+ * thin wrappers — the freshness discipline (F/scroll from the handle) stays at the call sites.
9
+ *
10
+ * 凍結軸の座標パーティション (v0.5 計画 §2 = 列、v0.6 計画 §2 = 行)。エンジンは先頭凍結セルを
11
+ * 木座標の生値・スクロール帯セルを 木座標 − scroll で描くため、viewport ↔ index の写像は凍結
12
+ * 接頭辞幅 F_px で分割される。純関数の**軸中立**内部 (frozenSizeOf / toViewportPos /
13
+ * viewportPosToIndex) に列 / 行の名前付き薄ラッパーを重ねる — 鮮度規律 (F / scroll はハンドル
14
+ * 読み) は呼び出しサイトの責務。
15
+ */
16
+
17
+ import type { AxisModel } from "./axisModel.ts"
18
+
19
+ /**
20
+ * Axis-neutral tree-prefix size of the frozen band (0 when frozen <= 0 or empty axis; totalSize
21
+ * when frozen covers every track). Both frozenWidthOf and frozenHeightOf delegate here.
22
+ * 軸中立の凍結帯木接頭辞サイズ (frozen ≤ 0 / 空軸は 0、全トラック凍結は totalSize)。
23
+ * frozenWidthOf / frozenHeightOf の共通実体。
24
+ */
25
+ const frozenSizeOf = (axis: AxisModel, frozen: number): number => {
26
+ if (frozen <= 0 || axis.count === 0) {
27
+ return 0
28
+ }
29
+ return frozen >= axis.count ? axis.totalSize() : axis.offsetOf(frozen)
30
+ }
31
+
32
+ /**
33
+ * Axis-neutral forward map: index to viewport position. Frozen leading tracks are static; scroll
34
+ * tracks subtract the scroll offset.
35
+ * 軸中立の順写像: index → viewport 位置。先頭凍結トラックは静的、スクロールトラックは scroll を引く。
36
+ */
37
+ const toViewportPos = (axis: AxisModel, index: number, scroll: number, frozen: number): number => (index < frozen ? axis.offsetOf(index) : axis.offsetOf(index) - scroll)
38
+
39
+ /**
40
+ * Axis-neutral inverse map: viewport position to index. The frozen branch exists ONLY when
41
+ * F_px > 0 — below F_px it resolves at the raw tree coordinate (negative p clamps into the
42
+ * leading frozen track, Excel-like nearest visible); at/after F_px the scroll band continues at
43
+ * tree = p + scroll. At F_px = 0 every p (incl. negative) keeps the v0.4 scroll-relative form
44
+ * (guard prevents an unguarded p < 0 from sinking into indexAt(p) = track 0).
45
+ * 軸中立の逆写像: viewport 位置 → index。凍結分岐は F_px > 0 のときのみ — F_px 未満は木座標の
46
+ * 生値 (負 p は先頭凍結トラックへクランプ)、以上はスクロール帯 (木 = p + scroll)。F_px = 0 は
47
+ * 負 p も含め全域 v0.4 形 (無ガードの p < 0 → indexAt(p) = トラック 0 の吸込を防ぐ)。
48
+ */
49
+ const viewportPosToIndex = (axis: AxisModel, p: number, scroll: number, frozenSize: number): number => (frozenSize > 0 && p < frozenSize ? axis.indexAt(p) : axis.indexAt(p + scroll))
50
+
51
+ /**
52
+ * Tree-prefix width of the frozen band on this axis (0 when F <= 0; totalSize when F covers
53
+ * every column). The render-surface twin of the engine's getFrozenSize().width — the pair-call
54
+ * discipline keeps the two equal.
55
+ * この軸の凍結帯の木接頭辞幅 (F ≤ 0 は 0、全列凍結は totalSize)。エンジン getFrozenSize().width
56
+ * の render 面双子 — 対呼び出し規律が両者を一致させる。
57
+ */
58
+ export const frozenWidthOf = (axis: AxisModel, frozenCols: number): number => frozenSizeOf(axis, frozenCols)
59
+
60
+ /**
61
+ * Frozen-band tree-prefix HEIGHT H_F (row twin of frozenWidthOf — v0.6 plan §2).
62
+ * 凍結帯の木接頭辞高 H_F (frozenWidthOf の行双子 — v0.6 計画 §2)。
63
+ */
64
+ export const frozenHeightOf = (axis: AxisModel, frozenRows: number): number => frozenSizeOf(axis, frozenRows)
65
+
66
+ /**
67
+ * Forward map: column index to viewport x (the left edge). Frozen columns are static; scroll
68
+ * columns subtract hx (v0.5 plan §2).
69
+ * 順写像: 列 index → viewport x (左端)。凍結列は静的、スクロール列は hx を引く。
70
+ */
71
+ export const colToViewportX = (axis: AxisModel, col: number, hx: number, frozenCols: number): number => toViewportPos(axis, col, hx, frozenCols)
72
+
73
+ /**
74
+ * Forward map: row index to viewport y (the top edge). Frozen rows static; scroll rows subtract vy (v0.6 plan §2).
75
+ * 順写像: 行 index → viewport y (上端)。凍結行は静的、スクロール行は vy を引く (v0.6 計画 §2)。
76
+ */
77
+ export const rowToViewportY = (axis: AxisModel, row: number, vy: number, frozenRows: number): number => toViewportPos(axis, row, vy, frozenRows)
78
+
79
+ /**
80
+ * Inverse map: viewport x to column index. The frozen branch exists ONLY when W_F > 0: below
81
+ * W_F the frozen band resolves at the raw tree coordinate (negative px clamps into the leading
82
+ * frozen column — Excel-like nearest visible); at or right of W_F the scroll band continues at
83
+ * tree = px + hx. At W_F = 0 every px — including negative (pointer left of the cells area
84
+ * during a leftward drag) — keeps the v0.4 hx-relative form (R2 fix: an unguarded `px < 0`
85
+ * would swallow negative px into indexAt(px) → column 0, breaking the F = 0 identity).
86
+ * 逆写像: viewport x → 列 index。凍結分岐は W_F > 0 のときのみ存在: W_F 未満は凍結帯
87
+ * (木座標の生値 — 負の px は先頭凍結列へクランプ。Excel 同型の最寄り可視)、W_F 以上は
88
+ * スクロール帯 (木座標 = px + hx)。W_F = 0 は負の px (左ドラッグでセル領域外へ出た
89
+ * ポインタ) も含め全域が v0.4 の hx 相対形 (R2 是正: 無ガードの `px < 0` は負 px を
90
+ * indexAt(px) = 列 0 へ吸い込み、F = 0 恒等を破る)。
91
+ */
92
+ export const viewportXToCol = (axis: AxisModel, px: number, hx: number, frozenWidth: number): number => viewportPosToIndex(axis, px, hx, frozenWidth)
93
+
94
+ /**
95
+ * Inverse map: viewport y to row index (row twin of viewportXToCol — v0.6 plan §2). Guarded on
96
+ * H_F > 0; H_F = 0 keeps the v0.4 vy-relative form for all py including negative.
97
+ * 逆写像: viewport y → 行 index (viewportXToCol の行双子 — v0.6 計画 §2)。H_F > 0 ガードつき、
98
+ * H_F = 0 は負 py も含め v0.4 の vy 相対形。
99
+ */
100
+ export const viewportYToRow = (axis: AxisModel, py: number, vy: number, frozenHeight: number): number => viewportPosToIndex(axis, py, vy, frozenHeight)
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @fileoverview UI label vocabulary (v0.8 §1.6 / §4.1.17) — the ONE label bag of the package.
3
+ * The v0.7 `sinkAriaLabel` / `rootAriaLabel` props are folded into it (clean cut-over: no alias
4
+ * props remain), joined by the popover / indicator strings of the rich-cell UI. Pure, React-free.
5
+ *
6
+ * Contract (each clause is pinned by labels.spec.ts):
7
+ * - `SpreadsheetLabels` is the closed key set; `DEFAULT_LABELS` supplies an English default for
8
+ * every key. Count-aware keys are typed `string | ((n: number) => string)`.
9
+ * - `resolveLabels(overrides)` is the single fold point: a key takes its override when the
10
+ * override is not `undefined` and the default otherwise (exactly the destructuring-default
11
+ * semantics the two folded props had in v0.7), then `assertLabels` runs on the result.
12
+ * `undefined` overrides resolve to `DEFAULT_LABELS` by identity.
13
+ * - `assertLabels`: `sinkAriaLabel` MUST be a string mentioning "Escape" (WCAG 2.1.2 escape-method
14
+ * advisement, spec 00 §10-4) — otherwise a RangeError, thrown at mount by the root.
15
+ * - `resolveLabel(labels, key, n)` is the single read point: a function-valued label receives `n`
16
+ * (omitting `n` for a function value is a RangeError — never a silent 0), a string is returned
17
+ * as-is (`n` ignored, so a consumer may override a count key with a plain string).
18
+ *
19
+ * UI ラベル語彙 (v0.8 §1.6 / §4.1.17) — パッケージ唯一のラベル集合。v0.7 の `sinkAriaLabel` /
20
+ * `rootAriaLabel` prop を畳み込み (クリーンカットオーバー: 別名 prop は残さない)、リッチセル UI の
21
+ * ポップオーバー / インジケータ文言を加える。純関数・React 非依存。
22
+ * 契約: `SpreadsheetLabels` は閉じたキー集合、`DEFAULT_LABELS` は全キーの英語既定、件数キーは
23
+ * `string | ((n: number) => string)`。`resolveLabels` が唯一の畳込み点 (undefined でない override を
24
+ * 採用、それ以外は既定 — 畳み込んだ 2 prop が v0.7 で持っていた分割代入既定と同じ意味論。
25
+ * override 未指定は `DEFAULT_LABELS` を identity で返却) で、続けて `assertLabels`
26
+ * (`sinkAriaLabel` は "Escape" を含む文字列であること — spec 00 §10-4、違反はマウント時 RangeError)。
27
+ * `resolveLabel` が唯一の読み出し点 (関数値は n 必須 — 省略は RangeError、文字列はそのまま)。
28
+ */
29
+
30
+ /**
31
+ * The closed label vocabulary. Every key has a default in DEFAULT_LABELS; consumers override any
32
+ * subset through the `labels` prop (SpreadsheetLabelOverrides).
33
+ * 閉じたラベル語彙。全キーが DEFAULT_LABELS に既定を持ち、消費者は `labels` prop で任意の部分集合を上書き。
34
+ */
35
+ export type SpreadsheetLabels = {
36
+ /** Accessible name of the sink (grid input receptor); MUST mention "Escape" (spec 00 §10-4). / シンクのアクセシブルネーム ("Escape" 必須)。 */
37
+ sinkAriaLabel: string
38
+ /** Accessible name of the root role="group" landing point. / root 着地点のアクセシブルネーム。 */
39
+ rootAriaLabel: string
40
+ /** Note popover heading. / ノートポップオーバーの見出し。 */
41
+ noteTitle: string
42
+ /** Note popover save button. / ノートの保存ボタン。 */
43
+ noteSave: string
44
+ /** Note popover hint in the normal state (Escape saves and closes). / 通常状態のノートヒント。 */
45
+ noteEscapeHint: string
46
+ /** Note popover hint in the rejected state (Ctrl+Enter retries, Escape closes without saving). / 拒否状態のノートヒント。 */
47
+ noteRejectedHint: string
48
+ /** Comment popover heading. / コメントポップオーバーの見出し。 */
49
+ commentTitle: string
50
+ /** Comment reply field label. / 返信入力欄のラベル。 */
51
+ commentReply: string
52
+ /** Comment post button. / コメント投稿ボタン。 */
53
+ commentPost: string
54
+ /** Comment count text; a function receives the thread length. / コメント件数 (関数はスレッド長を受け取る)。 */
55
+ commentCount: string | ((n: number) => string)
56
+ /** Link popover heading. / リンクポップオーバーの見出し。 */
57
+ linkTitle: string
58
+ /** Link popover display-text field label. / リンク表示文字列の入力欄ラベル。 */
59
+ linkTextField: string
60
+ /** Link popover URL field label. / リンク URL の入力欄ラベル。 */
61
+ linkUrlField: string
62
+ /** Link popover open button (only when onHyperlinkOpen is declared). / リンクを開くボタン。 */
63
+ linkOpen: string
64
+ /** Link popover save button. / リンクの保存ボタン。 */
65
+ linkSave: string
66
+ /** Validation-list popover heading. / 検証リストポップオーバーの見出し。 */
67
+ listTitle: string
68
+ /** Validation-list popover text when no option matches. / 候補なしの表示。 */
69
+ listEmpty: string
70
+ /** Validation-list popover overflow row (options beyond the render cap); a function receives the hidden-option count. / 検証リストの上限超過行 (描画上限を超えた候補) — 関数は非表示件数を受け取る。 */
71
+ listMore: string | ((n: number) => string)
72
+ /** Message popover heading. / メッセージポップオーバーの見出し。 */
73
+ messageTitle: string
74
+ /** Message shown when a strict validation rule rejects the typed value. / 厳格検証で拒否されたときの文言。 */
75
+ validationRejected: string
76
+ /** Close button of every popover. / ポップオーバー共通の閉じるボタン。 */
77
+ popoverClose: string
78
+ /** Accessible label of the note indicator on a cell. / ノートインジケータのラベル。 */
79
+ indicatorNote: string
80
+ /** Accessible label of the comment indicator on a cell. / コメントインジケータのラベル。 */
81
+ indicatorComment: string
82
+ /** Accessible label of the invalid-value indicator on a cell. / 無効値インジケータのラベル。 */
83
+ indicatorInvalid: string
84
+ }
85
+
86
+ /**
87
+ * The `labels` prop shape: any subset of SpreadsheetLabels (an omitted or `undefined` key keeps
88
+ * the default). Keys are closed to SpreadsheetLabels.
89
+ * `labels` prop の形: SpreadsheetLabels の任意の部分集合 (省略 / undefined は既定のまま)。キーは
90
+ * SpreadsheetLabels に閉じる。
91
+ */
92
+ export type SpreadsheetLabelOverrides = { readonly [K in keyof SpreadsheetLabels]?: SpreadsheetLabels[K] }
93
+
94
+ /**
95
+ * Every label key, in declaration order — the iteration source of the fold and the census list
96
+ * for docs / tests. Compile-time proof below that it covers EVERY SpreadsheetLabels key.
97
+ * 全ラベルキー (宣言順) — 畳込みの走査元と docs / テストの census 表。直下で全キー網羅を型水準で証明。
98
+ */
99
+ export const LABEL_KEYS = [
100
+ "sinkAriaLabel",
101
+ "rootAriaLabel",
102
+ "noteTitle",
103
+ "noteSave",
104
+ "noteEscapeHint",
105
+ "noteRejectedHint",
106
+ "commentTitle",
107
+ "commentReply",
108
+ "commentPost",
109
+ "commentCount",
110
+ "linkTitle",
111
+ "linkTextField",
112
+ "linkUrlField",
113
+ "linkOpen",
114
+ "linkSave",
115
+ "listTitle",
116
+ "listEmpty",
117
+ "listMore",
118
+ "messageTitle",
119
+ "validationRejected",
120
+ "popoverClose",
121
+ "indicatorNote",
122
+ "indicatorComment",
123
+ "indicatorInvalid",
124
+ ] as const satisfies readonly (keyof SpreadsheetLabels)[]
125
+
126
+ /** Compile-time proof that LABEL_KEYS lists EVERY SpreadsheetLabels key (a key added to the type but not to the list would never be folded). / LABEL_KEYS が SpreadsheetLabels の全キーを網羅することの型水準の証明 (型に増えたキーが表に無いと畳込みから漏れる)。 */
127
+ const labelKeysCoverEveryKey: [Exclude<keyof SpreadsheetLabels, (typeof LABEL_KEYS)[number]>] extends [never] ? true : never = true
128
+ void labelKeysCoverEveryKey
129
+
130
+ /**
131
+ * 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";
133
+ * `commentCount` and `listMore` pluralise ("1 comment" / "n comments", "1 more value" / "n more values"). Frozen.
134
+ * 全キーの英語既定。`sinkAriaLabel` / `rootAriaLabel` は v0.7 の prop 既定と byte 同一 (畳込みは
135
+ * DOM 恒等)、`noteEscapeHint` = "Escape saves and closes"、`commentCount` / `listMore` は単複を区別。凍結済み。
136
+ */
137
+ export const DEFAULT_LABELS: SpreadsheetLabels = Object.freeze({
138
+ sinkAriaLabel: "Spreadsheet grid. Press Escape to move focus out of the grid.",
139
+ rootAriaLabel: "Spreadsheet",
140
+ noteTitle: "Note",
141
+ noteSave: "Save",
142
+ noteEscapeHint: "Escape saves and closes",
143
+ noteRejectedHint: "The note was rejected. Press Ctrl+Enter to retry or Escape to close without saving",
144
+ commentTitle: "Comments",
145
+ commentReply: "Reply",
146
+ commentPost: "Post",
147
+ commentCount: (n: number): string => (n === 1 ? "1 comment" : `${n} comments`),
148
+ linkTitle: "Link",
149
+ linkTextField: "Text",
150
+ linkUrlField: "URL",
151
+ linkOpen: "Open",
152
+ linkSave: "Save",
153
+ listTitle: "Choose a value",
154
+ listEmpty: "No matching values",
155
+ listMore: (n: number): string => (n === 1 ? "1 more value — keep typing to narrow" : `${n} more values — keep typing to narrow`),
156
+ messageTitle: "Data validation",
157
+ validationRejected: "The value does not satisfy this cell's validation rule",
158
+ popoverClose: "Close",
159
+ indicatorNote: "Has a note",
160
+ indicatorComment: "Has comments",
161
+ indicatorInvalid: "Invalid value",
162
+ })
163
+
164
+ /**
165
+ * Mount-time check of a resolved label bag: `sinkAriaLabel` must be a string that mentions
166
+ * "Escape" (case-sensitive — the WCAG 2.1.2 escape-method advisement of spec 00 §10-4). Anything
167
+ * else is a consumer configuration error ⇒ RangeError (fail-fast, never silently repaired).
168
+ * 解決済みラベル集合のマウント時検証: `sinkAriaLabel` は "Escape" を含む文字列であること
169
+ * (大文字小文字を区別 — spec 00 §10-4 の WCAG 2.1.2 脱出告知)。それ以外は消費者の設定誤りとして
170
+ * RangeError (fail-fast、黙って修復しない)。
171
+ */
172
+ export const assertLabels = (labels: SpreadsheetLabels): void => {
173
+ const sink = labels.sinkAriaLabel
174
+ // 文字列以外 (JS 呼び手) と Escape 告知の欠落は同じ設定誤り
175
+ if (typeof sink !== "string" || !sink.includes("Escape")) {
176
+ throw new RangeError(`[spreadsheet] labels.sinkAriaLabel must be a string mentioning "Escape" (WCAG 2.1.2 escape advisement, spec 00 §10-4); got ${JSON.stringify(sink)}`)
177
+ }
178
+ }
179
+
180
+ /** Writes one resolved label; generic so the union-typed key of the fold loop type-checks against the per-key value type. / 解決済みラベルを 1 件書く (畳込みループの union キーをキー毎の値型と突き合わせるための総称関数)。 */
181
+ const putLabel = <K extends keyof SpreadsheetLabels>(target: SpreadsheetLabels, key: K, value: SpreadsheetLabels[K]): void => {
182
+ target[key] = value
183
+ }
184
+
185
+ /**
186
+ * The single fold point of the `labels` prop: every key takes the override when it is not
187
+ * `undefined`, the default otherwise (destructuring-default semantics of the folded v0.7 props);
188
+ * the result is validated by assertLabels and frozen. `undefined` overrides return DEFAULT_LABELS
189
+ * by identity. Inputs are never mutated.
190
+ * `labels` prop の唯一の畳込み点: 各キーは override が undefined でなければ override、さもなくば
191
+ * 既定 (畳み込んだ v0.7 prop の分割代入既定の意味論)。結果は assertLabels で検証し凍結。override
192
+ * 未指定は DEFAULT_LABELS を identity で返す。入力は変更しない。
193
+ */
194
+ export const resolveLabels = (overrides: SpreadsheetLabelOverrides | undefined): SpreadsheetLabels => {
195
+ // override なしは既定そのもの (identity 安定 — 消費者の useMemo 再計算でも同一 object)
196
+ if (overrides === undefined) {
197
+ return DEFAULT_LABELS
198
+ }
199
+ const resolved: SpreadsheetLabels = { ...DEFAULT_LABELS }
200
+ // 明示 undefined は「未指定」= 既定のまま (分割代入既定と同じ)
201
+ for (const key of LABEL_KEYS) {
202
+ const value = overrides[key]
203
+ if (value !== undefined) {
204
+ putLabel(resolved, key, value)
205
+ }
206
+ }
207
+ assertLabels(resolved)
208
+ return Object.freeze(resolved)
209
+ }
210
+
211
+ /**
212
+ * The single read point: resolves one label to its display string. A function-valued label is
213
+ * called with `n` (a missing `n` is a caller bug ⇒ RangeError — no silent 0); a string label is
214
+ * returned as-is and ignores `n`.
215
+ * 唯一の読み出し点: ラベル 1 件を表示文字列へ解決。関数値は n で呼ぶ (n 省略は呼び手バグとして
216
+ * RangeError — 黙って 0 にしない)、文字列値はそのまま (n は無視)。
217
+ */
218
+ export const resolveLabel = (labels: SpreadsheetLabels, key: keyof SpreadsheetLabels, n?: number): string => {
219
+ const value = labels[key]
220
+ // 関数値だけが件数を要する
221
+ if (typeof value === "function") {
222
+ if (n === undefined) {
223
+ throw new RangeError(`[spreadsheet] labels.${key} is count-aware; resolveLabel needs n`)
224
+ }
225
+ return value(n)
226
+ }
227
+ return value
228
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @fileoverview Tiny external store for the scroll view state. Scroll positions change every
3
+ * frame, so they must NOT live in React state of the root — and since v0.4 they do not live
4
+ * here either: the VirtualGrid handle is the SINGLE truth for both axes (`getScrollPosition()`
5
+ * / `getViewportSize()` — synchronous ref reads), and this store carries only the values React
6
+ * leaves need for re-render loops: the row/column RENDERING ranges (RowHeader / ColumnHeader
7
+ * placement) and the geometry/edit versions (re-slice levers). vy remains here solely as the
8
+ * rAF-notified mirror the row-side leaves subscribe to; there is deliberately NO hx and NO
9
+ * bodyWidth field (the v0.3 sync-hx SSOT is retired — §7-4 of the VirtualGrid design plan:
10
+ * freshness reads belong to the handle, and a store copy would be a second source of truth).
11
+ *
12
+ * スクロール視点の極小外部ストア。毎フレーム変わる値を root の state に置けないのは従来どおり
13
+ * だが、v0.4 からは**本ストアにも置かない**: 両軸の位置は VirtualGrid ハンドル
14
+ * (`getScrollPosition()` / `getViewportSize()` — 同期 ref 読み) が単一の真実で、ここは React の
15
+ * 葉が再レンダーに必要とする値だけを運ぶ — 行 / 列の描画レンジ (RowHeader / ColumnHeader の
16
+ * 配置) と幾何 / 編集 version (再スライスのレバー)。vy は行側の葉が購読する rAF 通知写像として
17
+ * のみ残る。hx / bodyWidth フィールドは**意図的に存在しない** (v0.3 の同期 hx SSOT は退役 —
18
+ * 設計計画 §7-4: 鮮度読みはハンドルへ、ストア複製は第 2 の真実になる)。
19
+ */
20
+
21
+ /** Snapshot of the scroll view state. / スクロール視点状態のスナップショット。 */
22
+ export type ViewState = {
23
+ /** rAF-notified mirror of the vertical position (leaves only; freshness reads use the handle). / 縦位置の rAF 通知写像 (葉専用 — 鮮度読みはハンドル)。 */
24
+ vy: number
25
+ rowStart: number
26
+ rowEnd: number
27
+ /** Rendering column range from VirtualGrid's onRangeChange (overscan-inclusive; ColumnHeader source — the §7-5 re-render loop). / VirtualGrid onRangeChange 由来の描画列レンジ (ColumnHeader の再レンダー源 — §7-5)。 */
28
+ colStart: number
29
+ colEnd: number
30
+ colVersion: number
31
+ rowGeomVersion: number
32
+ editVersion: number
33
+ }
34
+
35
+ /** The store contract. / ストア契約。 */
36
+ export type ViewStore = {
37
+ get: () => ViewState
38
+ setVy: (vy: number) => void
39
+ /** Rendering row range from onRangeChange (overscan-inclusive; RowHeader source). / onRangeChange 由来の描画行レンジ。 */
40
+ setRowRange: (start: number, end: number) => void
41
+ /** Rendering column range from onRangeChange (overscan-inclusive; ColumnHeader source — row-symmetric §7-5). / onRangeChange 由来の描画列レンジ (行側 setRowRange の完全対称)。 */
42
+ setColRange: (start: number, end: number) => void
43
+ /** Bumped by resizeColumn to re-render the column header (cells re-slice via the grid's own width epoch). / 列幅変更 (resizeColumn) の列ヘッダー再レンダー通知 (セル再スライスはグリッド側の幅エポックが担う)。 */
44
+ bumpColVersion: () => void
45
+ /** Bumped by resizeRow to re-place row labels. / 行ジオメトリ変更のヘッダー再配置通知。 */
46
+ bumpRowGeometry: () => void
47
+ /** Bumped after each successful applyEdits (internal half of renderVersion). / applyEdits 成功の再描画通知。 */
48
+ bumpEdit: () => void
49
+ /**
50
+ * The ONLY subscription form: all changes coalesce inside one frame into a single rAF
51
+ * notification. getSnapshot always reads the synchronous latest value, so delivery timing
52
+ * never harms correctness. A separate synchronous subscribe deliberately does not exist
53
+ * (the v0.3 bodyWidth sync-notify exception retired with the field — width now lives in
54
+ * the VirtualGrid viewport and its ResizeObserver drives React directly).
55
+ * 唯一の購読形式。全変更はフレーム内 rAF 量子化で 1 通知に合流する。getSnapshot は常に同期
56
+ * 最新値を読むため配送タイミングは正しさを害さない。同期購読は意図的に存在しない (v0.3 の
57
+ * bodyWidth 同期通知例外はフィールドごと退役 — 幅は VirtualGrid ビューポートが所有)。
58
+ */
59
+ subscribeCoalesced: (listener: () => void) => () => void
60
+ }
61
+
62
+ /** Vertical scrollbar width in px (SSOT — §6.9-3; also the grid's horizontal bar height). / 縦バー幅 px (SSOT — 横バー高も同値)。 */
63
+ export const SCROLLBAR_WIDTH = 12
64
+
65
+ /** Creates a view store instance. / viewStore インスタンスを構築。 */
66
+ export const createViewStore = (): ViewStore => {
67
+ let state: ViewState = { vy: 0, rowStart: 0, rowEnd: 0, colStart: 0, colEnd: 0, colVersion: 0, rowGeomVersion: 0, editVersion: 0 }
68
+ const listeners = new Set<() => void>()
69
+ let rafId: number | null = null
70
+ const flush = () => {
71
+ // 共有 rAF 1 本で全リスナーへ通知。bail はリスナー側 (useSyncExternalStore の
72
+ // スナップショット比較) が担うため、ここでは無差別に呼んでよい
73
+ rafId = null
74
+ for (const listener of [...listeners]) {
75
+ listener()
76
+ }
77
+ }
78
+ const schedule = () => {
79
+ // 購読者ゼロなら予約しない (unmount 後の set が rAF をリークしない — 単体テスト (c))
80
+ if (listeners.size === 0) {
81
+ return
82
+ }
83
+ // フレーム内の何度目の変更でも予約は 1 本 — これが量子化の本体
84
+ if (rafId === null) {
85
+ rafId = requestAnimationFrame(flush)
86
+ }
87
+ }
88
+ const set = (partial: Partial<ViewState>) => {
89
+ state = { ...state, ...partial }
90
+ schedule()
91
+ }
92
+ return {
93
+ get: () => state,
94
+ setVy: (vy) => {
95
+ if (vy !== state.vy) {
96
+ set({ vy })
97
+ }
98
+ },
99
+ setRowRange: (rowStart, rowEnd) => {
100
+ if (rowStart !== state.rowStart || rowEnd !== state.rowEnd) {
101
+ set({ rowStart, rowEnd })
102
+ }
103
+ },
104
+ setColRange: (colStart, colEnd) => {
105
+ if (colStart !== state.colStart || colEnd !== state.colEnd) {
106
+ set({ colStart, colEnd })
107
+ }
108
+ },
109
+ bumpColVersion: () => set({ colVersion: state.colVersion + 1 }),
110
+ bumpRowGeometry: () => set({ rowGeomVersion: state.rowGeomVersion + 1 }),
111
+ bumpEdit: () => set({ editVersion: state.editVersion + 1 }),
112
+ subscribeCoalesced: (listener) => {
113
+ listeners.add(listener)
114
+ return () => {
115
+ listeners.delete(listener)
116
+ if (listeners.size === 0 && rafId !== null) {
117
+ // 全購読解除で保留 rAF を回収 (unmount 後の宙吊り通知を残さない)
118
+ cancelAnimationFrame(rafId)
119
+ rafId = null
120
+ }
121
+ }
122
+ },
123
+ }
124
+ }