@threestarsoftware/tssgrid 0.1.3 → 0.1.5

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/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;
@@ -197,6 +202,10 @@
197
202
  this.rowReorder = !!opts.rowReorder;
198
203
  this.rowReorderWhole = opts.rowReorder === 'header'; // 'header'=行ヘッダーセル全体が移動ハンドル(クリック=選択/ドラッグ=移動)/true=⠿アイコンのみ
199
204
  this.rowReorderMark = opts.rowReorderMark === undefined ? 'before' : opts.rowReorderMark; // ⠿の位置: 'before'(番号の前/既定) | 'after'(番号の後) | false(出さない)
205
+ // 右端に非データの「削除列」(🗑)を1列出す。クリックで deleteRows(既存の remove_row と同一経路・1 history・undo で戻せる)。
206
+ // 非データ・非編集・非選択・幅固定・並べ替え/リサイズ対象外=getRows/toCSV/列インデックスに現れない。allowDeleteRows:false なら出さない。
207
+ this.rowDelete = !!opts.rowDelete;
208
+ this.rowDeleteW = (opts.rowDeleteWidth | 0) || 36; // 削除列の幅(px)
200
209
  // 選択カーソル(緑の選択枠・範囲・セルハイライト・フィルハンドル)を表示するか。
201
210
  // false で表示専用グリッド向けに非表示(内部の選択は保持=コピーは可)。
202
211
  this.cursor = opts.cursor !== false;
@@ -222,6 +231,11 @@
222
231
  this.onBeforeAutofill = opts.onBeforeAutofill || null; // (src,target)=>false で取消
223
232
  this.onAfterAutofill = opts.onAfterAutofill || null; // (src,target)
224
233
  this.copyPaste = opts.copyPaste !== false; // false でコピー/カット/ペースト無効
234
+ // 共有エディタへのブラウザ自動入力(Chrome のポイントカード/住所/支払い等)を強めに抑止する。
235
+ // 既定 false(name+autocomplete=off の通常抑止のみ)。true で autocomplete='new-password' に切替=
236
+ // Chrome の loyalty/payment autofill を確実に止める('off' は無視される)。副作用として環境により鍵アイコンや
237
+ // 生成PW提案が出ることがあるため opt-in。周囲に自前のラベル付き input(フォーム塊)を並べる埋め込みで有効。
238
+ this.suppressAutofill = !!opts.suppressAutofill;
225
239
  // 列幅オートフィット(内容に合わせて自動調整)。境界ダブルクリック / autoSizeColumn でも。
226
240
  this.autoColumnSize = !!opts.autoColumnSize;
227
241
  // データ入力支援
@@ -235,6 +249,9 @@
235
249
  this.virtual = !!opts.virtual;
236
250
  this._vBuf = (opts.virtual && typeof opts.virtual === 'object' && opts.virtual.buffer) || 6; // 窓の上下バッファ行数
237
251
  this._vStart = 0; this._vEnd = 0; this._vRAF = 0; this._vRendered = false;
252
+ this._vpStart = -1; this._vpEnd = -1; // onViewportChange の最後に通知した範囲(同一範囲の再発火を抑止)
253
+ this.dataComplete = true; // 遅延ロード(push): false のとき data に PENDING 行が混じる(setRowCount)。sort/filter/spare の判定に使う
254
+ this._pendingCount = 0; // PENDING 行数(fillRows で減らし 0 で dataComplete=true へ)
238
255
  if (this.virtual && (this.frozenRows || this.frozenCols || this.wordWrap)) {
239
256
  try { console.warn('[TssGrid] virtual:true は固定行列/折り返し非対応のため、それらを無効化しました'); } catch (_) {}
240
257
  this.frozenRows = 0; this.frozenCols = 0; this.wordWrap = false;
@@ -316,25 +333,109 @@
316
333
  }
317
334
 
318
335
  // ---- 取得用 API ----
319
- getData() { return this.data.map(row => row.slice()); } // 常に 2次元配列(後方互換)
320
- // オブジェクト配列で取り出す。_src(元の非表示フィールド)にキー列の現在値を上書きして返す。
336
+ // 常に 2次元配列(後方互換)。遅延ロード中は「ロード済みのみ」返す(未取得 PENDING 行は除外=AG Grid の as-rendered ポリシー。
337
+ // 全件が要る export はプラグインが全件 fillRows してから呼ぶ=dataComplete で通常経路)。
338
+ 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()); }
339
+ // オブジェクト配列で取り出す。_src(元の非表示フィールド)にキー列の現在値を上書きして返す。遅延ロード中はロード済みのみ。
321
340
  getRows() {
322
- return this.data.map((row, r) => {
341
+ const P = TssGrid.PENDING, out = [];
342
+ for (let r = 0; r < this.ROWS; r++) {
343
+ const row = this.data[r]; if (row === P) continue; // 未取得行は除外
323
344
  const o = this._src ? Object.assign({}, this._src[r]) : {};
324
345
  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
- });
346
+ out.push(o);
347
+ }
348
+ return out;
327
349
  }
328
350
  getRow(r) { return this.getRows()[r]; }
329
351
  // 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 の列方向版。
352
+ 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]); }
353
+ // 全列を「列ごとの値配列」で。見出しキーは data キー > headers。getRows の列方向版。遅延ロード中はロード済みのみ。
332
354
  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]); }
355
+ const out = {}, rows = this.dataComplete ? this.data : this.data.filter(row => row !== TssGrid.PENDING);
356
+ 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
357
  return out;
