@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,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Row/column-symmetric axis size model on top of FenwickMapTree (integer-px contract).
|
|
3
|
+
* Positions are logical px from the axis origin; sizes are non-negative integers (0 = hidden).
|
|
4
|
+
*
|
|
5
|
+
* FenwickMapTree 上の行・列対称寸法モデル (整数 px 契約)。位置は軸原点からの論理 px、
|
|
6
|
+
* サイズは非負整数 (0 = 非表示)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { FenwickMapTree } from "@aiquants/virtualscroll"
|
|
10
|
+
import { useRef } from "react"
|
|
11
|
+
|
|
12
|
+
/** Axis size/offset/hit-test contract. / 軸の寸法・オフセット・ヒットテスト契約。 */
|
|
13
|
+
export type AxisModel = {
|
|
14
|
+
count: number
|
|
15
|
+
sizeOf: (index: number) => number
|
|
16
|
+
/** Start offset from the axis origin in logical px. / 軸原点からの開始オフセット。 */
|
|
17
|
+
offsetOf: (index: number) => number
|
|
18
|
+
/** Logical position to index (right-open, zero-width skip, edge snap to nearest positive). / 論理座標 → index。 */
|
|
19
|
+
indexAt: (position: number) => number
|
|
20
|
+
totalSize: () => number
|
|
21
|
+
/** Resize (rounded to integer; negative / non-finite throws; 0 hides). / サイズ変更。 */
|
|
22
|
+
resize: (index: number, size: number) => void
|
|
23
|
+
/**
|
|
24
|
+
* Hidden-track total, maintained O(1) at BOTH write points (the eager construction loop —
|
|
25
|
+
* initialSizeOf bypasses resize — and the resize seam) on the ROUNDED stored size
|
|
26
|
+
* (resize(0.4) stores 0 = hidden; comparing the raw argument would miss it). Backs the
|
|
27
|
+
* §5.4(b) row-axis UI gate; box-rebuild resets it automatically (counter lives in the box).
|
|
28
|
+
* 非表示トラック総数 (§5.4(b))。書込 2 点 (eager 構築ループ — initialSizeOf は resize を
|
|
29
|
+
* 通らない第 2 の書込点 — と resize シーム) で丸め後の格納サイズに対し O(1) 維持。
|
|
30
|
+
* カウンタは軸ボックス内在のため再構築リセットは自動。
|
|
31
|
+
*/
|
|
32
|
+
hiddenCount: () => number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Builds the Fenwick tree with numeric base + eager per-index application.
|
|
37
|
+
* 数値ベース + eager 適用で木を構築するモジュールレベル純関数 (レンダー中に呼んでも副作用なし)。
|
|
38
|
+
*
|
|
39
|
+
* ❗ 関数を FenwickMapTree の valueOrFn に渡してはならない — 関数初期値は「baseValue 推定の
|
|
40
|
+
* サンプリング (100 点以下、mode / median)」であり per-index 値は materialize 経由でしか
|
|
41
|
+
* 具現化されない。渡すと初期幅が一様値へ黙って化ける (§6.2.2-1)。
|
|
42
|
+
*/
|
|
43
|
+
const buildAxisTree = (count: number, defaultSize: number, initialSizeOf?: (index: number) => number): { tree: FenwickMapTree; initialHidden: number } => {
|
|
44
|
+
const built = new FenwickMapTree(count, defaultSize)
|
|
45
|
+
let initialHidden = 0
|
|
46
|
+
if (initialSizeOf) {
|
|
47
|
+
// eager 適用: 非 default 値だけが差分 Map に載りメモリはスパースなまま。
|
|
48
|
+
// O(count) の関数評価が掛かるため、巨大グリッド (10 万行超) では initialSizeOf を
|
|
49
|
+
// 渡さず必要行だけ後から resize する (props docstring に明記)
|
|
50
|
+
for (let i = 0; i < count; i += 1) {
|
|
51
|
+
const v = Math.round(initialSizeOf(i))
|
|
52
|
+
if (v !== defaultSize) {
|
|
53
|
+
built.update(i, v)
|
|
54
|
+
}
|
|
55
|
+
// hiddenCount の第 2 の書込点 (§5.4(b) — initialSizeOf は resize シームを通らない)
|
|
56
|
+
if (v === 0) {
|
|
57
|
+
initialHidden += 1
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { tree: built, initialHidden }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Constructs the model + tree pair for one key. / 1 キーぶんのモデル + 木を構築。 */
|
|
65
|
+
const buildAxisBox = (count: number, defaultSize: number, initialSizeOf?: (index: number) => number): { key: string; model: AxisModel } => {
|
|
66
|
+
const { tree, initialHidden } = buildAxisTree(count, defaultSize, initialSizeOf)
|
|
67
|
+
// 非表示総数はボックス内在 (再構築で自動リセット)。判定は常に丸め後の格納サイズ (§5.4(b))
|
|
68
|
+
let hidden = initialHidden
|
|
69
|
+
const offsetOf = (index: number): number => {
|
|
70
|
+
// prefixSum は自身を含む累積 (inclusive) — 開始オフセットは自身の寸法を引いて得る
|
|
71
|
+
const { cumulative, currentValue } = tree.prefixSum(index)
|
|
72
|
+
return cumulative - currentValue
|
|
73
|
+
}
|
|
74
|
+
const indexAt = (position: number): number => {
|
|
75
|
+
// 空軸・全 0 軸に「幅正の最近傍」は存在しない (fail-fast — §6.2.2-5)
|
|
76
|
+
if (count === 0 || tree.getTotal() === 0) {
|
|
77
|
+
throw new RangeError("[spreadsheet] indexAt requires a non-empty axis with positive total size")
|
|
78
|
+
}
|
|
79
|
+
// ❗ 入力はまず floor する。ヒットテスト入力は getBoundingClientRect の小数 + scrollBy の
|
|
80
|
+
// float 累積で小数になり、floor しないと各行下端 0.5px 帯が隣の行へ誤判定される
|
|
81
|
+
const p = Math.floor(position)
|
|
82
|
+
// 整数 px 契約により累積は常に整数。+0.5 は必ず区間内部に落ち、幅 0 行 (累積が増えない) を
|
|
83
|
+
// findIndexAtOrAfter が自然にスキップする。境界 p = offsetOf(k) は k 自身に、
|
|
84
|
+
// 区間終端 p = offsetOf(k) + sizeOf(k) は次の幅正行に解決される
|
|
85
|
+
const clamped = Math.min(Math.max(p, 0), tree.getTotal() - 1)
|
|
86
|
+
return tree.findIndexAtOrAfter(clamped + 0.5).index
|
|
87
|
+
}
|
|
88
|
+
const model: AxisModel = {
|
|
89
|
+
count,
|
|
90
|
+
sizeOf: (index) => tree.get(index),
|
|
91
|
+
offsetOf,
|
|
92
|
+
indexAt,
|
|
93
|
+
totalSize: () => tree.getTotal(),
|
|
94
|
+
resize: (index, size) => {
|
|
95
|
+
if (!(Number.isFinite(size) && size >= 0)) {
|
|
96
|
+
throw new RangeError(`[spreadsheet] resize requires a finite size >= 0, received ${size}`)
|
|
97
|
+
}
|
|
98
|
+
// hiddenCount は 0 ↔ 正の遷移で ±1 (丸め後の格納サイズ同士の比較 — §5.4(b))
|
|
99
|
+
const next = Math.round(size)
|
|
100
|
+
const prev = tree.get(index)
|
|
101
|
+
if ((prev === 0) !== (next === 0)) {
|
|
102
|
+
hidden += next === 0 ? 1 : -1
|
|
103
|
+
}
|
|
104
|
+
// 戻り値は設けない (総寸法は totalSize() を読む — update の返り値に依存した契約を作らない)
|
|
105
|
+
tree.update(index, next)
|
|
106
|
+
},
|
|
107
|
+
hiddenCount: () => hidden,
|
|
108
|
+
}
|
|
109
|
+
return { key: `${count}:${defaultSize}`, model }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Fenwick-jump visible-track scan (§5.1 — O(log n) even across a 100k hidden run). Forward:
|
|
114
|
+
* lands on offsetOf(from) + sizeOf(from) (a hidden `from` contributes 0, so the position IS
|
|
115
|
+
* the next visible track's start and indexAt's boundary rule resolves to it). Deliberately NO
|
|
116
|
+
* max(1, ·): it would overjump a 1px-wide visible track and falsely report "none visible"
|
|
117
|
+
* from the last 1px track (an empirically proven off-by-one). Returns null when no visible
|
|
118
|
+
* track exists in the direction — the caller chooses "stay" or "retry opposite".
|
|
119
|
+
* Fenwick ジャンプの可視トラック走査 (§5.1 — 10 万連続非表示でも O(log n))。前方は
|
|
120
|
+
* offsetOf(from) + sizeOf(from) へ着地 (非表示 from は寸法 0 で次可視の開始位置に一致し、
|
|
121
|
+
* indexAt の境界規則がそのまま次可視へ解決する)。max(1, ·) は幅 1px トラックの飛び越しと
|
|
122
|
+
* 最終可視 1px での「可視なし」誤報を生むため意図的に使わない。方向に可視が無ければ null。
|
|
123
|
+
*/
|
|
124
|
+
export const nextVisibleIndex = (axis: AxisModel, from: number, step: 1 | -1): number | null => {
|
|
125
|
+
if (step > 0) {
|
|
126
|
+
// 自身の区間終端 = 次可視トラックの開始オフセット (整数 px 契約により常に整数)
|
|
127
|
+
const offset = axis.offsetOf(from) + axis.sizeOf(from)
|
|
128
|
+
return offset >= axis.totalSize() ? null : axis.indexAt(offset)
|
|
129
|
+
}
|
|
130
|
+
// 後方: 自身の開始オフセットの 1px 手前は直前の幅正トラックの区間内部に落ちる
|
|
131
|
+
const offset = axis.offsetOf(from) - 1
|
|
132
|
+
return offset < 0 ? null : axis.indexAt(offset)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Enumerates the VISIBLE indices inside [from, to] via Fenwick jumps (§5.4 — the render-window
|
|
137
|
+
* enumeration primitive: a K-long hidden run inside the window costs O(log n), not O(K)).
|
|
138
|
+
* Computed once per slice by the callers and closed into the row renderers.
|
|
139
|
+
* [from, to] 内の可視 index を Fenwick ジャンプで列挙 (§5.4 — 描画ウィンドウ列挙の基本手。
|
|
140
|
+
* 窓内 K 連続非表示ランのコストは O(K) でなく O(log n))。呼び出し側がスライス 1 回につき
|
|
141
|
+
* 1 度算出し行レンダラーへ閉じ込める。
|
|
142
|
+
*/
|
|
143
|
+
export const visibleIndicesInRange = (axis: AxisModel, from: number, to: number): number[] => {
|
|
144
|
+
// 空ウィンドウ (from > to — 全列非表示 / 空軸で列窓が空) は空列挙 (仕様の空結果)
|
|
145
|
+
if (from > to || axis.count === 0 || axis.totalSize() === 0) {
|
|
146
|
+
return []
|
|
147
|
+
}
|
|
148
|
+
const result: number[] = []
|
|
149
|
+
// 起点自身が非表示なら次可視へジャンプしてから列挙を始める
|
|
150
|
+
let index: number | null = axis.sizeOf(from) > 0 ? from : nextVisibleIndex(axis, from, 1)
|
|
151
|
+
while (index !== null && index <= to) {
|
|
152
|
+
result.push(index)
|
|
153
|
+
index = nextVisibleIndex(axis, index, 1)
|
|
154
|
+
}
|
|
155
|
+
return result
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Axis model hook. The tree lives in a ref-based lazy box keyed by count:defaultSize —
|
|
160
|
+
* NOT useMemo (React may discard memo caches, silently dropping user resize diffs).
|
|
161
|
+
* Rebuild on key change intentionally DROPS resize diffs (explicit v0.1 contract). The
|
|
162
|
+
* FLIP side is also contractual: the key is adapter-independent, so a sheet switch to an
|
|
163
|
+
* adapter with the SAME count/defaultSize reuses the box and CARRIES resize diffs across
|
|
164
|
+
* sheets (per-sheet sizes are a Phase 3+ decision — include adapter identity in the key then).
|
|
165
|
+
*
|
|
166
|
+
* 軸モデルフック。木は count:defaultSize キーの ref 遅延ボックスに保持 — useMemo は不可
|
|
167
|
+
* (React はキャッシュを破棄してよく、リサイズ差分が黙って消える)。キー変更時の再構築で
|
|
168
|
+
* リサイズ差分が消えるのは v0.1 の明示契約。裏面も契約: キーは adapter 非依存のため、同一
|
|
169
|
+
* count / defaultSize のシート切替ではボックスが再利用されリサイズ差分が持ち越される
|
|
170
|
+
* (シート別サイズは Phase 3 以降の判断 — その際は adapter identity をキーへ含める)。
|
|
171
|
+
* initialSizeOf は初回 identity を捕捉し、以後の関数差し替えは無視される
|
|
172
|
+
* (「initial」の流儀 — 可変ストアを読む安定クロージャは可)。
|
|
173
|
+
*/
|
|
174
|
+
export const useAxisModel = (count: number, defaultSize: number, initialSizeOf?: (index: number) => number): AxisModel => {
|
|
175
|
+
if (!(Number.isInteger(defaultSize) && defaultSize > 0)) {
|
|
176
|
+
throw new TypeError(`[spreadsheet] useAxisModel requires a positive integer defaultSize, received ${defaultSize}`)
|
|
177
|
+
}
|
|
178
|
+
const capturedInitial = useRef(initialSizeOf).current
|
|
179
|
+
const box = useRef<{ key: string; model: AxisModel } | null>(null)
|
|
180
|
+
const key = `${count}:${defaultSize}`
|
|
181
|
+
if (box.current === null || box.current.key !== key) {
|
|
182
|
+
// count / defaultSize 変更時のみ再構築 (差分は消える = 明示契約)
|
|
183
|
+
box.current = buildAxisBox(count, defaultSize, capturedInitial)
|
|
184
|
+
}
|
|
185
|
+
return box.current.model
|
|
186
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Cell annotation vocabulary (v0.8, ADR-13): the comment-thread types on the read
|
|
3
|
+
* side (`CellComment` — what `getCellComments` returns) and the write side (`CellCommentInput` —
|
|
4
|
+
* what `CellPatch.comments` carries), the hyperlink scheme allowlist with its normaliser, and the
|
|
5
|
+
* whole-row / whole-column range predicates shared by clip serialisation. Comment ids are
|
|
6
|
+
* CONSUMER-scoped: an input carrying an `id` asks the consumer to keep that comment verbatim, an
|
|
7
|
+
* input without one is a new comment the consumer stamps (id / author / clock) — this package
|
|
8
|
+
* never mints an id, an author or a timestamp (the reply UI sends `[...existing, { text }]`, the
|
|
9
|
+
* paste path strips ids, `null` clears the thread). React-free and total (spec 08 §3).
|
|
10
|
+
*
|
|
11
|
+
* セル注釈の語彙 (v0.8、ADR-13): コメントスレッドの読み側型 (`CellComment` — getCellComments の
|
|
12
|
+
* 戻り) と書き側型 (`CellCommentInput` — CellPatch.comments が運ぶ)、リンクスキーム許可リストと
|
|
13
|
+
* その正規化、クリップ直列化が共有する全行 / 全列判定。コメント id は consumer スコープ: id つき
|
|
14
|
+
* 入力は「そのコメントを verbatim に保持」、id なし入力は「新規 (consumer が id / author / 時計を
|
|
15
|
+
* 刻印)」— パッケージは id も author も時刻も一切採番しない (返信 UI は `[...existing, { text }]`、
|
|
16
|
+
* paste 経路は id を剥がし、`null` はスレッド消去)。React 非依存・全域関数 (spec 08 §3)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { CellRange } from "./cellCoord.ts"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One comment as the consumer stores it (read side of `getCellComments`): every field is
|
|
23
|
+
* present because the consumer stamped it when the comment was created.
|
|
24
|
+
* consumer が保持するコメント 1 件 (getCellComments の読み側): 作成時に consumer が刻印するため
|
|
25
|
+
* 全フィールドが揃う。
|
|
26
|
+
*/
|
|
27
|
+
export type CellComment = { id: string; author: string; text: string; createdAt: string }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One comment as written through `CellPatch.comments` (the WHOLE thread travels): `id` present
|
|
31
|
+
* = keep that existing comment verbatim; `id` absent = a new comment whose id / author /
|
|
32
|
+
* createdAt the consumer mints (`author` / `createdAt` are carried only when a paste re-plays a
|
|
33
|
+
* foreign thread). A `CellComment` is assignable to this type as is, so a thread can be spread
|
|
34
|
+
* into a reply unchanged.
|
|
35
|
+
* CellPatch.comments で書かれるコメント 1 件 (スレッド全体を運ぶ): `id` あり = その既存コメントを
|
|
36
|
+
* verbatim に保持、`id` なし = 新規 (id / author / createdAt は consumer が採番 — author /
|
|
37
|
+
* createdAt は paste が外来スレッドを再生するときだけ載る)。`CellComment` はそのまま代入可能で、
|
|
38
|
+
* 既存スレッドを返信へ無変更で展開できる。
|
|
39
|
+
*/
|
|
40
|
+
export type CellCommentInput = { id?: string; text: string; author?: string; createdAt?: string }
|
|
41
|
+
|
|
42
|
+
/** Allowed hyperlink schemes (ADR-10 / DX-9) — a closed set, nothing else is ever navigated. / 許可リンクスキーム (ADR-10 / DX-9) — 閉じた集合、他は決して航行しない。 */
|
|
43
|
+
const HYPERLINK_SCHEMES: ReadonlySet<string> = new Set(["http:", "https:", "mailto:", "tel:"])
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalises a hyperlink candidate: trims, parses it with the WHATWG URL parser (no base URL —
|
|
47
|
+
* a relative path, a bare host or an empty string is not a link), keeps only the allow-listed
|
|
48
|
+
* schemes and returns the parser's serialisation (`href`: scheme and host lower-cased, an
|
|
49
|
+
* `http(s)` origin gets its `/` path). Anything else is null — dropped at parse time, never
|
|
50
|
+
* navigated, never substituted.
|
|
51
|
+
* リンク候補の正規化: トリム → WHATWG URL 解析 (基点 URL なし — 相対パス・裸のホスト・空文字は
|
|
52
|
+
* リンクでない) → 許可スキームのみ → パーサの直列化 (`href`: スキームとホストは小文字、http(s) の
|
|
53
|
+
* origin には `/` パスが付く)。他は null — 解析時に落とし、航行も代替もしない。
|
|
54
|
+
*/
|
|
55
|
+
export const normalizeHyperlink = (text: string): string | null => {
|
|
56
|
+
const trimmed = text.trim()
|
|
57
|
+
// WHATWG で解析できない文字列 (相対パス・空文字・裸のホスト名・ホスト欠落) はリンクにならない — 基点 URL で補わない
|
|
58
|
+
if (!URL.canParse(trimmed)) {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
const url = new URL(trimmed)
|
|
62
|
+
// 許可スキーム以外 (javascript: / data: / file: / ftp: …) は解析時に落とす — 決して航行しない
|
|
63
|
+
return HYPERLINK_SCHEMES.has(url.protocol) ? url.href : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The adapter-read trust boundary for the hyperlink aspect (ADR-10): maps a raw
|
|
68
|
+
* `getCellHyperlink` result onto the scheme allowlist — `undefined` stays `undefined`, an
|
|
69
|
+
* unnormalisable URL is treated as link-less (`undefined`: never navigated, never exported into
|
|
70
|
+
* a clip flavour, never re-committed through `CellPatch.hyperlink`), and a valid one becomes its
|
|
71
|
+
* canonical href. Every v0.8 read of the aspect goes through here (open seams, the link marker,
|
|
72
|
+
* the link popover, `readClipCell` and `readFillSource`).
|
|
73
|
+
* リンク側面のアダプタ読みの信頼境界 (ADR-10): 生の `getCellHyperlink` 結果を許可スキーム集合へ
|
|
74
|
+
* 写す — `undefined` はそのまま、正規化不能 URL はリンク無し扱い (`undefined`: 航行・クリップ
|
|
75
|
+
* フレーバーへの書出・`CellPatch.hyperlink` への再確定をしない)、有効 URL は正準 href。v0.8 の
|
|
76
|
+
* 同側面の読み (開通シーム・リンク指標・link ポップオーバー・`readClipCell`・`readFillSource`)
|
|
77
|
+
* は全てここを通る。
|
|
78
|
+
*/
|
|
79
|
+
export const normalizeAdapterHyperlink = (raw: string | undefined): string | undefined => {
|
|
80
|
+
// 側面なし (undefined) はそのまま — 正規化不能だけをリンク無しへ畳む (代替 URL は作らない)
|
|
81
|
+
if (raw === undefined) {
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
const normalized = normalizeHyperlink(raw)
|
|
85
|
+
return normalized === null ? undefined : normalized
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether the range spans every column (whole rows — e.g. a row-header selection); shared by clip serialisation. / レンジが全列に及ぶか (全行 — 行ヘッダー選択など)。クリップ直列化が共有。 */
|
|
89
|
+
export const isWholeRows = (range: CellRange, colCount: number): boolean => range.startCol === 0 && range.endCol === colCount - 1
|
|
90
|
+
|
|
91
|
+
/** Whether the range spans every row (whole columns — e.g. a column-header selection); shared by clip serialisation. / レンジが全行に及ぶか (全列 — 列ヘッダー選択など)。クリップ直列化が共有。 */
|
|
92
|
+
export const isWholeCols = (range: CellRange, rowCount: number): boolean => range.startRow === 0 && range.endRow === rowCount - 1
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Coordinate / range / operation vocabulary shared across the whole grid:
|
|
3
|
+
* cell-coordinate and range pure functions plus the public named unions (SpreadsheetDirection,
|
|
4
|
+
* SpreadsheetOperation). Every range in the system is a normalized rectangle built via
|
|
5
|
+
* makeRange.
|
|
6
|
+
*
|
|
7
|
+
* 座標・レンジ・操作の語彙: セル座標とレンジの純関数群 + 公開の名前つき union
|
|
8
|
+
* (SpreadsheetDirection / SpreadsheetOperation)。システム内の全レンジは makeRange で構築
|
|
9
|
+
* された正規化矩形。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A single cell position (0-based). / 単一セル位置 (0 始まり)。 */
|
|
13
|
+
export type CellCoord = { row: number; col: number }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Grid navigation direction (public vocabulary). Package-prefixed: a bare `Direction` would
|
|
17
|
+
* clash with consumer-side types of the same name.
|
|
18
|
+
* グリッド航行方向 (公開語彙)。素の `Direction` は consumer 側の同名型と衝突しやすいため
|
|
19
|
+
* パッケージ接頭辞つき。
|
|
20
|
+
*/
|
|
21
|
+
export type SpreadsheetDirection = "up" | "down" | "left" | "right"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Bulk-operation kind for the ADR-12 rejection channel (public vocabulary). Serialize-only
|
|
25
|
+
* gestures ("copy" / "cut"), grid geometry ("resize") and the WRITE gestures ("delete" /
|
|
26
|
+
* "paste" / "fill" and, since v0.8, the format commands "format", the self-calculation
|
|
27
|
+
* commands "calc" and the note / comment / hyperlink / validation writes "annotate"). The write
|
|
28
|
+
* subset is exactly the non-edit `EditRejection.source` vocabulary (sealed by the type-parity
|
|
29
|
+
* guard in dataAdapter.spec.ts). Each widening (v0.2 "fill" / "resize", v0.8 the three
|
|
30
|
+
* command kinds) is a 0.x breaking change for consumers who annotated the callback parameter
|
|
31
|
+
* with the old inline union — contravariant position.
|
|
32
|
+
* ADR-12 拒否チャネルの一括操作種別 (公開語彙)。直列化のみのジェスチャ ("copy" / "cut")・
|
|
33
|
+
* グリッド幾何 ("resize")・**書込**ジェスチャ ("delete" / "paste" / "fill" と、v0.8 からの書式
|
|
34
|
+
* コマンド "format"・自己計算コマンド "calc"・ノート / コメント / リンク / 検証の書込
|
|
35
|
+
* "annotate")。書込部分集合は非 edit の `EditRejection.source` 語彙と過不足なく一致
|
|
36
|
+
* (dataAdapter.spec.ts の型パリティガードが封印)。各拡張 (v0.2 の "fill" / "resize"、v0.8 の
|
|
37
|
+
* コマンド 3 種) は旧インライン union を明示注釈していた consumer に対する 0.x の破壊枠
|
|
38
|
+
* (コールバック引数 = 反変位置)。
|
|
39
|
+
*/
|
|
40
|
+
export type SpreadsheetOperation = "copy" | "cut" | "delete" | "paste" | "fill" | "resize" | "format" | "calc" | "annotate"
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A normalized rectangular range (startRow <= endRow, startCol <= endCol). Construct ONLY via
|
|
44
|
+
* {@link makeRange}. / 正規化済み矩形レンジ。必ず {@link makeRange} で構築。
|
|
45
|
+
*/
|
|
46
|
+
export type CellRange = { startRow: number; startCol: number; endRow: number; endCol: number }
|
|
47
|
+
|
|
48
|
+
/** A rectangle in logical px. / 論理 px の矩形。 */
|
|
49
|
+
export type LogicalRect = { top: number; left: number; width: number; height: number }
|
|
50
|
+
|
|
51
|
+
/** Builds a normalized range from two corners. / 2 隅から正規化レンジを構築。 */
|
|
52
|
+
export const makeRange = (a: CellCoord, b: CellCoord): CellRange => ({
|
|
53
|
+
startRow: Math.min(a.row, b.row),
|
|
54
|
+
endRow: Math.max(a.row, b.row),
|
|
55
|
+
startCol: Math.min(a.col, b.col),
|
|
56
|
+
endCol: Math.max(a.col, b.col),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** Whether the range contains the cell. / レンジがセルを含むか。 */
|
|
60
|
+
export const rangeContains = (range: CellRange, cell: CellCoord): boolean => cell.row >= range.startRow && cell.row <= range.endRow && cell.col >= range.startCol && cell.col <= range.endCol
|
|
61
|
+
|
|
62
|
+
/** Row/column extents of a range. / レンジの行数・列数。 */
|
|
63
|
+
export const rangeSize = (range: CellRange): { rows: number; cols: number } => ({
|
|
64
|
+
rows: range.endRow - range.startRow + 1,
|
|
65
|
+
cols: range.endCol - range.startCol + 1,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/** Total cell count across ranges WITHOUT materializing. / 実体化せずレンジ群の総セル数を見積もる (重複は重複のまま数え、上限判定を保守側に倒す)。 */
|
|
69
|
+
export const countRangeCells = (ranges: readonly CellRange[]): number => ranges.reduce((sum, r) => sum + rangeSize(r).rows * rangeSize(r).cols, 0)
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Enumerates the union of ranges exactly once per cell (row-major).
|
|
73
|
+
* レンジ群の和集合をセル単位で**重複なく**行優先順に列挙する単一シーム。Delete のような
|
|
74
|
+
* 「レンジ列挙由来」の編集で同一セルの無意味な二重編集を作らないためのもの (cut → paste が
|
|
75
|
+
* **意図的に**積む重複 (後勝ち) とは別問題 — そちらは applyEdits の順序契約が扱う)。
|
|
76
|
+
*/
|
|
77
|
+
export const enumerateRangeCells = (ranges: readonly CellRange[], visit: (cell: CellCoord) => void): void => {
|
|
78
|
+
if (ranges.length === 1) {
|
|
79
|
+
// 最頻ケース (単一レンジ) は Set を作らない高速路
|
|
80
|
+
const r = ranges[0]
|
|
81
|
+
for (let row = r.startRow; row <= r.endRow; row += 1) {
|
|
82
|
+
for (let col = r.startCol; col <= r.endCol; col += 1) {
|
|
83
|
+
visit({ row, col })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
const seen = new Set<string>()
|
|
89
|
+
for (const r of ranges) {
|
|
90
|
+
for (let row = r.startRow; row <= r.endRow; row += 1) {
|
|
91
|
+
for (let col = r.startCol; col <= r.endCol; col += 1) {
|
|
92
|
+
const key = `${row}:${col}`
|
|
93
|
+
if (!seen.has(key)) {
|
|
94
|
+
seen.add(key)
|
|
95
|
+
visit({ row, col })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Clamps a coordinate into grid bounds (fail-fast on empty grid). / 空グリッドは throw のクランプ。 */
|
|
103
|
+
export const clampCoord = (cell: CellCoord, rowCount: number, colCount: number): CellCoord => {
|
|
104
|
+
if (rowCount <= 0 || colCount <= 0) {
|
|
105
|
+
throw new RangeError(`[spreadsheet] clampCoord requires a non-empty grid, received ${rowCount} x ${colCount}`)
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
row: Math.min(Math.max(Math.trunc(cell.row), 0), rowCount - 1),
|
|
109
|
+
col: Math.min(Math.max(Math.trunc(cell.col), 0), colCount - 1),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Structural equality of two coordinates. / 2 座標の構造等価。 */
|
|
114
|
+
export const coordEquals = (a: CellCoord, b: CellCoord): boolean => a.row === b.row && a.col === b.col
|