@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,279 @@
1
+ /**
2
+ * @fileoverview Self-calculation over the selected cells' texts (v0.8 §4.1.12 — the
3
+ * `multiplyBy100` / `multiplyBy1000` / `divideBy100` / `divideBy1000` / `signReversal` commands,
4
+ * ported from the sheets-enhancer extension's `calculateClipboardData`). Pure, React / DOM-free.
5
+ *
6
+ * Contract (each clause is pinned by selfCalc.spec.ts):
7
+ * - Text side (`scaleDecimalText` / `reverseSignText`): "is it a number" is decided by
8
+ * `parseNumber` from cellFormat.ts (the single source of the decimal grammar); the arithmetic is
9
+ * EXACT string digit shifting / sign flipping — no double is ever constructed, so integers beyond
10
+ * 2^53 keep every digit and "0.07" × 100 is "7" (not 7.000000000000001). The output is the
11
+ * canonical decimal (no "+", no "-0", no redundant leading / trailing zeros); an exponent-form
12
+ * input keeps its exponent form with the exponent shifted ("1e3" × 100 = "1e5"). Non-numeric
13
+ * text (for the sign: anything but TRUE / FALSE, case-insensitive) ⇒ null = "leave the cell";
14
+ * so is a RESULT that leaves the parseNumber grammar / finite envelope ("9e307" × 100,
15
+ * "1e-9999" ÷ 100, a 308-digit plain integer × 100) — the operation must never write text the
16
+ * package itself no longer recognises as a number (one-way domain escape; Excel refuses too).
17
+ * - Formula side (`scaleFormula` / `reverseSignFormula`): reversible wrap / peel. Wrap =
18
+ * `=(body)*100` / `=(body)/100` / `=-(body)`; peel removes exactly our own INVERSE wrapper
19
+ * (`=(inner)/100` under ×100, `=(inner)*100` under ÷100, `=-(inner)` under sign) and ONLY when
20
+ * the leading "(" pairs with the ")" before the suffix — "…" string literals and '…' quoted
21
+ * sheet names are opaque (a doubled quote toggles the literal state twice, so no escape rule).
22
+ * The extension's greedy `^\((.+)\)/100$` mis-peels `=(A1)/100+(B1)/100` into the broken
23
+ * `=A1)/100+(B1`; here that formula wraps.
24
+ * - A formula is "=" + a non-empty body; anything else is a caller bug ⇒ RangeError (fail-fast,
25
+ * the same rule getCellFormula's "=" contract enforces). An exponent outside ±2 / ±3 is a
26
+ * RangeError too (JS callers).
27
+ * - Whether a cell IS a formula is the CALLER's decision (the `getCellFormula` capability branch
28
+ * in useCommands): no function here sniffs a leading "=" on the text side, so a v0.7 adapter
29
+ * without `getCellFormula` sees "=A1" as plain text (untouched) — never as a formula.
30
+ *
31
+ * 選択セルの text に対する自己計算 (v0.8 §4.1.12 — multiplyBy100 / multiplyBy1000 / divideBy100 /
32
+ * divideBy1000 / signReversal、sheets-enhancer 拡張 `calculateClipboardData` の移植)。純関数・
33
+ * React / DOM 非依存。契約: text 側は「数値か」を cellFormat.ts の parseNumber で決め (十進文法の
34
+ * 単一真実源)、演算は文字列の桁シフト / 符号反転で**正確** (double を作らない — 2^53 超は全桁保持、
35
+ * "0.07"×100 = "7")。出力は正準十進 ("+" / "-0" / 余分な前後 0 なし)、指数形入力は指数形のまま指数を
36
+ * シフト。非数値 (符号は大文字小文字を問わない TRUE / FALSE 以外) は null = セルを触らない。**結果**が
37
+ * parseNumber の文法 / 有限域を出る演算 ("9e307"×100 / "1e-9999"÷100 / 308 桁整数の ×100) も同じ
38
+ * null (数値領域からの片道脱出を書かない — Excel も拒否)。数式側は
39
+ * 可逆ラップ / 剥離 — 剥離は自前の**逆**ラッパだけ、かつ先頭 "(" が接尾辞直前の ")" と対になるとき
40
+ * だけ (文字列リテラル / クオート済みシート名は不透過。拡張の貪欲 regex は `=(A1)/100+(B1)/100` を
41
+ * `=A1)/100+(B1` に壊すが、ここではラップする)。数式は "=" + 非空本体、それ以外と ±2 / ±3 以外の
42
+ * 指数は呼び手バグとして RangeError。「数式か」は呼び手 (useCommands の getCellFormula 能力分岐) が
43
+ * 決め、text 側で "=" を嗅がない (getCellFormula 未宣言の v0.7 アダプタでは "=A1" は素の text)。
44
+ */
45
+
46
+ import { parseNumber } from "../model/cellFormat.ts"
47
+
48
+ /** Power-of-ten exponent of a self-calc scale: +2 / +3 multiply by 100 / 1000, -2 / -3 divide. / 自己計算の 10 の冪指数: +2 / +3 は ×100 / ×1000、-2 / -3 は ÷100 / ÷1000。 */
49
+ export type ScaleExponent = 2 | 3 | -2 | -3
50
+
51
+ /** Decomposed decimal literal: sign, all digits without the point, point position (digits before the point) and the literal exponent (null when the input had none). / 分解した十進リテラル: 符号、小数点抜きの全桁、小数点位置 (点より前の桁数)、リテラル指数 (無ければ null)。 */
52
+ type DecimalParts = { negative: boolean; digits: string; point: number; exponent: number | null }
53
+
54
+ /** Rejects an exponent outside the ±2 / ±3 vocabulary (JS callers) with RangeError. / ±2 / ±3 以外の指数 (JS 呼び手) を RangeError で拒否。 */
55
+ const assertScaleExponent = (exponent: number): void => {
56
+ if (exponent !== 2 && exponent !== 3 && exponent !== -2 && exponent !== -3) {
57
+ throw new RangeError(`[spreadsheet] self-calc exponent must be 2, 3, -2 or -3; got ${String(exponent)}`)
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Splits a parseNumber-validated literal into its parts. The grammar guarantees a sign of at most
63
+ * one character, at least one digit, at most one point and an exponent of 1-4 digits, so every
64
+ * slice below is total.
65
+ * parseNumber 検証済みリテラルを部品へ分解。文法が符号 1 文字以下・桁 1 以上・点 1 以下・指数 1-4 桁を
66
+ * 保証するため、以下のスライスは全域で安全。
67
+ */
68
+ const splitDecimal = (literal: Intl.StringNumericLiteral): DecimalParts => {
69
+ let rest: string = literal
70
+ let negative = false
71
+ // 符号 (+ は落とす、- は保持)
72
+ if (rest.startsWith("-")) {
73
+ negative = true
74
+ rest = rest.slice(1)
75
+ } else if (rest.startsWith("+")) {
76
+ rest = rest.slice(1)
77
+ }
78
+ // 指数部 (1-4 桁の整数 — Number は正確)
79
+ const at = rest.search(/[eE]/)
80
+ let exponent: number | null = null
81
+ if (at >= 0) {
82
+ exponent = Number(rest.slice(at + 1))
83
+ rest = rest.slice(0, at)
84
+ }
85
+ // 小数点で整数部 / 小数部へ
86
+ const dot = rest.indexOf(".")
87
+ const intPart = dot < 0 ? rest : rest.slice(0, dot)
88
+ const fracPart = dot < 0 ? "" : rest.slice(dot + 1)
89
+ return { negative, digits: intPart + fracPart, point: intPart.length, exponent }
90
+ }
91
+
92
+ /**
93
+ * Canonical decimal text for a digit run with the point after `point` digits (`point` may fall
94
+ * left of the first digit or right of the last): leading / trailing zeros dropped, zero unsigned.
95
+ * 桁列と小数点位置 (先頭より左 / 末尾より右も可) から正準十進文字列を作る: 前後の余分な 0 を落とし、
96
+ * ゼロは符号を持たない。
97
+ */
98
+ const renderDecimal = (negative: boolean, digits: string, point: number): string => {
99
+ let int: string
100
+ let frac: string
101
+ // 小数点位置を桁列の範囲へ収める (左へ出れば 0 詰め、右へ出れば 0 付加)
102
+ if (point <= 0) {
103
+ int = "0"
104
+ frac = "0".repeat(-point) + digits
105
+ } else if (point >= digits.length) {
106
+ int = digits + "0".repeat(point - digits.length)
107
+ frac = ""
108
+ } else {
109
+ int = digits.slice(0, point)
110
+ frac = digits.slice(point)
111
+ }
112
+ // 正準化: 整数部の先頭 0 (1 桁は残す) と小数部の末尾 0 を落とす
113
+ int = int.replace(/^0+(?=\d)/, "")
114
+ frac = frac.replace(/0+$/, "")
115
+ // ゼロは符号を持たない
116
+ if (int === "0" && frac === "") {
117
+ return "0"
118
+ }
119
+ return `${negative ? "-" : ""}${int}${frac === "" ? "" : `.${frac}`}`
120
+ }
121
+
122
+ /**
123
+ * Canonical text of decimal parts after shifting the power of ten by `shift`: a plain literal
124
+ * moves its point, an exponent-form literal keeps its (normalised) mantissa and shifts the
125
+ * exponent; a zero mantissa is "0" whatever the exponent. A result that leaves the parseNumber
126
+ * grammar / finite envelope (an exponent grown past 4 digits, or a value overflowing double to
127
+ * Infinity — plain OR exponent form) is null = the cell is left alone, the same rule as
128
+ * non-numeric input.
129
+ * 10 の冪を `shift` だけずらした正準文字列: 通常形は小数点を移動、指数形は (正準化した) 仮数を保って
130
+ * 指数をずらす。仮数ゼロは指数に依らず "0"。結果が parseNumber の文法 / 有限域を出る場合 (指数の
131
+ * 5 桁化、通常形 / 指数形を問わない double の Infinity 溢れ) は非数値入力と同じ null = セルを
132
+ * 触らない。
133
+ */
134
+ const renderShifted = (parts: DecimalParts, shift: number): string | null => {
135
+ let candidate: string
136
+ if (parts.exponent === null) {
137
+ candidate = renderDecimal(parts.negative, parts.digits, parts.point + shift)
138
+ } else {
139
+ const mantissa = renderDecimal(parts.negative, parts.digits, parts.point)
140
+ candidate = mantissa === "0" ? "0" : `${mantissa}e${parts.exponent + shift}`
141
+ }
142
+ // 結果が自前の数値文法 / 有限域を出ると (例: "9e307"×100 = Infinity 溢れ、"1e-9999"÷100 = 指数
143
+ // 5 桁化) セルが数値領域から片道脱出する — 逆演算も null 化して自己計算では戻せなくなるため、
144
+ // Excel が拒否するのと同様に「触らない」へ倒す
145
+ return parseNumber(candidate) === null ? null : candidate
146
+ }
147
+
148
+ /**
149
+ * Decimal string × 10^exponent EXACTLY (string digit shift — no double). Non-numeric text (by
150
+ * parseNumber) ⇒ null (the cell is left alone); a result outside the parseNumber grammar / finite
151
+ * envelope ⇒ null too (no one-way escape from the numeric domain). Output is canonical (see
152
+ * fileoverview).
153
+ * 十進文字列の正確な 10^exponent 倍 (文字列の桁シフト — double 不使用)。非数値 (parseNumber 判定) は
154
+ * null (セルを触らない)。結果が parseNumber の文法 / 有限域を出る場合も null (数値領域からの片道
155
+ * 脱出を書かない)。出力は正準形 (fileoverview 参照)。
156
+ */
157
+ export const scaleDecimalText = (text: string, exponent: ScaleExponent): string | null => {
158
+ assertScaleExponent(exponent)
159
+ const literal = parseNumber(text)
160
+ // 数値でなければ触らない
161
+ if (literal === null) {
162
+ return null
163
+ }
164
+ return renderShifted(splitDecimal(literal), exponent)
165
+ }
166
+
167
+ /**
168
+ * Sign reversal of a cell text: a number (by parseNumber) negates exactly (zero stays "0"),
169
+ * TRUE / FALSE (case-insensitive, trimmed) flip to the canonical upper-case opposite, anything
170
+ * else ⇒ null (the cell is left alone).
171
+ * セル text の符号反転: 数値 (parseNumber 判定) は正確に反転 (ゼロは "0" のまま)、TRUE / FALSE
172
+ * (大文字小文字を問わず、trim 後) は正準大文字の逆へ、それ以外は null (セルを触らない)。
173
+ */
174
+ export const reverseSignText = (text: string): string | null => {
175
+ const literal = parseNumber(text)
176
+ if (literal !== null) {
177
+ const parts = splitDecimal(literal)
178
+ return renderShifted({ ...parts, negative: !parts.negative }, 0)
179
+ }
180
+ // 真偽値 (Excel / Sheets の大文字小文字を問わない TRUE / FALSE) は反転して正準大文字へ
181
+ const upper = text.trim().toUpperCase()
182
+ if (upper === "TRUE") {
183
+ return "FALSE"
184
+ }
185
+ if (upper === "FALSE") {
186
+ return "TRUE"
187
+ }
188
+ return null
189
+ }
190
+
191
+ /** Body of a formula ("=" + non-empty body); anything else is a caller bug ⇒ RangeError. / 数式の本体 ("=" + 非空本体)。それ以外は呼び手バグとして RangeError。 */
192
+ const formulaBody = (formula: string): string => {
193
+ if (!formula.startsWith("=") || formula.length === 1) {
194
+ throw new RangeError(`[spreadsheet] self-calc expects a formula ("=" followed by a body); got ${JSON.stringify(formula)}`)
195
+ }
196
+ return formula.slice(1)
197
+ }
198
+
199
+ /**
200
+ * True when `text` has balanced parentheses — depth never negative and zero at the end — with
201
+ * "…" string literals and '…' quoted sheet names opaque (a doubled quote closes and reopens, which
202
+ * is the same state as an escape — no escape rule needed); an unterminated quote is unbalanced.
203
+ * `text` の括弧が釣り合う (深さが負にならず末尾で 0) とき true。"…" 文字列リテラルと '…' クオート済み
204
+ * シート名は不透過 (二重化クオートは閉じ + 再開で脱出と同じ状態 — 脱出規則は不要)、未終端クオートは
205
+ * 不釣合い扱い。
206
+ */
207
+ const isParenBalanced = (text: string): boolean => {
208
+ let depth = 0
209
+ let quote: '"' | "'" | null = null
210
+ for (let i = 0; i < text.length; i++) {
211
+ const ch = text[i]
212
+ if (quote !== null) {
213
+ // クオート内: 同種のクオートで閉じる (二重化 "" / '' は閉じ + 再開と同じ状態遷移なので脱出規則は不要)
214
+ if (ch === quote) {
215
+ quote = null
216
+ }
217
+ continue
218
+ }
219
+ if (ch === '"' || ch === "'") {
220
+ quote = ch
221
+ } else if (ch === "(") {
222
+ depth++
223
+ } else if (ch === ")") {
224
+ depth--
225
+ // 閉じ過多は先頭 "(" が接尾辞直前の ")" と対でない証拠
226
+ if (depth < 0) {
227
+ return false
228
+ }
229
+ }
230
+ }
231
+ return quote === null && depth === 0
232
+ }
233
+
234
+ /**
235
+ * Inner text when `body` is exactly `open + inner + close` with a non-empty, balanced inner (so
236
+ * the "(" ending `open` pairs with the ")" starting `close`); null otherwise.
237
+ * `body` が `open + inner + close` そのもので inner が非空かつ釣り合う (open 末尾の "(" が close 先頭の
238
+ * ")" と対になる) とき inner、それ以外は null。
239
+ */
240
+ const peelWrapper = (body: string, open: string, close: string): string | null => {
241
+ // 形が合わなければ自前ラッパではない
242
+ if (body.length <= open.length + close.length || !body.startsWith(open) || !body.endsWith(close)) {
243
+ return null
244
+ }
245
+ const inner = body.slice(open.length, body.length - close.length)
246
+ return isParenBalanced(inner) ? inner : null
247
+ }
248
+
249
+ /**
250
+ * Wraps a formula as `=(body)*100` / `*1000` / `/100` / `/1000`, or peels our own INVERSE wrapper
251
+ * (`=(inner)/100` under ×100 etc.) back to `=inner` — reversible: ×100 then ÷100 returns the
252
+ * original. Same-direction wrappers nest (`=((A1)*100)*100`). RangeError for a non-formula or an
253
+ * exponent outside ±2 / ±3.
254
+ * 数式を `=(body)*100` / `*1000` / `/100` / `/1000` へラップ、または自前の**逆**ラッパ (×100 なら
255
+ * `=(inner)/100` 等) を `=inner` へ剥離 — 可逆 (×100 の後の ÷100 は元へ)。同方向は入れ子
256
+ * (`=((A1)*100)*100`)。非数式と ±2 / ±3 以外の指数は RangeError。
257
+ */
258
+ export const scaleFormula = (formula: string, exponent: ScaleExponent): string => {
259
+ assertScaleExponent(exponent)
260
+ const body = formulaBody(formula)
261
+ const power = Math.abs(exponent) === 2 ? "100" : "1000"
262
+ // ラップ演算子と、剥離対象 (逆演算) の演算子
263
+ const wrapOp = exponent > 0 ? "*" : "/"
264
+ const peelOp = exponent > 0 ? "/" : "*"
265
+ const inner = peelWrapper(body, "(", `)${peelOp}${power}`)
266
+ return inner === null ? `=(${body})${wrapOp}${power}` : `=${inner}`
267
+ }
268
+
269
+ /**
270
+ * Wraps a formula as `=-(body)`, or peels our own wrapper `=-(inner)` back to `=inner` —
271
+ * reversible (two reversals return the original). RangeError for a non-formula.
272
+ * 数式を `=-(body)` へラップ、または自前ラッパ `=-(inner)` を `=inner` へ剥離 — 可逆 (2 回で元へ)。
273
+ * 非数式は RangeError。
274
+ */
275
+ export const reverseSignFormula = (formula: string): string => {
276
+ const body = formulaBody(formula)
277
+ const inner = peelWrapper(body, "-(", ")")
278
+ return inner === null ? `=-(${body})` : `=${inner}`
279
+ }