336
358
  }
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(); }
359
+ 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(); }
360
+ // 背景で"育てる"追記(progressive): data 末尾へ rows を足し ROWS を更新、可視窓+上下スペーサだけ再描画する。
361
+ // buildTable 全再描画をしない=スクロール位置・アクティブセル・フォーカス・選択範囲・編集中のエディタと値を保つ。
362
+ // history には積まない(背景成長は undo 対象外)。既存の setData/insertRows 経路は不変=完全 opt-in。
363
+ // 用途: プラグインが「先頭ページを描く→残りを idle で appendRows」して first-paint を早める土台。
364
+ // rows は現在のデータと同形(オブジェクトモードならオブジェクト配列/配列モードなら行配列)。opts は将来用(keepScroll/noHistory は
365
+ // 実装が tbody 差し替えのみ=スクロール保持・履歴不変を常に満たすため現状は指定不要)。
366
+ // 注意: フィルタ中(_allRows)はマスタにも積んで desync を防ぐ(部分ロード中のフィルタは想定外だが壊さない)。
367
+ // 非virtual+minSpareRows では追記行が末尾スペア行の後ろに入る(育成は主に virtual 用途)。
368
+ appendRows(rows, opts = {}) {
369
+ if (!Array.isArray(rows) || !rows.length) return;
370
+ const n0 = this.ROWS;
371
+ for (const src of rows) {
372
+ const srcIsObj = src && !Array.isArray(src) && typeof src === 'object';
373
+ const srcObj = srcIsObj ? Object.assign({}, src) : {};
374
+ let rowArr;
375
+ if (srcIsObj) rowArr = this._objToRow(src); // オブジェクト→キー列で行配列化
376
+ else { rowArr = Array.isArray(src) ? src.slice(0, this.COLS) : []; while (rowArr.length < this.COLS) rowArr.push(''); } // 配列は COLS 長に整える
377
+ this.data.push(rowArr);
378
+ this.rowH.push(undefined);
379
+ if (this._src) this._src.push(srcObj);
380
+ if (this._meta) this._meta.set(rowArr, { a: new Map(), ro: new Set(), h: undefined }); // フィルタが張った per-row メタも用意
381
+ if (this._allRows) { this._allRows.push(rowArr); if (this._allSrc) this._allSrc.push(srcObj); } // フィルタ中はマスタも同期
382
+ }
383
+ this.ROWS = this.data.length;
384
+ if (!this.table) return; // 未マウントなら次の buildTable が拾う
385
+ if (this.virtual) {
386
+ // 窓+上下スペーサを作り直す(force)。下スペーサ高が ROWS で再計算され新行までスクロール可能に。
387
+ // 窓が末尾に無い(新行が可視外)時も安全=可視 ~33 行の innerHTML 差し替えのみ・scrollTop 不変・
388
+ // エディタは tbody 外のオーバーレイなので値も維持。
389
+ this._renderWindow(this._vWindow(), true);
390
+ } else {
391
+ const tbody = this.table.querySelector('tbody');
392
+ if (tbody) { let html = ''; for (let r = n0; r < this.ROWS; r++) html += this._rowHTML(r); tbody.insertAdjacentHTML('beforeend', html); }
393
+ }
394
+ // 追記前が floor 以下(最後の1行=クリア/minRows>1 ロック)だった時だけ、既存行の🗑 状態を再描画せず更新
395
+ // (appendRows は既存行を描き直さないため title/disabled が古いまま残るのを消す。floor 超過後は常態=更新不要)。
396
+ if (this._hasDelCol() && n0 <= Math.max(1, this.minRows)) this._refreshRowDel();
397
+ }
398
+ // 遅延ロード(push・Phase B): 総行数を total に確保する。現行の行はそのまま・不足分を PENDING 行で埋め、超過は切る。
399
+ // dataComplete=false になり、この間 minSpareRows は自動無効(末尾スペアと PENDING tail は両立不可)。
400
+ // virtual 前提(PENDING 領域はスペーサに畳まれ描画されない=軽い)。プラグインは onViewportChange→fillRows で実データを流し込む。
401
+ // setData で解除(実データに差し替わり dataComplete=true)。
402
+ setRowCount(total) {
403
+ total = Math.max(0, total | 0);
404
+ const cur = this.ROWS;
405
+ if (total > cur) { for (let r = cur; r < total; r++) { this.data.push(TssGrid.PENDING); this.rowH.push(undefined); } }
406
+ else if (total < cur) { this.data.length = total; this.rowH.length = total; }
407
+ this.ROWS = total;
408
+ let pend = 0; for (let r = 0; r < this.ROWS; r++) if (this.data[r] === TssGrid.PENDING) pend++; // 確保時の一度きり O(N)。以降 fillRows が差分で維持
409
+ this._pendingCount = pend; this.dataComplete = pend === 0;
410
+ if (this.active.r >= this.ROWS) this.active = { r: Math.max(0, this.ROWS - 1), c: this.active.c }; // アクティブが範囲外なら詰める
411
+ if (this.table) { if (this.virtual) this._renderWindow(this._vWindow(), true); else this.buildTable(); }
412
+ }
413
+ // 遅延ロード(push・Phase B): PENDING 行 [start, start+rows.length) を実データに差し替える(onViewportChange への応答)。
414
+ // 塗り替え範囲が可視窓に重なる時だけ窓を再描画(buildTable しない)=スクロール/選択/アクティブは不変。窓外は data 差し替えのみ。
415
+ // rows は現在のデータと同形(オブジェクトモードはオブジェクト配列/配列モードは行配列)。範囲外(>=ROWS)は無視。
416
+ fillRows(start, rows) {
417
+ if (!Array.isArray(rows) || !rows.length) return;
418
+ start = Math.max(0, start | 0);
419
+ for (let i = 0; i < rows.length; i++) {
420
+ const r = start + i; if (r >= this.ROWS) break;
421
+ const src = rows[i];
422
+ const srcIsObj = src && !Array.isArray(src) && typeof src === 'object';
423
+ const srcObj = srcIsObj ? Object.assign({}, src) : {};
424
+ let rowArr;
425
+ if (srcIsObj) rowArr = this._objToRow(src);
426
+ else { rowArr = Array.isArray(src) ? src.slice(0, this.COLS) : []; while (rowArr.length < this.COLS) rowArr.push(''); }
427
+ if (this.data[r] === TssGrid.PENDING) this._pendingCount--; // PENDING→実データ(既に実データの再fill は count 不変)
428
+ this.data[r] = rowArr;
429
+ if (this._src) this._src[r] = srcObj;
430
+ if (this._meta) this._meta.set(rowArr, { a: new Map(), ro: new Set(), h: undefined });
431
+ }
432
+ if (this._pendingCount < 0) this._pendingCount = 0;
433
+ this.dataComplete = this._pendingCount === 0;
434
+ if (!this.table) return;
435
+ if (this.virtual) {
436
+ if (start < this._vEnd && start + rows.length > this._vStart) this._renderWindow(this._vWindow(), true); // 可視窓に重なる時だけ再描画
437
+ } else this.buildTable();
438
+ }
338
439
  // 初期カーソル位置を [r,c] に解決(c は index か data キー)。範囲内へクランプ。
