@threestarsoftware/tssgrid 0.0.4 → 0.0.6

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 ADDED
@@ -0,0 +1,75 @@
1
+ # 変更履歴 (Changelog)
2
+
3
+ TssGrid の各リリースの変更点。日付は JST。形式は [Keep a Changelog](https://keepachangelog.com/ja/1.1.0/) 準拠(ゆるめ)。
4
+ 数値は特記なき限り**自社調べ**(headless Chrome / Windows 11・環境差あり)。
5
+
6
+ ## [0.0.6] - 2026-06-30
7
+
8
+ ### 追加
9
+ - ドラッグ選択・フィルハンドルが**表示領域の外(上下左右)まで自動スクロールしながら伸びる**ように。ビューも一緒にスクロールし、速度は端からのはみ出し量に比例(少し超え=ゆっくり/大きく超え=速く)。仮想スクロール時・非仮想時の両方で動作。
10
+ - `rowHeaderLabel` オプション: 行ヘッダの表示を指定(`false`=数字なしの空ヘッダ/関数 `(r)=>文字列`=カスタム表示/未指定=従来の行番号)。空にしても溝は残り、クリックでの行選択は効く。
11
+ - `rowReorder: 'header'`: 行ヘッダーセル(左端の行番号の列)**全体**を行移動の掴み手に(クリック=行選択/ドラッグ=移動)。従来の `rowReorder: true` は ⠿ アイコンのみ掴める(不変)。
12
+ - `rowReorderMark` オプション: 行ヘッダーの ⠿ マークの位置を `'before'`(番号の前・既定)/ `'after'`(番号の後)/ `false`(出さない)から選べる。
13
+
14
+ ### 修正
15
+ - 仮想スクロールでドラッグ選択/フィルが窓の端で止まっていた問題。
16
+ - フィルが端を1行超えただけで大量に伸びてしまう暴走を修正(伸ばす先を常にポインタ位置のセルに)。
17
+ - `examples/virtual.html`: 部署ドロップダウンの選択肢が出ない不具合(`options` 指定漏れ)を修正/表示中インジケータの更新でツールバーがずれる問題を修正/パネルを縦リサイズ可能に。
18
+
19
+ ### ドキュメント
20
+ - 比較表(`examples/compare.html`): 仮想スクロールの行を「対応(`virtual:true`・opt-in)」に更新し、デモリンクに `virtual.html` を追加。
21
+
22
+ ## [0.0.5] - 2026-06-25
23
+
24
+ ### 追加
25
+ - **仮想スクロール `virtual: true`**(コア)— 大量行を「画面に見える窓ぶん+バッファだけ」描画。全件を一度に DOM 化しないので、初期描画が一瞬・スクロールも軽い。有効化はオプション一行。
26
+ - 自社調べ: 10万行で初回描画 ≈ 46ms(全件描画 ≈ 10.5秒に対して)。デモ: `examples/virtual.html`。
27
+ - 制約(v1): 行高一定/固定行列・折り返し・セル結合は非対応(指定時は自動無効化)。
28
+ - **`rowHeaderWidth` オプション**(コア)— 大量行で行番号の桁数が増える時に行ヘッダ幅を調整。
29
+
30
+ ### ドキュメント
31
+ - README: 非破壊フィルタの説明を実測値に正確化(「絞り込み後が軽い」を自社調べ値で)+仮想スクロール節を追加。
32
+ - 比較表(`examples/compare.html`)に Tabulator を追加(公式デモ準拠)。
33
+
34
+ ## [0.0.4] - 2026-06-24
35
+
36
+ ### 追加
37
+ - 見積書デモ(`examples/quote.html`)— 印刷/日付ピッカー/列幅固定/Enter 横送り(`nextCell`)。
38
+ - `navSkipReadOnly` オプション(Tab/Enter 送りで readonly セルを飛ばす=伝票入力向け)。
39
+ - プラグイン `tss-datetime`(日付+時刻の同時ピッカー・曜日つき・別フィールド結合・時刻のみ対応)。
40
+
41
+ ### 修正
42
+ - 編集中の `Ctrl+Z` で元の値に戻せない不具合(input 編集を Esc 同等でキャンセル)。
43
+ - グリッド外周の右・下が二重線になる不具合(最終行/列のセル罫線を枠線に一本化)。
44
+ - 全幅見出し帯の結合セルで列/行選択が他列へ広がる不具合(Excel 流にクランプ)。
45
+ - 結合解除で見出しラベルが消える不具合。
46
+
47
+ ## [0.0.3] - 2026-06-23
48
+
49
+ ### 追加
50
+ - プラグイン `tss-sum`(条件付き合計)+請求書デモで採用。
51
+
52
+ ### 修正
53
+ - 請求書デモの合計欄を別グリッド化して金額列に完全整列/No. 列見出しが選択色になる不具合/印刷で選択枠を非表示。
54
+
55
+ ## [0.0.2] - 2026-06-22
56
+
57
+ ### 追加
58
+ - **npm パッケージ化**(`@threestarsoftware/tssgrid`)+公開 API の型定義 `tssgrid.d.ts`。
59
+ - 請求書(インボイス対応)デモ。
60
+ - サイト: `/docs` ドキュメント・`/api` リファレンス(`.d.ts` から自動生成)・会社名フッター。
61
+
62
+ ### 修正
63
+ - 行/列ヘッダ選択中の右クリックで複数選択が解除される不具合。
64
+
65
+ ## [0.0.1] - 2026-06-19
66
+
67
+ - 初回公開。コア(編集グリッド/日本語 IME 堅牢・全角半角・セル結合・固定行列・非破壊フィルタ・条件付き書式・コピー/貼付・Undo/Redo ほか)+同梱プラグイン+サンプル一式。
68
+ - 目玉デモ(方眼ガント/在庫管理)をサイトでライブ化。
69
+
70
+ [0.0.6]: https://github.com/threestarsoftware/TssGrid/releases/tag/v0.0.6
71
+ [0.0.5]: https://github.com/threestarsoftware/TssGrid/releases/tag/v0.0.5
72
+ [0.0.4]: https://github.com/threestarsoftware/TssGrid/releases/tag/v0.0.4
73
+ [0.0.3]: https://github.com/threestarsoftware/TssGrid/releases/tag/v0.0.3
74
+ [0.0.2]: https://github.com/threestarsoftware/TssGrid/releases/tag/v0.0.2
75
+ [0.0.1]: https://github.com/threestarsoftware/TssGrid/releases/tag/v0.0.1
package/README.md CHANGED
@@ -238,7 +238,8 @@ grid.moveRows(r0, r1, to); // 連続する複数行をま
238
238
 
239
239
  ### 非破壊フィルタ(`filter` / `clearFilter`)
240
240
 
241
- **コア内蔵**の非破壊フィルタ(行インデックスマッパ)。全行は内部の `_allRows`(マスタ)に保持したまま、**可視部分集合だけ描画**します。**10万行でも絞り込んだ数百行だけ描く**ので、仮想スクロール無しで軽い → 動く例: [`examples/filter-live.html`](https://tssgrid.threestarsoftware.co.jp/examples/filter-live.html)。
241
+ **コア内蔵**の非破壊フィルタ(行インデックスマッパ)。全行は内部の `_allRows`(マスタ)に保持したまま、**絞り込んだ可視部分集合だけを描画**します。要点は**絞り込み後の描画・ライブ検索が軽い**こと(自社調べ:10万行を保持して約200行に絞り込み → 初回描画 ≈ 0.5 秒、絞り込み済みのライブ検索 ≈ 数 ms)→ 動く例: [`examples/filter-live.html`](https://tssgrid.threestarsoftware.co.jp/examples/filter-live.html)。
242
+ なお**全件をそのままスクロール表示**したい大量行は、仮想スクロール `virtual: true` を使います(下記)。
242
243
 
243
244
  ```js
244
245
  grid.filter(row => row[2] >= 100); // 述語: (row, masterIndex, src) => bool
@@ -254,6 +255,18 @@ grid.getAllRows(); // マスタ全行(フィルタ中で
254
255
  - 制限(v1): フィルタ中は **列の挿入削除・行移動・セル結合**はロック(解除してから)。`filter`/`clearFilter` は**Undo履歴をクリア**(ビューindex基準の履歴が境界を跨ぐと不整合になるため)。
255
256
  - **フィルタUI**(ヘッダの絞り込み等)はコアに入れず**プラグイン** `tss-filter`(下記)で。素朴な `setData` 差し替え方式の旧サンプルは [`examples/filter.html`](https://tssgrid.threestarsoftware.co.jp/examples/filter.html)。
256
257
 
258
+ ### 仮想スクロール(`virtual: true`)
259
+
260
+ 大量行を**全件そのままスクロール表示**したい時に。全行は `data` に保持したまま、**画面に見える窓ぶん+バッファだけを描画**します(固定行高ウィンドウイング)。全件を一度に DOM 化しないので**初期描画が一瞬・スクロールも軽い**。有効化は**オプション一行**だけ。
261
+
262
+ ```js
263
+ new TssGrid(el, { virtual: true, data, /* … */ });
264
+ new TssGrid(el, { virtual: { buffer: 10 }, data }); // 窓の上下バッファ行数(既定6)
265
+ ```
266
+ - **自社調べ**(headless Chrome / Win11): 10 万行で**初回描画 ≈ 46 ms**(全件描画 ≈ 10.5 秒に対して)。スクロール 1 コマの窓差し替え ≈ 3 ms。動く例: [`examples/virtual.html`](https://tssgrid.threestarsoftware.co.jp/examples/virtual.html)。
267
+ - 制約(v1): **行高一定**/**固定行列・折り返し・セル結合は非対応**(指定時は自動で無効化+警告)。行番号の桁数が増える場合は `rowHeaderWidth` で行ヘッダ幅を広げられます。
268
+ - フィルタとの関係: `filter()` は「絞り込んだ部分集合だけ描く」軽さ、`virtual` は「絞らず全件をスクロールする」軽さ。用途で使い分け(v1 では併用は未対応)。
269
+
257
270
  ### 同梱プラグイン: ヘッダ オートフィルタ(filter UI)
258
271
 
259
272
  `plugins/tss-filter.js`+`.css`= 各見出しの**ロート(漏斗)アイコン**をクリックで**値チェックリスト+検索**のポップアップ(Excel風。ソートの▲▼と区別できるアイコン)。複数列の条件は **AND**。中身は上の**コア `filter()` を呼ぶだけ**。動く例: [`examples/filter-ui.html`](https://tssgrid.threestarsoftware.co.jp/examples/filter-ui.html)。
@@ -1108,7 +1121,7 @@ command は `{ apply(), revert(), label }`。
1108
1121
  ## 制限
1109
1122
 
1110
1123
  - 外部(Excel等)→グリッドの貼り付けはブラウザのクリップボード読取許可が必要。`file://` で読めない場合は http(ローカルサーバ)で開く。グリッド間・グリッド内のコピペは内部バッファで常に動く。
1111
- - 仮想スクロール未対応(大量行が必要なら今後)。日付の連番フィルはコピー扱い(数値のみ連番)。
1124
+ - 仮想スクロールは `virtual: true` で対応(固定行高・固定行列/折り返し/セル結合は非対応)。日付の連番フィルはコピー扱い(数値のみ連番)。
1112
1125
 
1113
1126
  ## License
1114
1127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threestarsoftware/tssgrid",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "軽量・ノービルド・日本語IME堅牢な編集グリッド(vanilla JS / 依存なし / MIT)。Excel ライクな入力画面を <script> タグ1本で。",
5
5
  "license": "MIT",
6
6
  "author": "ThreeStarSoftware (https://www.threestarsoftware.co.jp/)",
@@ -52,6 +52,7 @@
52
52
  "src/tssgrid.d.ts",
53
53
  "plugins/",
54
54
  "README.md",
55
+ "CHANGELOG.md",
55
56
  "LICENSE"
56
57
  ]
57
58
  }
package/src/tssgrid.css CHANGED
@@ -66,6 +66,8 @@
66
66
  .tssgrid .tg-table tr:last-child > th { border-bottom: none; }
67
67
  .tssgrid .tg-table tr > td:last-child,
68
68
  .tssgrid .tg-table tr > th:last-child { border-right: none; }
69
+ /* 仮想スクロールのスペーサ行(窓の上下を埋めて総高を保つだけの不可視行)。罫線/背景/余白を持たない。 */
70
+ .tssgrid .tg-table tr.tg-vspace > td { border: none; padding: 0; background: transparent; }
69
71
  .tssgrid .tg-table th { background: var(--tg-colheader-bg); color: var(--tg-header-fg); font-weight: 600; text-align: center; }
70
72
  /* z-index 階層(.tg-wrap 内): 通常セル 0 < 本体sticky(rowhead/frozen) 2 < 選択オーバーレイ 3
71
73
  < editor/select 4 < fillhandle 5 < ヘッダ thead 6 < 角/固定列ヘッダ 7 < リサイズ線 8。
@@ -132,6 +134,11 @@
132
134
  .tssgrid .tg-rowmove { cursor: grab; color: #b3b6c8; margin-right: 2px; font-size: 11px; line-height: 1; user-select: none; vertical-align: 1px; }
133
135
  .tssgrid .tg-rowmove:hover { color: var(--tg-accent); }
134
136
  .tssgrid .tg-rowmove:active { cursor: grabbing; }
137
+ .tssgrid .tg-rowmove.tg-mark-after { margin-right: 0; margin-left: 4px; } /* ⠿ を番号の後ろに置く時の間隔 */
138
+ .tssgrid .tg-rowmove.tg-mark-only { margin: 0; } /* 番号なし=⠿だけ=前後の区別をやめて中央に */
139
+ /* rowReorder:'header' = 行ヘッダーセル全体が移動ハンドル(クリック=選択/ドラッグ=移動) */
140
+ .tssgrid .tg-table th.rowhead.tg-rowmove-cell { cursor: grab; }
141
+ .tssgrid .tg-table th.rowhead.tg-rowmove-cell:active { cursor: grabbing; }
135
142
  .tssgrid .tg-colgrip:hover { background: var(--tg-edit-accent); opacity: .5; }
136
143
  .tssgrid .tg-rowgrip:hover { background: var(--tg-edit-accent); opacity: .5; }
137
144
  /* リサイズのプレビュー線(ドラッグ中だけ表示、離して確定) */
package/src/tssgrid.d.ts CHANGED
@@ -126,6 +126,17 @@ export interface TssGridOptions {
126
126
  cursor?: boolean;
127
127
  stretchH?: 'none' | 'last' | 'all';
128
128
  rowHeaders?: boolean;
129
+ /** 行番号列の幅(px)。既定 40。大量行で桁数が増える時に広げる。 */
130
+ rowHeaderWidth?: number;
131
+ /** 行ヘッダの表示内容。未指定=行番号(r+1) / false=数字なし(空) / (r)=>文字列=カスタム表示。 */
132
+ rowHeaderLabel?: false | ((r: number) => string | number);
133
+ /** 行のドラッグ並べ替え。true=行ヘッダーセルの ⠿ アイコンを掴んで移動 / 'header'=行ヘッダーセル全体が移動ハンドル(クリック=選択/ドラッグ=移動)。 */
134
+ rowReorder?: boolean | 'header';
135
+ /** 行ヘッダーの ⠿ マークの位置。'before'=番号の前(既定)/ 'after'=番号の後 / false=マークを出さない。 */
136
+ rowReorderMark?: 'before' | 'after' | false;
137
+ /** 仮想スクロール(固定行高ウィンドウイング)。大量行を可視窓だけ描画。true か { buffer:窓上下バッファ行数(既定6) }。
138
+ * 制約(v1): 行高一定/固定行列・折り返し・セル結合は非対応(指定時は自動無効化)。 */
139
+ virtual?: boolean | { buffer?: number };
129
140
  colHeaders?: boolean;
130
141
  hiddenColumns?: number[];
131
142
  wordWrap?: boolean;
package/src/tssgrid.js CHANGED
@@ -146,6 +146,7 @@
146
146
 
147
147
  this.active = { r: 0, c: 0 }; this.extent = { r: 0, c: 0 };
148
148
  this.mode = 'nav'; this.dragging = false; this.filling = null;
149
+ this._asRAF = 0; this._dragDir = 0; this._fillRow = null; // ドラッグ/フィルの端自動スクロール状態
149
150
  this.pendingCut = null;
150
151
  this.headerDrag = null; this._colAnchor = 0; this._rowAnchor = 0; // 行/列ヘッダ選択
151
152
  this._headerSel = null; // 'col'/'row'/'all'=列/行/全選択モード(結合に拡張せず実列/行にクランプ=Excel流)。null=通常セル選択
@@ -183,9 +184,13 @@
183
184
  this.resizeRows = opts.resizeRows !== false;
184
185
  // 行/列ヘッダの表示(false で行番号列・ヘッダ行を隠す)
185
186
  this.rowHeaders = opts.rowHeaders !== false;
187
+ this.rowHeaderW = (opts.rowHeaderWidth | 0) || 40; // 行番号列の幅(px)。大量行で桁数が増える時に広げる
188
+ this.rowHeaderLabel = opts.rowHeaderLabel; // 行ヘッダ表示: 未指定=行番号(r+1) / false=数字なし(空) / (r)=>文字列=カスタム
186
189
  this.colHeaders = opts.colHeaders !== false;
187
190
  // 行ヘッダの「つまみ」をドラッグして行を並べ替える(行番号列が必要)
188
191
  this.rowReorder = !!opts.rowReorder;
192
+ this.rowReorderWhole = opts.rowReorder === 'header'; // 'header'=行ヘッダーセル全体が移動ハンドル(クリック=選択/ドラッグ=移動)/true=⠿アイコンのみ
193
+ this.rowReorderMark = opts.rowReorderMark === undefined ? 'before' : opts.rowReorderMark; // ⠿の位置: 'before'(番号の前/既定) | 'after'(番号の後) | false(出さない)
189
194
  // 選択カーソル(緑の選択枠・範囲・セルハイライト・フィルハンドル)を表示するか。
190
195
  // false で表示専用グリッド向けに非表示(内部の選択は保持=コピーは可)。
191
196
  this.cursor = opts.cursor !== false;
@@ -212,6 +217,16 @@
212
217
  this.autoWrapRow = !!opts.autoWrapRow; // Tab で行末→次行頭に折り返し
213
218
  this.autoWrapCol = !!opts.autoWrapCol; // Enter で列末→次列頭に折り返し
214
219
  this.navSkipReadOnly = !!opts.navSkipReadOnly; // Tab/Enter 送りで readonly セルを飛ばす(伝票入力向け=編集する所だけ移動)
220
+ // 仮想スクロール(簡易版・固定行高ウィンドウイング): 大量行で「可視窓+バッファ」だけ描画。
221
+ // 全行は data に保持・行座標は絶対のまま、tbody は窓ぶん+上下スペーサ行のみ。幾何はモデル(一定行高)。
222
+ // 制約(v1): 行高一定/セル結合・固定行列・折り返し非対応。指定時は無効化して警告。OFF時は一切作動せず従来通り。
223
+ this.virtual = !!opts.virtual;
224
+ this._vBuf = (opts.virtual && typeof opts.virtual === 'object' && opts.virtual.buffer) || 6; // 窓の上下バッファ行数
225
+ this._vStart = 0; this._vEnd = 0; this._vRAF = 0; this._vRendered = false;
226
+ if (this.virtual && (this.frozenRows || this.frozenCols || this.wordWrap)) {
227
+ try { console.warn('[TssGrid] virtual:true は固定行列/折り返し非対応のため、それらを無効化しました'); } catch (_) {}
228
+ this.frozenRows = 0; this.frozenCols = 0; this.wordWrap = false;
229
+ }
215
230
  // 行/列数の下限・上限(挿入/削除の境界。min は初期パディングにも使う)
216
231
  this.minRows = opts.minRows | 0; this.maxRows = opts.maxRows | 0;
217
232
  this.minCols = opts.minCols | 0; this.maxCols = opts.maxCols | 0;
@@ -510,6 +525,8 @@
510
525
  destroy() {
511
526
  if (this._destroyed) return;
512
527
  this._destroyed = true;
528
+ if (this._vRAF) { try { cancelAnimationFrame(this._vRAF); } catch (_) {} this._vRAF = 0; } // 仮想スクロールの保留フレームを破棄
529
+ this._stopAutoScroll(); // ドラッグ自動スクロールの保留フレームを破棄
513
530
  for (const p of this._plugins) { if (p.handle && typeof p.handle.destroy === 'function') { try { p.handle.destroy(); } catch (_) {} } }
514
531
  this._plugins = [];
515
532
  if (this._ro) { try { this._ro.disconnect(); } catch (_) {} this._ro = null; }
@@ -970,7 +987,14 @@
970
987
 
971
988
  _colWidth(c) { return this.colW[c] != null ? this.colW[c] : this.defColW; }
972
989
  _rowHeight(r) { return this.rowH[r] != null ? this.rowH[r] : this.defRowH; }
973
- _rhW() { return this.rowHeaders ? 40 : 0; } // 行ヘッダ列の幅(非表示なら 0)
990
+ _rhW() { return this.rowHeaders ? this.rowHeaderW : 0; } // 行ヘッダ列の幅(非表示なら 0)
991
+ // 行ヘッダの表示内容: false=空(数字なし)/ 関数=カスタム(エスケープ)/ 既定=行番号(r+1)。
992
+ _rowHeadLabel(r) {
993
+ const L = this.rowHeaderLabel;
994
+ if (L === false) return '';
995
+ if (typeof L === 'function') { let v; try { v = L(r); } catch (_) {} return TssGrid.esc(v == null ? '' : v); }
996
+ return (r + 1);
997
+ }
974
998
  // ---- 列の非表示 ----
975
999
  _isHidden(c) { return this.hiddenCols.has(c); }
976
1000
  _visCols() { const a = []; for (let c = 0; c < this.COLS; c++) if (!this.hiddenCols.has(c)) a.push(c); return a; }
@@ -1096,10 +1120,107 @@
1096
1120
  top += tr.offsetHeight;
1097
1121
  });
1098
1122
  }
