@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,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Paste-special engine (v0.8 §1.7 / §4.1.9): the paste options vocabulary, the
|
|
3
|
+
* mode × aspect table and the pure transforms over a ClipPayload that the paste path composes —
|
|
4
|
+
* transposition (axes AND formula offsets), the write-gate strip of undeclared aspects, the
|
|
5
|
+
* per-cell patch per mode (values / formats / formulas / no-borders / widths / validation /
|
|
6
|
+
* conditional formats / comments) with Excel's arithmetic operations, skip-blanks and the
|
|
7
|
+
* inject-into-empty option, and the conditional-format range translation per tile. Pure,
|
|
8
|
+
* React-free and TOTAL fail-soft like cellFormat.ts: a payload the caller hands in is never
|
|
9
|
+
* mutated, nothing throws on data (a missing cell reads as blank, a dictionary index outside
|
|
10
|
+
* its dictionary reads as an absent aspect), and every transform returns its INPUT BY IDENTITY
|
|
11
|
+
* when it changes nothing (a 1×1 transpose without sizes / relative formulas, a strip with every
|
|
12
|
+
* aspect declared, an empty rule list). Nothing here reads an adapter: the caller supplies the
|
|
13
|
+
* target's text / format through lazy thunks that are called only when the mode needs them.
|
|
14
|
+
*
|
|
15
|
+
* Engine contract (each clause is load-bearing and mutation-gated in pasteSpecial.spec.ts):
|
|
16
|
+
* - MODE × ASPECT TABLE: `MODE_ASPECTS[mode]` lists which aspects a mode writes — `text`,
|
|
17
|
+
* `formula` (formulas resolved from the R1C1 form; off ⇒ the value text `t` is written),
|
|
18
|
+
* `format`, `borders` (only meaningful with `format`; off = "all except borders"), `cf`,
|
|
19
|
+
* `note`, `comments`, `hyperlink`, `validation`, `sizes` (column widths / row heights the
|
|
20
|
+
* payload carries when the copy was whole columns / rows). `all` and `noBorders` carry every
|
|
21
|
+
* cell aspect + CF + sizes; `formats` = format + borders + CF; `values` / `formulas` = text
|
|
22
|
+
* only (`formulas` resolves formulas, `values` never does); `columnWidths` = sizes only;
|
|
23
|
+
* `validation` / `conditionalFormats` / `commentsAndNotes` = that aspect only.
|
|
24
|
+
* - WRITE GATE ("what cannot be read cannot be written"): `stripUndeclaredAspects` removes every
|
|
25
|
+
* undeclared aspect from the payload BEFORE tiling (`f` without getCellFormula, `fmt` + the
|
|
26
|
+
* `formats` dictionary without getCellFormat, `val` + `validations`, `note`, `comments`,
|
|
27
|
+
* `link`, `conditionalFormats`), keeping every untouched cell / row / dictionary by identity;
|
|
28
|
+
* `patchFromClipCell` additionally gates each aspect on the same capabilities, so a v0.7
|
|
29
|
+
* adapter (no capabilities) receives exactly `{ text }` patches — the v0.7 identity.
|
|
30
|
+
* - PATCH PER CELL: under a mode that carries an aspect, a source cell WITHOUT that aspect
|
|
31
|
+
* writes `null` (clear the target — Excel's "All" / "Formats" / "Validation" replace the
|
|
32
|
+
* destination aspect); `format` is the canonical form of the dictionary entry, computed ONCE
|
|
33
|
+
* per dictionary index and shared by identity across every cell that references it; under
|
|
34
|
+
* `noBorders` the source borders are dropped and the target's EXISTING borders are kept (the
|
|
35
|
+
* only read of the target's format); `comments` travel id-less (every pasted comment is new).
|
|
36
|
+
* A patch without any key is `null` (the cell is not written).
|
|
37
|
+
* - SKIP BLANKS: the only per-cell skip. A blank source cell (`t === ""` and no `f`; formatting,
|
|
38
|
+
* notes, validation do not count — Excel checks contents only) leaves the target untouched in
|
|
39
|
+
* every mode, before any other rule. A formula returning "" is not blank.
|
|
40
|
+
* - OPERATIONS (Excel Paste Special add / subtract / multiply / divide; the copied value is the
|
|
41
|
+
* right operand): applied to the text aspect only, the target text being the target's EDIT
|
|
42
|
+
* text (its formula when it holds one). A non-numeric target text is skipped (null); a blank
|
|
43
|
+
* target is 0; numeric text is coerced through parseNumber's strict decimal grammar; a numeric
|
|
44
|
+
* result is written with 15 significant digits (no binary artefacts: 0.1 + 0.2 ⇒ "0.3"), never
|
|
45
|
+
* "-0", and a non-finite result leaves the target. A formula SOURCE is resolved at the target
|
|
46
|
+
* (`fromR1C1`) and becomes the right operand of a formula: `=(target)op(operand)` — the target
|
|
47
|
+
* side is its formula body, its number, or 0 when blank. A formula TARGET is wrapped the same
|
|
48
|
+
* way with a numeric source: `=(SUM(A1:A3))/(2)`. Division by a numeric zero source leaves the
|
|
49
|
+
* target (formula sources cannot be tested and are wrapped). A BLANK source counts as 0 under
|
|
50
|
+
* add / subtract — the numeric target text is re-entered as a number (Excel's text-to-number
|
|
51
|
+
* trick), a formula or blank target is left alone — and leaves the target under multiply /
|
|
52
|
+
* divide. A non-numeric source text (incl. TRUE / FALSE) leaves the target. Under a mode
|
|
53
|
+
* without `formula` (values) the source's formula is invisible: its value text is the operand.
|
|
54
|
+
* - INJECT INTO EMPTY: with `injectIntoEmpty`, a mode that does not carry `format` still writes
|
|
55
|
+
* the source's canonical format into a target whose text is "" (the sheets-enhancer paste
|
|
56
|
+
* setting generalised to the whole format); nothing is written when the source has no format.
|
|
57
|
+
* - TRANSPOSE: `transposePayload` swaps rows / cols and cell (r, c) ↦ (c, r); every RELATIVE
|
|
58
|
+
* reference of a formula is rotated with the geometry (`R[a]C[b]` ⇒ `R[b]C[a]`, a relative
|
|
59
|
+
* whole column becomes a relative whole row and vice versa, ranges end-wise), while any
|
|
60
|
+
* reference with an absolute axis is left in place (Excel: "use absolute references to keep
|
|
61
|
+
* them fixed"; a mixed reference cannot rotate without moving its anchor). Conditional-format
|
|
62
|
+
* ranges swap axes. Sizes are axis-bound and do not transpose: the result carries no
|
|
63
|
+
* `colWidths` / `rowHeights` and `wholeRows` / `wholeCols` are false. Dictionaries and the
|
|
64
|
+
* `clipId` are kept by identity; the caller then tiles the transposed payload normally
|
|
65
|
+
* (Sheets-style deviation — Excel refuses a transposed tile). The declared `rows × cols` is the
|
|
66
|
+
* shape of truth: a ragged row reads missing cells as blank and drops surplus cells.
|
|
67
|
+
* - CF TRANSLATION: `translateConditionalFormats` moves every origin-relative range of every rule
|
|
68
|
+
* by every tile origin and merges them into ONE input per source rule (rule order kept,
|
|
69
|
+
* `stopIfTrue` only when present); ranges are not clipped to the grid (a tile entered at the
|
|
70
|
+
* grid edge may extend past it — the consumer clamps, as it does for its own rules).
|
|
71
|
+
*
|
|
72
|
+
* 貼り付けスペシャルエンジン (v0.8 §1.7 / §4.1.9): 貼り付けオプション語彙、モード × 側面表、
|
|
73
|
+
* 貼り付け経路が合成する ClipPayload 上の純変換 — 転置 (軸と数式オフセット)、未宣言側面の書込
|
|
74
|
+
* ゲート除去、モード別のセル単位パッチ (値 / 書式 / 数式 / 罫線なし / 幅 / 検証 / 条件付き書式 /
|
|
75
|
+
* コメント) と Excel の演算・空白スキップ・空セル注入、条件付き書式レンジのタイル毎平行移動。
|
|
76
|
+
* cellFormat.ts と同じく純関数・React 非依存・全域 fail-soft: 渡された payload は変更せず、
|
|
77
|
+
* データで throw せず (欠けたセルは空白、辞書外索引は側面不在)、無変化の変換は入力を identity で
|
|
78
|
+
* 返す。アダプタは読まない — ターゲットの text / format は呼び出し側の遅延 thunk で受け取り、
|
|
79
|
+
* モードが必要とするときだけ呼ぶ。
|
|
80
|
+
* 契約: モード × 側面表 (`formula` off = 値文字列 `t`、`borders` off = 罫線を除く全て)、書込
|
|
81
|
+
* ゲート (`stripUndeclaredAspects` は未宣言側面を tiling 前に除去し、`patchFromClipCell` も
|
|
82
|
+
* 同じ能力で側面毎に門番 — v0.7 アダプタは `{ text }` だけ = v0.7 恒等)、セル毎パッチ (運ぶ
|
|
83
|
+
* 側面をソースが持たなければ `null` で消去、format は辞書索引毎に 1 回だけ正準化し identity 共有、
|
|
84
|
+
* `noBorders` はソース罫線を落としターゲット既存罫線を保つ、空パッチは `null`)、空白スキップ
|
|
85
|
+
* (唯一のセル単位スキップ、内容だけで判定)、演算 (非数ターゲットは skip、空 = 0、15 有効桁、
|
|
86
|
+
* -0 なし、非有限は不変、数式ソース / 数式ターゲットは `=(左)演算子(右)` へラップ、数値 0 除算は
|
|
87
|
+
* 不変、空ソースは + − で数として再入力 / × ÷ で不変、非数ソースは不変、values では数式は不可視)、
|
|
88
|
+
* 空セル注入、転置 (相対参照だけ回転、絶対軸を持つ参照は据え置き、CF レンジは軸交換、寸法は
|
|
89
|
+
* 運ばない、宣言形状が真実で欠けは空白・余剰は捨てる)、CF 平行移動 (規則毎に 1 入力へ統合、
|
|
90
|
+
* グリッドへの切り詰めはしない)。
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
import type { CellCoord } from "../model/cellCoord.ts"
|
|
94
|
+
import { type CellFormat, canonicalizeFormat, parseNumber } from "../model/cellFormat.ts"
|
|
95
|
+
import type { ConditionalFormatRuleInput } from "../model/conditionalFormat.ts"
|
|
96
|
+
import type { AspectCapabilities, CellPatch } from "../model/dataAdapter.ts"
|
|
97
|
+
import { createFormulaMemo, formatReference, fromR1C1, type ReferenceToken, tokenizeFormula, toR1C1 } from "../model/formulaRefs.ts"
|
|
98
|
+
import type { ClipCell, ClipPayload } from "./clipPayload.ts"
|
|
99
|
+
|
|
100
|
+
/** The paste-special modes (Excel Paste Special ∪ Sheets Paste special, §1.7). / 貼り付けスペシャルのモード (§1.7)。 */
|
|
101
|
+
export type PasteMode = "all" | "values" | "formats" | "formulas" | "noBorders" | "columnWidths" | "validation" | "conditionalFormats" | "commentsAndNotes"
|
|
102
|
+
|
|
103
|
+
/** Excel's Paste Special operation — the copied value is the RIGHT operand. / Excel の演算 — コピー値が右オペランド。 */
|
|
104
|
+
export type PasteOperation = "add" | "subtract" | "multiply" | "divide"
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* One paste gesture's options (§1.7): the mode, an optional arithmetic operation on the text
|
|
108
|
+
* aspect, skip-blanks, transpose and the inject-into-empty format option.
|
|
109
|
+
* 1 回の貼り付けのオプション (§1.7): モード、text 側面への任意の演算、空白スキップ、転置、
|
|
110
|
+
* 空セルへの書式注入。
|
|
111
|
+
*/
|
|
112
|
+
export type PasteOptions = { mode: PasteMode; operation?: PasteOperation; skipBlanks?: boolean; transpose?: boolean; injectIntoEmpty?: boolean }
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The aspects one mode writes (one row of `MODE_ASPECTS`). `formula` is only meaningful with
|
|
116
|
+
* `text`, `borders` only with `format`.
|
|
117
|
+
* 1 モードが書く側面 (`MODE_ASPECTS` の 1 行)。`formula` は `text` と、`borders` は `format` と
|
|
118
|
+
* 組でだけ意味を持つ。
|
|
119
|
+
*/
|
|
120
|
+
export type PasteAspects = {
|
|
121
|
+
readonly text: boolean
|
|
122
|
+
readonly formula: boolean
|
|
123
|
+
readonly format: boolean
|
|
124
|
+
readonly borders: boolean
|
|
125
|
+
readonly cf: boolean
|
|
126
|
+
readonly note: boolean
|
|
127
|
+
readonly comments: boolean
|
|
128
|
+
readonly hyperlink: boolean
|
|
129
|
+
readonly validation: boolean
|
|
130
|
+
readonly sizes: boolean
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The modes in §1.7 declaration order (census / menu order); its element type is the literal tuple so the spec can parity-guard it against `PasteMode`. / §1.7 の宣言順のモード一覧 (census / メニュー順)。要素型はリテラルタプルで、spec が `PasteMode` とのパリティを守る。 */
|
|
134
|
+
export const PASTE_MODES = Object.freeze(["all", "values", "formats", "formulas", "noBorders", "columnWidths", "validation", "conditionalFormats", "commentsAndNotes"] as const) satisfies readonly PasteMode[]
|
|
135
|
+
|
|
136
|
+
/** Builds one frozen aspect row from the aspects that are ON. / ON の側面から凍結した 1 行を組む。 */
|
|
137
|
+
const aspectRow = (on: readonly (keyof PasteAspects)[]): PasteAspects =>
|
|
138
|
+
Object.freeze({
|
|
139
|
+
text: on.includes("text"),
|
|
140
|
+
formula: on.includes("formula"),
|
|
141
|
+
format: on.includes("format"),
|
|
142
|
+
borders: on.includes("borders"),
|
|
143
|
+
cf: on.includes("cf"),
|
|
144
|
+
note: on.includes("note"),
|
|
145
|
+
comments: on.includes("comments"),
|
|
146
|
+
hyperlink: on.includes("hyperlink"),
|
|
147
|
+
validation: on.includes("validation"),
|
|
148
|
+
sizes: on.includes("sizes"),
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
/** Every aspect — the `all` row. / 全側面 — `all` 行。 */
|
|
152
|
+
const EVERY_ASPECT: readonly (keyof PasteAspects)[] = ["text", "formula", "format", "borders", "cf", "note", "comments", "hyperlink", "validation", "sizes"]
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The mode × aspect table (the spec 08 table, enumerable for the census test). Frozen at both
|
|
156
|
+
* levels.
|
|
157
|
+
* モード × 側面表 (spec 08 の表、census テストが列挙する)。両階層とも凍結。
|
|
158
|
+
*/
|
|
159
|
+
export const MODE_ASPECTS: { readonly [M in PasteMode]: PasteAspects } = Object.freeze({
|
|
160
|
+
all: aspectRow(EVERY_ASPECT),
|
|
161
|
+
values: aspectRow(["text"]),
|
|
162
|
+
formats: aspectRow(["format", "borders", "cf"]),
|
|
163
|
+
formulas: aspectRow(["text", "formula"]),
|
|
164
|
+
noBorders: aspectRow(EVERY_ASPECT.filter((aspect) => aspect !== "borders")),
|
|
165
|
+
columnWidths: aspectRow(["sizes"]),
|
|
166
|
+
validation: aspectRow(["validation"]),
|
|
167
|
+
conditionalFormats: aspectRow(["cf"]),
|
|
168
|
+
commentsAndNotes: aspectRow(["note", "comments"]),
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
/** The options of a plain Ctrl+V — mode `all`, nothing else. / 素の Ctrl+V のオプション — `all` のみ。 */
|
|
172
|
+
export const DEFAULT_PASTE_OPTIONS: PasteOptions = Object.freeze({ mode: "all" })
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One target cell as `patchFromClipCell` sees it: its coordinate (the formula resolution
|
|
176
|
+
* origin) and two lazy reads the engine calls only when the mode needs them — `readText`
|
|
177
|
+
* (edit text: the formula when the cell holds one) for operations and inject-into-empty,
|
|
178
|
+
* `readFormat` (base format) under `noBorders` only.
|
|
179
|
+
* `patchFromClipCell` が見るターゲットセル: 座標 (数式解決の原点) と、モードが必要とするときだけ
|
|
180
|
+
* 呼ばれる 2 つの遅延読み — `readText` (編集文字列: 数式があれば数式) は演算と空セル注入、
|
|
181
|
+
* `readFormat` (base 書式) は `noBorders` だけが呼ぶ。
|
|
182
|
+
*/
|
|
183
|
+
export type PasteTarget = { readonly row: number; readonly col: number; readonly readText: () => string; readonly readFormat: () => CellFormat | undefined }
|
|
184
|
+
|
|
185
|
+
/** The shared blank cell a ragged payload reads for a missing position. / 欠けた位置に読む共有の空白セル。 */
|
|
186
|
+
const BLANK_CELL: ClipCell = Object.freeze({ t: "" })
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The transposition pivot: both axes share it so rotating a relative offset about it is a plain
|
|
190
|
+
* row ↔ column swap, far enough from 0 that no real offset resolves below the grid.
|
|
191
|
+
* 転置の支点: 両軸で同じ値なので相対差分の回転は行 ↔ 列の単純交換になり、実在し得る差分が 0
|
|
192
|
+
* 未満へ落ちない距離にある。
|
|
193
|
+
*/
|
|
194
|
+
const TRANSPOSE_PIVOT: CellCoord = Object.freeze({ row: 1 << 30, col: 1 << 30 })
|
|
195
|
+
|
|
196
|
+
/** Operator text per operation. / 演算毎の演算子文字。 */
|
|
197
|
+
const OPERATOR_TEXT: { readonly [O in PasteOperation]: string } = Object.freeze({ add: "+", subtract: "-", multiply: "*", divide: "/" })
|
|
198
|
+
|
|
199
|
+
/** Blank = no contents: empty text and no formula (formatting / notes do not count). / 空白 = 内容なし: 空文字かつ数式なし (書式 / ノートは数えない)。 */
|
|
200
|
+
export const isBlankCell = (cell: ClipCell): boolean => cell.t === "" && cell.f === undefined
|
|
201
|
+
|
|
202
|
+
/** Whether an edit text is a formula (a lone "=" is text). / 編集文字列が数式か (単独の "=" は文字列)。 */
|
|
203
|
+
const isFormulaText = (text: string): boolean => text.length > 1 && text.startsWith("=")
|
|
204
|
+
|
|
205
|
+
/** IEEE arithmetic for one operation. / 1 演算の IEEE 算術。 */
|
|
206
|
+
const compute = (left: number, right: number, operation: PasteOperation): number => {
|
|
207
|
+
switch (operation) {
|
|
208
|
+
case "add":
|
|
209
|
+
return left + right
|
|
210
|
+
case "subtract":
|
|
211
|
+
return left - right
|
|
212
|
+
case "multiply":
|
|
213
|
+
return left * right
|
|
214
|
+
case "divide":
|
|
215
|
+
return left / right
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Cell text for a numeric result: 15 significant digits (Excel's display precision — hides
|
|
221
|
+
* binary artefacts), never "-0"; null when not finite.
|
|
222
|
+
* 数値結果のセル文字列: 15 有効桁 (Excel の表示精度 — 2 進誤差を隠す)、"-0" は出さない、非有限は null。
|
|
223
|
+
*/
|
|
224
|
+
const resultText = (value: number): string | null => {
|
|
225
|
+
if (!Number.isFinite(value)) {
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
// toPrecision(15) で丸め、Number で末尾 0 / 指数表記を正規化 (String(-0) は "0")
|
|
229
|
+
return String(Number(value.toPrecision(15)))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Excel Paste Special arithmetic on one target (rules in the fileoverview): returns the new
|
|
234
|
+
* cell text, or null to leave the target untouched. `targetText` is the target's EDIT text
|
|
235
|
+
* (its formula when it holds one); `target` resolves a formula source's R1C1 form.
|
|
236
|
+
* Excel の演算をターゲット 1 セルへ適用 (規則は fileoverview): 新しいセル文字列、または触らない
|
|
237
|
+
* null。`targetText` はターゲットの編集文字列 (数式があれば数式)、`target` は数式ソースの R1C1 を
|
|
238
|
+
* 解決する座標。
|
|
239
|
+
*/
|
|
240
|
+
export const applyOperation = (targetText: string, source: ClipCell, operation: PasteOperation, target: CellCoord): string | null => {
|
|
241
|
+
// ターゲットの分類: 数式 / 数値 (空は 0) / それ以外は触らない
|
|
242
|
+
const targetIsFormula = isFormulaText(targetText)
|
|
243
|
+
const targetNumber = targetText === "" ? "0" : parseNumber(targetText)
|
|
244
|
+
if (!targetIsFormula && targetNumber === null) {
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
const operator = OPERATOR_TEXT[operation]
|
|
248
|
+
// 数式ソース: ターゲットで解決した数式を右オペランドにしてラップ (0 除算は判定不能)
|
|
249
|
+
if (source.f !== undefined) {
|
|
250
|
+
const operand = fromR1C1(source.f, target)
|
|
251
|
+
const right = operand.startsWith("=") ? operand.slice(1) : operand
|
|
252
|
+
const left = targetIsFormula ? targetText.slice(1) : targetNumber
|
|
253
|
+
return `=(${left})${operator}(${right})`
|
|
254
|
+
}
|
|
255
|
+
// 空ソース: + − は 0 (数値テキストを数として再入力、数式 / 空ターゲットは不変)、× ÷ は不変
|
|
256
|
+
if (source.t === "") {
|
|
257
|
+
if (operation === "multiply" || operation === "divide" || targetIsFormula || targetText === "") {
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
return resultText(Number(targetNumber))
|
|
261
|
+
}
|
|
262
|
+
// 非数のソース文字列は触らない
|
|
263
|
+
const sourceNumber = parseNumber(source.t)
|
|
264
|
+
if (sourceNumber === null) {
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
const right = Number(sourceNumber)
|
|
268
|
+
// 数値 0 で割る貼り付けはターゲット不変
|
|
269
|
+
if (operation === "divide" && right === 0) {
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
// 数式ターゲットは数値ソースをそのまま右オペランドにしてラップ
|
|
273
|
+
if (targetIsFormula) {
|
|
274
|
+
return `=(${targetText.slice(1)})${operator}(${sourceNumber})`
|
|
275
|
+
}
|
|
276
|
+
return resultText(compute(Number(targetNumber), right, operation))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A swapped cell end (relative both ways — the caller checks). / 両端交換したセル端 (相対同士 — 判定は呼び出し側)。 */
|
|
280
|
+
const swapCellEnd = (end: { row: number; col: number; absRow: boolean; absCol: boolean }): { row: number; col: number; absRow: boolean; absCol: boolean } => ({ row: end.col, col: end.row, absRow: false, absCol: false })
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Rotates one A1 token about the shared pivot: fully relative references swap row ↔ column
|
|
284
|
+
* (cell, both range ends, whole column ↔ whole row); any absolute axis pins the token in place.
|
|
285
|
+
* Returns the input token by identity when unchanged.
|
|
286
|
+
* A1 トークン 1 つを共有支点で回転: 完全相対の参照は行 ↔ 列を交換 (セル、レンジ両端、全列 ↔ 全行)、
|
|
287
|
+
* 絶対軸が 1 つでもあれば据え置き。無変化は入力トークンを identity で返す。
|
|
288
|
+
*/
|
|
289
|
+
const rotateToken = (token: ReferenceToken): ReferenceToken => {
|
|
290
|
+
switch (token.kind) {
|
|
291
|
+
case "cell":
|
|
292
|
+
return token.absRow || token.absCol ? token : { ...token, row: token.col, col: token.row }
|
|
293
|
+
case "range":
|
|
294
|
+
return token.start.absRow || token.start.absCol || token.end.absRow || token.end.absCol ? token : { ...token, start: swapCellEnd(token.start), end: swapCellEnd(token.end) }
|
|
295
|
+
case "column":
|
|
296
|
+
return token.start.abs || token.end.abs ? token : { ...token, kind: "row", start: { row: token.start.col, abs: false }, end: { row: token.end.col, abs: false } }
|
|
297
|
+
case "row":
|
|
298
|
+
return token.start.abs || token.end.abs ? token : { ...token, kind: "column", start: { col: token.start.row, abs: false }, end: { col: token.end.row, abs: false } }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Rotates the relative references of one cell-relative R1C1 formula (`R[a]C[b]` ⇒ `R[b]C[a]`)
|
|
304
|
+
* through the A1 scanner: resolve at the pivot, rotate each token, re-encode at the pivot.
|
|
305
|
+
* Returns the same string when nothing rotates.
|
|
306
|
+
* セル相対 R1C1 数式 1 つの相対参照を回転 (`R[a]C[b]` ⇒ `R[b]C[a]`): 支点で A1 へ解決し、各
|
|
307
|
+
* トークンを回転し、支点で再符号化。回転する参照が無ければ同じ文字列。
|
|
308
|
+
*/
|
|
309
|
+
const transposeFormula = (formula: string): string => {
|
|
310
|
+
const a1 = fromR1C1(formula, TRANSPOSE_PIVOT)
|
|
311
|
+
const spans = tokenizeFormula(a1)
|
|
312
|
+
let out = ""
|
|
313
|
+
let last = 0
|
|
314
|
+
let changed = false
|
|
315
|
+
for (const span of spans) {
|
|
316
|
+
const rotated = rotateToken(span.token)
|
|
317
|
+
// 据え置きトークンは原文区間ごと残す (再直列化しない)
|
|
318
|
+
if (rotated === span.token) {
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
out += `${a1.slice(last, span.start)}${formatReference(rotated)}`
|
|
322
|
+
last = span.end
|
|
323
|
+
changed = true
|
|
324
|
+
}
|
|
325
|
+
if (!changed) {
|
|
326
|
+
return formula
|
|
327
|
+
}
|
|
328
|
+
return toR1C1(`${out}${a1.slice(last)}`, TRANSPOSE_PIVOT)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The cell at (row, col) of a payload — blank when the row is ragged / missing. / payload の (row, col) のセル — 行が欠けていれば空白。 */
|
|
332
|
+
const cellAt = (payload: ClipPayload, row: number, col: number): ClipCell => {
|
|
333
|
+
const line = payload.cells[row]
|
|
334
|
+
if (line === undefined) {
|
|
335
|
+
return BLANK_CELL
|
|
336
|
+
}
|
|
337
|
+
const cell = line[col]
|
|
338
|
+
return cell === undefined ? BLANK_CELL : cell
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Transposes a payload: rows ↔ cols, (r, c) ↦ (c, r), relative formula offsets rotated, CF
|
|
343
|
+
* ranges axis-swapped, sizes dropped (axis-bound), dictionaries and clipId by identity. A 1×1
|
|
344
|
+
* payload that nothing changes is returned by identity.
|
|
345
|
+
* payload の転置: rows ↔ cols、(r, c) ↦ (c, r)、相対数式差分は回転、CF レンジは軸交換、寸法は
|
|
346
|
+
* 落とす (軸に束縛)、辞書と clipId は identity。何も変わらない 1×1 は identity で返す。
|
|
347
|
+
*/
|
|
348
|
+
export const transposePayload = (payload: ClipPayload): ClipPayload => {
|
|
349
|
+
const { rows, cols } = payload
|
|
350
|
+
// 1 ジェスチャ 1 メモ: 同じ R1C1 文字列は 1 回だけ回転する
|
|
351
|
+
const rotate = createFormulaMemo(transposeFormula)
|
|
352
|
+
// 1×1 で寸法も whole フラグも無く、セルも回転しなければ恒等
|
|
353
|
+
let unchanged = rows === 1 && cols === 1 && payload.colWidths === undefined && payload.rowHeights === undefined && !payload.wholeRows && !payload.wholeCols
|
|
354
|
+
const cells: ClipCell[][] = []
|
|
355
|
+
for (let c = 0; c < cols; c += 1) {
|
|
356
|
+
const line: ClipCell[] = []
|
|
357
|
+
for (let r = 0; r < rows; r += 1) {
|
|
358
|
+
const cell = cellAt(payload, r, c)
|
|
359
|
+
// 数式のないセルはそのまま、回転しない数式も同一 object
|
|
360
|
+
let rotated = cell
|
|
361
|
+
if (cell.f !== undefined) {
|
|
362
|
+
const f = rotate(cell.f)
|
|
363
|
+
rotated = f === cell.f ? cell : { ...cell, f }
|
|
364
|
+
}
|
|
365
|
+
if (rotated !== cell || cell === BLANK_CELL) {
|
|
366
|
+
unchanged = false
|
|
367
|
+
}
|
|
368
|
+
line.push(rotated)
|
|
369
|
+
}
|
|
370
|
+
cells.push(line)
|
|
371
|
+
}
|
|
372
|
+
if (unchanged) {
|
|
373
|
+
return payload
|
|
374
|
+
}
|
|
375
|
+
// CF レンジは軸交換 (start ≤ end は保たれる)、規則の他フィールドは identity
|
|
376
|
+
const conditionalFormats =
|
|
377
|
+
payload.conditionalFormats.length === 0 ? payload.conditionalFormats : payload.conditionalFormats.map((rule) => ({ ...rule, ranges: rule.ranges.map((range) => ({ startRow: range.startCol, startCol: range.startRow, endRow: range.endCol, endCol: range.endRow })) }))
|
|
378
|
+
return { v: 1, rows: cols, cols: rows, clipId: payload.clipId, formats: payload.formats, validations: payload.validations, cells, wholeRows: false, wholeCols: false, conditionalFormats }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Whether a cell carries an aspect the capabilities do not declare. / 未宣言の側面をセルが持つか。 */
|
|
382
|
+
const hasUndeclared = (cell: ClipCell, c: AspectCapabilities): boolean =>
|
|
383
|
+
(!c.formula && cell.f !== undefined) || (!c.format && cell.fmt !== undefined) || (!c.validation && cell.val !== undefined) || (!c.note && cell.note !== undefined) || (!c.comments && cell.comments !== undefined) || (!c.hyperlink && cell.link !== undefined)
|
|
384
|
+
|
|
385
|
+
/** The cell without its undeclared aspects (a fresh object — the caller checked `hasUndeclared`). / 未宣言側面を除いたセル (新 object — 呼び出し側が `hasUndeclared` を確認済み)。 */
|
|
386
|
+
const stripCell = (cell: ClipCell, c: AspectCapabilities): ClipCell => {
|
|
387
|
+
const stripped: ClipCell = { t: cell.t }
|
|
388
|
+
if (c.formula && cell.f !== undefined) {
|
|
389
|
+
stripped.f = cell.f
|
|
390
|
+
}
|
|
391
|
+
if (c.format && cell.fmt !== undefined) {
|
|
392
|
+
stripped.fmt = cell.fmt
|
|
393
|
+
}
|
|
394
|
+
if (c.validation && cell.val !== undefined) {
|
|
395
|
+
stripped.val = cell.val
|
|
396
|
+
}
|
|
397
|
+
if (c.note && cell.note !== undefined) {
|
|
398
|
+
stripped.note = cell.note
|
|
399
|
+
}
|
|
400
|
+
if (c.comments && cell.comments !== undefined) {
|
|
401
|
+
stripped.comments = cell.comments
|
|
402
|
+
}
|
|
403
|
+
if (c.hyperlink && cell.link !== undefined) {
|
|
404
|
+
stripped.link = cell.link
|
|
405
|
+
}
|
|
406
|
+
return stripped
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The write gate: strips every aspect the adapter does not declare BEFORE tiling — `f`, `fmt`
|
|
411
|
+
* (+ the `formats` dictionary), `val` (+ `validations`), `note`, `comments`, `link`,
|
|
412
|
+
* `conditionalFormats`. Untouched cells, rows and dictionaries keep their identity; a payload
|
|
413
|
+
* with nothing to strip is returned by identity. Sizes are not an aspect (resizeTracks always
|
|
414
|
+
* exists).
|
|
415
|
+
* 書込ゲート: アダプタが宣言しない側面を tiling 前に除去 — `f`、`fmt` (+ `formats` 辞書)、`val`
|
|
416
|
+
* (+ `validations`)、`note`、`comments`、`link`、`conditionalFormats`。触れないセル / 行 / 辞書は
|
|
417
|
+
* identity を保ち、除去対象の無い payload は identity で返す。寸法は側面ではない (resizeTracks は
|
|
418
|
+
* 常に在る)。
|
|
419
|
+
*/
|
|
420
|
+
export const stripUndeclaredAspects = (payload: ClipPayload, capabilities: AspectCapabilities): ClipPayload => {
|
|
421
|
+
let changed = false
|
|
422
|
+
const cells: (readonly ClipCell[])[] = []
|
|
423
|
+
for (const line of payload.cells) {
|
|
424
|
+
// 行内に除去対象が無ければ行配列ごと identity
|
|
425
|
+
let lineChanged = false
|
|
426
|
+
const stripped: ClipCell[] = []
|
|
427
|
+
for (const cell of line) {
|
|
428
|
+
if (hasUndeclared(cell, capabilities)) {
|
|
429
|
+
stripped.push(stripCell(cell, capabilities))
|
|
430
|
+
lineChanged = true
|
|
431
|
+
} else {
|
|
432
|
+
stripped.push(cell)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (lineChanged) {
|
|
436
|
+
changed = true
|
|
437
|
+
}
|
|
438
|
+
cells.push(lineChanged ? stripped : line)
|
|
439
|
+
}
|
|
440
|
+
// 辞書は未宣言なら空へ (既に空なら identity)
|
|
441
|
+
const formats = !capabilities.format && payload.formats.length > 0 ? [] : payload.formats
|
|
442
|
+
const validations = !capabilities.validation && payload.validations.length > 0 ? [] : payload.validations
|
|
443
|
+
const conditionalFormats = !capabilities.cf && payload.conditionalFormats.length > 0 ? [] : payload.conditionalFormats
|
|
444
|
+
if (formats !== payload.formats || validations !== payload.validations || conditionalFormats !== payload.conditionalFormats) {
|
|
445
|
+
changed = true
|
|
446
|
+
}
|
|
447
|
+
if (!changed) {
|
|
448
|
+
return payload
|
|
449
|
+
}
|
|
450
|
+
return { ...payload, formats, validations, conditionalFormats, cells }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Per-payload canonical format dictionary (and its borderless twin), built once. / payload 毎の正準書式辞書 (と罫線なし版)、1 回だけ構築。 */
|
|
454
|
+
type FormatCache = { readonly canonical: readonly (CellFormat | undefined)[]; readonly borderless: readonly (CellFormat | undefined)[] }
|
|
455
|
+
|
|
456
|
+
/** Canonical dictionaries per payload object (a payload is treated as immutable). / payload object 毎の正準辞書 (payload は不変扱い)。 */
|
|
457
|
+
const formatCaches = new WeakMap<ClipPayload, FormatCache>()
|
|
458
|
+
|
|
459
|
+
/** The canonical dictionaries of a payload, built on first use. / payload の正準辞書 (初回使用時に構築)。 */
|
|
460
|
+
const formatCacheOf = (payload: ClipPayload): FormatCache => {
|
|
461
|
+
const hit = formatCaches.get(payload)
|
|
462
|
+
if (hit !== undefined) {
|
|
463
|
+
return hit
|
|
464
|
+
}
|
|
465
|
+
// 辞書 1 項目 = 1 正準 object: 参照する全セルが identity を共有する
|
|
466
|
+
const canonical = payload.formats.map((format) => canonicalizeFormat(format))
|
|
467
|
+
const borderless = canonical.map((format) => (format === undefined || format.borders === undefined ? format : canonicalizeFormat({ ...format, borders: undefined })))
|
|
468
|
+
const built: FormatCache = { canonical, borderless }
|
|
469
|
+
formatCaches.set(payload, built)
|
|
470
|
+
return built
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The dictionary entry an optional cell index addresses — undefined for an absent index or one
|
|
475
|
+
* outside the dictionary (total: never a throw, never a substitute; JS indexing already yields
|
|
476
|
+
* undefined for a negative / fractional / out-of-range index, so no extra guard is observable).
|
|
477
|
+
* 任意のセル索引が指す辞書項目 — 索引不在 / 辞書外は undefined (全域: throw も代替もしない。負 /
|
|
478
|
+
* 小数 / 範囲外の索引は JS の添字が既に undefined を返すので、余分な門番は観測不能)。
|
|
479
|
+
*/
|
|
480
|
+
const entryAt = <T>(entries: readonly T[], index: number | undefined): T | undefined => (index === undefined ? undefined : entries[index])
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The format aspect of a source cell under a format-carrying mode: the canonical dictionary
|
|
484
|
+
* entry (null when the source has none); under `noBorders` the borderless entry merged with the
|
|
485
|
+
* target's existing borders (the only target-format read).
|
|
486
|
+
* 書式を運ぶモードでのソースセルの書式側面: 正準辞書項目 (ソースに無ければ null)、`noBorders` では
|
|
487
|
+
* 罫線なし項目にターゲットの既存罫線を合成したもの (唯一のターゲット書式読み)。
|
|
488
|
+
*/
|
|
489
|
+
const resolveFormat = (payload: ClipPayload, cell: ClipCell, borders: boolean, target: PasteTarget): CellFormat | null => {
|
|
490
|
+
const cache = formatCacheOf(payload)
|
|
491
|
+
if (borders) {
|
|
492
|
+
const canonical = entryAt(cache.canonical, cell.fmt)
|
|
493
|
+
return canonical === undefined ? null : canonical
|
|
494
|
+
}
|
|
495
|
+
// noBorders: ソースの罫線を落とし、ターゲットの既存罫線を保つ
|
|
496
|
+
const source = entryAt(cache.borderless, cell.fmt)
|
|
497
|
+
const existing = target.readFormat()
|
|
498
|
+
const existingBorders = existing === undefined ? undefined : existing.borders
|
|
499
|
+
if (existingBorders === undefined) {
|
|
500
|
+
return source === undefined ? null : source
|
|
501
|
+
}
|
|
502
|
+
const merged = canonicalizeFormat(source === undefined ? { borders: existingBorders } : { ...source, borders: existingBorders })
|
|
503
|
+
return merged === undefined ? null : merged
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The text aspect of a source cell: the operation result (null = leave the target), the formula
|
|
508
|
+
* resolved at the target under a formula-carrying mode, else the value text.
|
|
509
|
+
* ソースセルの text 側面: 演算結果 (null = 触らない)、数式を運ぶモードならターゲットで解決した数式、
|
|
510
|
+
* それ以外は値文字列。
|
|
511
|
+
*/
|
|
512
|
+
const resolveText = (cell: ClipCell, formula: boolean, target: PasteTarget, operation: PasteOperation | undefined): string | null => {
|
|
513
|
+
// 数式を運ばないモードでは数式は不可視 — 値だけのセルとして扱う
|
|
514
|
+
const visible = formula || cell.f === undefined ? cell : { t: cell.t }
|
|
515
|
+
if (operation !== undefined) {
|
|
516
|
+
return applyOperation(target.readText(), visible, operation, target)
|
|
517
|
+
}
|
|
518
|
+
return visible.f === undefined ? visible.t : fromR1C1(visible.f, target)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Builds the CellPatch for one target cell from a source cell per `MODE_ASPECTS[options.mode]`
|
|
523
|
+
* ∧ `capabilities` (rules in the fileoverview): skip-blanks first, text (with the operation),
|
|
524
|
+
* format (or the inject-into-empty injection), note, comments, hyperlink, validation — each
|
|
525
|
+
* written as its value or `null` when the source lacks it. Null when the patch would carry no
|
|
526
|
+
* key. `target.readText` is called at most once and only for an operation or the inject check;
|
|
527
|
+
* `target.readFormat` only under `noBorders`.
|
|
528
|
+
* ソースセル 1 つから `MODE_ASPECTS[options.mode]` ∧ `capabilities` に従いターゲット 1 セルの
|
|
529
|
+
* CellPatch を組む (規則は fileoverview): 空白スキップ、text (演算込み)、format (または空セル
|
|
530
|
+
* 注入)、note、comments、hyperlink、validation — 各々ソースに無ければ `null`。キーが 1 つも
|
|
531
|
+
* 無ければ null。`target.readText` は演算か注入判定でだけ最大 1 回、`target.readFormat` は
|
|
532
|
+
* `noBorders` だけが呼ぶ。
|
|
533
|
+
*/
|
|
534
|
+
export const patchFromClipCell = (cell: ClipCell, payload: ClipPayload, target: PasteTarget, options: PasteOptions, capabilities: AspectCapabilities): CellPatch | null => {
|
|
535
|
+
const aspects = MODE_ASPECTS[options.mode]
|
|
536
|
+
// 唯一のセル単位スキップ: 空白ソースはどのモードでもターゲットを触らない
|
|
537
|
+
if (options.skipBlanks === true && isBlankCell(cell)) {
|
|
538
|
+
return null
|
|
539
|
+
}
|
|
540
|
+
const patch: CellPatch = {}
|
|
541
|
+
let written = false
|
|
542
|
+
if (aspects.text) {
|
|
543
|
+
const text = resolveText(cell, aspects.formula && capabilities.formula, target, options.operation)
|
|
544
|
+
if (text !== null) {
|
|
545
|
+
patch.text = text
|
|
546
|
+
written = true
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (capabilities.format) {
|
|
550
|
+
if (aspects.format) {
|
|
551
|
+
patch.format = resolveFormat(payload, cell, aspects.borders, target)
|
|
552
|
+
written = true
|
|
553
|
+
} else if (options.injectIntoEmpty === true && cell.fmt !== undefined && target.readText() === "") {
|
|
554
|
+
// 空ターゲットにだけソース書式を注入 (ソースに書式が無ければ何も書かない)
|
|
555
|
+
const injected = entryAt(formatCacheOf(payload).canonical, cell.fmt)
|
|
556
|
+
if (injected !== undefined) {
|
|
557
|
+
patch.format = injected
|
|
558
|
+
written = true
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (aspects.note && capabilities.note) {
|
|
563
|
+
patch.note = cell.note === undefined ? null : cell.note
|
|
564
|
+
written = true
|
|
565
|
+
}
|
|
566
|
+
if (aspects.comments && capabilities.comments) {
|
|
567
|
+
patch.comments = cell.comments === undefined ? null : cell.comments
|
|
568
|
+
written = true
|
|
569
|
+
}
|
|
570
|
+
if (aspects.hyperlink && capabilities.hyperlink) {
|
|
571
|
+
patch.hyperlink = cell.link === undefined ? null : cell.link
|
|
572
|
+
written = true
|
|
573
|
+
}
|
|
574
|
+
if (aspects.validation && capabilities.validation) {
|
|
575
|
+
const rule = entryAt(payload.validations, cell.val)
|
|
576
|
+
patch.validation = rule === undefined ? null : rule
|
|
577
|
+
written = true
|
|
578
|
+
}
|
|
579
|
+
return written ? patch : null
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Translates every origin-relative range of every rule by every tile origin and merges the
|
|
584
|
+
* results into ONE input per source rule (rule order kept; condition / style by identity;
|
|
585
|
+
* `stopIfTrue` only when present). An empty rule list is returned by identity; no tiles ⇒ [].
|
|
586
|
+
* Ranges are not clipped to the grid.
|
|
587
|
+
* 各規則の原点相対レンジを各タイル原点で平行移動し、ソース規則毎に 1 入力へ統合 (規則順維持、
|
|
588
|
+
* condition / style は identity、`stopIfTrue` は在るときだけ)。空の規則列は identity、タイル無しは
|
|
589
|
+
* []。グリッドへの切り詰めはしない。
|
|
590
|
+
*/
|
|
591
|
+
export const translateConditionalFormats = (rules: readonly ConditionalFormatRuleInput[], tiles: readonly CellCoord[]): readonly ConditionalFormatRuleInput[] => {
|
|
592
|
+
if (rules.length === 0) {
|
|
593
|
+
return rules
|
|
594
|
+
}
|
|
595
|
+
if (tiles.length === 0) {
|
|
596
|
+
return []
|
|
597
|
+
}
|
|
598
|
+
return rules.map((rule) => {
|
|
599
|
+
// タイル順 × レンジ順で平行移動した全レンジを 1 規則へ
|
|
600
|
+
const ranges = []
|
|
601
|
+
for (const tile of tiles) {
|
|
602
|
+
for (const range of rule.ranges) {
|
|
603
|
+
ranges.push({ startRow: tile.row + range.startRow, startCol: tile.col + range.startCol, endRow: tile.row + range.endRow, endCol: tile.col + range.endCol })
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return { ...rule, ranges }
|
|
607
|
+
})
|
|
608
|
+
}
|