339
440
  _resolveInitialCell() {
340
441
  const ic = this.initialCell; if (!ic) return [0, 0];
@@ -371,6 +472,7 @@
371
472
  const lines = [];
372
473
  if (o.headers && this.headers) lines.push(this.headers.slice(0, this.COLS).map(esc).join(','));
373
474
  for (let r = 0; r < this.ROWS; r++) {
475
+ if (this.data[r] === TssGrid.PENDING) continue; // 遅延ロード: 未取得行は出さない(loaded-only)
374
476
  if (o.skipEmpty && this._rowEmpty(r)) continue;
375
477
  const cells = [];
376
478
  for (let c = 0; c < this.COLS; c++) cells.push(esc(o.formatted ? this._displayValue(r, c) : this.data[r][c]));
@@ -434,8 +536,27 @@
434
536
  const na = TssGrid.toNum(va), nb = TssGrid.toNum(vb);
435
537
  return (na !== null && nb !== null) ? na - nb : String(va).localeCompare(String(vb));
436
538
  }
539
+ // 遅延ロード(push): sort/filter は「グリッドが計算しない・サーバ再フェッチ」(AG Grid ポリシー)。
540
+ // 状態を onSortFilterChange で通知し、ロード済みを PENDING へ戻す(パージ)=プラグインが新順で取り直す。
541
+ // 通知先が未設定なら破壊せず no-op(パージで画面を空にしない安全側)。呼び側は !dataComplete で本メソッドを使う。
542
+ _lazySortFilter(state) {
543
+ if (this.onSortFilterChange) {
544
+ try { this.onSortFilterChange(state); } catch (_) {}
545
+ this._purgeToPending(); // 窓再描画で onViewportChange も発火=プラグインが可視域を新順で取り直す
546
+ } else {
547
+ try { console.warn('[TssGrid] 遅延ロード中の sort/filter はサーバ側で解決してください(onSortFilterChange 未設定のため no-op)'); } catch (_) {}
548
+ }
549
+ }
550
+ // ロード済み行を全て PENDING に戻す(sort/filter 変更でブロックが陳腐化=AG Grid の cache purge 相当)。
551
+ _purgeToPending() {
552
+ for (let r = 0; r < this.ROWS; r++) { this.data[r] = TssGrid.PENDING; if (this._src) this._src[r] = {}; }
553
+ this._pendingCount = this.ROWS; this.dataComplete = false;
554
+ this._vpStart = -1; this._vpEnd = -1; // 次の窓通知を必ず出す(新順の取り直しを促す)
555
+ if (this.table) { if (this.virtual) this._renderWindow(this._vWindow(), true); else this.buildTable(); }
556
+ }
437
557
  // 列 c(index か data キー)で並べ替え。dir='asc'(既定)|'desc'。数値っぽければ数値比較、でなければ文字列比較。
438
558
  sortBy(c, dir) {
559
+ if (!this.dataComplete) return this._lazySortFilter({ type: 'sort', sort: [{ col: c, dir: dir || 'asc' }] }); // 遅延ロード中はサーバ委譲
439
560
  if (this._sortLocked()) return; // 結合中はソート禁止(値による組み替えで結合帯が散らばる)
440
561
  const ci = this._resolveCol(c); if (ci < 0) return;
441
562
  const sgn = dir === 'desc' ? -1 : 1;
@@ -447,6 +568,7 @@
447
568
  // 多段(複数列)ソート。specs=[{col, dir}] を優先順(先頭=第1キー)に。col=index か data キー、dir='asc'(既定)|'desc'。
448
569
  // 各段は sortBy と同じセル比較。前段が同値のときだけ次段で決める(安定ソート=残った同値は現在の並びを保つ)。空行は最下部固定。
449
570
  sortByCols(specs) {
571
+ if (!this.dataComplete) return this._lazySortFilter({ type: 'sort', sort: specs }); // 遅延ロード中はサーバ委譲
450
572
  if (this._sortLocked()) return; // 結合中はソート禁止
451
573
  const keys = (specs || [])
452
574
  .map(s => ({ ci: this._resolveCol(s && s.col), sgn: (s && s.dir === 'desc') ? -1 : 1 }))
@@ -462,6 +584,7 @@
462
584
  }
463
585
  // 任意比較で並べ替え。cmp(rowA, rowB) は行(配列 or オブジェクト)を受ける。空行は最下部固定。
464
586
  sortRows(cmp) {
587
+ if (!this.dataComplete) return this._lazySortFilter({ type: 'sort', sort: 'custom' }); // 遅延ロード中はサーバ委譲(JS比較子はサーバへ渡せない=custom 通知のみ)
465
588
  if (this._sortLocked()) return; // 結合中はソート禁止
466
589
  this._rememberOrder();
467
590
  const rows = this._src ? this.getRows() : this.data;
@@ -519,6 +642,7 @@
519
642
  }
520
643
  // 絞り込み。pred=(row, masterIndex, src)=>bool / {col:value|fn} / falsy=解除。非破壊(編集は参照でマスタへ反映)。
521
644
  filter(pred) {
645
+ if (!this.dataComplete) { this._lazySortFilter({ type: 'filter', filter: pred }); return false; } // 遅延ロード中はサーバ委譲(JS述語はサーバへ渡せない=通知のみ)
522
646
  if (this._merges && this._merges.length) return false; // v1: 結合中は不可
523
647
  const fn = this._normFilter(pred);
524
648
  if (!fn) return this.clearFilter();
@@ -646,6 +770,23 @@
646
770
  this.copybox = q('.tg-copybox'); this.fillhandle = q('.tg-fillhandle'); this.fillpreview = q('.tg-fillpreview');
647
771
  this.resizeGuide = q('.tg-resizeguide');
648
772
  this._edInput = q('input.tg-editor'); this._edMulti = q('.tg-editor-multi'); this.editor = this._edInput;
773
+ // ブラウザ/拡張の自動入力(Chrome の「ポイントカード/住所/支払い」等)抑止。
774
+ // name 無しだとブラウザが周囲のラベルからフィールド種別をML推論して誤爆する(会員番号扱い等)ため、
775
+ // 非意味的で一意な name を付けて種別マッチと入力記憶を避ける。autocomplete=off に加え各PWマネージャの無視属性も。
776
+ // ※Chrome のポイントカード/支払い系は autocomplete=off を無視することがあり100%ではない(確実に切るならブラウザ設定側)。
777
+ // suppressAutofill:true は autocomplete を 'new-password' に(Chrome の loyalty/payment は 'off' を無視するため)。
778
+ // 公式なフィールド無効化 API が将来出たら、この1行の値を差し替えれば全 TssGrid に波及する。
779
+ const _acVal = this.suppressAutofill ? 'new-password' : 'off';
780
+ const _eduid = 'tg-ed-' + (TssGrid._edSeq = (TssGrid._edSeq | 0) + 1);
781
+ [this._edInput, this._edMulti].forEach((el, i) => {
782
+ el.name = _eduid + '-' + i;
783
+ el.setAttribute('autocomplete', _acVal);
784
+ el.setAttribute('autocapitalize', 'off');
785
+ el.setAttribute('data-lpignore', 'true'); // LastPass
786
+ el.setAttribute('data-1p-ignore', 'true'); // 1Password
787
+ el.setAttribute('data-bwignore', 'true'); // Bitwarden
788
+ el.setAttribute('data-form-type', 'other'); // 一部ヒューリスティック向けヒント
789
+ });
649
790
  this.select = q('.tg-select'); this.dumpEl = q('.tg-dump');
650
791
  this.menuEl = q('.tg-menu');
651
792
  this.root = q('.tssgrid');
@@ -665,13 +806,14 @@
665
806
  activeTd() { return this.cellEl(this.active.r, this.active.c); }
666
807
  clampR(r) { return Math.max(0, Math.min(this.ROWS - 1, r)); }
667
808
  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); }
809
+ 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
810
 
670
811
  // ---- 列定義(セルタイプ / バリデータ) ----
671
812
  colCfg(c) { return this.columns[c] || {}; }
672
813
  colType(c) { return this.colCfg(c).type || 'text'; }
673
814
  // 読み取り専用判定(グリッド全体 / 列 readOnly: bool/関数 / セル単位 setCellReadOnly)。
674
815
  _isReadOnly(r, c) {
816
+ if (this.data[r] === TssGrid.PENDING) return true; // 遅延ロード: 未取得セルは「読込中」=編集不可(fillRows で実データが来たら普通に編集可)
675
817
  if (this.readOnly) return true;
676
818
  if (this._merges && this._isCovered(r, c)) return true; // 結合の従属セルは編集不可(貼付/フィルは既存の readOnly 経路で巻戻し)
677
819
  if (this._cellRO.has(r + ',' + c)) return true; // セル単位の実行時トグル
@@ -734,6 +876,7 @@
734
876
  _structRemapFn(info) {
735
877
  switch (info.type) {
736
878
  case 'insertRow': { const at = info.at; return (r, c) => [r >= at ? r + 1 : r, c]; }
879
+ case 'insertRows': { const { at, count } = info; return (r, c) => [r >= at ? r + count : r, c]; }
737
880
  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
881
  case 'insertCol': { const at = info.at; return (r, c) => [r, c >= at ? c + 1 : c]; }
739
882
  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 +892,7 @@
749
892
  for (const m of this._merges) {
750
893
  let { r, c, colspan } = m, rowspan = m.rowspan || 1;
751
894
  if (info.type === 'insertRow') { const at = info.at; if (at <= r) r++; else if (at < r + rowspan) rowspan++; }
895
+ else if (info.type === 'insertRows') { const { at, count } = info; if (at <= r) r += count; else if (at < r + rowspan) rowspan += count; }
752
896
  else if (info.type === 'deleteRows') {
753
897
  const { r0, r1 } = info, n = r1 - r0 + 1, top = r, bot = r + rowspan - 1;
754
898
  if (r0 <= top && r1 >= bot) continue; // 縦方向すべて消える
@@ -1025,6 +1169,7 @@
1025
1169
  if (keys.length) td.dataset.cstyle = keys.join(' '); else delete td.dataset.cstyle;
1026
1170
  }
1027
1171
  _renderCell(r, c) {
1172
+ if (this._renderPaused) { this._renderDirty = true; return; } // バッチ描画中は最後の buildTable が全セルを一括で描く
1028
1173
  if (this._merges && this._isCovered(r, c)) return; // 従属セルは描画対象なし(cellEl はアンカーへ解決=誤って上書きしない)
1029
1174
  const td = this.cellEl(r, c); if (!td) return;
1030
1175
  const ph = (this.data[r][c] === '' && this.colType(c) !== 'checkbox') ? this._placeholder(c) : null;
@@ -1044,6 +1189,27 @@
1044
1189
  _colWidth(c) { return this.colW[c] != null ? this.colW[c] : this.defColW; }
1045
1190
  _rowHeight(r) { return this.rowH[r] != null ? this.rowH[r] : this.defRowH; }
1046
1191
  _rhW() { return this.rowHeaders ? this.rowHeaderW : 0; } // 行ヘッダ列の幅(非表示なら 0)
1192
+ _hasDelCol() { return this.rowDelete && this.allowDeleteRows; } // 右端削除列を出すか(allowDeleteRows:false なら出さない)
1193
+ _delW() { return this._hasDelCol() ? this.rowDeleteW : 0; } // 削除列の幅(非表示なら 0)
1194
+ // 行末の削除セル(🗑)。data-r/data-c を持たない=各種 td[data-r] 探索に引っかからない(行番号は closest('tr[data-r]') から取る)。
1195
+ _delCellHTML(r) {
1196
+ if (!this._hasDelCol()) return '';
1197
+ const floor = Math.max(1, this.minRows);
1198
+ const canDelete = this.ROWS > floor;
1199
+ const soleClear = !canDelete && this.ROWS === 1; // 最後の1行=削除でなく「中身クリア」(行は残す)=🗑 は有効のまま
1200
+ const dis = (!canDelete && !soleClear) ? ' tg-disabled' : ''; // minRows>1 のロック時だけ無効(クリアもしない)
1201
+ const title = soleClear ? '内容をクリア' : '行を削除'; // 最後の1行は動作が「クリア」なのでツールチップも変える
1202
+ return '<td class="tg-rowdel-cell"><span class="tg-rowdel' + dis + '" title="' + title + '">🗑</span></td>';
1203
+ }
1204
+ // 🗑 の title/disabled は ROWS 依存(最後の1行=「内容をクリア」/minRows>1 ロック=無効)で全行一律。
1205
+ // 行数だけ変えて既存行を再描画しない経路(appendRows の非virtual)用に、描画済み🗑を現在の ROWS へ揃える。
1206
+ _refreshRowDel() {
1207
+ if (!this._hasDelCol() || !this.table) return;
1208
+ const canDelete = this.ROWS > Math.max(1, this.minRows);
1209
+ const soleClear = !canDelete && this.ROWS === 1;
1210
+ const dis = !canDelete && !soleClear, title = soleClear ? '内容をクリア' : '行を削除';
1211
+ this.table.querySelectorAll('tbody .tg-rowdel').forEach(el => { el.classList.toggle('tg-disabled', dis); el.setAttribute('title', title); });
1212
+ }
1047
1213
  // 行ヘッダの表示内容: false=空(数字なし)/ 関数=カスタム(エスケープ)/ 既定=行番号(r+1)。
1048
1214
  _rowHeadLabel(r) {
1049
1215
  const L = this.rowHeaderLabel;
@@ -1112,7 +1278,7 @@
1112
1278
  }
1113
1279
  toggleColumn(c) { this.hiddenCols.has(this.clampC(c)) ? this.showColumn(c) : this.hideColumn(c); }
1114
1280
  _placeholder(c) { const p = this.colCfg(c).placeholder; return p != null ? String(p) : this.placeholder; }
1115
- _totalWidth() { let w = this._rhW(); for (let c = 0; c < this.COLS; c++) if (!this._isHidden(c)) w += this._colWidth(c); return w; }
1281
+ _totalWidth() { let w = this._rhW(); for (let c = 0; c < this.COLS; c++) if (!this._isHidden(c)) w += this._colWidth(c); return w + this._delW(); }
1116
1282
  // テーブルに合計幅を明示。これが無いと狭い枠で fixed レイアウトが列を比例圧縮し、
1117
1283
  // 保存値(colW)と実描画幅がズレてリサイズのプレビュー線が外れる。
1118
1284
  _applyTableWidth() { this.table.style.width = this._totalWidth() + 'px'; }
@@ -1152,7 +1318,7 @@
1152
1318
  // 上から n 行を固定(実行時変更可)
1153
1319
  freezeRows(n) { this.frozenRows = Math.max(0, Math.min(this.ROWS, n | 0)); this.buildTable(); this.setActive(this.active.r, this.active.c); }
1154
1320
  // リーフ(実列に対応する)ヘッダ行を生成。labels 指定時はその文字列/ {label} を見出しに使う。
1155
- _leafHeaderTr(withCorner, labels) {
1321
+ _leafHeaderTr(withCorner, labels, delHdr) {
1156
1322
  let html = '<tr>' + (withCorner ? '<th class="rowhead corner"></th>' : '');
1157
1323
  for (let c = 0; c < this.COLS; c++) {
1158
1324
  const cls = [];
@@ -1165,6 +1331,7 @@
1165
1331
  if (lbl && typeof lbl === 'object') lbl = lbl.label != null ? lbl.label : '';
1166
1332
  html += '<th data-c="' + c + '"' + a + '>' + headCb + TssGrid.esc(lbl != null ? lbl : '') + grip + '</th>';
1167
1333
  }
1334
+ if (delHdr) html += '<th class="tg-rowdel-hdr"></th>'; // 右端削除列の見出し(無地・非データ)。ネストヘッダ時は角を rowspan で出すのでここでは出さない
1168
1335
  return html + '</tr>';
1169
1336
  }
1170
1337
  // ネストヘッダ: 各ヘッダ行を異なる top で sticky(段が重ならないように積む)。
@@ -1177,7 +1344,16 @@
1177
1344
  });
1178
1345
  }
1179
1346
  // 1行ぶんの <tr>…</tr> を生成(buildTable の本体ループと、仮想スクロールの窓再描画 _renderWindow で共用)。
1347
+ // 遅延ロード: 未取得(PENDING)行は data[r][c] を一切読まず「読込中」プレースホルダ行を描く。
1348
+ _pendingRowHTML(r) {
1349
+ const rh = this.rowHeaders;
1350
+ const head = rh ? '<th class="rowhead" data-r="' + r + '">' + this._rowHeadLabel(r) + '</th>' : '';
1351
+ const cell = '<td class="tg-pending-cell" colspan="' + this.COLS + '" data-r="' + r + '" data-c="0"><span class="tg-pending-dots"></span></td>';
1352
+ const del = this._hasDelCol() ? '<td class="tg-rowdel-cell"></td>' : ''; // 読込中行は🗑を出さない(値未取得)=列合わせの空セルのみ
1353
+ return '<tr data-r="' + r + '" class="tg-row tg-pending-row" style="height:' + this._rowHeight(r) + 'px">' + head + cell + del + '</tr>';
1354
+ }
1180
1355
  _rowHTML(r) {
1356
+ if (this.data[r] === TssGrid.PENDING) return this._pendingRowHTML(r); // 未取得行=読込中プレースホルダ(値は読まない)
1181
1357
  const rh = this.rowHeaders;
1182
1358
  const rgrip = (rh && this.resizeRows) ? '<div class="tg-rowgrip"></div>' : '';
1183
1359
  const canMove = rh && this.rowReorder && !this._hasVMerge();
@@ -1222,7 +1398,7 @@
1222
1398
  const csAttr = (cs > 1 ? ' colspan="' + cs + '"' : '') + (rs > 1 ? ' rowspan="' + rs + '"' : '');
1223
1399
  html += '<td' + a + ico + dccls + sty + titleAttr + csAttr + ' data-r="' + r + '" data-c="' + c + '">' + this._cellHTML(r, c) + '</td>';
1224
1400
  }
1225
- return html + '</tr>';
1401
+ return html + this._delCellHTML(r) + '</tr>';
1226
1402
  }
1227
1403
  // 仮想スクロール: 現在の scrollTop から描画すべき窓 [start,end)(絶対行)を算出。バッファ込み。
1228
1404
  _vWindow() {
@@ -1241,7 +1417,7 @@
1241
1417
  if (!force && this._vRendered && win.start === this._vStart && win.end === this._vEnd) return false;
1242
1418
  const tbody = this.table && this.table.querySelector('tbody');
1243
1419
  if (!tbody) return false;
1244
- const rh = this.rowHeaders, colsSpan = this.COLS + (rh ? 1 : 0);
1420
+ const rh = this.rowHeaders, colsSpan = this.COLS + (rh ? 1 : 0) + (this._hasDelCol() ? 1 : 0);
1245
1421
  const topH = win.start * this.defRowH, botH = (this.ROWS - win.end) * this.defRowH;
1246
1422
  let html = '';
1247
1423
  if (topH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + topH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
@@ -1251,8 +1427,15 @@
1251
1427
  this._vStart = win.start; this._vEnd = win.end; this._vRendered = true;
1252
1428
  this._syncHeaderCheckboxes();
1253
1429
  this.updateSelectionUI(); // セルが作り直されたので窓内の選択シェード/オーバーレイを再適用
1430
+ this._fireViewport(win.start, win.end); // 遅延ロード: 窓が変わったらプラグインへ通知(PENDING を fillRows で埋めさせる)
1254
1431
  return true;
1255
1432
  }
1433
+ // 遅延ロード: 描画窓が変わった時だけ onViewportChange(start,end) を発火(同一範囲は抑止=fillRows→再描画の再帰も止まる)。
1434
+ _fireViewport(start, end) {
1435
+ if (!this.onViewportChange || (start === this._vpStart && end === this._vpEnd)) return;
1436
+ this._vpStart = start; this._vpEnd = end;
1437
+ try { this.onViewportChange(start, end); } catch (_) {}
1438
+ }
1256
1439
  // 仮想スクロール: スクロールで窓がずれたら再描画(rAF スロットル)。
1257
1440
  _vSync() {
1258
1441
  if (!this.virtual || this._vRAF) return;
@@ -1275,11 +1458,13 @@
1275
1458
  this._renderWindow(this._vWindow(), false); // 窓を即同期(place 前に DOM を確定)
1276
1459
  }
1277
1460
  buildTable() {
1461
+ if (this._renderPaused) { this._renderDirty = true; return; } // バッチ描画中は最後にまとめて1回(_withBatchedRender)
1278
1462
  if (this._customEditor && this._customCancel) this._customCancel(); // 開いているカスタムエディタを閉じる(body直下ポップアップの取り残し防止・_commitActive と同じ扱い)
1279
1463
  this._selhdrFg = null; // 選択ヘッダ文字色キャッシュ破棄(テーマ変更に追従)
1280
1464
  const rh = this.rowHeaders;
1281
1465
  let html = '<colgroup>' + (rh ? '<col style="width:' + this.rowHeaderW + 'px">' : '');
1282
1466
  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">';
1467
+ if (this._hasDelCol()) html += '<col class="tg-rowdel-col" style="width:' + this.rowDeleteW + 'px">'; // 右端削除列(固定幅・システム列)
1283
1468
  html += '</colgroup>';
1284
1469
  if (this.colHeaders) {
1285
1470
  html += '<thead>';
@@ -1298,18 +1483,19 @@
1298
1483
  html += '<th class="tg-grouphdr' + froz + '" colspan="' + span + '" data-c0="' + c + '" data-c1="' + c1 + '">' + TssGrid.esc(label) + '</th>';
1299
1484
  c += span;
1300
1485
  }
1486
+ if (this._hasDelCol() && ri === 0) html += '<th class="tg-rowdel-hdr corner" rowspan="' + nRows + '"></th>'; // 右端削除列の角(全ヘッダ段を rowspan で覆う)
1301
1487
  html += '</tr>';
1302
1488
  });
1303
- html += this._leafHeaderTr(false, leaf); // リーフ段(角は上で rowspan 済み)
1489
+ html += this._leafHeaderTr(false, leaf, false); // リーフ段(左角も右削除角も上段で rowspan 済み)
1304
1490
  } else {
1305
- html += this._leafHeaderTr(rh, null);
1491
+ html += this._leafHeaderTr(rh, null, this._hasDelCol());
1306
1492
  }
1307
1493
  html += '</thead>';
1308
1494
  }
1309
1495
  html += '<tbody>';
1310
1496
  if (this.virtual) {
1311
1497
  const win = this._vWindow(); this._vStart = win.start; this._vEnd = win.end; this._vRendered = true;
1312
- const colsSpan = this.COLS + (rh ? 1 : 0);
1498
+ const colsSpan = this.COLS + (rh ? 1 : 0) + (this._hasDelCol() ? 1 : 0);
1313
1499
  const topH = win.start * this.defRowH, botH = (this.ROWS - win.end) * this.defRowH;
1314
1500
  if (topH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + topH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
1315
1501
  for (let r = win.start; r < win.end; r++) html += this._rowHTML(r);
@@ -1331,6 +1517,7 @@
1331
1517
  this._syncHeaderCheckboxes();
1332
1518
  this._buildGeomCache(); // 実測幾何キャッシュ(全 _apply 後=幅高が確定してから)
1333
1519
  this.renderDump();
1520
+ if (this.virtual) this._fireViewport(this._vStart, this._vEnd); // 遅延ロード: 初期/再構築後の窓もプラグインへ通知(table 確定後に発火)
1334
1521
  }
1335
1522
  // 各リーフ列ヘッダth(data-c 付き)を生成後に onHeaderRender へ渡す。ソートアイコン等の差し込み用。
1336
1523
  _applyHeaderRender() {
@@ -1384,7 +1571,7 @@
1384
1571
  if (this.stretchH === 'none' || !this.stretchH) return;
1385
1572
  const avail = this.wrap ? this.wrap.clientWidth : 0;
1386
1573
  if (!avail) return;
1387
- const base = this._rhW(), widths = [];
1574
+ const base = this._rhW() + this._delW(), widths = []; // 削除列は固定幅=ストレッチ対象外(余白計算から差し引く)
1388
1575
  let sum = 0;
1389
1576
  for (let c = 0; c < this.COLS; c++) { const wd = this._colWidth(c); widths.push(wd); sum += wd; }
1390
1577
  const extra = avail - base - sum;
@@ -1519,13 +1706,17 @@
1519
1706
  // 選択範囲が及ぶ行/列ヘッダをハイライト(Excel 風のアフォーダンス)。仮想時は行ヘッダを窓内にクランプ(大量行で querySelector を無駄打ちしない)。
1520
1707
  const hlo = this.virtual ? Math.max(r0, this._vStart) : r0;
1521
1708
  const hhi = this.virtual ? Math.min(r1, this._vEnd - 1) : r1;
1709
+ // 巨大選択(大量貼付/全選択など)は per-cell の背景シェードと行ヘッダ塗りを省き、selrange アウトライン(O(1)・
1710
+ // スクロール追従)に任せる=O(行×列) の DOM 変異を回避(1000行貼付で updateSelectionUI ~175ms→~1ms)。
1711
+ // 画面に収まる通常選択(~2000セル未満)は従来どおり全セルをシェード=見た目不変。
1712
+ const rows = hhi - hlo + 1, bigSel = rows > 300 || rows * (c1 - c0 + 1) > 2000;
1522
1713
  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'); }
1714
+ 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
1715
  this.wrap.style.setProperty('--tg-selhdr-fg', this._selhdrTextColor()); // 選択ヘッダ文字色を背景輝度から自動(暗背景→白)
1525
1716
  if (r0 === r1 && c0 === c1) return;
1526
1717
  const clampMerge = this._headerSel && this._merges; // 列/行選択では「範囲に収まらない結合」は塗らない(帯を塗り広げない)
1527
1718
  // 仮想スクロール: 窓内の行だけシェード(範囲枠 selrange は placeRect がモデル幾何で全域を描く)。
1528
- for (let r = hlo; r <= hhi; r++) for (let c = c0; c <= c1; c++) {
1719
+ if (!bigSel) for (let r = hlo; r <= hhi; r++) for (let c = c0; c <= c1; c++) {
1529
1720
  if (clampMerge) {
1530
1721
  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
1722
  if (a.r < r0 || mr1 > r1 || a.c < c0 || mc1 > c1) continue; // 結合が選択範囲に収まらない=塗らない
@@ -1561,6 +1752,7 @@
1561
1752
  return (this._selhdrFg = L < 0.55 ? '#fff' : '#1a3a6b');
1562
1753
  }
1563
1754
  updateSelectionUI() {
1755
+ if (this._renderPaused) return; // バッチ描画中は選択枠の位置決めを保留(resume 後に1回)
1564
1756
  if (!this.cursor) { // 表示専用: 選択枠・範囲・フィルハンドルを出さない(選択自体は内部に保持)
1565
1757
  this.selbox.style.display = 'none'; this.selrange.style.display = 'none'; this.fillhandle.style.display = 'none';
1566
1758
  return;
@@ -1730,6 +1922,7 @@
1730
1922
  }
1731
1923
  focusAndSelect(sel) {
1732
1924
  this.active = { ...sel.active }; this.extent = { ...sel.extent };
1925
+ if (this._renderPaused) { this._pendingSel = sel; return; } // バッチ描画中は選択を記録だけ→resume で1回反映
1733
1926
  if (this.virtual) this._vEnsureVisible(this.active.r);
1734
1927
  this._syncEditorNav(); this.editor.focus(); this.updateSelectionUI();
1735
1928
  if (!this.virtual) this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' });
@@ -2011,14 +2204,16 @@
2011
2204
  }
2012
2205
  this.renderDump();
2013
2206
  const grid = this;
2207
+ // minSpareRows: この編集で末尾スペア行が自動追加された分を、履歴コマンドに畳み込む
2208
+ // (undo で編集値と一緒に追加行も外す/redo で戻す)=「自動行追加が undo で消えない」不具合の解消。
2209
+ const spareAdded = this._ensureSpare();
2014
2210
  this.history.push({
2015
2211
  label: this.name,
2016
- apply() { grid._applyCells(changes, 'neu', 'redo'); grid.focusAndSelect(selAfter); },
2017
- revert() { grid._applyCells(changes, 'old', 'undo'); grid.focusAndSelect(selBefore); },
2212
+ apply() { grid._applyCells(changes, 'neu', 'redo'); grid._addSpareRows(spareAdded); grid.focusAndSelect(selAfter); },
2213
+ revert() { grid._applyCells(changes, 'old', 'undo'); grid._removeSpareRows(spareAdded); grid.focusAndSelect(selBefore); },
2018
2214
  });
2019
2215
  if (this.onAfterChange) { try { this.onAfterChange(this._pubChanges(changes, source), source); } catch (_) {} }
2020
2216
  this._syncHeaderCheckboxes(); // ヘッダ全選択チェックの見た目を同期
2021
- this._ensureSpare(); // 末尾の空行確保(minSpareRows)
2022
2217
  }
2023
2218
  commit() {
2024
2219
  const wasEditing = this.mode === 'edit';
@@ -2056,13 +2251,28 @@
2056
2251
  }
2057
2252
  }
2058
2253
  // 末尾に常に minSpareRows 行の空行を確保(足りなければ追加。履歴には積まない)。
2254
+ // 追加した空行数を返す(pushCmd が履歴コマンドに畳み込んで undo/redo で戻す/足すため)。
2059
2255
  _ensureSpare() {
2060
- if (!this.minSpareRows || this._allRows) return; // フィルタ中は末尾の自動空行を足さない(マスタ desync 回避)
2256
+ if (this._renderPaused) { this._renderDirty = true; return 0; } // バッチ描画中は保留→resume でまとめて確保
2257
+ if (!this.minSpareRows || this._allRows || !this.dataComplete) return 0; // フィルタ中/遅延ロード中(PENDING tail)は末尾の自動空行を足さない
2061
2258
  let empties = 0;
2062
2259
  for (let r = this.ROWS - 1; r >= 0; r--) { if (this._rowEmpty(r)) empties++; else break; }
2063
2260
  let added = 0;
2064
2261
  while (empties < this.minSpareRows && (!this.maxRows || this.ROWS < this.maxRows)) { this.data.push(new Array(this.COLS).fill('')); this.rowH.push(undefined); if (this._src) this._src.push({}); this.ROWS++; empties++; added++; } // maxRows で天井打ち(スペア行も超えない)
2065
2262
  if (added && this.table) { this.buildTable(); this.setActive(this.active.r, this.active.c); }
2263
+ return added;
2264
+ }
2265
+ // pushCmd の undo/redo 用: minSpareRows で自動追加された末尾空行 n 行を、編集の取消/やり直しと一緒に外す/戻す。
2266
+ _addSpareRows(n) {
2267
+ if (!n) return;
2268
+ for (let k = 0; k < n; k++) { this.data.push(new Array(this.COLS).fill('')); this.rowH.push(undefined); if (this._src) this._src.push({}); }
2269
+ this.ROWS += n; this.buildTable();
2270
+ }
2271
+ _removeSpareRows(n) {
2272
+ if (!n) return;
2273
+ n = Math.min(n, this.ROWS - 1); // 最低1行は残す(防御)
2274
+ this.data.length -= n; this.rowH.length -= n; if (this._src) this._src.length -= n;
2275
+ this.ROWS -= n; this.buildTable();
2066
2276
  }
2067
2277
  // 構造変更を実行+履歴に積む共通処理。doIt/undoIt は冪等(redo で再実行される)。
2068
2278
  _structCmd(doIt, undoIt, selAfterFn, selBefore, info) {
@@ -2089,26 +2299,40 @@
2089
2299
  if (at - 1 >= 0) { const i = this._allRows.indexOf(this.data[at - 1]); if (i >= 0) return i + 1; }
2090
2300
  return this._allRows.length;
2091
2301
  }
2092
- insertRow(ri, where = 'above') {
2302
+ insertRow(ri, where = 'above') { this.insertRows(ri, 1, where); }
2303
+ // 一括挿入プリミティブ: count 行を1回の buildTable・1つの history コマンドで挿入(Undo/Redo が1手で count 行まとめて戻る)。
2304
+ // 大量貼付など「まとめて足す」を O(N²)(1行ずつ insertRow)から O(N) に。既定 count=1 は insertRow と挙動等価。
2305
+ insertRows(ri, count = 1, where = 'above') {
2093
2306
  if (!this.allowInsertRows) return;
2094
- if (this.maxRows && this.ROWS >= this.maxRows) return; // 上限
2307
+ count = Math.max(0, count | 0);
2308
+ if (this.maxRows) count = Math.min(count, this.maxRows - this.ROWS); // 上限までにクランプ(超過分は足さない)
2309
+ if (count <= 0) return;
2095
2310
  if (this.mode === 'edit') this._commitActive();
2096
2311
  this._normalizeArrays();
2097
2312
  const at = Math.max(0, Math.min(this.ROWS, where === 'below' ? ri + 1 : ri));
2098
2313
  const before = this.snapSel(), grid = this, c = this.active.c, frozenBump = at < this.frozenRows;
2099
- let ref; // 挿入した行参照(フィルタ中の _allRows 追従・undo 用に保持)
2314
+ let refs; // 挿入した行参照の配列(フィルタ中の _allRows 追従・undo 用に保持)
2100
2315
  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++;
2316
+ refs = []; const hs = [], srcs = grid._src ? [] : null;
2317
+ for (let k = 0; k < count; k++) { refs.push(new Array(grid.COLS).fill('')); hs.push(undefined); if (srcs) srcs.push({}); }
2318
+ // マスタ挿入位置は data を splice する前の位置関係から決める(_masterInsertIndex と同じ規則をバッチ用に)
2319
+ let mi = -1;
2320
+ if (grid._allRows) {
2321
+ mi = grid._allRows.length;
2322
+ if (at < grid.data.length) { const i = grid._allRows.indexOf(grid.data[at]); if (i >= 0) mi = i; }
2323
+ else if (at - 1 >= 0) { const i = grid._allRows.indexOf(grid.data[at - 1]); if (i >= 0) mi = i + 1; }
2324
+ }
2325
+ grid.data.splice(at, 0, ...refs); grid.rowH.splice(at, 0, ...hs); if (grid._src) grid._src.splice(at, 0, ...srcs); grid.ROWS += count;
2326
+ if (grid._allRows) { grid._allRows.splice(mi, 0, ...refs); if (grid._allSrc) grid._allSrc.splice(mi, 0, ...refs.map(() => ({}))); } // マスタにも一括挿入
2327
+ if (frozenBump) grid.frozenRows += count;
2105
2328
  };
2106
2329
  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--;
2330
+ grid.data.splice(at, count); grid.rowH.splice(at, count); if (grid._src) grid._src.splice(at, count); grid.ROWS -= count;
2331
+ 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); } } }
2332
+ if (frozenBump) grid.frozenRows -= count;
2110
2333
  };
