@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.
- package/CHANGELOG.md +1103 -0
- package/LICENSE.md +155 -0
- package/README.md +817 -0
- package/dist/index.cjs +21 -0
- package/dist/index.d.cts +1960 -0
- package/dist/index.d.ts +1960 -0
- package/dist/index.js +7711 -0
- package/dist/styles/spreadsheet.css +2 -0
- package/dist/styles/spreadsheet.standalone.css +3 -0
- package/package.json +94 -0
- package/src/Spreadsheet.tsx +2313 -0
- package/src/clipboard/clipPayload.ts +1242 -0
- package/src/clipboard/fixtures/excel.fixture.ts +301 -0
- package/src/clipboard/fixtures/html.fixture.ts +90 -0
- package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
- package/src/clipboard/htmlInterop.ts +1694 -0
- package/src/clipboard/numberFormatCodec.ts +870 -0
- package/src/clipboard/pasteSpecial.ts +608 -0
- package/src/clipboard/tiling.ts +370 -0
- package/src/clipboard/tsv.ts +186 -0
- package/src/clipboard/useClipboard.ts +1154 -0
- package/src/commands/selfCalc.ts +279 -0
- package/src/commands/useCommands.ts +946 -0
- package/src/editor/useCellEditor.ts +708 -0
- package/src/grid/CellView.tsx +51 -0
- package/src/grid/GridBody.tsx +489 -0
- package/src/header/ColumnHeader.tsx +167 -0
- package/src/header/RowHeader.tsx +144 -0
- package/src/header/columnName.ts +31 -0
- package/src/header/resizeGates.ts +146 -0
- package/src/header/resizeStrips.ts +79 -0
- package/src/header/useHeaderResize.ts +598 -0
- package/src/header/useHeaderSelection.ts +454 -0
- package/src/index.ts +122 -0
- package/src/keyboard/compositionGuard.ts +53 -0
- package/src/keyboard/ensureCellVisible.ts +77 -0
- package/src/keyboard/shortcuts.ts +718 -0
- package/src/keyboard/useGridKeyboard.ts +940 -0
- package/src/model/axisModel.ts +186 -0
- package/src/model/cellAnnotations.ts +92 -0
- package/src/model/cellCoord.ts +114 -0
- package/src/model/cellFormat.ts +562 -0
- package/src/model/cellPaint.ts +537 -0
- package/src/model/conditionalFormat.ts +805 -0
- package/src/model/dataAdapter.ts +478 -0
- package/src/model/dataValidation.ts +542 -0
- package/src/model/formulaRefs.ts +984 -0
- package/src/model/frozenPartition.ts +100 -0
- package/src/model/labels.ts +228 -0
- package/src/model/viewStore.ts +124 -0
- package/src/overlay/SelectionOverlay.tsx +1425 -0
- package/src/overlay/useFillHandle.ts +432 -0
- package/src/popover/CommentPopover.tsx +109 -0
- package/src/popover/LinkPopover.tsx +108 -0
- package/src/popover/ListPopover.tsx +161 -0
- package/src/popover/MessagePopover.tsx +36 -0
- package/src/popover/NotePopover.tsx +106 -0
- package/src/popover/NoteTip.tsx +26 -0
- package/src/popover/PopoverLayer.tsx +210 -0
- package/src/popover/placement.ts +138 -0
- package/src/popover/usePopovers.ts +642 -0
- package/src/selection/selectionState.ts +239 -0
- package/src/selection/useAutoScroller.ts +226 -0
- package/src/selection/usePointDrag.ts +273 -0
- package/src/selection/useSelectionEngine.ts +687 -0
- package/src/styles/components.entry.css +10 -0
- package/src/styles/spreadsheet.css +1029 -0
- package/src/styles/standalone.entry.css +9 -0
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Formula reference engine (v0.8 §2.8): a hand-rolled A1 / R1C1 scanner and the
|
|
3
|
+
* pure transforms built on it — shifting on paste, the R1C1 relative form carried by the clip
|
|
4
|
+
* payload, the F4 anchor cycle, reference text for pointing insertion, and the pointing
|
|
5
|
+
* predicate (§2.7 / design v5). Pure, React-free and TOTAL fail-soft like cellFormat.ts: text
|
|
6
|
+
* that is not a formula or not a reference passes through verbatim; the only throws are
|
|
7
|
+
* RangeErrors on invalid consumer input (non-integer deltas / origins / coordinates, a
|
|
8
|
+
* non-positive memo limit — fail-fast on the API surface, never a silent substitution).
|
|
9
|
+
*
|
|
10
|
+
* Grammar (charCode loop, ≤ 1 µs per typical formula, no regex in the hot path):
|
|
11
|
+
* - cell `$?LETTERS$?DIGITS` (row ≥ 1; ≤ 11 letters / ≤ 15 digits so every index stays a safe
|
|
12
|
+
* integer — columns beyond XFD are accepted, the grid bounds the axis, not Excel), range
|
|
13
|
+
* `cell:cell`, whole column `$?LETTERS:$?LETTERS`, whole row `$?DIGITS:$?DIGITS`;
|
|
14
|
+
* - a sheet qualifier is kept VERBATIM and never interpreted (`Sheet1!`, `'Sheet 1'!` with
|
|
15
|
+
* `''` escape, 3-D `Sheet1:Sheet3!`, unquoted non-ASCII names);
|
|
16
|
+
* - string literals (`"…"` with `""` escape) are opaque; a word followed by `(` is a function
|
|
17
|
+
* call (`LOG10(` is never the cell `LOG10`); a word continuing into `_ . [ $` or a non-ASCII
|
|
18
|
+
* code unit is a name (`A1_x`, `Table1[Col]`); a bracket run is opaque like a string literal
|
|
19
|
+
* (structured references `Table1[Q1]` / `[@Col]`, nesting, `'` escaping the next char, an
|
|
20
|
+
* unterminated run swallows the rest) — Excel / Sheets never rewrite text inside `[...]`, so
|
|
21
|
+
* its contents are never handed to either grammar; `#REF!` stays `#REF!`;
|
|
22
|
+
* - R1C1 in the Google Sheets spelling: `R[-1]C[0]` relative (a zero offset is spelled
|
|
23
|
+
* `[0]`), `R3C2` absolute (1-based), mixed `R2C[7]`, whole axis `C[-6]` / `R[2]`; bare
|
|
24
|
+
* `R` / `C` are accepted on input as offset 0. As in Excel, names that spell a valid R1C1
|
|
25
|
+
* reference (`R`, `C`, `RC`, `R1C1`) are reserved — the round trip cannot tell them apart.
|
|
26
|
+
*
|
|
27
|
+
* Memoisation guidance: every transform re-scans its input; a caller that sees the same
|
|
28
|
+
* formula text many times (a 100k-cell paste of ≤ 100 distinct formulas) owns ONE bounded
|
|
29
|
+
* `Map<string, …>` built with {@link createFormulaMemo} (`createFormulaMemo(tokenizeFormula)`
|
|
30
|
+
* is the tokenizer cache). {@link fromR1C1} carries its own R1C1 parse memo, so the paste path
|
|
31
|
+
* meets the §5.6 budget without caller cooperation.
|
|
32
|
+
*
|
|
33
|
+
* 数式参照エンジン (v0.8 §2.8): 手書き A1 / R1C1 スキャナと、その上の純変換 — 貼り付け時の
|
|
34
|
+
* シフト、クリップが運ぶ R1C1 相対形、F4 アンカー循環、ポインティング挿入文字列、ポインティング
|
|
35
|
+
* 述語 (§2.7 / 設計 v5)。cellFormat.ts と同じく純関数・React 非依存・全経路 fail-soft (数式で
|
|
36
|
+
* ない / 参照でない文字列は素通し)。throw は不正な consumer 入力 (非整数の差分 / 原点 / 座標、
|
|
37
|
+
* 非正のメモ上限) の RangeError のみ (API 面の fail-fast — 黙った値代替はしない)。
|
|
38
|
+
* 文法: セル `$?英字$?数字` (行 ≥ 1、英字 ≤ 11 / 数字 ≤ 15 で全索引が安全整数、XFD 超の列も
|
|
39
|
+
* 受理)、レンジ、全列 / 全行、シート修飾子は**原文のまま**保持、文字列リテラルは不透過、`(`
|
|
40
|
+
* が続く語は関数、`_ . [ $` や非 ASCII に続く語は名前、角括弧の連なり (構造化参照 `Table1[Q1]` /
|
|
41
|
+
* `[@Col]`、入れ子可、`'` は次の 1 文字を脱出、未終端は末尾まで) は文字列リテラル同様不透過で
|
|
42
|
+
* 中身は決して走査しない (Excel / Sheets は `[...]` 内を書き換えない)。R1C1 は Google Sheets 綴り (相対 0 も
|
|
43
|
+
* `[0]`、絶対は 1 始まり、素の `R` / `C` は入力時のみ差分 0 として受理)。Excel 同様、R1C1
|
|
44
|
+
* 参照と同綴りの名前 (`R` / `C` / `RC` / `R1C1`) は予約 — 往復で区別できない。
|
|
45
|
+
* メモ指針: 各変換は毎回走査する。同じ数式文字列を何度も見る呼び出し側 (≤ 100 種の数式の
|
|
46
|
+
* 10 万セル貼り付け) は {@link createFormulaMemo} で上限つき `Map<string, …>` を 1 つ持つ
|
|
47
|
+
* (`createFormulaMemo(tokenizeFormula)` がトークナイザキャッシュ)。{@link fromR1C1} は
|
|
48
|
+
* 自前の R1C1 解析メモを持ち、貼り付け経路は呼び出し側の協力なしに §5.6 予算を満たす。
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import type { CellCoord, CellRange } from "./cellCoord.ts"
|
|
52
|
+
|
|
53
|
+
/** One end of a cell range (0-based, per-axis `$` anchors). / レンジの片端 (0 始まり・軸毎の `$` アンカー)。 */
|
|
54
|
+
export type ReferenceCell = { row: number; col: number; absRow: boolean; absCol: boolean }
|
|
55
|
+
|
|
56
|
+
/** One end of a whole-column reference. / 全列参照の片端。 */
|
|
57
|
+
export type ReferenceColumn = { col: number; abs: boolean }
|
|
58
|
+
|
|
59
|
+
/** One end of a whole-row reference. / 全行参照の片端。 */
|
|
60
|
+
export type ReferenceRow = { row: number; abs: boolean }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A scanned A1 reference. Indices are 0-based grid coordinates; `sheet` is the verbatim
|
|
64
|
+
* qualifier text before `!` (quotes included) when the reference is sheet-qualified. Whole-axis
|
|
65
|
+
* kinds always carry both ends (`A:A` has `start.col === end.col`).
|
|
66
|
+
* 走査済み A1 参照。索引は 0 始まりのグリッド座標、`sheet` はシート修飾時の `!` 前の原文
|
|
67
|
+
* (クオート込み)。全軸種は常に両端を持つ (`A:A` は `start.col === end.col`)。
|
|
68
|
+
*/
|
|
69
|
+
export type ReferenceToken =
|
|
70
|
+
| { kind: "cell"; row: number; col: number; absRow: boolean; absCol: boolean; sheet?: string }
|
|
71
|
+
| { kind: "range"; start: ReferenceCell; end: ReferenceCell; sheet?: string }
|
|
72
|
+
| { kind: "column"; start: ReferenceColumn; end: ReferenceColumn; sheet?: string }
|
|
73
|
+
| { kind: "row"; start: ReferenceRow; end: ReferenceRow; sheet?: string }
|
|
74
|
+
|
|
75
|
+
/** A reference with its `[start, end)` character span in the formula (sheet part included). / 数式内の文字区間 `[start, end)` つき参照 (シート部を含む)。 */
|
|
76
|
+
export type ReferenceSpan = { start: number; end: number; token: ReferenceToken }
|
|
77
|
+
|
|
78
|
+
/** Which axis a pointing insertion spans (cell / drag rectangle, column headers, row headers). / ポインティング挿入が張る軸 (セル / ドラッグ矩形、列ヘッダー、行ヘッダー)。 */
|
|
79
|
+
export type ReferenceAxis = "cells" | "columns" | "rows"
|
|
80
|
+
|
|
81
|
+
/** Where a pointed reference goes: inserted at the caret, or replacing the token under it. / 指した参照の行き先: キャレット位置へ挿入、またはキャレット下トークンの置換。 */
|
|
82
|
+
export type PointingTarget = { insertAt: number } | { replace: [number, number] }
|
|
83
|
+
|
|
84
|
+
/** The Excel reference error every out-of-grid shift collapses to. / グリッド外へのシフトが必ず畳まれる Excel の参照エラー。 */
|
|
85
|
+
const REF_ERROR = "#REF!"
|
|
86
|
+
|
|
87
|
+
/** Column-letter bound: 26^11 < 2^53, so any accepted column index is a safe integer. / 列英字の上限 (26^11 < 2^53 — 受理した列索引は必ず安全整数)。 */
|
|
88
|
+
const MAX_COLUMN_LETTERS = 11
|
|
89
|
+
|
|
90
|
+
/** Digit bound for row / R1C1 indices: 10^15 < 2^53. / 行 / R1C1 索引の桁上限 (10^15 < 2^53)。 */
|
|
91
|
+
const MAX_INDEX_DIGITS = 15
|
|
92
|
+
|
|
93
|
+
/** Memo overflow guard (the plan's memo ceiling); the map is cleared, never evicted one by one. / メモ安全弁 (計画のメモ上限)。個別追い出しはせず全消去。 */
|
|
94
|
+
const FORMULA_MEMO_LIMIT = 4096
|
|
95
|
+
|
|
96
|
+
// 走査で比較する ASCII コード (charCodeAt ループ — 正規表現は熱経路に置かない)
|
|
97
|
+
const CODE_BANG = 33
|
|
98
|
+
const CODE_QUOTE = 34
|
|
99
|
+
const CODE_DOLLAR = 36
|
|
100
|
+
const CODE_AMPERSAND = 38
|
|
101
|
+
const CODE_APOSTROPHE = 39
|
|
102
|
+
const CODE_LPAREN = 40
|
|
103
|
+
const CODE_ASTERISK = 42
|
|
104
|
+
const CODE_PLUS = 43
|
|
105
|
+
const CODE_COMMA = 44
|
|
106
|
+
const CODE_MINUS = 45
|
|
107
|
+
const CODE_DOT = 46
|
|
108
|
+
const CODE_SLASH = 47
|
|
109
|
+
const CODE_COLON = 58
|
|
110
|
+
const CODE_SEMICOLON = 59
|
|
111
|
+
const CODE_LT = 60
|
|
112
|
+
const CODE_EQUALS = 61
|
|
113
|
+
const CODE_GT = 62
|
|
114
|
+
const CODE_UPPER_C = 67
|
|
115
|
+
const CODE_UPPER_R = 82
|
|
116
|
+
const CODE_LBRACKET = 91
|
|
117
|
+
const CODE_RBRACKET = 93
|
|
118
|
+
const CODE_CARET = 94
|
|
119
|
+
const CODE_UNDERSCORE = 95
|
|
120
|
+
|
|
121
|
+
/** ASCII letter test. / ASCII 英字判定。 */
|
|
122
|
+
const isLetter = (code: number): boolean => (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
|
|
123
|
+
|
|
124
|
+
/** ASCII digit test. / ASCII 数字判定。 */
|
|
125
|
+
const isDigit = (code: number): boolean => code >= 48 && code <= 57
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Identifier continuation: letters, digits, `_`, `.`, `$` and every non-ASCII code unit — the
|
|
129
|
+
* characters a name, number literal or unquoted sheet name is made of.
|
|
130
|
+
* 識別子の継続文字: 英数字・`_`・`.`・`$`・全非 ASCII — 名前 / 数値リテラル / 非クオートの
|
|
131
|
+
* シート名を構成する文字。
|
|
132
|
+
*/
|
|
133
|
+
const isIdentifierChar = (code: number): boolean => isLetter(code) || isDigit(code) || code === CODE_UNDERSCORE || code === CODE_DOT || code === CODE_DOLLAR || code >= 0x80
|
|
134
|
+
|
|
135
|
+
/** JavaScript `\s` membership (the `\s*` of the pointing trigger, hand-rolled). / JavaScript の `\s` 判定 (ポインティング契機の `\s*` を手書き)。 */
|
|
136
|
+
const isSpace = (code: number): boolean => code === 32 || (code >= 9 && code <= 13) || code === 0xa0 || code === 0x1680 || (code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029 || code === 0x202f || code === 0x205f || code === 0x3000 || code === 0xfeff
|
|
137
|
+
|
|
138
|
+
/** Membership in the pointing trigger set `[=(,;+\-*\/^&<>:]` (§2.7). / ポインティング契機集合 `[=(,;+\-*\/^&<>:]` の判定 (§2.7)。 */
|
|
139
|
+
const isTrigger = (code: number): boolean =>
|
|
140
|
+
code === CODE_EQUALS ||
|
|
141
|
+
code === CODE_LPAREN ||
|
|
142
|
+
code === CODE_COMMA ||
|
|
143
|
+
code === CODE_SEMICOLON ||
|
|
144
|
+
code === CODE_PLUS ||
|
|
145
|
+
code === CODE_MINUS ||
|
|
146
|
+
code === CODE_ASTERISK ||
|
|
147
|
+
code === CODE_SLASH ||
|
|
148
|
+
code === CODE_CARET ||
|
|
149
|
+
code === CODE_AMPERSAND ||
|
|
150
|
+
code === CODE_LT ||
|
|
151
|
+
code === CODE_GT ||
|
|
152
|
+
code === CODE_COLON
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether a reference candidate ending at `pos` is delimited — i.e. not the prefix of a longer
|
|
156
|
+
* name (`A1_x`), a function call (`LOG10(`), a sheet qualifier (`Sheet1!`) or a structured
|
|
157
|
+
* reference (`Table1[`).
|
|
158
|
+
* `pos` で終わる参照候補が区切られているか — より長い名前 (`A1_x`) / 関数呼出 (`LOG10(`) /
|
|
159
|
+
* シート修飾子 (`Sheet1!`) / 構造化参照 (`Table1[`) の接頭辞でないこと。
|
|
160
|
+
*/
|
|
161
|
+
const isDelimited = (text: string, pos: number): boolean => {
|
|
162
|
+
if (pos >= text.length) {
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
const code = text.charCodeAt(pos)
|
|
166
|
+
return !(isIdentifierChar(code) || code === CODE_LPAREN || code === CODE_BANG || code === CODE_LBRACKET)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Index just past the identifier run starting at `from` (`from` itself when none). / `from` から始まる識別子連なりの直後 (無ければ `from`)。 */
|
|
170
|
+
const skipIdentifier = (text: string, from: number): number => {
|
|
171
|
+
let i = from
|
|
172
|
+
while (i < text.length && isIdentifierChar(text.charCodeAt(i))) {
|
|
173
|
+
i += 1
|
|
174
|
+
}
|
|
175
|
+
return i
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Index just past a quoted run opened at `from` (`"…"` or `'…'`, doubled quote = escape);
|
|
180
|
+
* `text.length` when unterminated.
|
|
181
|
+
* `from` で開くクオート区間 (`"…"` / `'…'`、二重クオートはエスケープ) の直後。未終端なら
|
|
182
|
+
* `text.length`。
|
|
183
|
+
*/
|
|
184
|
+
const skipQuoted = (text: string, from: number, quote: number): number => {
|
|
185
|
+
let i = from + 1
|
|
186
|
+
while (i < text.length) {
|
|
187
|
+
if (text.charCodeAt(i) === quote) {
|
|
188
|
+
// 二重クオートはエスケープ — 区間を閉じない
|
|
189
|
+
if (text.charCodeAt(i + 1) === quote) {
|
|
190
|
+
i += 2
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
return i + 1
|
|
194
|
+
}
|
|
195
|
+
i += 1
|
|
196
|
+
}
|
|
197
|
+
return text.length
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Index just past the structured-reference bracket run opened at `from` (`[` on entry): tracks
|
|
202
|
+
* nesting depth (`Table1[[#Totals],[Q1]]`) and honours the `'` escape of structured references
|
|
203
|
+
* (`'[ '] '' '#` — the `'` makes the next code unit literal); `text.length` when unterminated
|
|
204
|
+
* (the run is opaque to the end, like an unterminated string literal).
|
|
205
|
+
* `from` (`[` の位置) で開く構造化参照の角括弧連なりの直後。入れ子深度を追跡し
|
|
206
|
+
* (`Table1[[#Totals],[Q1]]`)、構造化参照の `'` 脱出 (`'[ '] '' '#` — `'` は次の 1 文字を素通し)
|
|
207
|
+
* を尊重。未終端なら `text.length` (未終端リテラルと同じく末尾まで不透過)。
|
|
208
|
+
*/
|
|
209
|
+
const skipBrackets = (text: string, from: number): number => {
|
|
210
|
+
let depth = 0
|
|
211
|
+
let i = from
|
|
212
|
+
while (i < text.length) {
|
|
213
|
+
const code = text.charCodeAt(i)
|
|
214
|
+
if (code === CODE_APOSTROPHE) {
|
|
215
|
+
// 構造化参照の脱出: ' は直後の 1 文字を字義どおりにする ('[ '] '' '#)
|
|
216
|
+
i += 2
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
if (code === CODE_LBRACKET) {
|
|
220
|
+
depth += 1
|
|
221
|
+
} else if (code === CODE_RBRACKET) {
|
|
222
|
+
depth -= 1
|
|
223
|
+
if (depth === 0) {
|
|
224
|
+
return i + 1
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
i += 1
|
|
228
|
+
}
|
|
229
|
+
return text.length
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A parsed reference plus the index just past it. / 解析済み参照と直後の索引。 */
|
|
233
|
+
type Parsed<T> = { end: number; token: T }
|
|
234
|
+
|
|
235
|
+
/** A positional grammar: parse one reference at `from` or report null (no boundary policing). / 位置文法: `from` の参照を 1 つ解析するか null (境界判定は含まない)。 */
|
|
236
|
+
type ParseAt<T> = (text: string, from: number) => Parsed<T> | null
|
|
237
|
+
|
|
238
|
+
/** A span of any token flavour (A1 or R1C1). / 任意トークン種 (A1 / R1C1) の区間。 */
|
|
239
|
+
type Span<T> = { start: number; end: number; token: T }
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Parses the reference after the `!` at `bang` and records it with the verbatim sheet part
|
|
243
|
+
* `text[sheetStart, bang)`; returns the resume index (past the reference, or past the `!`).
|
|
244
|
+
* `bang` の `!` 直後の参照を解析し、原文のシート部 `text[sheetStart, bang)` を付けて記録。
|
|
245
|
+
* 再開位置 (参照の直後、無ければ `!` の直後) を返す。
|
|
246
|
+
*/
|
|
247
|
+
const scanQualified = <T extends { sheet?: string }>(text: string, sheetStart: number, bang: number, parseAt: ParseAt<T>, spans: Span<T>[]): number => {
|
|
248
|
+
const parsed = parseAt(text, bang + 1)
|
|
249
|
+
if (parsed !== null && isDelimited(text, parsed.end)) {
|
|
250
|
+
spans.push({ start: sheetStart, end: parsed.end, token: { ...parsed.token, sheet: text.slice(sheetStart, bang) } })
|
|
251
|
+
return parsed.end
|
|
252
|
+
}
|
|
253
|
+
return bang + 1
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The shared scanner: walks the text once, skipping string literals and structured-reference
|
|
258
|
+
* bracket runs (both opaque), resolving sheet qualifiers (quoted, unquoted, 3-D) and handing
|
|
259
|
+
* each word start to the positional grammar. A name followed by `[` (`Table1[Q1]`) lands the
|
|
260
|
+
* resume index on the `[`, so the bracket branch below covers named and bare (`[@Col]`) runs
|
|
261
|
+
* alike; a valid R1C1 axis (`R[-1]`) never reaches it — `parseAt` consumes those brackets.
|
|
262
|
+
* 共通スキャナ: 文字列リテラルと構造化参照の角括弧連なり (どちらも不透過) を飛ばし、シート
|
|
263
|
+
* 修飾子 (クオート / 非クオート / 3-D) を解決し、各語頭を位置文法へ渡す 1 パス走査。`[` が続く
|
|
264
|
+
* 名前 (`Table1[Q1]`) は再開位置が `[` に着地するため、下の角括弧分岐が名前つき / 裸
|
|
265
|
+
* (`[@Col]`) の両形を覆う。正しい R1C1 軸 (`R[-1]`) の括弧は `parseAt` が消費し、ここへ来ない。
|
|
266
|
+
*/
|
|
267
|
+
const scan = <T extends { sheet?: string }>(text: string, parseAt: ParseAt<T>): Span<T>[] => {
|
|
268
|
+
const spans: Span<T>[] = []
|
|
269
|
+
const n = text.length
|
|
270
|
+
let i = 0
|
|
271
|
+
while (i < n) {
|
|
272
|
+
const code = text.charCodeAt(i)
|
|
273
|
+
if (code === CODE_QUOTE) {
|
|
274
|
+
// 文字列リテラルは不透過
|
|
275
|
+
i = skipQuoted(text, i, CODE_QUOTE)
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (code === CODE_APOSTROPHE) {
|
|
279
|
+
// クオート付きシート名 — 直後が `!` のときだけ参照が続く
|
|
280
|
+
const close = skipQuoted(text, i, CODE_APOSTROPHE)
|
|
281
|
+
i = text.charCodeAt(close) === CODE_BANG ? scanQualified(text, i, close, parseAt, spans) : close
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
if (code === CODE_LBRACKET) {
|
|
285
|
+
// 構造化参照の角括弧連なりは不透過 — Excel / Sheets は [...] 内を書き換えないため、中身 (Q1 等) を文法へ渡すと列名を壊す
|
|
286
|
+
i = skipBrackets(text, i)
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
if (!isIdentifierChar(code)) {
|
|
290
|
+
i += 1
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
const parsed = parseAt(text, i)
|
|
294
|
+
if (parsed !== null && isDelimited(text, parsed.end)) {
|
|
295
|
+
spans.push({ start: i, end: parsed.end, token: parsed.token })
|
|
296
|
+
i = parsed.end
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
// 名前 / 関数名 / 数値リテラル / 非クオートのシート名: 識別子の連なりを読み飛ばす
|
|
300
|
+
let j = skipIdentifier(text, i)
|
|
301
|
+
// 3-D シート `Sheet1:Sheet3!` — `:` + 識別子 + `!` の形だけ修飾子へ取り込む
|
|
302
|
+
if (text.charCodeAt(j) === CODE_COLON) {
|
|
303
|
+
const k = skipIdentifier(text, j + 1)
|
|
304
|
+
if (k > j + 1 && text.charCodeAt(k) === CODE_BANG) {
|
|
305
|
+
j = k
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
i = text.charCodeAt(j) === CODE_BANG ? scanQualified(text, i, j, parseAt, spans) : j
|
|
309
|
+
}
|
|
310
|
+
return spans
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A parsed `$?LETTERS` column part (0-based index). / 解析済み `$?英字` 列部 (0 始まり索引)。 */
|
|
314
|
+
type ColumnPart = { end: number; col: number; abs: boolean }
|
|
315
|
+
|
|
316
|
+
/** Reads `$?LETTERS` at `from` (≤ 11 letters, case-insensitive) or null. / `from` の `$?英字` (≤ 11 字・大小無視) を読むか null。 */
|
|
317
|
+
const readColumnPart = (text: string, from: number): ColumnPart | null => {
|
|
318
|
+
let i = from
|
|
319
|
+
const abs = text.charCodeAt(i) === CODE_DOLLAR
|
|
320
|
+
if (abs) {
|
|
321
|
+
i += 1
|
|
322
|
+
}
|
|
323
|
+
let col = 0
|
|
324
|
+
let letters = 0
|
|
325
|
+
while (i < text.length) {
|
|
326
|
+
const code = text.charCodeAt(i)
|
|
327
|
+
if (!isLetter(code)) {
|
|
328
|
+
break
|
|
329
|
+
}
|
|
330
|
+
letters += 1
|
|
331
|
+
if (letters > MAX_COLUMN_LETTERS) {
|
|
332
|
+
return null
|
|
333
|
+
}
|
|
334
|
+
// 下位 5 ビットが A=1 … Z=26 (大小文字共通) — 26 進の桁上げ
|
|
335
|
+
col = col * 26 + (code & 0x1f)
|
|
336
|
+
i += 1
|
|
337
|
+
}
|
|
338
|
+
return letters === 0 ? null : { end: i, col: col - 1, abs }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** A digit run's value and the index just past it. / 数字連なりの値と直後の索引。 */
|
|
342
|
+
type Digits = { end: number; value: number }
|
|
343
|
+
|
|
344
|
+
/** Reads a digit run at `from` (1-15 digits, so the value is a safe integer) or null. / `from` の数字連なり (1〜15 桁 — 値は安全整数) を読むか null。 */
|
|
345
|
+
const readDigits = (text: string, from: number): Digits | null => {
|
|
346
|
+
let i = from
|
|
347
|
+
let value = 0
|
|
348
|
+
while (i < text.length) {
|
|
349
|
+
const code = text.charCodeAt(i)
|
|
350
|
+
if (!isDigit(code)) {
|
|
351
|
+
break
|
|
352
|
+
}
|
|
353
|
+
// 16 桁目で拒否 — 安全整数を超える索引は文法水準で遮断する
|
|
354
|
+
if (i - from >= MAX_INDEX_DIGITS) {
|
|
355
|
+
return null
|
|
356
|
+
}
|
|
357
|
+
value = value * 10 + (code - 48)
|
|
358
|
+
i += 1
|
|
359
|
+
}
|
|
360
|
+
return i === from ? null : { end: i, value }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** A parsed `$?DIGITS` index part (0-based). / 解析済み `$?数字` 索引部 (0 始まり)。 */
|
|
364
|
+
type IndexPart = { end: number; index: number; abs: boolean }
|
|
365
|
+
|
|
366
|
+
/** Reads `$?DIGITS` at `from` (value ≥ 1 — `A0` is a name, not a cell) or null. / `from` の `$?数字` (値 ≥ 1 — `A0` はセルでなく名前) を読むか null。 */
|
|
367
|
+
const readIndexPart = (text: string, from: number): IndexPart | null => {
|
|
368
|
+
const abs = text.charCodeAt(from) === CODE_DOLLAR
|
|
369
|
+
const digits = readDigits(text, abs ? from + 1 : from)
|
|
370
|
+
return digits === null || digits.value === 0 ? null : { end: digits.end, index: digits.value - 1, abs }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The A1 positional grammar: cell (`:` cell)? | column `:` column | row `:` row. A cell whose
|
|
375
|
+
* `:` is not followed by a cell yields the cell alone (`A1:B` → `A1`).
|
|
376
|
+
* A1 位置文法: セル (`:` セル)? | 列 `:` 列 | 行 `:` 行。`:` の後にセルが続かないセルは
|
|
377
|
+
* セル単独 (`A1:B` → `A1`)。
|
|
378
|
+
*/
|
|
379
|
+
const parseA1At: ParseAt<ReferenceToken> = (text, from) => {
|
|
380
|
+
const colA = readColumnPart(text, from)
|
|
381
|
+
if (colA !== null) {
|
|
382
|
+
const rowA = readIndexPart(text, colA.end)
|
|
383
|
+
if (rowA !== null) {
|
|
384
|
+
// セル — `:` セル が続けばレンジ
|
|
385
|
+
if (text.charCodeAt(rowA.end) === CODE_COLON) {
|
|
386
|
+
const colB = readColumnPart(text, rowA.end + 1)
|
|
387
|
+
if (colB !== null) {
|
|
388
|
+
const rowB = readIndexPart(text, colB.end)
|
|
389
|
+
if (rowB !== null) {
|
|
390
|
+
return {
|
|
391
|
+
end: rowB.end,
|
|
392
|
+
token: { kind: "range", start: { row: rowA.index, col: colA.col, absRow: rowA.abs, absCol: colA.abs }, end: { row: rowB.index, col: colB.col, absRow: rowB.abs, absCol: colB.abs } },
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { end: rowA.end, token: { kind: "cell", row: rowA.index, col: colA.col, absRow: rowA.abs, absCol: colA.abs } }
|
|
398
|
+
}
|
|
399
|
+
// 英字のみ — `:` 英字 が続くときだけ全列
|
|
400
|
+
if (text.charCodeAt(colA.end) === CODE_COLON) {
|
|
401
|
+
const colB = readColumnPart(text, colA.end + 1)
|
|
402
|
+
if (colB !== null) {
|
|
403
|
+
return { end: colB.end, token: { kind: "column", start: { col: colA.col, abs: colA.abs }, end: { col: colB.col, abs: colB.abs } } }
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return null
|
|
407
|
+
}
|
|
408
|
+
// 数字始まり — `:` 数字 が続くときだけ全行
|
|
409
|
+
const rowA = readIndexPart(text, from)
|
|
410
|
+
if (rowA !== null && text.charCodeAt(rowA.end) === CODE_COLON) {
|
|
411
|
+
const rowB = readIndexPart(text, rowA.end + 1)
|
|
412
|
+
if (rowB !== null) {
|
|
413
|
+
return { end: rowB.end, token: { kind: "row", start: { row: rowA.index, abs: rowA.abs }, end: { row: rowB.index, abs: rowB.abs } } }
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return null
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Scans the A1 references of a formula in order (string literals opaque, function names and
|
|
421
|
+
* names skipped, sheet qualifiers kept verbatim). A pure scanner: it does not police the
|
|
422
|
+
* leading `=` — the transforms and predicates do.
|
|
423
|
+
* 数式の A1 参照を出現順に走査 (文字列リテラル不透過、関数名 / 名前は読み飛ばし、シート修飾子は
|
|
424
|
+
* 原文保持)。純粋なスキャナで先頭 `=` は検査しない — それは変換と述語の責務。
|
|
425
|
+
*/
|
|
426
|
+
export const tokenizeFormula = (formula: string): readonly ReferenceSpan[] => scan(formula, parseA1At)
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Bounded per-string memo factory — the "Map<string, T> per caller" of §2.8. Wrap any pure
|
|
430
|
+
* per-formula computation (`createFormulaMemo(tokenizeFormula)` is the tokenizer cache); a hit
|
|
431
|
+
* returns the identical value, the map is cleared when `limit` entries are reached.
|
|
432
|
+
* `compute` must not return `undefined` (that is the miss sentinel). A non-positive or
|
|
433
|
+
* non-integer limit is a RangeError.
|
|
434
|
+
* 上限つき文字列メモのファクトリ — §2.8 の「呼び出し側毎の Map<string, T>」。数式単位の純計算を
|
|
435
|
+
* 包む (`createFormulaMemo(tokenizeFormula)` がトークナイザキャッシュ)。ヒットは同一値を返し、
|
|
436
|
+
* `limit` 件に達したら全消去。`compute` は `undefined` を返してはならない (ミス番兵)。非正 /
|
|
437
|
+
* 非整数の上限は RangeError。
|
|
438
|
+
*/
|
|
439
|
+
export const createFormulaMemo = <T>(compute: (formula: string) => T, limit: number = FORMULA_MEMO_LIMIT): ((formula: string) => T) => {
|
|
440
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
441
|
+
throw new RangeError(`[spreadsheet] createFormulaMemo requires a positive integer limit, received ${limit}`)
|
|
442
|
+
}
|
|
443
|
+
const cache = new Map<string, T>()
|
|
444
|
+
return (formula) => {
|
|
445
|
+
const hit = cache.get(formula)
|
|
446
|
+
if (hit !== undefined) {
|
|
447
|
+
return hit
|
|
448
|
+
}
|
|
449
|
+
// 上限到達で全消去 (語彙は貼り付け単位で有限 — 個別追い出しの帳簿を持たない)
|
|
450
|
+
if (cache.size >= limit) {
|
|
451
|
+
cache.clear()
|
|
452
|
+
}
|
|
453
|
+
const value = compute(formula)
|
|
454
|
+
cache.set(formula, value)
|
|
455
|
+
return value
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Column index → letters (0 → A, 25 → Z, 26 → AA, unbounded). / 列索引 → 英字 (0 → A、25 → Z、26 → AA、上限なし)。 */
|
|
460
|
+
const columnName = (col: number): string => {
|
|
461
|
+
let n = col + 1
|
|
462
|
+
let name = ""
|
|
463
|
+
while (n > 0) {
|
|
464
|
+
const rem = (n - 1) % 26
|
|
465
|
+
name = String.fromCharCode(65 + rem) + name
|
|
466
|
+
n = Math.floor((n - 1) / 26)
|
|
467
|
+
}
|
|
468
|
+
return name
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** `$?LETTERS$?DIGITS` of one range end. / レンジ片端の `$?英字$?数字`。 */
|
|
472
|
+
const cellText = (cell: ReferenceCell): string => `${cell.absCol ? "$" : ""}${columnName(cell.col)}${cell.absRow ? "$" : ""}${cell.row + 1}`
|
|
473
|
+
|
|
474
|
+
/** Verbatim sheet qualifier plus `!`, or empty. / 原文シート修飾子 + `!`、無ければ空。 */
|
|
475
|
+
const sheetPrefix = (token: { sheet?: string }): string => (token.sheet === undefined ? "" : `${token.sheet}!`)
|
|
476
|
+
|
|
477
|
+
/** Serialises a token back to A1 text (uppercase letters, sheet part verbatim). / トークンを A1 文字列へ直列化 (英字は大文字、シート部は原文)。 */
|
|
478
|
+
export const formatReference = (token: ReferenceToken): string => {
|
|
479
|
+
const prefix = sheetPrefix(token)
|
|
480
|
+
switch (token.kind) {
|
|
481
|
+
case "cell":
|
|
482
|
+
return `${prefix}${cellText(token)}`
|
|
483
|
+
case "range":
|
|
484
|
+
return `${prefix}${cellText(token.start)}:${cellText(token.end)}`
|
|
485
|
+
case "column":
|
|
486
|
+
return `${prefix}${token.start.abs ? "$" : ""}${columnName(token.start.col)}:${token.end.abs ? "$" : ""}${columnName(token.end.col)}`
|
|
487
|
+
case "row":
|
|
488
|
+
return `${prefix}${token.start.abs ? "$" : ""}${token.start.row + 1}:${token.end.abs ? "$" : ""}${token.end.row + 1}`
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Fail-fast guard for a grid index handed in by a consumer. / consumer から渡されたグリッド索引の fail-fast ガード。 */
|
|
493
|
+
const assertGridIndex = (value: number, what: string): void => {
|
|
494
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
495
|
+
throw new RangeError(`[spreadsheet] ${what} must be a non-negative integer, received ${value}`)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The relative reference text pointing inserts for a cell / rectangle (`A1`, `A1:B2`), a column
|
|
501
|
+
* band (`A:A`, `A:C`) or a row band (`1:1`, `2:5`). A 1×1 rectangle under `"cells"` is a cell.
|
|
502
|
+
* Non-integer / negative coordinates are a RangeError.
|
|
503
|
+
* ポインティングがセル / 矩形 (`A1`, `A1:B2`)・列帯 (`A:A`, `A:C`)・行帯 (`1:1`, `2:5`) に
|
|
504
|
+
* 挿入する相対参照文字列。`"cells"` の 1×1 矩形はセル。非整数 / 負の座標は RangeError。
|
|
505
|
+
*/
|
|
506
|
+
export const formatRangeReference = (target: CellCoord | CellRange, axis: ReferenceAxis): string => {
|
|
507
|
+
const range: CellRange = "startRow" in target ? target : { startRow: target.row, startCol: target.col, endRow: target.row, endCol: target.col }
|
|
508
|
+
assertGridIndex(range.startRow, "formatRangeReference startRow")
|
|
509
|
+
assertGridIndex(range.startCol, "formatRangeReference startCol")
|
|
510
|
+
assertGridIndex(range.endRow, "formatRangeReference endRow")
|
|
511
|
+
assertGridIndex(range.endCol, "formatRangeReference endCol")
|
|
512
|
+
switch (axis) {
|
|
513
|
+
case "columns":
|
|
514
|
+
return formatReference({ kind: "column", start: { col: range.startCol, abs: false }, end: { col: range.endCol, abs: false } })
|
|
515
|
+
case "rows":
|
|
516
|
+
return formatReference({ kind: "row", start: { row: range.startRow, abs: false }, end: { row: range.endRow, abs: false } })
|
|
517
|
+
case "cells":
|
|
518
|
+
if (range.startRow === range.endRow && range.startCol === range.endCol) {
|
|
519
|
+
return formatReference({ kind: "cell", row: range.startRow, col: range.startCol, absRow: false, absCol: false })
|
|
520
|
+
}
|
|
521
|
+
return formatReference({
|
|
522
|
+
kind: "range",
|
|
523
|
+
start: { row: range.startRow, col: range.startCol, absRow: false, absCol: false },
|
|
524
|
+
end: { row: range.endRow, col: range.endCol, absRow: false, absCol: false },
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Rebuilds a formula with every span replaced by `render(span)` (`null` = `#REF!` behind the
|
|
531
|
+
* verbatim sheet part). No spans ⇒ the SAME string instance (identity for the untouched case).
|
|
532
|
+
* 各区間を `render(span)` で置換して数式を再構築 (`null` = 原文シート部つきの `#REF!`)。
|
|
533
|
+
* 区間なしなら**同一**文字列インスタンス (無変更ケースの恒等)。
|
|
534
|
+
*/
|
|
535
|
+
const rewrite = <T extends { sheet?: string }>(formula: string, spans: readonly Span<T>[], render: (token: T) => string | null): string => {
|
|
536
|
+
if (spans.length === 0) {
|
|
537
|
+
return formula
|
|
538
|
+
}
|
|
539
|
+
let out = ""
|
|
540
|
+
let last = 0
|
|
541
|
+
for (const span of spans) {
|
|
542
|
+
const text = render(span.token)
|
|
543
|
+
out += formula.slice(last, span.start) + (text === null ? `${sheetPrefix(span.token)}${REF_ERROR}` : text)
|
|
544
|
+
last = span.end
|
|
545
|
+
}
|
|
546
|
+
return out + formula.slice(last)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Whether the text is a formula (leading `=`). / 数式 (先頭 `=`) か。 */
|
|
550
|
+
const isFormula = (text: string): boolean => text.charCodeAt(0) === CODE_EQUALS
|
|
551
|
+
|
|
552
|
+
/** Shifts one range end's relative parts; null when an index leaves the grid. / レンジ片端の相対部をシフト。索引がグリッド外へ出れば null。 */
|
|
553
|
+
const shiftCell = (cell: ReferenceCell, dRow: number, dCol: number): ReferenceCell | null => {
|
|
554
|
+
const row = cell.absRow ? cell.row : cell.row + dRow
|
|
555
|
+
const col = cell.absCol ? cell.col : cell.col + dCol
|
|
556
|
+
return row < 0 || col < 0 ? null : { row, col, absRow: cell.absRow, absCol: cell.absCol }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Shifts one token's relative parts; null when any index leaves the grid (⇒ `#REF!`). / トークンの相対部をシフト。索引がグリッド外へ出れば null (⇒ `#REF!`)。 */
|
|
560
|
+
const shiftToken = (token: ReferenceToken, dRow: number, dCol: number): ReferenceToken | null => {
|
|
561
|
+
switch (token.kind) {
|
|
562
|
+
case "cell": {
|
|
563
|
+
const cell = shiftCell(token, dRow, dCol)
|
|
564
|
+
return cell === null ? null : { ...token, ...cell }
|
|
565
|
+
}
|
|
566
|
+
case "range": {
|
|
567
|
+
const start = shiftCell(token.start, dRow, dCol)
|
|
568
|
+
const end = shiftCell(token.end, dRow, dCol)
|
|
569
|
+
return start === null || end === null ? null : { ...token, start, end }
|
|
570
|
+
}
|
|
571
|
+
case "column": {
|
|
572
|
+
const start = token.start.abs ? token.start.col : token.start.col + dCol
|
|
573
|
+
const end = token.end.abs ? token.end.col : token.end.col + dCol
|
|
574
|
+
return start < 0 || end < 0 ? null : { ...token, start: { col: start, abs: token.start.abs }, end: { col: end, abs: token.end.abs } }
|
|
575
|
+
}
|
|
576
|
+
case "row": {
|
|
577
|
+
const start = token.start.abs ? token.start.row : token.start.row + dRow
|
|
578
|
+
const end = token.end.abs ? token.end.row : token.end.row + dRow
|
|
579
|
+
return start < 0 || end < 0 ? null : { ...token, start: { row: start, abs: token.start.abs }, end: { row: end, abs: token.end.abs } }
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Shifts the relative parts of every reference by (dRow, dCol) — the paste-offset transform.
|
|
586
|
+
* Absolute (`$`) parts stay; a reference shifted below row / column 0 becomes `#REF!` (behind
|
|
587
|
+
* its verbatim sheet part). Non-formula text and reference-free formulas return the same
|
|
588
|
+
* string instance. Non-integer deltas are a RangeError.
|
|
589
|
+
* 全参照の相対部を (dRow, dCol) だけシフト — 貼り付けオフセット変換。絶対 (`$`) 部は不変、
|
|
590
|
+
* 行 / 列 0 未満へ出た参照は (原文シート部つきの) `#REF!`。非数式・参照なし数式は同一文字列
|
|
591
|
+
* インスタンス。非整数の差分は RangeError。
|
|
592
|
+
*/
|
|
593
|
+
export const shiftFormula = (formula: string, dRow: number, dCol: number): string => {
|
|
594
|
+
if (!(Number.isInteger(dRow) && Number.isInteger(dCol))) {
|
|
595
|
+
throw new RangeError(`[spreadsheet] shiftFormula requires integer deltas, received ${dRow}, ${dCol}`)
|
|
596
|
+
}
|
|
597
|
+
if (!isFormula(formula)) {
|
|
598
|
+
return formula
|
|
599
|
+
}
|
|
600
|
+
return rewrite(formula, tokenizeFormula(formula), (token) => {
|
|
601
|
+
const shifted = shiftToken(token, dRow, dCol)
|
|
602
|
+
return shifted === null ? null : formatReference(shifted)
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Fail-fast guard for an origin coordinate. / 原点座標の fail-fast ガード。 */
|
|
607
|
+
const assertOrigin = (origin: CellCoord, what: string): void => {
|
|
608
|
+
assertGridIndex(origin.row, `${what} origin.row`)
|
|
609
|
+
assertGridIndex(origin.col, `${what} origin.col`)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** One R1C1 axis text: absolute `R3` (1-based) or relative `R[-1]` (zero spelled `[0]`). / R1C1 の軸文字列: 絶対 `R3` (1 始まり) か相対 `R[-1]` (0 も `[0]`)。 */
|
|
613
|
+
const r1c1Axis = (letter: string, index: number, abs: boolean, originIndex: number): string => (abs ? `${letter}${index + 1}` : `${letter}[${index - originIndex}]`)
|
|
614
|
+
|
|
615
|
+
/** One R1C1 range end (row then column). / R1C1 レンジ片端 (行、列の順)。 */
|
|
616
|
+
const r1c1Cell = (cell: ReferenceCell, origin: CellCoord): string => `${r1c1Axis("R", cell.row, cell.absRow, origin.row)}${r1c1Axis("C", cell.col, cell.absCol, origin.col)}`
|
|
617
|
+
|
|
618
|
+
/** Serialises an A1 token in R1C1 relative to `origin`. / A1 トークンを `origin` 基準の R1C1 へ直列化。 */
|
|
619
|
+
const formatR1C1 = (token: ReferenceToken, origin: CellCoord): string => {
|
|
620
|
+
const prefix = sheetPrefix(token)
|
|
621
|
+
switch (token.kind) {
|
|
622
|
+
case "cell":
|
|
623
|
+
return `${prefix}${r1c1Cell(token, origin)}`
|
|
624
|
+
case "range":
|
|
625
|
+
return `${prefix}${r1c1Cell(token.start, origin)}:${r1c1Cell(token.end, origin)}`
|
|
626
|
+
case "column":
|
|
627
|
+
return `${prefix}${r1c1Axis("C", token.start.col, token.start.abs, origin.col)}:${r1c1Axis("C", token.end.col, token.end.abs, origin.col)}`
|
|
628
|
+
case "row":
|
|
629
|
+
return `${prefix}${r1c1Axis("R", token.start.row, token.start.abs, origin.row)}:${r1c1Axis("R", token.end.row, token.end.abs, origin.row)}`
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Converts a formula to R1C1 relative to `origin` (Google Sheets spelling) — the form the clip
|
|
635
|
+
* payload carries so a paste at any target resolves with {@link fromR1C1}. Non-formula text
|
|
636
|
+
* and reference-free formulas return the same string instance; an invalid origin is a
|
|
637
|
+
* RangeError.
|
|
638
|
+
* 数式を `origin` 基準の R1C1 (Google Sheets 綴り) へ変換 — クリップが運び、任意の貼り付け先で
|
|
639
|
+
* {@link fromR1C1} が解決する形。非数式・参照なし数式は同一文字列インスタンス、不正な原点は
|
|
640
|
+
* RangeError。
|
|
641
|
+
*/
|
|
642
|
+
export const toR1C1 = (formula: string, origin: CellCoord): string => {
|
|
643
|
+
assertOrigin(origin, "toR1C1")
|
|
644
|
+
if (!isFormula(formula)) {
|
|
645
|
+
return formula
|
|
646
|
+
}
|
|
647
|
+
return rewrite(formula, tokenizeFormula(formula), (token) => formatR1C1(token, origin))
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** One R1C1 axis: relative offset (`[n]`) or absolute 1-based index. / R1C1 の 1 軸: 相対差分 (`[n]`) か絶対 1 始まり索引。 */
|
|
651
|
+
type R1C1Axis = { rel: boolean; n: number }
|
|
652
|
+
|
|
653
|
+
/** Both axes of one R1C1 cell end (`R…C…`). / R1C1 セル片端 (`R…C…`) の両軸。 */
|
|
654
|
+
type R1C1Cell = { row: R1C1Axis; col: R1C1Axis }
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* One parsed R1C1 end with the index just past it: `R…C…` (cell), `R…` alone (row) or `C…`
|
|
658
|
+
* alone (column).
|
|
659
|
+
* 解析済み R1C1 片端と直後の索引: `R…C…` (セル)、`R…` 単独 (行)、`C…` 単独 (列)。
|
|
660
|
+
*/
|
|
661
|
+
type R1C1End = { end: number } & ({ shape: "cell"; cell: R1C1Cell } | { shape: "row"; row: R1C1Axis } | { shape: "column"; col: R1C1Axis })
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* A parsed R1C1 reference: a start end and, for a range, a second end of the same shape
|
|
665
|
+
* (`null` = single). / 解析済み R1C1 参照: 始端と、レンジなら同形の終端 (`null` = 単独)。
|
|
666
|
+
*/
|
|
667
|
+
type R1C1Token = { shape: "cell"; start: R1C1Cell; end: R1C1Cell | null; sheet?: string } | { shape: "row"; start: R1C1Axis; end: R1C1Axis | null; sheet?: string } | { shape: "column"; start: R1C1Axis; end: R1C1Axis | null; sheet?: string }
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Reads one axis part `R` / `C` at `from`: `[n]` / `[-n]` relative (≤ 15 digits), digits
|
|
671
|
+
* absolute (≥ 1), or bare (relative 0). Case-insensitive letter; null on mismatch / malformed
|
|
672
|
+
* (`R0`, `R[+1]`, `R[]`, `R[1`).
|
|
673
|
+
* `from` の軸部 `R` / `C` を読む: `[n]` / `[-n]` 相対 (≤ 15 桁)、数字は絶対 (≥ 1)、素の字は
|
|
674
|
+
* 相対 0。字は大小無視、不一致 / 不正形 (`R0` / `R[+1]` / `R[]` / `R[1`) は null。
|
|
675
|
+
*/
|
|
676
|
+
const readR1C1Axis = (text: string, from: number, letter: number): { end: number; axis: R1C1Axis } | null => {
|
|
677
|
+
const code = text.charCodeAt(from)
|
|
678
|
+
if (code !== letter && code !== letter + 32) {
|
|
679
|
+
return null
|
|
680
|
+
}
|
|
681
|
+
const i = from + 1
|
|
682
|
+
if (text.charCodeAt(i) === CODE_LBRACKET) {
|
|
683
|
+
// 相対 `[±n]` — 符号は `-` のみ、閉じ `]` 必須
|
|
684
|
+
const negative = text.charCodeAt(i + 1) === CODE_MINUS
|
|
685
|
+
const digits = readDigits(text, negative ? i + 2 : i + 1)
|
|
686
|
+
if (digits === null || text.charCodeAt(digits.end) !== CODE_RBRACKET) {
|
|
687
|
+
return null
|
|
688
|
+
}
|
|
689
|
+
return { end: digits.end + 1, axis: { rel: true, n: negative ? -digits.value : digits.value } }
|
|
690
|
+
}
|
|
691
|
+
const digits = readDigits(text, i)
|
|
692
|
+
if (digits === null) {
|
|
693
|
+
// 数字なし = 素の字 (相対 0)
|
|
694
|
+
return { end: i, axis: { rel: true, n: 0 } }
|
|
695
|
+
}
|
|
696
|
+
// 絶対 `n` は 1 始まり — `R0` は不正形
|
|
697
|
+
return digits.value === 0 ? null : { end: digits.end, axis: { rel: false, n: digits.value } }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Reads one R1C1 end (`R…C…`, `R…` or `C…`) at `from` or null. / `from` の R1C1 片端 (`R…C…` / `R…` / `C…`) を読むか null。 */
|
|
701
|
+
const readR1C1End = (text: string, from: number): R1C1End | null => {
|
|
702
|
+
const row = readR1C1Axis(text, from, CODE_UPPER_R)
|
|
703
|
+
const col = readR1C1Axis(text, row === null ? from : row.end, CODE_UPPER_C)
|
|
704
|
+
if (row !== null && col !== null) {
|
|
705
|
+
return { end: col.end, shape: "cell", cell: { row: row.axis, col: col.axis } }
|
|
706
|
+
}
|
|
707
|
+
if (row !== null) {
|
|
708
|
+
return { end: row.end, shape: "row", row: row.axis }
|
|
709
|
+
}
|
|
710
|
+
return col === null ? null : { end: col.end, shape: "column", col: col.axis }
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** The single-end token of one parsed end. / 解析済み片端 1 つの単独トークン。 */
|
|
714
|
+
const singleR1C1 = (first: R1C1End): R1C1Token => {
|
|
715
|
+
switch (first.shape) {
|
|
716
|
+
case "cell":
|
|
717
|
+
return { shape: "cell", start: first.cell, end: null }
|
|
718
|
+
case "row":
|
|
719
|
+
return { shape: "row", start: first.row, end: null }
|
|
720
|
+
case "column":
|
|
721
|
+
return { shape: "column", start: first.col, end: null }
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** The range token of two ends of the same shape; null when the shapes differ (`R1C1:R1`). / 同形の両端によるレンジトークン。形が異なれば null (`R1C1:R1`)。 */
|
|
726
|
+
const rangeR1C1 = (first: R1C1End, second: R1C1End): R1C1Token | null => {
|
|
727
|
+
if (first.shape === "cell" && second.shape === "cell") {
|
|
728
|
+
return { shape: "cell", start: first.cell, end: second.cell }
|
|
729
|
+
}
|
|
730
|
+
if (first.shape === "row" && second.shape === "row") {
|
|
731
|
+
return { shape: "row", start: first.row, end: second.row }
|
|
732
|
+
}
|
|
733
|
+
if (first.shape === "column" && second.shape === "column") {
|
|
734
|
+
return { shape: "column", start: first.col, end: second.col }
|
|
735
|
+
}
|
|
736
|
+
return null
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** The R1C1 positional grammar: end (`:` end of the same shape)?. / R1C1 位置文法: 片端 (`:` 同形の片端)?。 */
|
|
740
|
+
const parseR1C1At: ParseAt<R1C1Token> = (text, from) => {
|
|
741
|
+
const first = readR1C1End(text, from)
|
|
742
|
+
if (first === null) {
|
|
743
|
+
return null
|
|
744
|
+
}
|
|
745
|
+
if (text.charCodeAt(first.end) === CODE_COLON) {
|
|
746
|
+
const second = readR1C1End(text, first.end + 1)
|
|
747
|
+
if (second !== null) {
|
|
748
|
+
const range = rangeR1C1(first, second)
|
|
749
|
+
if (range !== null) {
|
|
750
|
+
return { end: second.end, token: range }
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return { end: first.end, token: singleR1C1(first) }
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** R1C1 tokenizations performed so far — the paste memo's misses (see `r1c1TokenizeCount`). / これまでの R1C1 トークナイズ回数 — 貼り付けメモのミス数 (`r1c1TokenizeCount` 参照)。 */
|
|
758
|
+
let r1c1Tokenizations = 0
|
|
759
|
+
|
|
760
|
+
/** Scans the R1C1 references of a string (same skipping rules as the A1 scanner) and counts the scan. / 文字列の R1C1 参照を走査し (読み飛ばし規則は A1 スキャナと同一)、走査を数える。 */
|
|
761
|
+
const tokenizeR1C1 = (r1c1: string): readonly Span<R1C1Token>[] => {
|
|
762
|
+
r1c1Tokenizations += 1
|
|
763
|
+
return scan(r1c1, parseR1C1At)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Paste-path memo: one R1C1 parse per distinct string (§2.8 "R1C1 文字列毎に memo"). / 貼り付け経路のメモ: 相異なる文字列毎に 1 回の R1C1 解析 (§2.8)。 */
|
|
767
|
+
const memoizedR1C1 = createFormulaMemo(tokenizeR1C1)
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Diagnostic seam (barrel-private): the number of R1C1 tokenizations {@link fromR1C1} has run
|
|
771
|
+
* since module load — a memo hit adds nothing, so a 20,000-formula paste over 100 distinct
|
|
772
|
+
* strings raises it by exactly 100. The paste budget spec pins the memo wiring on this count
|
|
773
|
+
* (structural gate) instead of on a wall clock.
|
|
774
|
+
* 診断シーム (バレル非公開): モジュール読込以降に {@link fromR1C1} が走らせた R1C1 トークナイズ
|
|
775
|
+
* 回数 — メモのヒットは増やさないので、異なり 100 本の数式 20,000 個の貼り付けはちょうど 100
|
|
776
|
+
* 増える。貼り付け予算 spec はメモの結線を壁時計でなくこの回数で封印する (構造ゲート)。
|
|
777
|
+
*/
|
|
778
|
+
export const r1c1TokenizeCount = (): number => r1c1Tokenizations
|
|
779
|
+
|
|
780
|
+
/** Resolves one R1C1 axis to a 0-based grid index (may be negative ⇒ `#REF!`). / R1C1 の 1 軸を 0 始まりのグリッド索引へ解決 (負なら `#REF!`)。 */
|
|
781
|
+
const resolveAxis = (axis: R1C1Axis, originIndex: number): number => (axis.rel ? originIndex + axis.n : axis.n - 1)
|
|
782
|
+
|
|
783
|
+
/** Resolves one R1C1 cell end to a range end; null when off-grid. / R1C1 セル片端をレンジ片端へ解決。グリッド外なら null。 */
|
|
784
|
+
const resolveCell = (cell: R1C1Cell, origin: CellCoord): ReferenceCell | null => {
|
|
785
|
+
const row = resolveAxis(cell.row, origin.row)
|
|
786
|
+
const col = resolveAxis(cell.col, origin.col)
|
|
787
|
+
return row < 0 || col < 0 ? null : { row, col, absRow: !cell.row.rel, absCol: !cell.col.rel }
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Resolves a parsed R1C1 token to an A1 token at `origin`; null when any index is off-grid. A
|
|
792
|
+
* single-end whole axis (`R[1]`) resolves to both ends of the A1 form (`2:2`).
|
|
793
|
+
* 解析済み R1C1 トークンを `origin` で A1 トークンへ解決。索引がグリッド外なら null。単独片端の
|
|
794
|
+
* 全軸 (`R[1]`) は A1 形の両端 (`2:2`) へ解決。
|
|
795
|
+
*/
|
|
796
|
+
const resolveR1C1 = (token: R1C1Token, origin: CellCoord): ReferenceToken | null => {
|
|
797
|
+
const sheet = token.sheet === undefined ? {} : { sheet: token.sheet }
|
|
798
|
+
switch (token.shape) {
|
|
799
|
+
case "cell": {
|
|
800
|
+
const start = resolveCell(token.start, origin)
|
|
801
|
+
if (start === null) {
|
|
802
|
+
return null
|
|
803
|
+
}
|
|
804
|
+
if (token.end === null) {
|
|
805
|
+
return { kind: "cell", ...start, ...sheet }
|
|
806
|
+
}
|
|
807
|
+
const end = resolveCell(token.end, origin)
|
|
808
|
+
return end === null ? null : { kind: "range", start, end, ...sheet }
|
|
809
|
+
}
|
|
810
|
+
case "row": {
|
|
811
|
+
const endAxis = token.end === null ? token.start : token.end
|
|
812
|
+
const start = resolveAxis(token.start, origin.row)
|
|
813
|
+
const end = resolveAxis(endAxis, origin.row)
|
|
814
|
+
return start < 0 || end < 0 ? null : { kind: "row", start: { row: start, abs: !token.start.rel }, end: { row: end, abs: !endAxis.rel }, ...sheet }
|
|
815
|
+
}
|
|
816
|
+
case "column": {
|
|
817
|
+
const endAxis = token.end === null ? token.start : token.end
|
|
818
|
+
const start = resolveAxis(token.start, origin.col)
|
|
819
|
+
const end = resolveAxis(endAxis, origin.col)
|
|
820
|
+
return start < 0 || end < 0 ? null : { kind: "column", start: { col: start, abs: !token.start.rel }, end: { col: end, abs: !endAxis.rel }, ...sheet }
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Resolves an R1C1 formula to A1 at `origin` — the paste side of {@link toR1C1}. A relative
|
|
827
|
+
* offset landing below row / column 0 becomes `#REF!` (behind its verbatim sheet part); text
|
|
828
|
+
* the R1C1 grammar rejects (`R0C1`, `R[+1]`, `R[1`) stays verbatim. Parsed forms are memoised
|
|
829
|
+
* per distinct string, so a 100k-cell paste of ≤ 100 distinct formulas parses ≤ 100 times.
|
|
830
|
+
* Non-formula text returns the same string instance; an invalid origin is a RangeError.
|
|
831
|
+
* R1C1 数式を `origin` で A1 へ解決 — {@link toR1C1} の貼り付け側。行 / 列 0 未満へ落ちる相対
|
|
832
|
+
* 差分は (原文シート部つきの) `#REF!`、R1C1 文法が拒む文字列 (`R0C1` / `R[+1]` / `R[1`) は
|
|
833
|
+
* 原文のまま。解析形は相異なる文字列毎にメモされ、≤ 100 種の数式の 10 万セル貼り付けは解析
|
|
834
|
+
* ≤ 100 回。非数式は同一文字列インスタンス、不正な原点は RangeError。
|
|
835
|
+
*/
|
|
836
|
+
export const fromR1C1 = (r1c1: string, origin: CellCoord): string => {
|
|
837
|
+
assertOrigin(origin, "fromR1C1")
|
|
838
|
+
if (!isFormula(r1c1)) {
|
|
839
|
+
return r1c1
|
|
840
|
+
}
|
|
841
|
+
return rewrite(r1c1, memoizedR1C1(r1c1), (token) => {
|
|
842
|
+
const resolved = resolveR1C1(token, origin)
|
|
843
|
+
return resolved === null ? null : formatReference(resolved)
|
|
844
|
+
})
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Next anchor state in Excel's F4 order: A1 → $A$1 → A$1 → $A1 → A1, keyed on (absCol, absRow).
|
|
849
|
+
* Excel の F4 順の次アンカー状態: A1 → $A$1 → A$1 → $A1 → A1 ((absCol, absRow) で決定)。
|
|
850
|
+
*/
|
|
851
|
+
const nextAnchor = (absCol: boolean, absRow: boolean): { absCol: boolean; absRow: boolean } => {
|
|
852
|
+
if (!(absCol || absRow)) {
|
|
853
|
+
return { absCol: true, absRow: true }
|
|
854
|
+
}
|
|
855
|
+
if (absCol && absRow) {
|
|
856
|
+
return { absCol: false, absRow: true }
|
|
857
|
+
}
|
|
858
|
+
if (!absCol && absRow) {
|
|
859
|
+
return { absCol: true, absRow: false }
|
|
860
|
+
}
|
|
861
|
+
return { absCol: false, absRow: false }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* F4 cycle on one token: cells follow A1 → $A$1 → A$1 → $A1; a range moves BOTH ends to the
|
|
866
|
+
* state after its start end (mixed ends converge); whole columns / rows toggle `$` on both ends
|
|
867
|
+
* from the start end. The sheet part is preserved.
|
|
868
|
+
* 1 トークンの F4 循環: セルは A1 → $A$1 → A$1 → $A1、レンジは始端の次状態を**両端**に適用
|
|
869
|
+
* (不揃いの両端は収束)、全列 / 全行は始端基準で両端の `$` をトグル。シート部は保持。
|
|
870
|
+
*/
|
|
871
|
+
export const cycleAnchor = (token: ReferenceToken): ReferenceToken => {
|
|
872
|
+
switch (token.kind) {
|
|
873
|
+
case "cell":
|
|
874
|
+
return { ...token, ...nextAnchor(token.absCol, token.absRow) }
|
|
875
|
+
case "range": {
|
|
876
|
+
const next = nextAnchor(token.start.absCol, token.start.absRow)
|
|
877
|
+
return { ...token, start: { ...token.start, ...next }, end: { ...token.end, ...next } }
|
|
878
|
+
}
|
|
879
|
+
case "column": {
|
|
880
|
+
const abs = !token.start.abs
|
|
881
|
+
return { ...token, start: { col: token.start.col, abs }, end: { col: token.end.col, abs } }
|
|
882
|
+
}
|
|
883
|
+
case "row": {
|
|
884
|
+
const abs = !token.start.abs
|
|
885
|
+
return { ...token, start: { row: token.start.row, abs }, end: { row: token.end.row, abs } }
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Whether `caret` is a valid caret position inside a formula body (past the `=`). / `caret` が数式本体 (`=` より後) の妥当なキャレット位置か。 */
|
|
891
|
+
const isFormulaCaret = (value: string, caret: number): boolean => isFormula(value) && Number.isInteger(caret) && caret >= 1 && caret <= value.length
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* The reference whose span contains the caret (`start ≤ caret ≤ end`, both edges inclusive so
|
|
895
|
+
* F4 works right after typing a reference) — the F4 target. Null outside a formula, for a
|
|
896
|
+
* caret outside `[1, length]` (a DOM-sourced value — fail-soft, not a throw), or when no
|
|
897
|
+
* reference is under the caret.
|
|
898
|
+
* キャレットを区間に含む参照 (`start ≤ caret ≤ end`、参照直後でも F4 が効くよう両端含む) —
|
|
899
|
+
* F4 の対象。数式外・`[1, length]` 外のキャレット (DOM 由来の値 — throw でなく fail-soft)・
|
|
900
|
+
* 参照下でない場合は null。
|
|
901
|
+
*/
|
|
902
|
+
export const findReferenceAt = (value: string, caret: number): ReferenceSpan | null => {
|
|
903
|
+
if (!isFormulaCaret(value, caret)) {
|
|
904
|
+
return null
|
|
905
|
+
}
|
|
906
|
+
for (const span of tokenizeFormula(value)) {
|
|
907
|
+
if (span.start <= caret && caret <= span.end) {
|
|
908
|
+
return span
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return null
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* The composed F4 operation: cycles the reference under the caret and returns the new text
|
|
916
|
+
* with the caret placed right after the rewritten reference; null when there is nothing to
|
|
917
|
+
* cycle (the keystroke falls through).
|
|
918
|
+
* 合成された F4 操作: キャレット下の参照を循環させ、書き換えた参照の直後にキャレットを置いた
|
|
919
|
+
* 新テキストを返す。循環対象が無ければ null (キーは素通し)。
|
|
920
|
+
*/
|
|
921
|
+
export const cycleAnchorAt = (text: string, caret: number): { text: string; caret: number } | null => {
|
|
922
|
+
const span = findReferenceAt(text, caret)
|
|
923
|
+
if (span === null) {
|
|
924
|
+
return null
|
|
925
|
+
}
|
|
926
|
+
const replaced = formatReference(cycleAnchor(span.token))
|
|
927
|
+
return { text: `${text.slice(0, span.start)}${replaced}${text.slice(span.end)}`, caret: span.start + replaced.length }
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Whether the prefix `value[0, end)` — with string literals opaque — ends in a pointing trigger
|
|
932
|
+
* followed by optional whitespace (`/[=(,;+\-*\/^&<>:]\s*$/`). A prefix ending inside a
|
|
933
|
+
* string literal never triggers.
|
|
934
|
+
* 接頭辞 `value[0, end)` (文字列リテラルは不透過) がポインティング契機 + 任意の空白で終わるか
|
|
935
|
+
* (`/[=(,;+\-*\/^&<>:]\s*$/`)。文字列リテラルの内側で終わる接頭辞は決して契機にならない。
|
|
936
|
+
*/
|
|
937
|
+
const endsWithTrigger = (value: string, end: number): boolean => {
|
|
938
|
+
let lastSignificant = -1
|
|
939
|
+
let i = 0
|
|
940
|
+
while (i < end) {
|
|
941
|
+
const code = value.charCodeAt(i)
|
|
942
|
+
if (code === CODE_QUOTE) {
|
|
943
|
+
// リテラルは 1 個の不透過な有意文字として扱う (閉じ位置が接頭辞外なら内側で終了)
|
|
944
|
+
const close = skipQuoted(value, i, CODE_QUOTE)
|
|
945
|
+
if (close > end) {
|
|
946
|
+
return false
|
|
947
|
+
}
|
|
948
|
+
lastSignificant = CODE_QUOTE
|
|
949
|
+
i = close
|
|
950
|
+
continue
|
|
951
|
+
}
|
|
952
|
+
if (!isSpace(code)) {
|
|
953
|
+
lastSignificant = code
|
|
954
|
+
}
|
|
955
|
+
i += 1
|
|
956
|
+
}
|
|
957
|
+
return isTrigger(lastSignificant)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* The pointing predicate (§2.7 / design v5): for a formula value and caret, where a pointed
|
|
962
|
+
* reference goes. `{ replace: [start, end] }` when the caret sits on a reference that follows
|
|
963
|
+
* a trigger (`=A1|` → replace `A1`), `{ insertAt: caret }` when the prefix ends in a trigger
|
|
964
|
+
* plus optional whitespace (`=SUM(|`), otherwise null — a printable character or a string
|
|
965
|
+
* literal therefore closes pointing by construction. Pure and stateless: evaluate it at every
|
|
966
|
+
* decision point instead of caching a flag. A caret outside `[1, length]` is null (fail-soft).
|
|
967
|
+
* ポインティング述語 (§2.7 / 設計 v5): 数式値とキャレットから、指した参照の行き先。契機に続く
|
|
968
|
+
* 参照上にキャレットがあれば `{ replace: [start, end] }` (`=A1|` → `A1` を置換)、接頭辞が
|
|
969
|
+
* 契機 + 任意の空白で終われば `{ insertAt: caret }` (`=SUM(|`)、それ以外は null — 印字文字や
|
|
970
|
+
* 文字列リテラルは構造的にポインティングを閉じる。純粋・無状態: フラグを持たず判断点毎に評価。
|
|
971
|
+
* `[1, length]` 外のキャレットは null (fail-soft)。
|
|
972
|
+
*/
|
|
973
|
+
export const isPointingPrefix = (value: string, caret: number): PointingTarget | null => {
|
|
974
|
+
if (!isFormulaCaret(value, caret)) {
|
|
975
|
+
return null
|
|
976
|
+
}
|
|
977
|
+
for (const span of tokenizeFormula(value)) {
|
|
978
|
+
if (span.start <= caret && caret <= span.end) {
|
|
979
|
+
// 契機に続くトークンだけが置換対象 (`=SUM(A1 B1|` の B1 は空白交差 — 対象外)
|
|
980
|
+
return endsWithTrigger(value, span.start) ? { replace: [span.start, span.end] } : null
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return endsWithTrigger(value, caret) ? { insertAt: caret } : null
|
|
984
|
+
}
|