1123
+ // 1行ぶんの <tr>…</tr> を生成(buildTable の本体ループと、仮想スクロールの窓再描画 _renderWindow で共用)。
1124
+ _rowHTML(r) {
1125
+ const rh = this.rowHeaders;
1126
+ const rgrip = (rh && this.resizeRows) ? '<div class="tg-rowgrip"></div>' : '';
1127
+ const canMove = rh && this.rowReorder && !this._hasVMerge();
1128
+ const lbl = this._rowHeadLabel(r);
1129
+ const after = this.rowReorderMark === 'after' && lbl !== ''; // 番号が無いなら「前/後」は無意味=中央に固定(マージンも消す)
1130
+ const markCls = 'tg-rowmove' + (lbl === '' ? ' tg-mark-only' : (after ? ' tg-mark-after' : ''));
1131
+ const mark = (canMove && this.rowReorderMark !== false) ? '<span class="' + markCls + '" title="ドラッグで行を移動">⠿</span>' : '';
1132
+ const inner = after ? (lbl + mark) : (mark + lbl); // ⠿ を番号の前/後に(rowReorderMark)。番号なしは前後どちらでも中央
1133
+ const rhCls = (canMove && this.rowReorderWhole) ? ' tg-rowmove-cell' : ''; // 'header'=行ヘッダーセル全体を掴み手に(grab カーソル)。番号/⠿の表示は独立
1134
+ const rcls = (r < this.frozenRows) ? (' tg-frozen-row' + (r === this.frozenRows - 1 ? ' tg-frozen-row-edge' : '')) : ''; // 行固定
1135
+ let html = '<tr data-r="' + r + '" class="tg-row' + rcls + '" style="height:' + this._rowHeight(r) + 'px">' + (rh ? '<th class="rowhead' + rhCls + '" data-r="' + r + '">' + inner + rgrip + '</th>' : '');
1136
+ for (let c = 0; c < this.COLS; c++) {
1137
+ if (this._merges && this._isCovered(r, c)) continue; // 結合に覆われた従属セルは td を出さない(アンカーが colspan で覆う)
1138
+ const cs = this._merges ? this._colspanAt(r, c) : 1; // 結合アンカーは colspan で従属列ぶんを覆う
1139
+ const rs = this._merges ? this._rowspanAt(r, c) : 1; // 縦結合は rowspan で従属行ぶんを覆う
1140
+ const cls = [];
1141
+ if (cs > 1 || rs > 1) cls.push('tg-merged'); // 結合アンカー(CSS で中央寄せ等のフック)
1142
+ if (c < this.frozenCols) { cls.push('tg-frozen'); if (c === this.frozenCols - 1) cls.push('tg-frozen-edge'); }
1143
+ if (this._isHidden(c)) cls.push('tg-hidden'); // 非表示列
1144
+ if (this.colType(c) === 'dropdown') cls.push('tg-dropdown'); // ▾ キャレット表示用
1145
+ if (this.colType(c) === 'number') cls.push('tg-num'); // 数値は右寄せ
1146
+ if (this._isReadOnly(r, c)) cls.push('tg-readonly'); // 読み取り専用(淡色+カーソル既定)
1147
+ if (this.colCfg(c).wordWrap) cls.push('tg-wrap-cell'); // 列単位の折り返し
1148
+ if (this.data[r][c] === '' && this._placeholder(c) != null) cls.push('tg-placeholder'); // 空セルのヒント
1149
+ const al = this._cellAlign(r, c); // 整列(列既定+セル上書き)
1150
+ if (al.h) cls.push('tg-h-' + al.h); if (al.v) cls.push('tg-v-' + al.v);
1151
+ let titleAttr = '';
1152
+ if (this._marksInvalid) { const _iv = this._coerceCell(r, c, this.data[r][c]); if (!_iv.ok) { cls.push('tg-invalid'); titleAttr = ' data-err-level="' + TssGrid.esc(_iv.level || 'error') + '"'; if (this.invalidTitle) titleAttr += ' title="' + TssGrid.esc(_iv.message || '入力値が不正です') + '"'; } }
1153
+ const ed = this.colCfg(c).editor; // カスタムエディタが icon を申告していれば右端に表示
1154
+ const ico = (ed && ed.icon) ? ' data-ico="' + TssGrid.esc(ed.icon) + '"' : '';
1155
+ if (ico) cls.push('tg-haspicker');
1156
+ const ccls = (this.cellClass || this.colCfg(c).cellClass) ? this._cellClasses(r, c) : []; // 条件付き書式
1157
+ for (const k of ccls) cls.push(k);
1158
+ const dccls = ccls.length ? ' data-ccls="' + TssGrid.esc(ccls.join(' ')) + '"' : '';
1159
+ let sty = ''; // セル単位インラインスタイル(連続色・データバー幅などCSS変数も)
1160
+ if (this.cellStyle || this.colCfg(c).cellStyle) {
1161
+ const so = this._cellStyleObj(r, c), sk = Object.keys(so);
1162
+ if (sk.length) { let css = ''; for (const k of sk) css += k + ':' + so[k] + ';'; sty = ' style="' + TssGrid.esc(css) + '" data-cstyle="' + TssGrid.esc(sk.join(' ')) + '"'; }
1163
+ }
1164
+ const a = cls.length ? ' class="' + cls.join(' ') + '"' : '';
1165
+ const csAttr = (cs > 1 ? ' colspan="' + cs + '"' : '') + (rs > 1 ? ' rowspan="' + rs + '"' : '');
1166
+ html += '<td' + a + ico + dccls + sty + titleAttr + csAttr + ' data-r="' + r + '" data-c="' + c + '">' + this._cellHTML(r, c) + '</td>';
1167
+ }
1168
+ return html + '</tr>';
1169
+ }
1170
+ // 仮想スクロール: 現在の scrollTop から描画すべき窓 [start,end)(絶対行)を算出。バッファ込み。
1171
+ _vWindow() {
1172
+ const rowH = this.defRowH;
1173
+ const viewH = (this.wrap && this.wrap.clientHeight) || parseInt(this.height, 10) || 400;
1174
+ const visible = Math.ceil(viewH / rowH) + 1;
1175
+ const scrollTop = this.wrap ? this.wrap.scrollTop : 0;
1176
+ let start = Math.floor(scrollTop / rowH) - this._vBuf;
1177
+ if (start < 0) start = 0;
1178
+ let end = start + visible + this._vBuf * 2;
1179
+ if (end > this.ROWS) end = this.ROWS;
1180
+ return { start, end };
1181
+ }
1182
+ // 仮想スクロール: tbody を窓ぶん+上下スペーサ行に差し替え(buildTable せず軽量再描画)。
1183
+ _renderWindow(win, force) {
1184
+ if (!force && this._vRendered && win.start === this._vStart && win.end === this._vEnd) return false;
1185
+ const tbody = this.table && this.table.querySelector('tbody');
1186
+ if (!tbody) return false;
1187
+ const rh = this.rowHeaders, colsSpan = this.COLS + (rh ? 1 : 0);
1188
+ const topH = win.start * this.defRowH, botH = (this.ROWS - win.end) * this.defRowH;
1189
+ let html = '';
1190
+ if (topH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + topH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
1191
+ for (let r = win.start; r < win.end; r++) html += this._rowHTML(r);
1192
+ if (botH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + botH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
1193
+ tbody.innerHTML = html;
1194
+ this._vStart = win.start; this._vEnd = win.end; this._vRendered = true;
1195
+ this._syncHeaderCheckboxes();
1196
+ this.updateSelectionUI(); // セルが作り直されたので窓内の選択シェード/オーバーレイを再適用
1197
+ return true;
1198
+ }
1199
+ // 仮想スクロール: スクロールで窓がずれたら再描画(rAF スロットル)。
1200
+ _vSync() {
1201
+ if (!this.virtual || this._vRAF) return;
1202
+ this._vRAF = requestAnimationFrame(() => {
1203
+ this._vRAF = 0;
1204
+ if (this._destroyed) return;
1205
+ this._renderWindow(this._vWindow(), false);
1206
+ });
1207
+ }
1208
+ // 仮想スクロール: 行 r を可視域に入れる(scrollTop 調整→窓を同期描画)。setActive/setExtent から。
1209
+ _vEnsureVisible(r) {
1210
+ if (!this.virtual || !this.wrap) return;
1211
+ const rowH = this.defRowH, head = this._theadH || 0;
1212
+ const rowTop = head + r * rowH;
1213
+ const cur = this.wrap.scrollTop, viewH = this.wrap.clientHeight || 400;
1214
+ let st = cur;
1215
+ if (rowTop < cur + head) st = rowTop - head; // 上のヘッダ下に隠れてる→上端へ
1216
+ else if (rowTop + rowH > cur + viewH) st = rowTop + rowH - viewH; // 下に隠れてる→下端へ
1217
+ if (st !== cur) this.wrap.scrollTop = st;
1218
+ this._renderWindow(this._vWindow(), false); // 窓を即同期(place 前に DOM を確定)
1219
+ }
1099
1220
  buildTable() {
1100
1221
  this._selhdrFg = null; // 選択ヘッダ文字色キャッシュ破棄(テーマ変更に追従)
1101
1222
  const rh = this.rowHeaders;
1102
- let html = '<colgroup>' + (rh ? '<col style="width:40px">' : '');
1223
+ let html = '<colgroup>' + (rh ? '<col style="width:' + this.rowHeaderW + 'px">' : '');
1103
1224
  for (let c = 0; c < this.COLS; c++) html += '<col data-c="' + c + '"' + (this._isHidden(c) ? ' class="tg-hidden"' : '') + ' style="width:' + (this._isHidden(c) ? 0 : this._colWidth(c)) + 'px">';
1104
1225
  html += '</colgroup>';
1105
1226
  if (this.colHeaders) {
@@ -1128,44 +1249,15 @@
1128
1249
  html += '</thead>';
1129
1250
  }
1130
1251
  html += '<tbody>';
1131
- for (let r = 0; r < this.ROWS; r++) {
1132
- const rgrip = (rh && this.resizeRows) ? '<div class="tg-rowgrip"></div>' : '';
1133
- const dgrip = (rh && this.rowReorder && !this._hasVMerge()) ? '<span class="tg-rowmove" title="ドラッグで行を移動">⠿</span>' : ''; // 横結合は行と一緒に移動可/縦結合中は不可
1134
- const rcls = (r < this.frozenRows) ? (' tg-frozen-row' + (r === this.frozenRows - 1 ? ' tg-frozen-row-edge' : '')) : ''; // 行固定
1135
- html += '<tr data-r="' + r + '" class="tg-row' + rcls + '" style="height:' + this._rowHeight(r) + 'px">' + (rh ? '<th class="rowhead" data-r="' + r + '">' + dgrip + (r + 1) + rgrip + '</th>' : '');
1136
- for (let c = 0; c < this.COLS; c++) {
1137
- if (this._merges && this._isCovered(r, c)) continue; // 結合に覆われた従属セルは td を出さない(アンカーが colspan で覆う)
1138
- const cs = this._merges ? this._colspanAt(r, c) : 1; // 結合アンカーは colspan で従属列ぶんを覆う
1139
- const rs = this._merges ? this._rowspanAt(r, c) : 1; // 縦結合は rowspan で従属行ぶんを覆う
1140
- const cls = [];
1141
- if (cs > 1 || rs > 1) cls.push('tg-merged'); // 結合アンカー(CSS で中央寄せ等のフック)
1142
- if (c < this.frozenCols) { cls.push('tg-frozen'); if (c === this.frozenCols - 1) cls.push('tg-frozen-edge'); }
1143
- if (this._isHidden(c)) cls.push('tg-hidden'); // 非表示列
1144
- if (this.colType(c) === 'dropdown') cls.push('tg-dropdown'); // ▾ キャレット表示用
1145
- if (this.colType(c) === 'number') cls.push('tg-num'); // 数値は右寄せ
1146
- if (this._isReadOnly(r, c)) cls.push('tg-readonly'); // 読み取り専用(淡色+カーソル既定)
1147
- if (this.colCfg(c).wordWrap) cls.push('tg-wrap-cell'); // 列単位の折り返し
1148
- if (this.data[r][c] === '' && this._placeholder(c) != null) cls.push('tg-placeholder'); // 空セルのヒント
1149
- const al = this._cellAlign(r, c); // 整列(列既定+セル上書き)
1150
- if (al.h) cls.push('tg-h-' + al.h); if (al.v) cls.push('tg-v-' + al.v);
1151
- let titleAttr = '';
1152
- if (this._marksInvalid) { const _iv = this._coerceCell(r, c, this.data[r][c]); if (!_iv.ok) { cls.push('tg-invalid'); titleAttr = ' data-err-level="' + TssGrid.esc(_iv.level || 'error') + '"'; if (this.invalidTitle) titleAttr += ' title="' + TssGrid.esc(_iv.message || '入力値が不正です') + '"'; } }
1153
- const ed = this.colCfg(c).editor; // カスタムエディタが icon を申告していれば右端に表示
1154
- const ico = (ed && ed.icon) ? ' data-ico="' + TssGrid.esc(ed.icon) + '"' : '';
1155
- if (ico) cls.push('tg-haspicker');
1156
- const ccls = (this.cellClass || this.colCfg(c).cellClass) ? this._cellClasses(r, c) : []; // 条件付き書式
1157
- for (const k of ccls) cls.push(k);
1158
- const dccls = ccls.length ? ' data-ccls="' + TssGrid.esc(ccls.join(' ')) + '"' : '';
1159
- let sty = ''; // セル単位インラインスタイル(連続色・データバー幅などCSS変数も)
1160
- if (this.cellStyle || this.colCfg(c).cellStyle) {
1161
- const so = this._cellStyleObj(r, c), sk = Object.keys(so);
1162
- if (sk.length) { let css = ''; for (const k of sk) css += k + ':' + so[k] + ';'; sty = ' style="' + TssGrid.esc(css) + '" data-cstyle="' + TssGrid.esc(sk.join(' ')) + '"'; }
1163
- }
1164
- const a = cls.length ? ' class="' + cls.join(' ') + '"' : '';
1165
- const csAttr = (cs > 1 ? ' colspan="' + cs + '"' : '') + (rs > 1 ? ' rowspan="' + rs + '"' : '');
1166
- html += '<td' + a + ico + dccls + sty + titleAttr + csAttr + ' data-r="' + r + '" data-c="' + c + '">' + this._cellHTML(r, c) + '</td>';
1167
- }
1168
- html += '</tr>';
1252
+ if (this.virtual) {
1253
+ const win = this._vWindow(); this._vStart = win.start; this._vEnd = win.end; this._vRendered = true;
1254
+ const colsSpan = this.COLS + (rh ? 1 : 0);
1255
+ const topH = win.start * this.defRowH, botH = (this.ROWS - win.end) * this.defRowH;
1256
+ if (topH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + topH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
1257
+ for (let r = win.start; r < win.end; r++) html += this._rowHTML(r);
1258
+ if (botH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + botH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
1259
+ } else {
1260
+ for (let r = 0; r < this.ROWS; r++) html += this._rowHTML(r);
1169
1261
  }
1170
1262
  html += '</tbody>';
1171
1263
  this.table.innerHTML = html;
@@ -1262,7 +1354,8 @@
1262
1354
  // 通常は buildTable/_reflow(ResizeObserver が table/wrap を監視)で自動更新される)。
1263
1355
  refreshGeometry() { this._buildGeomCache(); this.updateSelectionUI(); }
1264
1356
  place(elem, td) {
1265
- const r = +td.dataset.r, c = +td.dataset.c, R = this._cellRectAuto(r, c);
1357
+ // 仮想スクロールで active が窓外だと td=null。その場合は active 座標+モデル幾何で配置(窓外なら overflow で隠れる)。
1358
+ const r = td ? +td.dataset.r : this.active.r, c = td ? +td.dataset.c : this.active.c, R = this._cellRectAuto(r, c);
1266
1359
  elem.style.left = R.left + 'px'; elem.style.top = R.top + 'px';
1267
1360
  elem.style.width = R.width + 'px'; elem.style.height = R.height + 'px';
1268
1361
  this._clipOverlay(elem, R.left, R.top, c, r);
@@ -1290,9 +1383,15 @@
1290
1383
  // 行高は wordWrap で、列幅は stretch の DOM 改変で可変なので、モデル計算でなく実測が要る。
1291
1384
  _buildGeomCache() {
1292
1385
  if (!this.wrap || !this.table) { this._rowTops = this._colLefts = null; return; }
1293
- const trs = this.table.querySelectorAll('tbody tr');
1294
- const RT = new Array(this.ROWS + 1); RT[0] = this._theadH || 0;
1295
- for (let r = 0; r < this.ROWS; r++) { const tr = trs[r]; RT[r + 1] = RT[r] + (tr ? tr.getBoundingClientRect().height : this._rowHeight(r)); }
1386
+ const RT = new Array(this.ROWS + 1);
1387
+ if (this.virtual) {
1388
+ const base = this._theadH || 0, rowH = this.defRowH; // 仮想モードは一定行高=モデルで全行(窓外も算出可)
1389
+ for (let r = 0; r <= this.ROWS; r++) RT[r] = base + r * rowH;
1390
+ } else {
1391
+ const trs = this.table.querySelectorAll('tbody tr');
1392
+ RT[0] = this._theadH || 0;
1393
+ for (let r = 0; r < this.ROWS; r++) { const tr = trs[r]; RT[r + 1] = RT[r] + (tr ? tr.getBoundingClientRect().height : this._rowHeight(r)); }
1394
+ }
1296
1395
  this._rowTops = RT;
1297
1396
  // 列幅: リーフヘッダ th(マージ無しで確実)→ 無ければ colgroup col。stretch 後の実幅を拾う。
1298
1397
  const colEl = c => (this.colHeaders ? this.table.querySelector('thead th[data-c="' + c + '"]') : null) || this.table.querySelector('colgroup col[data-c="' + c + '"]');
@@ -1347,18 +1446,21 @@
1347
1446
  highlightRange() {
1348
1447
  this.table.querySelectorAll('td.sel, th.selhdr').forEach(el => el.classList.remove('sel', 'selhdr'));
1349
1448
  const { r0, r1, c0, c1 } = this.rectRange();
1350
- // 選択範囲が及ぶ行/列ヘッダをハイライト(Excel 風のアフォーダンス)
1449
+ // 選択範囲が及ぶ行/列ヘッダをハイライト(Excel 風のアフォーダンス)。仮想時は行ヘッダを窓内にクランプ(大量行で querySelector を無駄打ちしない)。
1450
+ const hlo = this.virtual ? Math.max(r0, this._vStart) : r0;
1451
+ const hhi = this.virtual ? Math.min(r1, this._vEnd - 1) : r1;
1351
1452
  for (let c = c0; c <= c1; c++) { const th = this.table.querySelector('thead th[data-c="' + c + '"]'); if (th) th.classList.add('selhdr'); }
1352
- for (let r = r0; r <= r1; r++) { const th = this.table.querySelector('th.rowhead[data-r="' + r + '"]'); if (th) th.classList.add('selhdr'); }
1453
+ for (let r = hlo; r <= hhi; r++) { const th = this.table.querySelector('th.rowhead[data-r="' + r + '"]'); if (th) th.classList.add('selhdr'); }
1353
1454
  this.wrap.style.setProperty('--tg-selhdr-fg', this._selhdrTextColor()); // 選択ヘッダ文字色を背景輝度から自動(暗背景→白)
1354
1455
  if (r0 === r1 && c0 === c1) return;
1355
1456
  const clampMerge = this._headerSel && this._merges; // 列/行選択では「範囲に収まらない結合」は塗らない(帯を塗り広げない)
1356
- for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {
1457
+ // 仮想スクロール: 窓内の行だけシェード(範囲枠 selrange は placeRect がモデル幾何で全域を描く)。
1458
+ for (let r = hlo; r <= hhi; r++) for (let c = c0; c <= c1; c++) {
1357
1459
  if (clampMerge) {
1358
1460
  const a = this._anchorOf(r, c), mc1 = a.c + this._colspanAt(a.r, a.c) - 1, mr1 = a.r + this._rowspanAt(a.r, a.c) - 1;
1359
1461
  if (a.r < r0 || mr1 > r1 || a.c < c0 || mc1 > c1) continue; // 結合が選択範囲に収まらない=塗らない
1360
1462
  }
1361
- this.cellEl(r, c).classList.add('sel');
1463
+ const el = this.cellEl(r, c); if (el) el.classList.add('sel');
1362
1464
  }
1363
1465
  }
1364
1466
  // 選択ヘッダの文字色を --tg-selhdr-bg の輝度から自動決定(暗背景→白/明背景→濃紺)。
@@ -1437,15 +1539,18 @@
1437
1539
  if (!this._beforeSel(a, a)) return;
1438
1540
  this._headerSel = null;
1439
1541
  this.active = a; this.extent = { ...a };
1542
+ if (this.virtual) this._vEnsureVisible(a.r); // 窓に入れてから placement(窓外なら activeTd=null になるため)
1440
1543
  this.place(this.editor, this.activeTd()); this.toNav(); this.editor.focus(); this.updateSelectionUI();
1441
- this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' });
1544
+ if (!this.virtual) this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' }); // 仮想は _vEnsureVisible が可視化済み
1442
1545
  this._fireSelection();
1443
1546
  }
1444
1547
  setExtent(r, c) {
1445
1548
  const e = { r: this.clampR(r), c: this.clampC(c) };
1446
1549
  if (!this._beforeSel(this.active, e)) return;
1447
- this.extent = e; this.updateSelectionUI();
1448
- this.cellEl(this.extent.r, this.extent.c).scrollIntoView({ block: 'nearest', inline: 'nearest' });
1550
+ this.extent = e;
1551
+ if (this.virtual) this._vEnsureVisible(e.r);
1552
+ this.updateSelectionUI();
1553
+ if (!this.virtual) this.cellEl(this.extent.r, this.extent.c).scrollIntoView({ block: 'nearest', inline: 'nearest' });
1449
1554
  this._fireSelection();
1450
1555
  }
1451
1556
  selectRect(r0, c0, r1, c1) {
@@ -1458,8 +1563,9 @@
1458
1563
  }
1459
1564
  focusAndSelect(sel) {
1460
1565
  this.active = { ...sel.active }; this.extent = { ...sel.extent };
1566
+ if (this.virtual) this._vEnsureVisible(this.active.r);
1461
1567
  this.place(this.editor, this.activeTd()); this.toNav(); this.editor.focus(); this.updateSelectionUI();
1462
- this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' });
1568
+ if (!this.virtual) this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' });
1463
1569
  this._fireSelection();
1464
1570
  }
1465
1571
 
@@ -1610,7 +1716,10 @@
1610
1716
  } else if (th.dataset.r != null) { // 行ヘッダ
1611
1717
  const r = +th.dataset.r;
1612
1718
  if (e.shiftKey) this._extendRow(r); else this.selectRow(r);
1613
- this.editor.focus(); this.headerDrag = 'row';
1719
+ this.editor.focus();
1720
+ // 'header'モード: 行ヘッダーセル全体が移動ハンドル=行を選択した上で移動を armする(クリック=選択のまま/ドラッグで移動)。縦結合/フィルタ中は不可。
1721
+ if (this.rowReorderWhole && !e.shiftKey && !this._hasVMerge() && !this._allRows) this._startRowMove(e);
1722
+ else this.headerDrag = 'row'; // 通常: ドラッグで複数行選択
1614
1723
  } else { // 左上の角 → 全選択
1615
1724
  this.selectAll(); this.editor.focus();
1616
1725
  }
@@ -2109,11 +2218,72 @@
2109
2218
  clearCopyMarquee() { this.copybox.style.display = 'none'; this.pendingCut = null; }
2110
2219
 
2111
2220
  _tdAtPoint(e) { const el = document.elementFromPoint(e.clientX, e.clientY); return el && el.closest ? el.closest('td[data-r]') : null; }
2221
+ // ポインタ Y から絶対行を算出(td が無い窓外/スペーサ上でも使える=仮想スクロールのドラッグ用。一定行高モデル)。
2222
+ _rowFromPointerY(clientY) {
2223
+ const wr = this.wrap.getBoundingClientRect();
2224
+ const contentY = clientY - wr.top + this.wrap.scrollTop - (this.colHeaders ? (this._theadH || 0) : 0);
2225
+ return Math.max(0, Math.min(this.ROWS - 1, Math.floor(contentY / this.defRowH)));
2226
+ }
2227
+ // ポインタ X から列を算出(_colLefts 累積から。隠し列は飛ばす)。
2228
+ _colFromPointerX(clientX) {
2229
+ const wr = this.wrap.getBoundingClientRect(), x = clientX - wr.left + this.wrap.scrollLeft, L = this._colLefts;
2230
+ if (L) for (let c = this.COLS - 1; c >= 0; c--) if (!this._isHidden(c) && x >= L[c]) return c;
2231
+ return this.extent.c;
2232
+ }
2233
+ _stopAutoScroll() { if (this._asRAF) { try { cancelAnimationFrame(this._asRAF); } catch (_) {} this._asRAF = 0; } this._dragDir = 0; }
2234
+ _updateFillPreview(r, c, alt) {
2235
+ this._fillRow = r;
2236
+ const fr = this.fillRectFromPointer(r, c, this.filling.src, alt);
2237
+ if (fr) { this.placeRect(this.fillpreview, fr.r0, fr.c0, fr.r1, fr.c1); this.fillpreview.style.display = 'block'; }
2238
+ else this.fillpreview.style.display = 'none';
2239
+ }
2240
+ // ドラッグ/フィル中に上下端を越えたら自動スクロールしながら選択/プレビューを伸ばす。
2241
+ // 重要: 伸ばす先は「カウンタ加算」でなく常に【ポインタ位置の行】=端を1pxだけ超えたら1行しか伸びない(暴走しない)。
2242
+ // 自動スクロール速度は端からのはみ出し量に比例(少し超えた=ゆっくり / 大きく超えた=速く)。
2243
+ _edgeAutoScroll(e, mode) {
2244
+ this._dragY = e.clientY; this._dragX = e.clientX; this._dragAlt = !!e.altKey; this._dragMode = mode;
2245
+ const r = this._rowFromPointerY(e.clientY), c = this._colFromPointerX(e.clientX);
2246
+ if (mode === 'drag') this._extendTo(r, c); else this._updateFillPreview(r, c, this._dragAlt); // まずポインタ位置のセルへ
2247
+ const wr = this.wrap.getBoundingClientRect();
2248
+ const headB = wr.top + (this.colHeaders ? (this._theadH || 0) : 0); // 上端=ヘッダ下
2249
+ const leftB = wr.left + this._frozenPaneW(); // 左端=固定ペイン(行番号+固定列)の右
2250
+ const past = e.clientY > wr.bottom || e.clientY < headB || e.clientX > wr.right || e.clientX < leftB;
2251
+ if (past && !this._asRAF) this._asRAF = requestAnimationFrame(() => this._autoScrollTick());
2252
+ }
2253
+ _autoScrollTick() {
2254
+ this._asRAF = 0;
2255
+ if (!this.dragging && !this.filling) return;
2256
+ const wr = this.wrap.getBoundingClientRect();
2257
+ const headB = wr.top + (this.colHeaders ? (this._theadH || 0) : 0), leftB = wr.left + this._frozenPaneW();
2258
+ const amp = d => Math.min(24, 2 + d * 0.2); // px/フレーム=はみ出し量に比例(上限24・暴走しない)
2259
+ let v = 0, vh = 0;
2260
+ if (this._dragY > wr.bottom) v = amp(this._dragY - wr.bottom); else if (this._dragY < headB) v = -amp(headB - this._dragY);
2261
+ if (this._dragX > wr.right) vh = amp(this._dragX - wr.right); else if (this._dragX < leftB) vh = -amp(leftB - this._dragX);
2262
+ if (v !== 0) {
2263
+ const before = this.wrap.scrollTop;
2264
+ this.wrap.scrollTop = Math.max(0, before + v);
2265
+ if (this.wrap.scrollTop !== before && this.virtual) this._renderWindow(this._vWindow(), false); // 縦は仮想時のみ再窓(非仮想で呼ぶと tbody を壊す)
2266
+ }
2267
+ if (vh !== 0) this.wrap.scrollLeft = Math.max(0, this.wrap.scrollLeft + vh); // 横は全列描画なので scrollLeft だけ
2268
+ const r = this._rowFromPointerY(this._dragY), c = this._colFromPointerX(this._dragX); // 伸ばす先は常にポインタのセル
2269
+ if (this._dragMode === 'drag') this._extendTo(r, c); else this._updateFillPreview(r, c, this._dragAlt);
2270
+ const maxTop = this.wrap.scrollHeight - this.wrap.clientHeight, maxLeft = this.wrap.scrollWidth - this.wrap.clientWidth;
2271
+ const vStuck = (v > 0 && this.wrap.scrollTop >= maxTop) || (v < 0 && this.wrap.scrollTop <= 0);
2272
+ const hStuck = (vh > 0 && this.wrap.scrollLeft >= maxLeft) || (vh < 0 && this.wrap.scrollLeft <= 0);
2273
+ if ((v !== 0 && !vStuck) || (vh !== 0 && !hStuck)) this._asRAF = requestAnimationFrame(() => this._autoScrollTick());
2274
+ }
2275
+ // setExtent の自動可視化(_vEnsureVisible)を伴わない版=自動スクロールは _autoScrollTick が手動制御するため、二重スクロールを避ける。
2276
+ _extendTo(r, c) {
2277
+ const e = { r: this.clampR(r), c: this.clampC(c) };
2278
+ if (!this._beforeSel(this.active, e)) return;
2279
+ this.extent = e; this.updateSelectionUI(); this._fireSelection();
2280
+ }
2112
2281
 
2113
2282
  // スクロール / リサイズでセル位置が動いた時、エディタと選択枠を貼り直す
2114
2283
  _reflow() {
2115
2284
  if (this.stretchH !== 'none') this._applyStretch(); // 枠幅が変わったら余白を再配分
2116
2285
  this._buildGeomCache(); // 幅/枠が変わったので実測幾何を取り直す(リサイズ/ResizeObserver/window resize)
2286
+ if (this.virtual) this._renderWindow(this._vWindow(), false); // 枠高が変われば窓行数も変わる=窓を同期
2117
2287
  const td = this.activeTd(); if (!td) return;
2118
2288
  this.place(this.editor, td);
2119
2289
  if (this.select && this.select.style.display !== 'none') this.place(this.select, td);
@@ -2121,6 +2291,7 @@
2121
2291
  }
2122
2292
 
2123
2293
  _bind() {
2294
+ if (this.virtual) this.wrap.addEventListener('scroll', () => this._vSync(), { passive: true }); // 仮想スクロール: 窓再描画
2124
2295
  this.editor.addEventListener('keydown', (e) => this._onKey(e));
2125
2296
  this.editor.addEventListener('compositionstart', () => {
2126
2297
  if (this.mode !== 'nav') return;
@@ -2158,7 +2329,7 @@
2158
2329
  const cls = e.target.classList;
2159
2330
  if (cls && cls.contains('tg-colgrip')) { this._startColResize(e); return; } // 列幅リサイズ
2160
2331
  if (cls && cls.contains('tg-rowgrip')) { this._startRowResize(e); return; } // 行高リサイズ
2161
- if (cls && cls.contains('tg-rowmove')) { this._startRowMove(e); return; } // 行つまみドラッグ移動
2332
+ if (cls && cls.contains('tg-rowmove') && !this.rowReorderWhole) { this._startRowMove(e); return; } // ⠿つまみドラッグ移動('header'モードはセル全体扱い=_onHeaderMouseDown 経由で選択も伴う)
2162
2333
  const th = e.target.closest('th');
2163
2334
  if (th) { this._onHeaderMouseDown(e, th); return; } // 行/列/全体 選択
2164
2335
  const td = e.target.closest('td[data-r]'); if (!td) return;
@@ -2188,7 +2359,7 @@
2188
2359
  const td = e.target.closest('td[data-r]'); if (!td) return;
2189
2360
  this.setActive(+td.dataset.r, +td.dataset.c); this._beginEdit(this.active.r, this.active.c);
2190
2361
  });
2191
- this.fillhandle.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); this.filling = { src: this.rectRange() }; });
2362
+ this.fillhandle.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); this.filling = { src: this.rectRange() }; this._fillRow = null; });
2192
2363
  // 右クリックメニュー(行/列の挿入・削除)
2193
2364
  this.table.addEventListener('contextmenu', (e) => this._onContextMenu(e));
2194
2365
  this.menuEl.addEventListener('mousedown', (e) => {
@@ -2256,12 +2427,15 @@
2256
2427
  }
2257
2428
  return;
2258
2429
  }
2259
- if (this.dragging) { const td = this._tdAtPoint(e); if (td) this.setExtent(+td.dataset.r, +td.dataset.c); }
2430
+ if (this.dragging) {
2431
+ const td = this._tdAtPoint(e);
2432
+ if (td) { this._stopAutoScroll(); this.setExtent(+td.dataset.r, +td.dataset.c); }
2433
+ else this._edgeAutoScroll(e, 'drag'); // 窓外/端=自動スクロール+モデル幾何で伸ばす
2434
+ }
2260
2435
  else if (this.filling) {
2261
- const td = this._tdAtPoint(e); if (!td) return;
2262
- const fr = this.fillRectFromPointer(+td.dataset.r, +td.dataset.c, this.filling.src, e.altKey);
2263
- if (fr) { this.placeRect(this.fillpreview, fr.r0, fr.c0, fr.r1, fr.c1); this.fillpreview.style.display = 'block'; }
2264
- else this.fillpreview.style.display = 'none';
2436
+ const td = this._tdAtPoint(e);
2437
+ if (td) { this._stopAutoScroll(); this._updateFillPreview(+td.dataset.r, +td.dataset.c, e.altKey); }
2438
+ else this._edgeAutoScroll(e, 'fill');
2265
2439
  }
2266
2440
  });