2111
- this._structCmd(doIt, undoIt, () => ({ active: { r: at, c }, extent: { r: at, c } }), before, { type: 'insertRow', at });
2334
+ const type = count === 1 ? 'insertRow' : 'insertRows'; // 単行は従来どおり type:'insertRow'(後方互換)/複数は 'insertRows'
2335
+ this._structCmd(doIt, undoIt, () => ({ active: { r: at, c }, extent: { r: at + count - 1, c } }), before, { type, at, count });
2112
2336
  }
2113
2337
  deleteRows(r0, r1) {
2114
2338
  if (!this.allowDeleteRows) return;
@@ -2135,6 +2359,15 @@
2135
2359
  };
2136
2360
  this._structCmd(doIt, undoIt, () => { const rr = Math.min(r0, grid.ROWS - 1); return { active: { r: rr, c }, extent: { r: rr, c } }; }, before, { type: 'deleteRows', r0, r1 });
2137
2361
  }
2362
+ // 右端🗑の「最後の1行」用: 0行にはできない(削除ガード)ので、行は残して中身だけ空にする
2363
+ // (「消してから空行を足す」のと正味同じ結果を、0行に一瞬も落とさず 1 undo で)。readOnly セルはスキップ。
2364
+ clearRow(r) {
2365
+ if (r < 0 || r >= this.ROWS) return;
2366
+ if (this.mode === 'edit') this._commitActive();
2367
+ const before = this.snapSel(), changes = [];
2368
+ for (let c = 0; c < this.COLS; c++) { if (this._isReadOnly(r, c)) continue; this.setCell(r, c, '', changes); }
2369
+ this.pushCmd(changes, before, this.snapSel(), 'delete'); // changes 空(既に空/全 readOnly)なら pushCmd 側で no-op
2370
+ }
2138
2371
  insertCol(ci, where = 'left') {
2139
2372
  if (!this.allowInsertCols) return;
2140
2373
  if (this._allRows) return; // v1: フィルタ中は構造変更ロック
@@ -2207,6 +2440,26 @@
2207
2440
  revert() { for (let i = batch.length - 1; i >= 0; i--) batch[i].revert(); },
2208
2441
  });
