@threestarsoftware/tssgrid 0.1.3 → 0.1.4

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 CHANGED
@@ -3,6 +3,20 @@
3
3
  TssGrid の各リリースの変更点。日付は JST。形式は [Keep a Changelog](https://keepachangelog.com/ja/1.1.0/) 準拠(ゆるめ)。
4
4
  数値は特記なき限り**自社調べ**(headless Chrome / Windows 11・環境差あり)。
5
5
 
6
+ ## [0.1.4] - 2026-07-21
7
+
8
+ ### 追加
9
+ - **`appendRows(rows, opts?)`=背景で"育てる"progressive 追記**(コア)— `data` 末尾へ足し、可視窓+スペーサだけ再描画する。`buildTable` 全再描画をせず、**スクロール位置・アクティブセル・選択・編集中エディタと値を保ち、履歴にも積まない**。「先頭ページを描く→残りを idle で `appendRows`」で first-paint を早める土台。既存の `setData`/`insertRows` 経路は不変=opt-in。
10
+ - **遅延ロード(on-demand)=`setRowCount(total)` / `fillRows(start, rows)` / `onViewportChange(start, end)` / `onSortFilterChange(state)` / `TssGrid.PENDING`**(コア・`virtual:true` 前提)— 総件数を確保して未取得ぶんは「読込中」行で持ち、スクロールで `onViewportChange` が必要な範囲を通知→アプリが取得→`fillRows` で流し込む。**sort/filter は遅延中ローカル計算せず `onSortFilterChange` でサーバへ委譲**、**未取得セルは編集不可**(カーソルは乗れる)、**export(`getData`/`getRows`/`toCSV`)はロード済みのみ**返す。=AG Grid の Infinite Row Model 相当を、最小コアの push プリミティブで実装(datasource/ブロックキャッシュはコアに持たない=将来アドオンに委ねる)。大量一覧の first-paint を大きく縮められる。
11
+ - **`insertRows(ri, count?, where?)`=一括行挿入プリミティブ**(コア)— `count` 行を1回の再描画・1 undo で挿入。1行ずつの `insertRow` を繰り返す O(N²) を O(N) に。`insertRow` は `insertRows(…, 1, …)` へ委譲=後方互換。
12
+ - **`pasteAutoGrow`(オプション・既定 `false`)**(コア)— 貼付元の行数がグリッド行数を超える時、不足分を先に `insertRows` で一括生成してから貼る(Excel から貼れば貼った行数だけ行が増える)。行生成+値貼付は1回の undo で戻る。`maxRows` 上限内・超過は `pasteOverflow` に従う。
13
+
14
+ ### 変更
15
+ - **貼り付けを paste イベント主経路化**(コア)— Ctrl+V を `navigator.clipboard.readText()`(初回に**許可プロンプト**が出る・Chrome 仕様)から、ネイティブ paste イベント(`clipboardData.getData`)へ移した。ジェスチャー内で同期に読める=**許可プロンプト不要・より堅牢**(権限/非セキュアコンテキスト/非フォーカスで落ちない)。右クリックメニューの「貼り付け」とプログラム呼び出しは従来どおり `readText`。セル編集中の1セル貼付は native 挙動に委ねる(回帰なし)。
16
+
17
+ ### 改善(性能)
18
+ - **大量貼付を O(N) 化**(コア)— 1000行規模の貼付で per-cell 描画 N 回を「最後に `buildTable` 1回」へ畳み、巨大選択の選択塗り(従来 O(行×列))をアウトライン矩形へ委譲。`insertRows`/`pasteAutoGrow` と併せ、大量貼付が O(N²)→O(N)(自社調べ・実機で大幅短縮)。
19
+
6
20
  ## [0.1.3] - 2026-07-17
7
21
 
8
22
  ### 追加
package/README.md CHANGED
@@ -784,6 +784,7 @@ new TssGrid(el, { hiddenColumns: [1] }); // 初期で隠す
784
784
  | `invalidTitle` | `boolean` | `true` | `keep` 時、赤セルに validator メッセージを `title=`(ホバーで理由)。`false` でOFF。全件は `getInvalidCells()` |
785
785
  | `messages` | `{code:…}\|fn` | — | エラーの**共通メッセージカタログ**。`validator` が `{code,params,level}` を返すと本文を解決(`{label}`/`{value}`/params 差込)。本文を外部JSON/CSV/XMLで一元管理 |
786
786
  | `pasteOverflow` | `'clip'\|'error'` | `'clip'` | 貼り付けがグリッド外に超過した時。`clip`=はみ出し分を切り捨て / `error`=貼り付け中止 |
787
+ | `pasteAutoGrow` | `boolean` | `false` | 貼付元の行数がグリッド行数を超える時、不足分を **`insertRows` で先に一括生成**してから貼る(Excel から貼れば貼った行数だけ行が増える)。**行生成+値貼付は1回の undo で両方戻る**。`maxRows` 上限内・超過分は `pasteOverflow` に従う。既定 `false`=従来どおりはみ出しは切り捨て |
787
788
  | `colWidths` / `rowHeights` | `number[]` \| `{[key]:number}` | `[]` | 列幅 / 行高の初期値(px)。未指定の要素は既定値。`colWidths` は**配列(位置指定)でも、`{ dataキー: px }` のオブジェクト(列ID指定)でも可**=後者は列を入れ替えても幅が追従する。列定義側に `columns[c].width` で同居指定も可(並べ替え・コピペで幅が一緒に動く=最も壊れにくい)。優先度: 実行時リサイズ/明示 `colWidths` > `columns[c].width` > 既定 |
788
789
  | `defaultColWidth` / `defaultRowHeight` | `number` | `120` / `28` | 既定の列幅 / 行高(px) |
789
790
  | `minColWidth` / `minRowHeight` | `number` | `30` / `18` | リサイズの下限(px) |
@@ -850,7 +851,7 @@ new TssGrid(el, {
850
851
  });
851
852
  ```
852
853
 
853
- メソッド: `getData()` / `getRows()` / `getRow(r)` / `getColumn(cOrKey)` / `getColumns()` / `setData(rows)` / `getValue(r,cOrKey)` / `setValue(r,cOrKey,val,force?)` / `setValueRaw(r,cOrKey,val)`(履歴に積まない派生値書き込み・計算列用) / `hideColumn(c)` / `showColumn(c)` / `toggleColumn(c)` / `isColumnHidden(c)` / `getHiddenColumns()` / `setAlignment(align,range?)` / `getAlignment(r,c)` / `setCellReadOnly(flag,range?)` / `autoSizeColumn(c)` / `autoSizeAllColumns()` / `sortBy(cOrKey, dir)` / `sortRows(cmp)` / `addShortcut(s)` / `removeShortcut(name)` / `usePlugin(fn)` / `getPlugin(name)` / `destroy()` / `setActive(r,c)` / `selectRow(r)` / `selectCol(c)` / `selectAll()` / `setColWidth(c,px)` / `setRowHeight(r,px)` / `freezeCols(n)` / `freezeRows(n)` / `moveRow(from,to)` / `moveRows(r0,r1,to)` / `clearSort()` / `toCSV(opts)` / `downloadCSV(name,opts)` / `insertRow(ri,'above'|'below')` / `deleteRows(r0,r1?)` / `insertCol(ci,'left'|'right')` / `deleteCols(c0,c1?)` / `redraw()`(列定義変更後の再描画, データ・選択は保持)ほか。
854
+ メソッド: `getData()` / `getRows()` / `getRow(r)` / `getColumn(cOrKey)` / `getColumns()` / `setData(rows)` / `appendRows(rows,opts?)`(**背景で"育てる"追記**=data末尾に足し可視窓だけ再描画。buildTable全再描画せずスクロール位置・選択・編集中エディタを保ち履歴にも積まない。「先頭ページを描く→残りをidleでappendRows」でfirst-paintを早める土台。既存経路は不変のopt-in) / `setRowCount(total)` + `fillRows(start,rows)`(**遅延ロード=on-demand**=virtual前提。総件数を確保し未取得ぶんは「読込中」行、`onViewportChange(start,end)` で要る範囲を受け `fillRows` で実データを流し込む。sort/filterは遅延中ローカル計算せず `onSortFilterChange` でサーバ委譲、未取得セルは編集不可、export(getData/toCSV)はロード済みのみ=AG Grid の Infinite Row Model 相当を push で) / `getValue(r,cOrKey)` / `setValue(r,cOrKey,val,force?)` / `setValueRaw(r,cOrKey,val)`(履歴に積まない派生値書き込み・計算列用) / `hideColumn(c)` / `showColumn(c)` / `toggleColumn(c)` / `isColumnHidden(c)` / `getHiddenColumns()` / `setAlignment(align,range?)` / `getAlignment(r,c)` / `setCellReadOnly(flag,range?)` / `autoSizeColumn(c)` / `autoSizeAllColumns()` / `sortBy(cOrKey, dir)` / `sortRows(cmp)` / `addShortcut(s)` / `removeShortcut(name)` / `usePlugin(fn)` / `getPlugin(name)` / `destroy()` / `setActive(r,c)` / `selectRow(r)` / `selectCol(c)` / `selectAll()` / `setColWidth(c,px)` / `setRowHeight(r,px)` / `freezeCols(n)` / `freezeRows(n)` / `moveRow(from,to)` / `moveRows(r0,r1,to)` / `clearSort()` / `toCSV(opts)` / `downloadCSV(name,opts)` / `insertRow(ri,'above'|'below')` / `insertRows(ri,count?,'above'|'below')`(count 行を1回の再描画・1 undo で一括挿入=大量挿入が O(N)) / `deleteRows(r0,r1?)` / `insertCol(ci,'left'|'right')` / `deleteCols(c0,c1?)` / `redraw()`(列定義変更後の再描画, データ・選択は保持)ほか。
854
855
 
855
856
  ## イベント
856
857
 
@@ -890,8 +891,12 @@ new TssGrid(el, {
890
891
  onAfterCopy(data, { range, cut }) {},
891
892
 
892
893
  // 行/列の挿入・削除の前後。before は false で中止できる
893
- onBeforeStructureChange(info) { /* info.type = 'insertRow'|'deleteRows'|'insertCol'|'deleteCols'。return false で中止 */ },
894
+ onBeforeStructureChange(info) { /* info.type = 'insertRow'|'insertRows'|'deleteRows'|'insertCol'|'deleteCols'。return false で中止 */ },
894
895
  onStructureChange(info) {}, // 後。undo 時は info.undo = true
896
+
897
+ // 遅延ロード(push・on-demand)。setRowCount/fillRows と組で使う
898
+ onViewportChange(start, end) {}, // 描画窓が変わった=「今この範囲が要る」。ここで fillRows(start, 取得した行) する
899
+ onSortFilterChange(state) {}, // 遅延中の sort/filter 要求。ローカル計算せずサーバで並替/絞込→setRowCount+fillRows し直す(state.type='sort'|'filter')
895
900
  });
896
901
  ```
897
902
 
@@ -1109,11 +1114,11 @@ new TssGrid(el, {
1109
1114
  allowDeleteRows: true,
1110
1115
  allowInsertCols: true,
1111
1116
  allowDeleteCols: true,
1112
- onStructureChange: (info) => console.log(info.type), // 'insertRow' | 'deleteRows' | 'insertCol' | 'deleteCols'
1117
+ onStructureChange: (info) => console.log(info.type), // 'insertRow' | 'insertRows' | 'deleteRows' | 'insertCol' | 'deleteCols'
1113
1118
  });
1114
1119
  ```
1115
1120
 
1116
- プログラムからも操作できます: **`insertRow(ri, 'above'|'below')`** / **`deleteRows(r0, r1?)`** / **`insertCol(ci, 'left'|'right')`** / **`deleteCols(c0, c1?)`**。最低1行・1列は残します。
1121
+ プログラムからも操作できます: **`insertRow(ri, 'above'|'below')`** / **`insertRows(ri, count?, 'above'|'below')`**(`count` 行を **1回の再描画・1つの undo** で一括挿入=大量行の追加が O(N²)→O(N)。CSV取込・テンプレ行のまとめ足しに) / **`deleteRows(r0, r1?)`** / **`insertCol(ci, 'left'|'right')`** / **`deleteCols(c0, c1?)`**。最低1行・1列は残します。大量貼付で行を増やしたい場合は `pasteAutoGrow: true` も参照。
1117
1122
 
1118
1123
  既定メニューには行/列の挿入・削除に加え **コピー / 切り取り / 貼り付け / 内容をクリア** も入ります(クリップボードは `copyPaste:false` で消えます)。
1119
1124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threestarsoftware/tssgrid",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "軽量・ノービルド・日本語IME堅牢な編集グリッド(vanilla JS / 依存なし / MIT)。Excel ライクな入力画面を <script> タグ1本で。",
5
5
  "license": "MIT",
6
6
  "author": "ThreeStarSoftware (https://www.threestarsoftware.co.jp/)",
package/src/tssgrid.css CHANGED
@@ -230,3 +230,9 @@
230
230
  .tssgrid .tg-menu .tg-mi.tg-has-sub::after { content: '▶'; position: absolute; right: 10px; top: 0; bottom: 0; display: flex; align-items: center; font-size: 9px; color: #7d8a99; }
231
231
  .tssgrid .tg-menu .tg-submenu { display: none; position: absolute; left: 100%; top: -5px; background: #fff; border: 1px solid var(--tg-wrap-border); border-radius: var(--tg-radius); box-shadow: 0 4px 16px rgba(0,0,0,.18); padding: 4px 0; min-width: 150px; }
232
232
  .tssgrid .tg-menu .tg-mi.tg-has-sub:hover > .tg-submenu { display: block; }
233
+
234
+ /* 遅延ロード(push): 未取得(PENDING)行の「読込中」プレースホルダ。値は描かず淡いシマーの点線を出す */
235
+ .tssgrid .tg-table tr.tg-pending-row td.tg-pending-cell { color: var(--tg-muted); text-align: center; cursor: default; }
236
+ .tssgrid .tg-table tr.tg-pending-row .tg-pending-dots { display: inline-block; width: 40%; max-width: 220px; height: 8px; border-radius: 4px;
237
+ background: linear-gradient(90deg, #eef2f6 25%, #e2e8f0 37%, #eef2f6 63%); background-size: 400% 100%; animation: tg-pending-shimmer 1.2s ease-in-out infinite; }
238
+ @keyframes tg-pending-shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }
package/src/tssgrid.d.ts CHANGED
@@ -33,7 +33,7 @@ export interface Rejection { r: number; c: number; value: any; code?: string; le
33
33
  export interface ReadOnlyBlock { r: number; c: number; value: any; }
34
34
 
35
35
  /** 行/列の挿入・削除の情報(onStructureChange / onBeforeStructureChange)。 */
36
- export interface StructureInfo { type: 'insertRow' | 'deleteRows' | 'insertCol' | 'deleteCols'; undo?: boolean; [k: string]: any; }
36
+ export interface StructureInfo { type: 'insertRow' | 'insertRows' | 'deleteRows' | 'insertCol' | 'deleteCols'; undo?: boolean; [k: string]: any; }
37
37
 
38
38
  /** validator の戻り値: true=OK / 文字列=エラー文 / オブジェクト=メッセージカタログ解決用。 */
39
39
  export type ValidatorResult = true | string | { code: string; params?: Record<string, any>; level?: string };
@@ -107,6 +107,8 @@ export interface TssGridOptions {
107
107
  /** エラーの共通メッセージカタログ。 */
108
108
  messages?: Record<string, any> | ((info: { code: string; params?: any; level?: string }) => string);
109
109
  pasteOverflow?: 'clip' | 'error';
110
+ /** 貼付元の行数がグリッド行数を超える時、不足分を insertRows で一括生成してから貼る(既定 false=従来のクランプ)。行生成+値貼付は1回の undo で戻る。maxRows 上限内・超過分は pasteOverflow に従う。 */
111
+ pasteAutoGrow?: boolean;
110
112
  colWidths?: number[] | Record<string, number>;
111
113
  rowHeights?: number[] | Record<string, number>;
112
114
  defaultColWidth?: number;
@@ -191,6 +193,10 @@ export interface TssGridOptions {
191
193
  onAfterCopy?: (data: string[][], info: { range: SelectionRange; cut: boolean }) => void;
192
194
  onBeforeStructureChange?: (info: StructureInfo) => boolean | void;
193
195
  onStructureChange?: (info: StructureInfo) => void;
196
+ /** 遅延ロード(push): 描画窓が変わった時に呼ぶ (start,end)。未取得(PENDING)行を fillRows で埋めるトリガ。 */
197
+ onViewportChange?: (start: number, end: number) => void;
198
+ /** 遅延ロード(push): dataComplete=false で sort/filter 要求が来た時に呼ぶ。エンジンはローカル計算せず、これで通知+パージ=プラグインがサーバ側で並替/絞込して setRowCount+fillRows し直す。 */
199
+ onSortFilterChange?: (state: { type: 'sort' | 'filter'; sort?: any; filter?: any }) => void;
194
200
  onBeforeAutofill?: (src: SelectionRange, target: SelectionRange) => boolean | void;
195
201
  onAfterAutofill?: (src: SelectionRange, target: SelectionRange) => void;
196
202
  onBeforeKeyDown?: (e: KeyboardEvent) => boolean | void;
@@ -312,6 +318,26 @@ export declare class TssGrid {
312
318
  getValue(r: number, cOrKey: number | string): any;
313
319
  /** データを差し替え(履歴・フィルタ・整列状態はリセット)。 */
314
320
  setData(rows: any[][] | Record<string, any>[]): void;
321
+ /**
322
+ * 背景で"育てる"追記(progressive)。data 末尾へ rows を足し、可視窓+スペーサだけ再描画する。
323
+ * buildTable 全再描画をせず、スクロール位置・アクティブセル・フォーカス・選択・編集中のエディタと値を保ち、履歴にも積まない。
324
+ * 「先頭ページを描く→残りを idle で appendRows」して first-paint を早める土台(既存の setData/insertRows 経路は不変)。
325
+ * rows は現在のデータと同形(オブジェクトモードはオブジェクト配列/配列モードは行配列)。
326
+ */
327
+ appendRows(rows: any[][] | Record<string, any>[], opts?: { keepScroll?: boolean; noHistory?: boolean }): void;
328
+ /**
329
+ * 遅延ロード(push・on-demand)。総行数を total に確保し、未取得ぶんを PENDING 行で埋める(virtual 前提)。
330
+ * dataComplete=false になり、この間 minSpareRows は自動無効。onViewportChange で「今要る範囲」を受け fillRows で埋める流れ。
331
+ * setData で解除。AG Grid の Infinite Row Model 相当を push プリミティブで。
332
+ */
333
+ setRowCount(total: number): void;
334
+ /**
335
+ * 遅延ロード(push): PENDING 行 [start, start+rows.length) を実データに差し替える(onViewportChange への応答)。
336
+ * 塗り替え範囲が可視窓に重なる時だけ窓を再描画=スクロール/選択/アクティブは不変。全 PENDING が埋まると dataComplete=true。
337
+ */
338
+ fillRows(start: number, rows: any[][] | Record<string, any>[]): void;
339
+ /** 遅延ロード: 全行がロード済みなら true。setRowCount で false、全 fillRows 完了で true に戻る。 */
340
+ readonly dataComplete: boolean;
315
341
  /** 1セルに書き込み(履歴あり・検証/onBeforeChange 経由)。force=true で readOnly 貫通。 */
316
342
  setValue(r: number, cOrKey: number | string, val: any, force?: boolean): void;
317
343
  /** 履歴に積まず・検証も通さず直接書き込み(計算列など派生値用。readOnly 貫通)。 */
@@ -353,6 +379,8 @@ export declare class TssGrid {
353
379
 
354
380
  // ---- 行 / 列 構造 -------------------------------------------------------
355
381
  insertRow(ri: number, where?: 'above' | 'below'): void;
382
+ /** count 行を1回の再描画・1つの undo コマンドで挿入(大量挿入を O(N) に)。maxRows 上限でクランプ。既定 count=1 は insertRow と等価。 */
383
+ insertRows(ri: number, count?: number, where?: 'above' | 'below'): void;
356
384
  deleteRows(r0: number, r1?: number): void;
357
385
  insertCol(ci: number, where?: 'left' | 'right'): void;
358
386
  deleteCols(c0: number, c1?: number): void;
@@ -407,6 +435,9 @@ export declare class TssGrid {
407
435
  /** セルの DOM 要素を返す(低レベル・内部DOM構造に依存)。結合セルはアンカーに解決。 */
408
436
  cellEl(r: number, c: number): HTMLTableCellElement | null;
409
437
 
438
+ /** 遅延ロード: 未取得行を表す sentinel。setRowCount が未取得ぶんを data[r] に入れ、fillRows で実データへ差し替える。 */
439
+ static readonly PENDING: unique symbol;
440
+
410
441
  [k: string]: any;
411
442
  }
412
443
 
package/src/tssgrid.js CHANGED
@@ -127,6 +127,8 @@
127
127
  this.allowDeleteCols = opts.allowDeleteCols !== false;
128
128
  this.onStructureChange = opts.onStructureChange || null; // (info) 行/列の挿入・削除後
129
129
  this.onFilter = opts.onFilter || null; // ({count,total,cleared?}) 非破壊フィルタ適用/解除後
130
+ this.onViewportChange = opts.onViewportChange || null; // (start,end) 遅延ロード: 描画窓が変わった=「今この範囲が要る」通知(fillRows で応答)
131
+ this.onSortFilterChange = opts.onSortFilterChange || null; // (state) 遅延ロード: sort/filter はローカル計算せずサーバへ委譲=この通知で再フェッチさせる
130
132
  this.onBeforeStructureChange = opts.onBeforeStructureChange || null; // (info)=>false で挿入/削除を中止
131
133
  this.onAfterPaste = opts.onAfterPaste || null; // (data, {range}) 貼り付け確定後
132
134
  this.onBeforeCopy = opts.onBeforeCopy || null; // (data, {range})=>false 取消 / 配列で差替え
@@ -145,6 +147,9 @@
145
147
  // 貼り付けがグリッド範囲を超えた時: 'clip'(既定, はみ出し分を切り捨て) | 'error'(貼り付け中止)
146
148
  this.pasteOverflow = opts.pasteOverflow || 'clip';
147
149
  this.onPasteOverflow = opts.onPasteOverflow || null; // (info) => void。error 時に呼ぶ
150
+ // 貼り付け元の行数がグリッド行数を超える時、先に不足分を insertRows で一括生成してから貼る(既定 false=従来のクランプ)。
151
+ // 行生成+値貼付を1つの history にまとめる(1回の undo で両方戻る)。maxRows 上限内・超過分は pasteOverflow に従う。
152
+ this.pasteAutoGrow = !!opts.pasteAutoGrow;
148
153
 
149
154
  this.active = { r: 0, c: 0 }; this.extent = { r: 0, c: 0 };
150
155
  this.mode = 'nav'; this.dragging = false; this.filling = null;
@@ -235,6 +240,9 @@
235
240
  this.virtual = !!opts.virtual;
236
241
  this._vBuf = (opts.virtual && typeof opts.virtual === 'object' && opts.virtual.buffer) || 6; // 窓の上下バッファ行数
237
242
  this._vStart = 0; this._vEnd = 0; this._vRAF = 0; this._vRendered = false;
243
+ this._vpStart = -1; this._vpEnd = -1; // onViewportChange の最後に通知した範囲(同一範囲の再発火を抑止)
244
+ this.dataComplete = true; // 遅延ロード(push): false のとき data に PENDING 行が混じる(setRowCount)。sort/filter/spare の判定に使う
245
+ this._pendingCount = 0; // PENDING 行数(fillRows で減らし 0 で dataComplete=true へ)
238
246
  if (this.virtual && (this.frozenRows || this.frozenCols || this.wordWrap)) {
239
247
  try { console.warn('[TssGrid] virtual:true は固定行列/折り返し非対応のため、それらを無効化しました'); } catch (_) {}
240
248
  this.frozenRows = 0; this.frozenCols = 0; this.wordWrap = false;
@@ -316,25 +324,106 @@
316
324
  }
317
325
 
318
326
  // ---- 取得用 API ----
319
- getData() { return this.data.map(row => row.slice()); } // 常に 2次元配列(後方互換)
320
- // オブジェクト配列で取り出す。_src(元の非表示フィールド)にキー列の現在値を上書きして返す。
327
+ // 常に 2次元配列(後方互換)。遅延ロード中は「ロード済みのみ」返す(未取得 PENDING 行は除外=AG Grid の as-rendered ポリシー。
328
+ // 全件が要る export はプラグインが全件 fillRows してから呼ぶ=dataComplete で通常経路)。
329
+ getData() { if (this.dataComplete) return this.data.map(row => row.slice()); const P = TssGrid.PENDING; return this.data.filter(row => row !== P).map(row => row.slice()); }
330
+ // オブジェクト配列で取り出す。_src(元の非表示フィールド)にキー列の現在値を上書きして返す。遅延ロード中はロード済みのみ。
321
331
  getRows() {
322
- return this.data.map((row, r) => {
332
+ const P = TssGrid.PENDING, out = [];
333
+ for (let r = 0; r < this.ROWS; r++) {
334
+ const row = this.data[r]; if (row === P) continue; // 未取得行は除外
323
335
  const o = this._src ? Object.assign({}, this._src[r]) : {};
324
336
  for (let c = 0; c < this.COLS; c++) { const k = this._key(c); if (k != null) this._setPathCOW(o, k, row[c]); }
325
- return o;
326
- });
337
+ out.push(o);
338
+ }
339
+ return out;
327
340
  }
328
341
  getRow(r) { return this.getRows()[r]; }
329
342
  // getRow の対(行→列)。1列分の値を全行ぶん配列で。列は index か data キー。
330
- getColumn(c) { const ci = this._resolveCol(c); return ci < 0 ? undefined : this.data.map(row => row[ci]); }
331
- // 全列を「列ごとの値配列」で。見出しキーは data キー > headers。getRows の列方向版。
343
+ getColumn(c) { const ci = this._resolveCol(c); if (ci < 0) return undefined; const rows = this.dataComplete ? this.data : this.data.filter(row => row !== TssGrid.PENDING); return rows.map(row => row[ci]); }
344
+ // 全列を「列ごとの値配列」で。見出しキーは data キー > headers。getRows の列方向版。遅延ロード中はロード済みのみ。
332
345
  getColumns() {
333
- const out = {};
334
- for (let c = 0; c < this.COLS; c++) { const k = this._key(c); out[k != null ? k : this.headers[c]] = this.data.map(row => row[c]); }
346
+ const out = {}, rows = this.dataComplete ? this.data : this.data.filter(row => row !== TssGrid.PENDING);
347
+ for (let c = 0; c < this.COLS; c++) { const k = this._key(c); out[k != null ? k : this.headers[c]] = rows.map(row => row[c]); }
335
348
  return out;
336
349
  }
337
- setData(rows) { this._align.clear(); this._cellRO.clear(); this._unsortedData = null; this._allRows = this._allSrc = this._meta = this._filterFn = null; if (this.history) this.history.clear(); this._ingest(rows); this._padToMin(); this.buildTable(); const ic = this._resolveInitialCell(); this.setActive(ic[0], ic[1]); this._ensureSpare(); }
350
+ setData(rows) { this._align.clear(); this._cellRO.clear(); this._unsortedData = null; this._allRows = this._allSrc = this._meta = this._filterFn = null; this.dataComplete = true; this._pendingCount = 0; if (this.history) this.history.clear(); this._ingest(rows); this._padToMin(); this.buildTable(); const ic = this._resolveInitialCell(); this.setActive(ic[0], ic[1]); this._ensureSpare(); }
351
+ // 背景で"育てる"追記(progressive): data 末尾へ rows を足し ROWS を更新、可視窓+上下スペーサだけ再描画する。
352
+ // buildTable 全再描画をしない=スクロール位置・アクティブセル・フォーカス・選択範囲・編集中のエディタと値を保つ。
353
+ // history には積まない(背景成長は undo 対象外)。既存の setData/insertRows 経路は不変=完全 opt-in。
354
+ // 用途: プラグインが「先頭ページを描く→残りを idle で appendRows」して first-paint を早める土台。
355
+ // rows は現在のデータと同形(オブジェクトモードならオブジェクト配列/配列モードなら行配列)。opts は将来用(keepScroll/noHistory は
356
+ // 実装が tbody 差し替えのみ=スクロール保持・履歴不変を常に満たすため現状は指定不要)。
357
+ // 注意: フィルタ中(_allRows)はマスタにも積んで desync を防ぐ(部分ロード中のフィルタは想定外だが壊さない)。
358
+ // 非virtual+minSpareRows では追記行が末尾スペア行の後ろに入る(育成は主に virtual 用途)。
359
+ appendRows(rows, opts = {}) {
360
+ if (!Array.isArray(rows) || !rows.length) return;
361
+ const n0 = this.ROWS;
362
+ for (const src of rows) {
363
+ const srcIsObj = src && !Array.isArray(src) && typeof src === 'object';
364
+ const srcObj = srcIsObj ? Object.assign({}, src) : {};
365
+ let rowArr;
366
+ if (srcIsObj) rowArr = this._objToRow(src); // オブジェクト→キー列で行配列化
367
+ else { rowArr = Array.isArray(src) ? src.slice(0, this.COLS) : []; while (rowArr.length < this.COLS) rowArr.push(''); } // 配列は COLS 長に整える
368
+ this.data.push(rowArr);
369
+ this.rowH.push(undefined);
370
+ if (this._src) this._src.push(srcObj);
371
+ if (this._meta) this._meta.set(rowArr, { a: new Map(), ro: new Set(), h: undefined }); // フィルタが張った per-row メタも用意
372
+ if (this._allRows) { this._allRows.push(rowArr); if (this._allSrc) this._allSrc.push(srcObj); } // フィルタ中はマスタも同期
373
+ }
374
+ this.ROWS = this.data.length;
375
+ if (!this.table) return; // 未マウントなら次の buildTable が拾う
376
+ if (this.virtual) {
377
+ // 窓+上下スペーサを作り直す(force)。下スペーサ高が ROWS で再計算され新行までスクロール可能に。
378
+ // 窓が末尾に無い(新行が可視外)時も安全=可視 ~33 行の innerHTML 差し替えのみ・scrollTop 不変・
379
+ // エディタは tbody 外のオーバーレイなので値も維持。
380
+ this._renderWindow(this._vWindow(), true);
381
+ } else {
382
+ const tbody = this.table.querySelector('tbody');
383
+ if (tbody) { let html = ''; for (let r = n0; r < this.ROWS; r++) html += this._rowHTML(r); tbody.insertAdjacentHTML('beforeend', html); }
384
+ }
385
+ }
386
+ // 遅延ロード(push・Phase B): 総行数を total に確保する。現行の行はそのまま・不足分を PENDING 行で埋め、超過は切る。
387
+ // dataComplete=false になり、この間 minSpareRows は自動無効(末尾スペアと PENDING tail は両立不可)。
388
+ // virtual 前提(PENDING 領域はスペーサに畳まれ描画されない=軽い)。プラグインは onViewportChange→fillRows で実データを流し込む。
389
+ // setData で解除(実データに差し替わり dataComplete=true)。
390
+ setRowCount(total) {
391
+ total = Math.max(0, total | 0);
392
+ const cur = this.ROWS;
393
+ if (total > cur) { for (let r = cur; r < total; r++) { this.data.push(TssGrid.PENDING); this.rowH.push(undefined); } }
394
+ else if (total < cur) { this.data.length = total; this.rowH.length = total; }
395
+ this.ROWS = total;
396
+ let pend = 0; for (let r = 0; r < this.ROWS; r++) if (this.data[r] === TssGrid.PENDING) pend++; // 確保時の一度きり O(N)。以降 fillRows が差分で維持
397
+ this._pendingCount = pend; this.dataComplete = pend === 0;
398
+ if (this.active.r >= this.ROWS) this.active = { r: Math.max(0, this.ROWS - 1), c: this.active.c }; // アクティブが範囲外なら詰める
399
+ if (this.table) { if (this.virtual) this._renderWindow(this._vWindow(), true); else this.buildTable(); }
400
+ }
401
+ // 遅延ロード(push・Phase B): PENDING 行 [start, start+rows.length) を実データに差し替える(onViewportChange への応答)。
402
+ // 塗り替え範囲が可視窓に重なる時だけ窓を再描画(buildTable しない)=スクロール/選択/アクティブは不変。窓外は data 差し替えのみ。
403
+ // rows は現在のデータと同形(オブジェクトモードはオブジェクト配列/配列モードは行配列)。範囲外(>=ROWS)は無視。
404
+ fillRows(start, rows) {
405
+ if (!Array.isArray(rows) || !rows.length) return;
406
+ start = Math.max(0, start | 0);
407
+ for (let i = 0; i < rows.length; i++) {
408
+ const r = start + i; if (r >= this.ROWS) break;
409
+ const src = rows[i];
410
+ const srcIsObj = src && !Array.isArray(src) && typeof src === 'object';
411
+ const srcObj = srcIsObj ? Object.assign({}, src) : {};
412
+ let rowArr;
413
+ if (srcIsObj) rowArr = this._objToRow(src);
414
+ else { rowArr = Array.isArray(src) ? src.slice(0, this.COLS) : []; while (rowArr.length < this.COLS) rowArr.push(''); }
415
+ if (this.data[r] === TssGrid.PENDING) this._pendingCount--; // PENDING→実データ(既に実データの再fill は count 不変)
416
+ this.data[r] = rowArr;
417
+ if (this._src) this._src[r] = srcObj;
418
+ if (this._meta) this._meta.set(rowArr, { a: new Map(), ro: new Set(), h: undefined });
419
+ }
420
+ if (this._pendingCount < 0) this._pendingCount = 0;
421
+ this.dataComplete = this._pendingCount === 0;
422
+ if (!this.table) return;
423
+ if (this.virtual) {
424
+ if (start < this._vEnd && start + rows.length > this._vStart) this._renderWindow(this._vWindow(), true); // 可視窓に重なる時だけ再描画
425
+ } else this.buildTable();
426
+ }
338
427
  // 初期カーソル位置を [r,c] に解決(c は index か data キー)。範囲内へクランプ。
339
428
  _resolveInitialCell() {
340
429
  const ic = this.initialCell; if (!ic) return [0, 0];
@@ -371,6 +460,7 @@
371
460
  const lines = [];
372
461
  if (o.headers && this.headers) lines.push(this.headers.slice(0, this.COLS).map(esc).join(','));
373
462
  for (let r = 0; r < this.ROWS; r++) {
463
+ if (this.data[r] === TssGrid.PENDING) continue; // 遅延ロード: 未取得行は出さない(loaded-only)
374
464
  if (o.skipEmpty && this._rowEmpty(r)) continue;
375
465
  const cells = [];
376
466
  for (let c = 0; c < this.COLS; c++) cells.push(esc(o.formatted ? this._displayValue(r, c) : this.data[r][c]));
@@ -434,8 +524,27 @@
434
524
  const na = TssGrid.toNum(va), nb = TssGrid.toNum(vb);
435
525
  return (na !== null && nb !== null) ? na - nb : String(va).localeCompare(String(vb));
436
526
  }
527
+ // 遅延ロード(push): sort/filter は「グリッドが計算しない・サーバ再フェッチ」(AG Grid ポリシー)。
528
+ // 状態を onSortFilterChange で通知し、ロード済みを PENDING へ戻す(パージ)=プラグインが新順で取り直す。
529
+ // 通知先が未設定なら破壊せず no-op(パージで画面を空にしない安全側)。呼び側は !dataComplete で本メソッドを使う。
530
+ _lazySortFilter(state) {
531
+ if (this.onSortFilterChange) {
532
+ try { this.onSortFilterChange(state); } catch (_) {}
533
+ this._purgeToPending(); // 窓再描画で onViewportChange も発火=プラグインが可視域を新順で取り直す
534
+ } else {
535
+ try { console.warn('[TssGrid] 遅延ロード中の sort/filter はサーバ側で解決してください(onSortFilterChange 未設定のため no-op)'); } catch (_) {}
536
+ }
537
+ }
538
+ // ロード済み行を全て PENDING に戻す(sort/filter 変更でブロックが陳腐化=AG Grid の cache purge 相当)。
539
+ _purgeToPending() {
540
+ for (let r = 0; r < this.ROWS; r++) { this.data[r] = TssGrid.PENDING; if (this._src) this._src[r] = {}; }
541
+ this._pendingCount = this.ROWS; this.dataComplete = false;
542
+ this._vpStart = -1; this._vpEnd = -1; // 次の窓通知を必ず出す(新順の取り直しを促す)
543
+ if (this.table) { if (this.virtual) this._renderWindow(this._vWindow(), true); else this.buildTable(); }
544
+ }
437
545
  // 列 c(index か data キー)で並べ替え。dir='asc'(既定)|'desc'。数値っぽければ数値比較、でなければ文字列比較。
438
546
  sortBy(c, dir) {
547
+ if (!this.dataComplete) return this._lazySortFilter({ type: 'sort', sort: [{ col: c, dir: dir || 'asc' }] }); // 遅延ロード中はサーバ委譲
439
548
  if (this._sortLocked()) return; // 結合中はソート禁止(値による組み替えで結合帯が散らばる)
440
549
  const ci = this._resolveCol(c); if (ci < 0) return;
441
550
  const sgn = dir === 'desc' ? -1 : 1;
@@ -447,6 +556,7 @@
447
556
  // 多段(複数列)ソート。specs=[{col, dir}] を優先順(先頭=第1キー)に。col=index か data キー、dir='asc'(既定)|'desc'。
448
557
  // 各段は sortBy と同じセル比較。前段が同値のときだけ次段で決める(安定ソート=残った同値は現在の並びを保つ)。空行は最下部固定。
449
558
  sortByCols(specs) {
559
+ if (!this.dataComplete) return this._lazySortFilter({ type: 'sort', sort: specs }); // 遅延ロード中はサーバ委譲
450
560
  if (this._sortLocked()) return; // 結合中はソート禁止
451
561
  const keys = (specs || [])
452
562
  .map(s => ({ ci: this._resolveCol(s && s.col), sgn: (s && s.dir === 'desc') ? -1 : 1 }))
@@ -462,6 +572,7 @@
462
572
  }
463
573
  // 任意比較で並べ替え。cmp(rowA, rowB) は行(配列 or オブジェクト)を受ける。空行は最下部固定。
464
574
  sortRows(cmp) {
575
+ if (!this.dataComplete) return this._lazySortFilter({ type: 'sort', sort: 'custom' }); // 遅延ロード中はサーバ委譲(JS比較子はサーバへ渡せない=custom 通知のみ)
465
576
  if (this._sortLocked()) return; // 結合中はソート禁止
466
577
  this._rememberOrder();
467
578
  const rows = this._src ? this.getRows() : this.data;
@@ -519,6 +630,7 @@
519
630
  }
520
631
  // 絞り込み。pred=(row, masterIndex, src)=>bool / {col:value|fn} / falsy=解除。非破壊(編集は参照でマスタへ反映)。
521
632
  filter(pred) {
633
+ if (!this.dataComplete) { this._lazySortFilter({ type: 'filter', filter: pred }); return false; } // 遅延ロード中はサーバ委譲(JS述語はサーバへ渡せない=通知のみ)
522
634
  if (this._merges && this._merges.length) return false; // v1: 結合中は不可
523
635
  const fn = this._normFilter(pred);
524
636
  if (!fn) return this.clearFilter();
@@ -665,13 +777,14 @@
665
777
  activeTd() { return this.cellEl(this.active.r, this.active.c); }
666
778
  clampR(r) { return Math.max(0, Math.min(this.ROWS - 1, r)); }
667
779
  clampC(c) { return Math.max(0, Math.min(this.COLS - 1, c)); }
668
- renderDump() { if (this.dumpEl) this.dumpEl.textContent = JSON.stringify(this.data); if (this.onChange) this.onChange(this); }
780
+ renderDump() { if (this._renderPaused) { this._renderDirty = true; return; } if (this.dumpEl) this.dumpEl.textContent = JSON.stringify(this.data); if (this.onChange) this.onChange(this); }
669
781
 
670
782
  // ---- 列定義(セルタイプ / バリデータ) ----
671
783
  colCfg(c) { return this.columns[c] || {}; }
672
784
  colType(c) { return this.colCfg(c).type || 'text'; }
673
785
  // 読み取り専用判定(グリッド全体 / 列 readOnly: bool/関数 / セル単位 setCellReadOnly)。
674
786
  _isReadOnly(r, c) {
787
+ if (this.data[r] === TssGrid.PENDING) return true; // 遅延ロード: 未取得セルは「読込中」=編集不可(fillRows で実データが来たら普通に編集可)
675
788
  if (this.readOnly) return true;
676
789
  if (this._merges && this._isCovered(r, c)) return true; // 結合の従属セルは編集不可(貼付/フィルは既存の readOnly 経路で巻戻し)
677
790
  if (this._cellRO.has(r + ',' + c)) return true; // セル単位の実行時トグル
@@ -734,6 +847,7 @@
734
847
  _structRemapFn(info) {
735
848
  switch (info.type) {
736
849
  case 'insertRow': { const at = info.at; return (r, c) => [r >= at ? r + 1 : r, c]; }
850
+ case 'insertRows': { const { at, count } = info; return (r, c) => [r >= at ? r + count : r, c]; }
737
851
  case 'deleteRows': { const { r0, r1 } = info, n = r1 - r0 + 1; return (r, c) => (r >= r0 && r <= r1) ? null : [r > r1 ? r - n : r, c]; }
738
852
  case 'insertCol': { const at = info.at; return (r, c) => [r, c >= at ? c + 1 : c]; }
739
853
  case 'deleteCols': { const { c0, c1 } = info, m = c1 - c0 + 1; return (r, c) => (c >= c0 && c <= c1) ? null : [r, c > c1 ? c - m : c]; }
@@ -749,6 +863,7 @@
749
863
  for (const m of this._merges) {
750
864
  let { r, c, colspan } = m, rowspan = m.rowspan || 1;
751
865
  if (info.type === 'insertRow') { const at = info.at; if (at <= r) r++; else if (at < r + rowspan) rowspan++; }
866
+ else if (info.type === 'insertRows') { const { at, count } = info; if (at <= r) r += count; else if (at < r + rowspan) rowspan += count; }
752
867
  else if (info.type === 'deleteRows') {
753
868
  const { r0, r1 } = info, n = r1 - r0 + 1, top = r, bot = r + rowspan - 1;
754
869
  if (r0 <= top && r1 >= bot) continue; // 縦方向すべて消える
@@ -1025,6 +1140,7 @@
1025
1140
  if (keys.length) td.dataset.cstyle = keys.join(' '); else delete td.dataset.cstyle;
1026
1141
  }
1027
1142
  _renderCell(r, c) {
1143
+ if (this._renderPaused) { this._renderDirty = true; return; } // バッチ描画中は最後の buildTable が全セルを一括で描く
1028
1144
  if (this._merges && this._isCovered(r, c)) return; // 従属セルは描画対象なし(cellEl はアンカーへ解決=誤って上書きしない)
1029
1145
  const td = this.cellEl(r, c); if (!td) return;
1030
1146
  const ph = (this.data[r][c] === '' && this.colType(c) !== 'checkbox') ? this._placeholder(c) : null;
@@ -1177,7 +1293,15 @@
1177
1293
  });
1178
1294
  }
1179
1295
  // 1行ぶんの <tr>…</tr> を生成(buildTable の本体ループと、仮想スクロールの窓再描画 _renderWindow で共用)。
1296
+ // 遅延ロード: 未取得(PENDING)行は data[r][c] を一切読まず「読込中」プレースホルダ行を描く。
1297
+ _pendingRowHTML(r) {
1298
+ const rh = this.rowHeaders;
1299
+ const head = rh ? '<th class="rowhead" data-r="' + r + '">' + this._rowHeadLabel(r) + '</th>' : '';
1300
+ const cell = '<td class="tg-pending-cell" colspan="' + this.COLS + '" data-r="' + r + '" data-c="0"><span class="tg-pending-dots"></span></td>';
1301
+ return '<tr data-r="' + r + '" class="tg-row tg-pending-row" style="height:' + this._rowHeight(r) + 'px">' + head + cell + '</tr>';
1302
+ }
1180
1303
  _rowHTML(r) {
1304
+ if (this.data[r] === TssGrid.PENDING) return this._pendingRowHTML(r); // 未取得行=読込中プレースホルダ(値は読まない)
1181
1305
  const rh = this.rowHeaders;
1182
1306
  const rgrip = (rh && this.resizeRows) ? '<div class="tg-rowgrip"></div>' : '';
1183
1307
  const canMove = rh && this.rowReorder && !this._hasVMerge();
@@ -1251,8 +1375,15 @@
1251
1375
  this._vStart = win.start; this._vEnd = win.end; this._vRendered = true;
1252
1376
  this._syncHeaderCheckboxes();
1253
1377
  this.updateSelectionUI(); // セルが作り直されたので窓内の選択シェード/オーバーレイを再適用
1378
+ this._fireViewport(win.start, win.end); // 遅延ロード: 窓が変わったらプラグインへ通知(PENDING を fillRows で埋めさせる)
1254
1379
  return true;
1255
1380
  }
1381
+ // 遅延ロード: 描画窓が変わった時だけ onViewportChange(start,end) を発火(同一範囲は抑止=fillRows→再描画の再帰も止まる)。
1382
+ _fireViewport(start, end) {
1383
+ if (!this.onViewportChange || (start === this._vpStart && end === this._vpEnd)) return;
1384
+ this._vpStart = start; this._vpEnd = end;
1385
+ try { this.onViewportChange(start, end); } catch (_) {}
1386
+ }
1256
1387
  // 仮想スクロール: スクロールで窓がずれたら再描画(rAF スロットル)。
1257
1388
  _vSync() {
1258
1389
  if (!this.virtual || this._vRAF) return;
@@ -1275,6 +1406,7 @@
1275
1406
  this._renderWindow(this._vWindow(), false); // 窓を即同期(place 前に DOM を確定)
1276
1407
  }
1277
1408
  buildTable() {
1409
+ if (this._renderPaused) { this._renderDirty = true; return; } // バッチ描画中は最後にまとめて1回(_withBatchedRender)
1278
1410
  if (this._customEditor && this._customCancel) this._customCancel(); // 開いているカスタムエディタを閉じる(body直下ポップアップの取り残し防止・_commitActive と同じ扱い)
1279
1411
  this._selhdrFg = null; // 選択ヘッダ文字色キャッシュ破棄(テーマ変更に追従)
1280
1412
  const rh = this.rowHeaders;
@@ -1331,6 +1463,7 @@
1331
1463
  this._syncHeaderCheckboxes();
1332
1464
  this._buildGeomCache(); // 実測幾何キャッシュ(全 _apply 後=幅高が確定してから)
1333
1465
  this.renderDump();
1466
+ if (this.virtual) this._fireViewport(this._vStart, this._vEnd); // 遅延ロード: 初期/再構築後の窓もプラグインへ通知(table 確定後に発火)
1334
1467
  }
1335
1468
  // 各リーフ列ヘッダth(data-c 付き)を生成後に onHeaderRender へ渡す。ソートアイコン等の差し込み用。
1336
1469
  _applyHeaderRender() {
@@ -1519,13 +1652,17 @@
1519
1652
  // 選択範囲が及ぶ行/列ヘッダをハイライト(Excel 風のアフォーダンス)。仮想時は行ヘッダを窓内にクランプ(大量行で querySelector を無駄打ちしない)。
1520
1653
  const hlo = this.virtual ? Math.max(r0, this._vStart) : r0;
1521
1654
  const hhi = this.virtual ? Math.min(r1, this._vEnd - 1) : r1;
1655
+ // 巨大選択(大量貼付/全選択など)は per-cell の背景シェードと行ヘッダ塗りを省き、selrange アウトライン(O(1)・
1656
+ // スクロール追従)に任せる=O(行×列) の DOM 変異を回避(1000行貼付で updateSelectionUI ~175ms→~1ms)。
1657
+ // 画面に収まる通常選択(~2000セル未満)は従来どおり全セルをシェード=見た目不変。
1658
+ const rows = hhi - hlo + 1, bigSel = rows > 300 || rows * (c1 - c0 + 1) > 2000;
1522
1659
  for (let c = c0; c <= c1; c++) { const th = this.table.querySelector('thead th[data-c="' + c + '"]'); if (th) th.classList.add('selhdr'); }
1523
- for (let r = hlo; r <= hhi; r++) { const th = this.table.querySelector('th.rowhead[data-r="' + r + '"]'); if (th) th.classList.add('selhdr'); }
1660
+ if (!bigSel) for (let r = hlo; r <= hhi; r++) { const th = this.table.querySelector('th.rowhead[data-r="' + r + '"]'); if (th) th.classList.add('selhdr'); }
1524
1661
  this.wrap.style.setProperty('--tg-selhdr-fg', this._selhdrTextColor()); // 選択ヘッダ文字色を背景輝度から自動(暗背景→白)
1525
1662
  if (r0 === r1 && c0 === c1) return;
1526
1663
  const clampMerge = this._headerSel && this._merges; // 列/行選択では「範囲に収まらない結合」は塗らない(帯を塗り広げない)
1527
1664
  // 仮想スクロール: 窓内の行だけシェード(範囲枠 selrange は placeRect がモデル幾何で全域を描く)。
1528
- for (let r = hlo; r <= hhi; r++) for (let c = c0; c <= c1; c++) {
1665
+ if (!bigSel) for (let r = hlo; r <= hhi; r++) for (let c = c0; c <= c1; c++) {
1529
1666
  if (clampMerge) {
1530
1667
  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;
1531
1668
  if (a.r < r0 || mr1 > r1 || a.c < c0 || mc1 > c1) continue; // 結合が選択範囲に収まらない=塗らない
@@ -1561,6 +1698,7 @@
1561
1698
  return (this._selhdrFg = L < 0.55 ? '#fff' : '#1a3a6b');
1562
1699
  }
1563
1700
  updateSelectionUI() {
1701
+ if (this._renderPaused) return; // バッチ描画中は選択枠の位置決めを保留(resume 後に1回)
1564
1702
  if (!this.cursor) { // 表示専用: 選択枠・範囲・フィルハンドルを出さない(選択自体は内部に保持)
1565
1703
  this.selbox.style.display = 'none'; this.selrange.style.display = 'none'; this.fillhandle.style.display = 'none';
1566
1704
  return;
@@ -1730,6 +1868,7 @@
1730
1868
  }
1731
1869
  focusAndSelect(sel) {
1732
1870
  this.active = { ...sel.active }; this.extent = { ...sel.extent };
1871
+ if (this._renderPaused) { this._pendingSel = sel; return; } // バッチ描画中は選択を記録だけ→resume で1回反映
1733
1872
  if (this.virtual) this._vEnsureVisible(this.active.r);
1734
1873
  this._syncEditorNav(); this.editor.focus(); this.updateSelectionUI();
1735
1874
  if (!this.virtual) this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' });
@@ -2057,7 +2196,8 @@
2057
2196
  }
2058
2197
  // 末尾に常に minSpareRows 行の空行を確保(足りなければ追加。履歴には積まない)。
2059
2198
  _ensureSpare() {
2060
- if (!this.minSpareRows || this._allRows) return; // フィルタ中は末尾の自動空行を足さない(マスタ desync 回避)
2199
+ if (this._renderPaused) { this._renderDirty = true; return; } // バッチ描画中は保留→resume でまとめて確保
2200
+ if (!this.minSpareRows || this._allRows || !this.dataComplete) return; // フィルタ中/遅延ロード中(PENDING tail)は末尾の自動空行を足さない
2061
2201
  let empties = 0;
2062
2202
  for (let r = this.ROWS - 1; r >= 0; r--) { if (this._rowEmpty(r)) empties++; else break; }
2063
2203
  let added = 0;
@@ -2089,26 +2229,40 @@
2089
2229
  if (at - 1 >= 0) { const i = this._allRows.indexOf(this.data[at - 1]); if (i >= 0) return i + 1; }
2090
2230
  return this._allRows.length;
2091
2231
  }
2092
- insertRow(ri, where = 'above') {
2232
+ insertRow(ri, where = 'above') { this.insertRows(ri, 1, where); }
2233
+ // 一括挿入プリミティブ: count 行を1回の buildTable・1つの history コマンドで挿入(Undo/Redo が1手で count 行まとめて戻る)。
2234
+ // 大量貼付など「まとめて足す」を O(N²)(1行ずつ insertRow)から O(N) に。既定 count=1 は insertRow と挙動等価。
2235
+ insertRows(ri, count = 1, where = 'above') {
2093
2236
  if (!this.allowInsertRows) return;
2094
- if (this.maxRows && this.ROWS >= this.maxRows) return; // 上限
2237
+ count = Math.max(0, count | 0);
2238
+ if (this.maxRows) count = Math.min(count, this.maxRows - this.ROWS); // 上限までにクランプ(超過分は足さない)
2239
+ if (count <= 0) return;
2095
2240
  if (this.mode === 'edit') this._commitActive();
2096
2241
  this._normalizeArrays();
2097
2242
  const at = Math.max(0, Math.min(this.ROWS, where === 'below' ? ri + 1 : ri));
2098
2243
  const before = this.snapSel(), grid = this, c = this.active.c, frozenBump = at < this.frozenRows;
2099
- let ref; // 挿入した行参照(フィルタ中の _allRows 追従・undo 用に保持)
2244
+ let refs; // 挿入した行参照の配列(フィルタ中の _allRows 追従・undo 用に保持)
2100
2245
  const doIt = () => {
2101
- ref = new Array(grid.COLS).fill('');
2102
- grid.data.splice(at, 0, ref); grid.rowH.splice(at, 0, undefined); if (grid._src) grid._src.splice(at, 0, {}); grid.ROWS++;
2103
- if (grid._allRows) { const mi = grid._masterInsertIndex(at); grid._allRows.splice(mi, 0, ref); if (grid._allSrc) grid._allSrc.splice(mi, 0, {}); } // マスタにも挿入
2104
- if (frozenBump) grid.frozenRows++;
2246
+ refs = []; const hs = [], srcs = grid._src ? [] : null;
2247
+ for (let k = 0; k < count; k++) { refs.push(new Array(grid.COLS).fill('')); hs.push(undefined); if (srcs) srcs.push({}); }
2248
+ // マスタ挿入位置は data を splice する前の位置関係から決める(_masterInsertIndex と同じ規則をバッチ用に)
2249
+ let mi = -1;
2250
+ if (grid._allRows) {
2251
+ mi = grid._allRows.length;
2252
+ if (at < grid.data.length) { const i = grid._allRows.indexOf(grid.data[at]); if (i >= 0) mi = i; }
2253
+ else if (at - 1 >= 0) { const i = grid._allRows.indexOf(grid.data[at - 1]); if (i >= 0) mi = i + 1; }
2254
+ }
2255
+ grid.data.splice(at, 0, ...refs); grid.rowH.splice(at, 0, ...hs); if (grid._src) grid._src.splice(at, 0, ...srcs); grid.ROWS += count;
2256
+ if (grid._allRows) { grid._allRows.splice(mi, 0, ...refs); if (grid._allSrc) grid._allSrc.splice(mi, 0, ...refs.map(() => ({}))); } // マスタにも一括挿入
2257
+ if (frozenBump) grid.frozenRows += count;
2105
2258
  };
2106
2259
  const undoIt = () => {
2107
- grid.data.splice(at, 1); grid.rowH.splice(at, 1); if (grid._src) grid._src.splice(at, 1); grid.ROWS--;
2108
- if (grid._allRows && ref) { const i = grid._allRows.indexOf(ref); if (i >= 0) { grid._allRows.splice(i, 1); if (grid._allSrc) grid._allSrc.splice(i, 1); } }
2109
- if (frozenBump) grid.frozenRows--;
2260
+ grid.data.splice(at, count); grid.rowH.splice(at, count); if (grid._src) grid._src.splice(at, count); grid.ROWS -= count;
2261
+ if (grid._allRows && refs) { for (const ref of refs) { const i = grid._allRows.indexOf(ref); if (i >= 0) { grid._allRows.splice(i, 1); if (grid._allSrc) grid._allSrc.splice(i, 1); } } }
2262
+ if (frozenBump) grid.frozenRows -= count;
2110
2263
  };
2111
- this._structCmd(doIt, undoIt, () => ({ active: { r: at, c }, extent: { r: at, c } }), before, { type: 'insertRow', at });
2264
+ const type = count === 1 ? 'insertRow' : 'insertRows'; // 単行は従来どおり type:'insertRow'(後方互換)/複数は 'insertRows'
2265
+ this._structCmd(doIt, undoIt, () => ({ active: { r: at, c }, extent: { r: at + count - 1, c } }), before, { type, at, count });
2112
2266
  }
2113
2267
  deleteRows(r0, r1) {
2114
2268
  if (!this.allowDeleteRows) return;
@@ -2207,6 +2361,26 @@
2207
2361
  revert() { for (let i = batch.length - 1; i >= 0; i--) batch[i].revert(); },
2208
2362
  });
2209
2363
  }
2364
+ // 大量セル更新を「最後に1回 buildTable」へ畳む。中の buildTable/_renderCell/renderDump/選択UI は保留され、
2365
+ // resume 時に buildTable 1回+選択反映+renderDump 1回だけ走る(1行ずつ描く N 回の DOM 変異を消す)。
2366
+ // 例: 1000×5 の貼付が per-cell 描画 ~500ms → 一括 ~80ms。入れ子は外側が最後にまとめて描く。
2367
+ _withBatchedRender(fn) {
2368
+ if (this._renderPaused) return fn(); // 入れ子: 外側の resume に任せる
2369
+ this._renderPaused = true; this._renderDirty = false; this._pendingSel = null;
2370
+ let ret;
2371
+ try { ret = fn(); }
2372
+ finally {
2373
+ this._renderPaused = false;
2374
+ if (this._renderDirty) {
2375
+ this.buildTable(); // 全セルを1回のHTML生成で描く(末尾で renderDump=dumpEl/onChange も1回発火)
2376
+ this._ensureSpare(); // 保留していた末尾スペア行の確保
2377
+ const s = this._pendingSel; this._pendingSel = null;
2378
+ if (s) this.focusAndSelect(s); // 選択枠・スクロールを1回だけ
2379
+ }
2380
+ this._renderDirty = false;
2381
+ }
2382
+ return ret;
2383
+ }
2210
2384
 
2211
2385
  // ---- 右クリックメニュー ----
2212
2386
  _onContextMenu(e) {
@@ -2466,6 +2640,21 @@
2466
2640
  if (Array.isArray(ret)) g = ret;
2467
2641
  if (!g || !g.length) return;
2468
2642
  }
2643
+ // pasteAutoGrow=true では「行の一括生成」と「値の貼付」を1つの history にまとめる(1回の undo で両方戻る)。
2644
+ // 非オートグロー時は history.push が1回だけ=_batchHistory は素通し(従来とバイト等価)。
2645
+ let after;
2646
+ const body = () => { this._batchHistory(() => { after = this._pasteGridBody(g); }); };
2647
+ // 大量貼付だけ _withBatchedRender で「最後に buildTable 1回」へ畳む(per-cell 描画 N 回を消す=1000行 ~500ms→~80ms)。
2648
+ // 小さな貼付を大きなグリッドに落とす時は従来どおり per-cell(全再描画の方が高くつくため回避)。
2649
+ const willGrow = this.pasteAutoGrow && (this.active.r + g.length > this.ROWS);
2650
+ const heavy = willGrow || (g.length >= 8 && g.length * 5 >= this.ROWS); // 貼付が表の ~20% 以上を占める or 行が増える時だけ畳む
2651
+ if (heavy) this._withBatchedRender(body); else body();
2652
+ if (after && this.onAfterPaste) { // onAfterPaste は batch の外で呼ぶ(コールバック内の履歴操作を貼付コマンドに混ぜない)
2653
+ const range = { r0: after.active.r, c0: after.active.c, r1: after.extent.r, c1: after.extent.c };
2654
+ try { this.onAfterPaste(g, { range }); } catch (_) {}
2655
+ }
2656
+ }
2657
+ _pasteGridBody(g) {
2469
2658
  const before = this.snapSel(), changes = [], sel = this.rectRange();
2470
2659
  const visAll = this._visCols();
2471
2660
  let after;
@@ -2477,18 +2666,26 @@
2477
2666
  const r0 = this.active.r, c0 = this.active.c;
2478
2667
  let maxw = 0; for (let i = 0; i < g.length; i++) maxw = Math.max(maxw, g[i].length);
2479
2668
  const p0 = visAll.indexOf(c0), availCols = visAll.length - p0; // 貼り付けは可視列へ(隠し列をスキップ)
2480
- // 範囲超過チェック(オプトイン): はみ出すなら貼り付けを中止してエラー通知
2481
- if (this.pasteOverflow === 'error' && (r0 + g.length > this.ROWS || maxw > availCols)) {
2669
+ // オートグロー時に伸ばせる上限行数(maxRows があればそこまで/無ければ無制限)。非オートグローは現状の ROWS。
2670
+ const capRows = this.pasteAutoGrow ? (this.maxRows || Infinity) : this.ROWS;
2671
+ // 範囲超過チェック(オプトイン): 伸ばしても収まらない or 列がはみ出すなら貼り付けを中止してエラー通知(行生成の前に判定=原子性)
2672
+ if (this.pasteOverflow === 'error' && (r0 + g.length > capRows || maxw > availCols)) {
2482
2673
  if (this.onPasteOverflow) {
2483
2674
  try {
2484
2675
  this.onPasteOverflow({
2485
2676
  anchor: { r: r0, c: c0 }, height: g.length, width: maxw, rows: this.ROWS, cols: this.COLS,
2486
- overRows: Math.max(0, r0 + g.length - this.ROWS), overCols: Math.max(0, maxw - availCols),
2677
+ overRows: Math.max(0, r0 + g.length - capRows), overCols: Math.max(0, maxw - availCols),
2487
2678
  });
2488
2679
  } catch (_) {}
2489
2680
  }
2490
2681
  return; // 何も変更しない
2491
2682
  }
2683
+ // オートグロー: 不足分を先に一括生成(buildTable 1回・history 1コマンド)。超過は maxRows でクランプ、残りは pasteOverflow に従う。
2684
+ if (this.pasteAutoGrow && r0 + g.length > this.ROWS) {
2685
+ const target = this.maxRows ? Math.min(this.maxRows, r0 + g.length) : r0 + g.length;
2686
+ const grow = target - this.ROWS;
2687
+ if (grow > 0) this.insertRows(this.ROWS - 1, grow, 'below');
2688
+ }
2492
2689
  let lastC = c0;
2493
2690
  for (let i = 0; i < g.length; i++) for (let j = 0; j < g[i].length; j++) {
2494
2691
  const r = r0 + i, c = visAll[p0 + j]; if (r < this.ROWS && c != null) { this.setCell(r, c, g[i][j], changes); lastC = c; }
@@ -2502,10 +2699,7 @@
2502
2699
  this.pendingCut = null; this.copybox.style.display = 'none'; }
2503
2700
  this.pushCmd(changes, before, after, 'paste');
2504
2701
  this.focusAndSelect(after);
2505
- if (this.onAfterPaste) {
2506
- const range = { r0: after.active.r, c0: after.active.c, r1: after.extent.r, c1: after.extent.c };
2507
- try { this.onAfterPaste(g, { range }); } catch (_) {}
2508
- }
2702
+ return after;
2509
2703
  }
2510
2704
  clearCopyMarquee() { this.copybox.style.display = 'none'; this.pendingCut = null; }
2511
2705
 
@@ -2587,6 +2781,20 @@
2587
2781
  // input と textarea(multiline) の両方に同じ編集入口リスナを張る(this.editor はアクティブ列で張り替わる)。
2588
2782
  [this._edInput, this._edMulti].forEach((ed) => {
2589
2783
  ed.addEventListener('keydown', (e) => this._onKey(e));
2784
+ // 貼り付けの主経路=ネイティブ paste イベント(clipboardData.getData)。Ctrl+V のジェスチャー内で
2785
+ // 同期に読める=許可プロンプト不要+堅牢(readText の権限/非セキュア/非フォーカスで落ちる問題を回避)。
2786
+ // nav 中の editor は focus 代理なので native 挿入は常に止める(copyPaste=false でも挿入させない)。
2787
+ // 編集中は横取りせず native 貼付(1セルへの通常ペースト)に任せる。paste は beforeinput より先に来るため、
2788
+ // ここで preventDefault すれば beforeinput(insertFromPaste) の誤発火=エディタ誤オープンも起きない。
2789
+ // 右クリックメニューの「貼り付け」/プログラム呼び出しは paste イベントを伴わないので paste()=readText 経路を温存。
2790
+ ed.addEventListener('paste', (e) => {
2791
+ if (this.mode !== 'nav') return;
2792
+ e.preventDefault();
2793
+ if (!this.copyPaste) return;
2794
+ const text = (e.clipboardData && e.clipboardData.getData('text')) || '';
2795
+ const g = (text && text.length) ? TssGrid.parseTSV(text) : sharedClip; // clipboard 空/非テキストは内部コピー buffer へフォールバック
2796
+ if (g && g.length) this.pasteGrid(g);
2797
+ });
2590
2798
  // inline エディタへ入力を通知(打った/変換確定した内容で候補を絞る用)
2591
2799
  ed.addEventListener('input', () => {
2592
2800
  if (this._inlineEd && this._inlineEd.onInput) { try { this._inlineEd.onInput(this.editor.value, this._inlineCtx); } catch (_) {} }
@@ -2931,7 +3139,8 @@
2931
3139
  if (ctrl && (e.key.toLowerCase() === 'y' || (e.shiftKey && e.key.toLowerCase() === 'z'))) { e.preventDefault(); this.history.redo(); return; }
2932
3140
  if (ctrl && e.key.toLowerCase() === 'c') { e.preventDefault(); this.copy(false); return; }
2933
3141
  if (ctrl && e.key.toLowerCase() === 'x') { e.preventDefault(); this.copy(true); return; }
2934
- if (ctrl && e.key.toLowerCase() === 'v') { e.preventDefault(); this.paste(); return; }
3142
+ // Ctrl+V は keydown で拾わず native paste イベントへ委譲(editor の paste リスナが処理)。
3143
+ // ここで preventDefault すると paste イベント自体を止めかねないため分岐を置かない。
2935
3144
  if (e.shiftKey && e.key.startsWith('Arrow')) {
2936
3145
  e.preventDefault();
2937
3146
  let { r, c } = this.extent;
@@ -2958,6 +3167,8 @@
2958
3167
  }
2959
3168
  }
2960
3169
 
3170
+ TssGrid.PENDING = Symbol('tg-pending'); // 遅延ロード(push): 未取得行を表す sentinel。data[r] に入れると _rowHTML が「読込中」行を描く
3171
+
2961
3172
  // ---- UMD export ----
2962
3173
  const api = { TssGrid, HistoryManager };
2963
3174
  if (typeof module !== 'undefined' && module.exports) module.exports = api;