2267
2441
  this._on(document, 'mouseup', (e) => {
@@ -2280,8 +2454,9 @@
2280
2454
  return;
2281
2455
  }
2282
2456
  if (this.headerDrag) { this.headerDrag = null; return; }
2283
- if (this.dragging) { this.dragging = false; return; }
2457
+ if (this.dragging) { this.dragging = false; this._stopAutoScroll(); return; }
2284
2458
  if (this.filling) {
2459
+ this._stopAutoScroll();
2285
2460
  // autoInsertRow: フィルハンドルをテーブル下端より下へドロップしたら、足りない行を追加(縦フィル時)
2286
2461
  if (this.fillAutoInsertRow && this.fillDirection !== 'horizontal' && !this._tdAtPoint(e)) {
2287
2462
  const tb = this.table.getBoundingClientRect();
@@ -2291,8 +2466,11 @@
2291
2466
  this.buildTable();
2292
2467
  }
2293
2468
  }
2294
- const td = this._tdAtPoint(e) || this.cellEl(this.filling.src.r1, this.filling.src.c1);
2295
- const fr = this.fillRectFromPointer(+td.dataset.r, +td.dataset.c, this.filling.src, e.altKey);
2469
+ // 確定の対象セル: td があればそれ、無ければ(窓外)モデル幾何=直近のフィル行/ポインタ位置から。
2470
+ const td = this._tdAtPoint(e);
2471
+ const tr = td ? +td.dataset.r : (this._fillRow != null ? this._fillRow : this._rowFromPointerY(e.clientY));
2472
+ const tc = td ? +td.dataset.c : this._colFromPointerX(e.clientX);
2473
+ const fr = this.fillRectFromPointer(tr, tc, this.filling.src, e.altKey);
2296
2474
  this.fillpreview.style.display = 'none';
2297
2475
  const src = this.filling.src; this.filling = null;
2298
2476
  if (fr) { this.doFill(src, fr, e.ctrlKey || e.metaKey); this.selectRect(fr.r0, fr.c0, fr.r1, fr.c1); }