2209
2442
  }
2443
+ // 大量セル更新を「最後に1回 buildTable」へ畳む。中の buildTable/_renderCell/renderDump/選択UI は保留され、
2444
+ // resume 時に buildTable 1回+選択反映+renderDump 1回だけ走る(1行ずつ描く N 回の DOM 変異を消す)。
2445
+ // 例: 1000×5 の貼付が per-cell 描画 ~500ms → 一括 ~80ms。入れ子は外側が最後にまとめて描く。
2446
+ _withBatchedRender(fn) {
2447
+ if (this._renderPaused) return fn(); // 入れ子: 外側の resume に任せる
2448
+ this._renderPaused = true; this._renderDirty = false; this._pendingSel = null;
2449
+ let ret;
2450
+ try { ret = fn(); }
2451
+ finally {
2452
+ this._renderPaused = false;
2453
+ if (this._renderDirty) {
2454
+ this.buildTable(); // 全セルを1回のHTML生成で描く(末尾で renderDump=dumpEl/onChange も1回発火)
2455
+ this._ensureSpare(); // 保留していた末尾スペア行の確保
2456
+ const s = this._pendingSel; this._pendingSel = null;
2457
+ if (s) this.focusAndSelect(s); // 選択枠・スクロールを1回だけ
2458
+ }
2459
+ this._renderDirty = false;
2460
+ }
2461
+ return ret;
2462
+ }
2210
2463
 
2211
2464
  // ---- 右クリックメニュー ----
2212
2465
  _onContextMenu(e) {
@@ -2466,6 +2719,21 @@
2466
2719
  if (Array.isArray(ret)) g = ret;
2467
2720
  if (!g || !g.length) return;
2468
2721
  }
2722
+ // pasteAutoGrow=true では「行の一括生成」と「値の貼付」を1つの history にまとめる(1回の undo で両方戻る)。
2723
+ // 非オートグロー時は history.push が1回だけ=_batchHistory は素通し(従来とバイト等価)。
2724
+ let after;
2725
+ const body = () => { this._batchHistory(() => { after = this._pasteGridBody(g); }); };
2726
+ // 大量貼付だけ _withBatchedRender で「最後に buildTable 1回」へ畳む(per-cell 描画 N 回を消す=1000行 ~500ms→~80ms)。
2727
+ // 小さな貼付を大きなグリッドに落とす時は従来どおり per-cell(全再描画の方が高くつくため回避)。
2728
+ const willGrow = this.pasteAutoGrow && (this.active.r + g.length > this.ROWS);
2729
+ const heavy = willGrow || (g.length >= 8 && g.length * 5 >= this.ROWS); // 貼付が表の ~20% 以上を占める or 行が増える時だけ畳む
2730
+ if (heavy) this._withBatchedRender(body); else body();
2731
+ if (after && this.onAfterPaste) { // onAfterPaste は batch の外で呼ぶ(コールバック内の履歴操作を貼付コマンドに混ぜない)
2732
+ const range = { r0: after.active.r, c0: after.active.c, r1: after.extent.r, c1: after.extent.c };
2733
+ try { this.onAfterPaste(g, { range }); } catch (_) {}
2734
+ }
2735
+ }
2736
+ _pasteGridBody(g) {
2469
2737
  const before = this.snapSel(), changes = [], sel = this.rectRange();
2470
2738
  const visAll = this._visCols();
2471
2739
  let after;
@@ -2474,21 +2742,31 @@
2474
2742
  for (let r = sel.r0; r <= sel.r1; r++) for (let c = sel.c0; c <= sel.c1; c++) if (!this._isHidden(c)) this.setCell(r, c, v, changes);
2475
2743
  after = { active: { r: sel.r0, c: sel.c0 }, extent: { r: sel.r1, c: sel.c1 } };
2476
2744
  } else {
2477
- const r0 = this.active.r, c0 = this.active.c;
2745
+ // アクティブ列が隠し列だと(例: selectRow は active.c=0 を置く=先頭列が hiddenColumns の時)以下の indexOf が -1 になり、
2746
+ // 貼り付けが1列ずれて先頭値が落ちる。可視列へスナップしてから起点を取る(setActive と同じ「隠し列に置かない」規則)。
2747
+ const r0 = this.active.r, c0 = this._snapVisCol(this.active.c);
2478
2748
  let maxw = 0; for (let i = 0; i < g.length; i++) maxw = Math.max(maxw, g[i].length);
2479
2749
  const p0 = visAll.indexOf(c0), availCols = visAll.length - p0; // 貼り付けは可視列へ(隠し列をスキップ)
2480
- // 範囲超過チェック(オプトイン): はみ出すなら貼り付けを中止してエラー通知
2481
- if (this.pasteOverflow === 'error' && (r0 + g.length > this.ROWS || maxw > availCols)) {
2750
+ // オートグロー時に伸ばせる上限行数(maxRows があればそこまで/無ければ無制限)。非オートグローは現状の ROWS。
2751
+ const capRows = this.pasteAutoGrow ? (this.maxRows || Infinity) : this.ROWS;
2752
+ // 範囲超過チェック(オプトイン): 伸ばしても収まらない or 列がはみ出すなら貼り付けを中止してエラー通知(行生成の前に判定=原子性)
2753
+ if (this.pasteOverflow === 'error' && (r0 + g.length > capRows || maxw > availCols)) {
2482
2754
  if (this.onPasteOverflow) {
2483
2755
  try {
2484
2756
  this.onPasteOverflow({
2485
2757
  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),
2758
+ overRows: Math.max(0, r0 + g.length - capRows), overCols: Math.max(0, maxw - availCols),
2487
2759
  });
2488
2760
  } catch (_) {}
2489
2761
  }
2490
2762
  return; // 何も変更しない
2491
2763
  }
2764
+ // オートグロー: 不足分を先に一括生成(buildTable 1回・history 1コマンド)。超過は maxRows でクランプ、残りは pasteOverflow に従う。
2765
+ if (this.pasteAutoGrow && r0 + g.length > this.ROWS) {
2766
+ const target = this.maxRows ? Math.min(this.maxRows, r0 + g.length) : r0 + g.length;
2767
+ const grow = target - this.ROWS;
2768
+ if (grow > 0) this.insertRows(this.ROWS - 1, grow, 'below');
2769
+ }
2492
2770
  let lastC = c0;
2493
2771
  for (let i = 0; i < g.length; i++) for (let j = 0; j < g[i].length; j++) {
2494
2772
  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 +2780,7 @@
2502
2780
  this.pendingCut = null; this.copybox.style.display = 'none'; }
2503
2781
  this.pushCmd(changes, before, after, 'paste');
2504
2782
  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
- }
2783
+ return after;
2509
2784
  }
2510
2785
  clearCopyMarquee() { this.copybox.style.display = 'none'; this.pendingCut = null; }
2511
2786
 
@@ -2587,6 +2862,20 @@
2587
2862
  // input と textarea(multiline) の両方に同じ編集入口リスナを張る(this.editor はアクティブ列で張り替わる)。
2588
2863
  [this._edInput, this._edMulti].forEach((ed) => {
2589
2864
  ed.addEventListener('keydown', (e) => this._onKey(e));
2865
+ // 貼り付けの主経路=ネイティブ paste イベント(clipboardData.getData)。Ctrl+V のジェスチャー内で
2866
+ // 同期に読める=許可プロンプト不要+堅牢(readText の権限/非セキュア/非フォーカスで落ちる問題を回避)。
2867
+ // nav 中の editor は focus 代理なので native 挿入は常に止める(copyPaste=false でも挿入させない)。
2868
+ // 編集中は横取りせず native 貼付(1セルへの通常ペースト)に任せる。paste は beforeinput より先に来るため、
2869
+ // ここで preventDefault すれば beforeinput(insertFromPaste) の誤発火=エディタ誤オープンも起きない。
2870
+ // 右クリックメニューの「貼り付け」/プログラム呼び出しは paste イベントを伴わないので paste()=readText 経路を温存。
2871
+ ed.addEventListener('paste', (e) => {
2872
+ if (this.mode !== 'nav') return;
2873
+ e.preventDefault();
2874
+ if (!this.copyPaste) return;
2875
+ const text = (e.clipboardData && e.clipboardData.getData('text')) || '';
2876
+ const g = (text && text.length) ? TssGrid.parseTSV(text) : sharedClip; // clipboard 空/非テキストは内部コピー buffer へフォールバック
2877
+ if (g && g.length) this.pasteGrid(g);
2878
+ });
2590
2879
  // inline エディタへ入力を通知(打った/変換確定した内容で候補を絞る用)
2591
2880
  ed.addEventListener('input', () => {
2592
2881
  if (this._inlineEd && this._inlineEd.onInput) { try { this._inlineEd.onInput(this.editor.value, this._inlineCtx); } catch (_) {} }
@@ -2627,6 +2916,7 @@
2627
2916
  this.table.addEventListener('mousedown', (e) => {
2628
2917
  if (e.button !== 0) return; // 右/中クリックは選択ドラッグを開始しない(右は contextmenu で処理)
2629
2918
  const cls = e.target.classList;
2919
+ if (e.target.closest('.tg-rowdel-cell')) { e.preventDefault(); return; } // 削除列: 選択ドラッグを始めない(実削除は click で)。preventDefault で🗑のテキスト選択も抑止
2630
2920
  if (cls && cls.contains('tg-colgrip')) { this._startColResize(e); return; } // 列幅リサイズ
2631
2921
  if (cls && cls.contains('tg-rowgrip')) { this._startRowResize(e); return; } // 行高リサイズ
2632
2922
  if (cls && cls.contains('tg-rowmove') && !this.rowReorderWhole) { this._startRowMove(e); return; } // ⠿つまみドラッグ移動('header'モードはセル全体扱い=_onHeaderMouseDown 経由で選択も伴う)
@@ -2650,6 +2940,13 @@
2650
2940
  // ヘッダ全選択チェックは mousedown で処理済み。click のネイティブ・トグルを止めて二重反転を防ぐ
2651
2941
  this.table.addEventListener('click', (e) => {
2652
2942
  if (e.target.classList && e.target.classList.contains('tg-head-cb')) e.preventDefault();
2943
+ // 右端削除列の🗑クリック。通常は当該行を削除(remove_row と同一経路・1 undo で復活)。
2944
+ // 最後の1行(0行にはできない)は削除でなく「中身クリア(行は残す)」。minRows>1 のロック時は no-op(🗑 も無効表示)。
2945
+ const del = e.target.closest('.tg-rowdel-cell');
2946
+ if (del) {
2947
+ const tr = del.closest('tr[data-r]');
2948
+ if (tr) { const r = +tr.dataset.r; if (this.ROWS > Math.max(1, this.minRows)) this.deleteRows(r, r); else if (this.ROWS === 1) this.clearRow(r); }
2949
+ }
2653
2950
  });
2654
2951
  this.table.addEventListener('dblclick', (e) => {
2655
2952
  // 列境界グリップのダブルクリック=オートフィット(Excel風)
@@ -2931,7 +3228,8 @@
2931
3228
  if (ctrl && (e.key.toLowerCase() === 'y' || (e.shiftKey && e.key.toLowerCase() === 'z'))) { e.preventDefault(); this.history.redo(); return; }
2932
3229
  if (ctrl && e.key.toLowerCase() === 'c') { e.preventDefault(); this.copy(false); return; }
2933
3230
  if (ctrl && e.key.toLowerCase() === 'x') { e.preventDefault(); this.copy(true); return; }
2934
- if (ctrl && e.key.toLowerCase() === 'v') { e.preventDefault(); this.paste(); return; }
3231
+ // Ctrl+V は keydown で拾わず native paste イベントへ委譲(editor の paste リスナが処理)。
3232
+ // ここで preventDefault すると paste イベント自体を止めかねないため分岐を置かない。
2935
3233
  if (e.shiftKey && e.key.startsWith('Arrow')) {
2936
3234
  e.preventDefault();
2937
3235
  let { r, c } = this.extent;
@@ -2958,6 +3256,8 @@
2958
3256
  }
2959
3257
  }
2960
3258
 
3259
+ TssGrid.PENDING = Symbol('tg-pending'); // 遅延ロード(push): 未取得行を表す sentinel。data[r] に入れると _rowHTML が「読込中」行を描く
3260
+
2961
3261
  // ---- UMD export ----
2962
3262
  const api = { TssGrid, HistoryManager };
2963
3263
  if (typeof module !== 'undefined' && module.exports) module.exports = api;