@threestarsoftware/tssgrid 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tssgrid.js CHANGED
@@ -202,6 +202,10 @@
202
202
  this.rowReorder = !!opts.rowReorder;
203
203
  this.rowReorderWhole = opts.rowReorder === 'header'; // 'header'=行ヘッダーセル全体が移動ハンドル(クリック=選択/ドラッグ=移動)/true=⠿アイコンのみ
204
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)
205
209
  // 選択カーソル(緑の選択枠・範囲・セルハイライト・フィルハンドル)を表示するか。
206
210
  // false で表示専用グリッド向けに非表示(内部の選択は保持=コピーは可)。
207
211
  this.cursor = opts.cursor !== false;
@@ -227,6 +231,11 @@
227
231
  this.onBeforeAutofill = opts.onBeforeAutofill || null; // (src,target)=>false で取消
228
232
  this.onAfterAutofill = opts.onAfterAutofill || null; // (src,target)
229
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;
230
239
  // 列幅オートフィット(内容に合わせて自動調整)。境界ダブルクリック / autoSizeColumn でも。
231
240
  this.autoColumnSize = !!opts.autoColumnSize;
232
241
  // データ入力支援
@@ -382,6 +391,9 @@
382
391
  const tbody = this.table.querySelector('tbody');
383
392
  if (tbody) { let html = ''; for (let r = n0; r < this.ROWS; r++) html += this._rowHTML(r); tbody.insertAdjacentHTML('beforeend', html); }
384
393
  }
394
+ // 追記前が floor 以下(最後の1行=クリア/minRows>1 ロック)だった時だけ、既存行の🗑 状態を再描画せず更新
395
+ // (appendRows は既存行を描き直さないため title/disabled が古いまま残るのを消す。floor 超過後は常態=更新不要)。
396
+ if (this._hasDelCol() && n0 <= Math.max(1, this.minRows)) this._refreshRowDel();
385
397
  }
386
398
  // 遅延ロード(push・Phase B): 総行数を total に確保する。現行の行はそのまま・不足分を PENDING 行で埋め、超過は切る。
387
399
  // dataComplete=false になり、この間 minSpareRows は自動無効(末尾スペアと PENDING tail は両立不可)。
@@ -758,6 +770,23 @@
758
770
  this.copybox = q('.tg-copybox'); this.fillhandle = q('.tg-fillhandle'); this.fillpreview = q('.tg-fillpreview');
759
771
  this.resizeGuide = q('.tg-resizeguide');
760
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
+ });
761
790
  this.select = q('.tg-select'); this.dumpEl = q('.tg-dump');
762
791
  this.menuEl = q('.tg-menu');
763
792
  this.root = q('.tssgrid');
@@ -1002,7 +1031,7 @@
1002
1031
  }
1003
1032
  if (t === 'dropdown') {
1004
1033
  if (v === '') { if (cfg.allowEmpty === false) return { ok: false }; } // allowEmpty:false=必須選択(空は不可)
1005
- else if (!this._optList(c).some(o => o.value === v)) return { ok: false }; // 保存値は option の value と照合
1034
+ else if (!this._optList(c, r).some(o => o.value === v)) return { ok: false }; // 保存値は option の value と照合(行依存 options も r で解決)
1006
1035
  } else if (t === 'checkbox') {
1007
1036
  v = this._isCheckedVal(c, v) ? this._cbChecked(c) : this._cbUnchecked(c);
1008
1037
  }
@@ -1069,16 +1098,20 @@
1069
1098
  if (typeof cfg.format === 'string') return TssGrid._applyDatePattern(v, cfg.format); // 日付パターン表示
1070
1099
  if (cfg.type === 'number') return TssGrid._formatNumber(v, cfg); // 数値書式(カンマ/小数/前後綴り)
1071
1100
  if (cfg.type === 'time' && cfg.hour12) return TssGrid._fmtTime12(v); // 組込 12時間表示
1072
- if (cfg.type === 'dropdown') return this._optLabel(c, v); // options が {value,label} なら表示はラベル
1101
+ if (cfg.type === 'dropdown') return this._optLabel(r, c, v); // options が {value,label} なら表示はラベル(行依存 options も r で解決)
1073
1102
  return v;
1074
1103
  }
1075
1104
  // dropdown の options を [{value,label}] に正規化(文字列なら value=label)。値↔表示を分けられる。
1076
- _optList(c) {
1077
- return (this.colCfg(c).options || []).map(o =>
1105
+ // options が関数なら (r, row)=>[…] で行依存(依存ドロップダウン)。row はその行の値配列(this.data[r])。
1106
+ // 静的配列はそのまま(r は無視)=後方互換。
1107
+ _optList(c, r) {
1108
+ let opts = this.colCfg(c).options;
1109
+ if (typeof opts === 'function') { try { opts = opts(r, r != null ? this.data[r] : undefined) || []; } catch (_) { opts = []; } }
1110
+ return (opts || []).map(o =>
1078
1111
  (o && typeof o === 'object') ? { value: String(o.value), label: String(o.label != null ? o.label : o.value) }
1079
1112
  : { value: String(o), label: String(o) });
1080
1113
  }
1081
- _optLabel(c, value) { const o = this._optList(c).find(x => x.value === value); return o ? o.label : value; }
1114
+ _optLabel(r, c, value) { const o = this._optList(c, r).find(x => x.value === value); return o ? o.label : value; }
1082
1115
  // 編集開始時にエディタへ入れる文字列。number は Excel風に「素の数値」を出す(書式は表示専用)。
1083
1116
  // ネイティブ(date/time picker)は保存値、それ以外のテキスト編集は表示形。
1084
1117
  _editText(r, c) {
@@ -1092,7 +1125,12 @@
1092
1125
  return '<input type="checkbox" class="tg-cb" tabindex="-1"' + (this._isReadOnly(r, c) ? ' disabled' : '') + (this._isCheckedVal(c, this.data[r][c]) ? ' checked' : '') + '>';
1093
1126
  if (this.data[r][c] === '' && this._placeholder(c) != null) return TssGrid.esc(this._placeholder(c));
1094
1127
  // html:true の列は表示だけ生HTML(値はテキスト保存のまま)。エスケープを外す=XSS責任は format/値の提供側。
1095
- if (this.colCfg(c).html) return this._displayValue(r, c);
1128
+ // 空セルでも format を走らせる=ボタン/アイコン等の"飾り"を全行に描ける(_displayValue は空を素通しするので html+関数formatだけ特別扱い)。
1129
+ if (this.colCfg(c).html) {
1130
+ const fmt = this.colCfg(c).format;
1131
+ if (typeof fmt === 'function' && this.data[r][c] === '') { try { const o = fmt('', { r, c }); return o == null ? '' : String(o); } catch (_) { return ''; } }
1132
+ return this._displayValue(r, c);
1133
+ }
1096
1134
  const _h = TssGrid.esc(this._displayValue(r, c));
1097
1135
  // 複数行セルは絶対配置のクリップ箱で包む=行を押し広げない(行高一定・仮想スクロール維持/行高ぶんだけ表示)
1098
1136
  if (this.colCfg(c).multiline) return '<div class="tg-ml-clip">' + _h + '</div>';
@@ -1160,6 +1198,27 @@
1160
1198
  _colWidth(c) { return this.colW[c] != null ? this.colW[c] : this.defColW; }
1161
1199
  _rowHeight(r) { return this.rowH[r] != null ? this.rowH[r] : this.defRowH; }
1162
1200
  _rhW() { return this.rowHeaders ? this.rowHeaderW : 0; } // 行ヘッダ列の幅(非表示なら 0)
1201
+ _hasDelCol() { return this.rowDelete && this.allowDeleteRows; } // 右端削除列を出すか(allowDeleteRows:false なら出さない)
1202
+ _delW() { return this._hasDelCol() ? this.rowDeleteW : 0; } // 削除列の幅(非表示なら 0)
1203
+ // 行末の削除セル(🗑)。data-r/data-c を持たない=各種 td[data-r] 探索に引っかからない(行番号は closest('tr[data-r]') から取る)。
1204
+ _delCellHTML(r) {
1205
+ if (!this._hasDelCol()) return '';
1206
+ const floor = Math.max(1, this.minRows);
1207
+ const canDelete = this.ROWS > floor;
1208
+ const soleClear = !canDelete && this.ROWS === 1; // 最後の1行=削除でなく「中身クリア」(行は残す)=🗑 は有効のまま
1209
+ const dis = (!canDelete && !soleClear) ? ' tg-disabled' : ''; // minRows>1 のロック時だけ無効(クリアもしない)
1210
+ const title = soleClear ? '内容をクリア' : '行を削除'; // 最後の1行は動作が「クリア」なのでツールチップも変える
1211
+ return '<td class="tg-rowdel-cell"><span class="tg-rowdel' + dis + '" title="' + title + '">🗑</span></td>';
1212
+ }
1213
+ // 🗑 の title/disabled は ROWS 依存(最後の1行=「内容をクリア」/minRows>1 ロック=無効)で全行一律。
1214
+ // 行数だけ変えて既存行を再描画しない経路(appendRows の非virtual)用に、描画済み🗑を現在の ROWS へ揃える。
1215
+ _refreshRowDel() {
1216
+ if (!this._hasDelCol() || !this.table) return;
1217
+ const canDelete = this.ROWS > Math.max(1, this.minRows);
1218
+ const soleClear = !canDelete && this.ROWS === 1;
1219
+ const dis = !canDelete && !soleClear, title = soleClear ? '内容をクリア' : '行を削除';
1220
+ this.table.querySelectorAll('tbody .tg-rowdel').forEach(el => { el.classList.toggle('tg-disabled', dis); el.setAttribute('title', title); });
1221
+ }
1163
1222
  // 行ヘッダの表示内容: false=空(数字なし)/ 関数=カスタム(エスケープ)/ 既定=行番号(r+1)。
1164
1223
  _rowHeadLabel(r) {
1165
1224
  const L = this.rowHeaderLabel;
@@ -1228,7 +1287,7 @@
1228
1287
  }
1229
1288
  toggleColumn(c) { this.hiddenCols.has(this.clampC(c)) ? this.showColumn(c) : this.hideColumn(c); }
1230
1289
  _placeholder(c) { const p = this.colCfg(c).placeholder; return p != null ? String(p) : this.placeholder; }
1231
- _totalWidth() { let w = this._rhW(); for (let c = 0; c < this.COLS; c++) if (!this._isHidden(c)) w += this._colWidth(c); return w; }
1290
+ _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(); }
1232
1291
  // テーブルに合計幅を明示。これが無いと狭い枠で fixed レイアウトが列を比例圧縮し、
1233
1292
  // 保存値(colW)と実描画幅がズレてリサイズのプレビュー線が外れる。
1234
1293
  _applyTableWidth() { this.table.style.width = this._totalWidth() + 'px'; }
@@ -1268,7 +1327,7 @@
1268
1327
  // 上から n 行を固定(実行時変更可)
1269
1328
  freezeRows(n) { this.frozenRows = Math.max(0, Math.min(this.ROWS, n | 0)); this.buildTable(); this.setActive(this.active.r, this.active.c); }
1270
1329
  // リーフ(実列に対応する)ヘッダ行を生成。labels 指定時はその文字列/ {label} を見出しに使う。
1271
- _leafHeaderTr(withCorner, labels) {
1330
+ _leafHeaderTr(withCorner, labels, delHdr) {
1272
1331
  let html = '<tr>' + (withCorner ? '<th class="rowhead corner"></th>' : '');
1273
1332
  for (let c = 0; c < this.COLS; c++) {
1274
1333
  const cls = [];
@@ -1281,6 +1340,7 @@
1281
1340
  if (lbl && typeof lbl === 'object') lbl = lbl.label != null ? lbl.label : '';
1282
1341
  html += '<th data-c="' + c + '"' + a + '>' + headCb + TssGrid.esc(lbl != null ? lbl : '') + grip + '</th>';
1283
1342
  }
1343
+ if (delHdr) html += '<th class="tg-rowdel-hdr"></th>'; // 右端削除列の見出し(無地・非データ)。ネストヘッダ時は角を rowspan で出すのでここでは出さない
1284
1344
  return html + '</tr>';
1285
1345
  }
1286
1346
  // ネストヘッダ: 各ヘッダ行を異なる top で sticky(段が重ならないように積む)。
@@ -1298,7 +1358,8 @@
1298
1358
  const rh = this.rowHeaders;
1299
1359
  const head = rh ? '<th class="rowhead" data-r="' + r + '">' + this._rowHeadLabel(r) + '</th>' : '';
1300
1360
  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>';
1361
+ const del = this._hasDelCol() ? '<td class="tg-rowdel-cell"></td>' : ''; // 読込中行は🗑を出さない(値未取得)=列合わせの空セルのみ
1362
+ return '<tr data-r="' + r + '" class="tg-row tg-pending-row" style="height:' + this._rowHeight(r) + 'px">' + head + cell + del + '</tr>';
1302
1363
  }
1303
1364
  _rowHTML(r) {
1304
1365
  if (this.data[r] === TssGrid.PENDING) return this._pendingRowHTML(r); // 未取得行=読込中プレースホルダ(値は読まない)
@@ -1346,7 +1407,7 @@
1346
1407
  const csAttr = (cs > 1 ? ' colspan="' + cs + '"' : '') + (rs > 1 ? ' rowspan="' + rs + '"' : '');
1347
1408
  html += '<td' + a + ico + dccls + sty + titleAttr + csAttr + ' data-r="' + r + '" data-c="' + c + '">' + this._cellHTML(r, c) + '</td>';
1348
1409
  }
1349
- return html + '</tr>';
1410
+ return html + this._delCellHTML(r) + '</tr>';
1350
1411
  }
1351
1412
  // 仮想スクロール: 現在の scrollTop から描画すべき窓 [start,end)(絶対行)を算出。バッファ込み。
1352
1413
  _vWindow() {
@@ -1365,7 +1426,7 @@
1365
1426
  if (!force && this._vRendered && win.start === this._vStart && win.end === this._vEnd) return false;
1366
1427
  const tbody = this.table && this.table.querySelector('tbody');
1367
1428
  if (!tbody) return false;
1368
- const rh = this.rowHeaders, colsSpan = this.COLS + (rh ? 1 : 0);
1429
+ const rh = this.rowHeaders, colsSpan = this.COLS + (rh ? 1 : 0) + (this._hasDelCol() ? 1 : 0);
1369
1430
  const topH = win.start * this.defRowH, botH = (this.ROWS - win.end) * this.defRowH;
1370
1431
  let html = '';
1371
1432
  if (topH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + topH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
@@ -1412,6 +1473,7 @@
1412
1473
  const rh = this.rowHeaders;
1413
1474
  let html = '<colgroup>' + (rh ? '<col style="width:' + this.rowHeaderW + 'px">' : '');
1414
1475
  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">';
1476
+ if (this._hasDelCol()) html += '<col class="tg-rowdel-col" style="width:' + this.rowDeleteW + 'px">'; // 右端削除列(固定幅・システム列)
1415
1477
  html += '</colgroup>';
1416
1478
  if (this.colHeaders) {
1417
1479
  html += '<thead>';
@@ -1430,18 +1492,19 @@
1430
1492
  html += '<th class="tg-grouphdr' + froz + '" colspan="' + span + '" data-c0="' + c + '" data-c1="' + c1 + '">' + TssGrid.esc(label) + '</th>';
1431
1493
  c += span;
1432
1494
  }
1495
+ if (this._hasDelCol() && ri === 0) html += '<th class="tg-rowdel-hdr corner" rowspan="' + nRows + '"></th>'; // 右端削除列の角(全ヘッダ段を rowspan で覆う)
1433
1496
  html += '</tr>';
1434
1497
  });
1435
- html += this._leafHeaderTr(false, leaf); // リーフ段(角は上で rowspan 済み)
1498
+ html += this._leafHeaderTr(false, leaf, false); // リーフ段(左角も右削除角も上段で rowspan 済み)
1436
1499
  } else {
1437
- html += this._leafHeaderTr(rh, null);
1500
+ html += this._leafHeaderTr(rh, null, this._hasDelCol());
1438
1501
  }
1439
1502
  html += '</thead>';
1440
1503
  }
1441
1504
  html += '<tbody>';
1442
1505
  if (this.virtual) {
1443
1506
  const win = this._vWindow(); this._vStart = win.start; this._vEnd = win.end; this._vRendered = true;
1444
- const colsSpan = this.COLS + (rh ? 1 : 0);
1507
+ const colsSpan = this.COLS + (rh ? 1 : 0) + (this._hasDelCol() ? 1 : 0);
1445
1508
  const topH = win.start * this.defRowH, botH = (this.ROWS - win.end) * this.defRowH;
1446
1509
  if (topH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + topH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
1447
1510
  for (let r = win.start; r < win.end; r++) html += this._rowHTML(r);
@@ -1517,7 +1580,7 @@
1517
1580
  if (this.stretchH === 'none' || !this.stretchH) return;
1518
1581
  const avail = this.wrap ? this.wrap.clientWidth : 0;
1519
1582
  if (!avail) return;
1520
- const base = this._rhW(), widths = [];
1583
+ const base = this._rhW() + this._delW(), widths = []; // 削除列は固定幅=ストレッチ対象外(余白計算から差し引く)
1521
1584
  let sum = 0;
1522
1585
  for (let c = 0; c < this.COLS; c++) { const wd = this._colWidth(c); widths.push(wd); sum += wd; }
1523
1586
  const extra = avail - base - sum;
@@ -2150,14 +2213,16 @@
2150
2213
  }
2151
2214
  this.renderDump();
2152
2215
  const grid = this;
2216
+ // minSpareRows: この編集で末尾スペア行が自動追加された分を、履歴コマンドに畳み込む
2217
+ // (undo で編集値と一緒に追加行も外す/redo で戻す)=「自動行追加が undo で消えない」不具合の解消。
2218
+ const spareAdded = this._ensureSpare();
2153
2219
  this.history.push({
2154
2220
  label: this.name,
2155
- apply() { grid._applyCells(changes, 'neu', 'redo'); grid.focusAndSelect(selAfter); },
2156
- revert() { grid._applyCells(changes, 'old', 'undo'); grid.focusAndSelect(selBefore); },
2221
+ apply() { grid._applyCells(changes, 'neu', 'redo'); grid._addSpareRows(spareAdded); grid.focusAndSelect(selAfter); },
2222
+ revert() { grid._applyCells(changes, 'old', 'undo'); grid._removeSpareRows(spareAdded); grid.focusAndSelect(selBefore); },
2157
2223
  });
2158
2224
  if (this.onAfterChange) { try { this.onAfterChange(this._pubChanges(changes, source), source); } catch (_) {} }
2159
2225
  this._syncHeaderCheckboxes(); // ヘッダ全選択チェックの見た目を同期
2160
- this._ensureSpare(); // 末尾の空行確保(minSpareRows)
2161
2226
  }
2162
2227
  commit() {
2163
2228
  const wasEditing = this.mode === 'edit';
@@ -2195,14 +2260,28 @@
2195
2260
  }
2196
2261
  }
2197
2262
  // 末尾に常に minSpareRows 行の空行を確保(足りなければ追加。履歴には積まない)。
2263
+ // 追加した空行数を返す(pushCmd が履歴コマンドに畳み込んで undo/redo で戻す/足すため)。
2198
2264
  _ensureSpare() {
2199
- if (this._renderPaused) { this._renderDirty = true; return; } // バッチ描画中は保留→resume でまとめて確保
2200
- if (!this.minSpareRows || this._allRows || !this.dataComplete) return; // フィルタ中/遅延ロード中(PENDING tail)は末尾の自動空行を足さない
2265
+ if (this._renderPaused) { this._renderDirty = true; return 0; } // バッチ描画中は保留→resume でまとめて確保
2266
+ if (!this.minSpareRows || this._allRows || !this.dataComplete) return 0; // フィルタ中/遅延ロード中(PENDING tail)は末尾の自動空行を足さない
2201
2267
  let empties = 0;
2202
2268
  for (let r = this.ROWS - 1; r >= 0; r--) { if (this._rowEmpty(r)) empties++; else break; }
2203
2269
  let added = 0;
2204
2270
  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 で天井打ち(スペア行も超えない)
2205
2271
  if (added && this.table) { this.buildTable(); this.setActive(this.active.r, this.active.c); }
2272
+ return added;
2273
+ }
2274
+ // pushCmd の undo/redo 用: minSpareRows で自動追加された末尾空行 n 行を、編集の取消/やり直しと一緒に外す/戻す。
2275
+ _addSpareRows(n) {
2276
+ if (!n) return;
2277
+ 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({}); }
2278
+ this.ROWS += n; this.buildTable();
2279
+ }
2280
+ _removeSpareRows(n) {
2281
+ if (!n) return;
2282
+ n = Math.min(n, this.ROWS - 1); // 最低1行は残す(防御)
2283
+ this.data.length -= n; this.rowH.length -= n; if (this._src) this._src.length -= n;
2284
+ this.ROWS -= n; this.buildTable();
2206
2285
  }
2207
2286
  // 構造変更を実行+履歴に積む共通処理。doIt/undoIt は冪等(redo で再実行される)。
2208
2287
  _structCmd(doIt, undoIt, selAfterFn, selBefore, info) {
@@ -2289,6 +2368,15 @@
2289
2368
  };
2290
2369
  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 });
2291
2370
  }
2371
+ // 右端🗑の「最後の1行」用: 0行にはできない(削除ガード)ので、行は残して中身だけ空にする
2372
+ // (「消してから空行を足す」のと正味同じ結果を、0行に一瞬も落とさず 1 undo で)。readOnly セルはスキップ。
2373
+ clearRow(r) {
2374
+ if (r < 0 || r >= this.ROWS) return;
2375
+ if (this.mode === 'edit') this._commitActive();
2376
+ const before = this.snapSel(), changes = [];
2377
+ for (let c = 0; c < this.COLS; c++) { if (this._isReadOnly(r, c)) continue; this.setCell(r, c, '', changes); }
2378
+ this.pushCmd(changes, before, this.snapSel(), 'delete'); // changes 空(既に空/全 readOnly)なら pushCmd 側で no-op
2379
+ }
2292
2380
  insertCol(ci, where = 'left') {
2293
2381
  if (!this.allowInsertCols) return;
2294
2382
  if (this._allRows) return; // v1: フィルタ中は構造変更ロック
@@ -2663,7 +2751,9 @@
2663
2751
  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);
2664
2752
  after = { active: { r: sel.r0, c: sel.c0 }, extent: { r: sel.r1, c: sel.c1 } };
2665
2753
  } else {
2666
- const r0 = this.active.r, c0 = this.active.c;
2754
+ // アクティブ列が隠し列だと(例: selectRow は active.c=0 を置く=先頭列が hiddenColumns の時)以下の indexOf が -1 になり、
2755
+ // 貼り付けが1列ずれて先頭値が落ちる。可視列へスナップしてから起点を取る(setActive と同じ「隠し列に置かない」規則)。
2756
+ const r0 = this.active.r, c0 = this._snapVisCol(this.active.c);
2667
2757
  let maxw = 0; for (let i = 0; i < g.length; i++) maxw = Math.max(maxw, g[i].length);
2668
2758
  const p0 = visAll.indexOf(c0), availCols = visAll.length - p0; // 貼り付けは可視列へ(隠し列をスキップ)
2669
2759
  // オートグロー時に伸ばせる上限行数(maxRows があればそこまで/無ければ無制限)。非オートグローは現状の ROWS。
@@ -2835,6 +2925,7 @@
2835
2925
  this.table.addEventListener('mousedown', (e) => {
2836
2926
  if (e.button !== 0) return; // 右/中クリックは選択ドラッグを開始しない(右は contextmenu で処理)
2837
2927
  const cls = e.target.classList;
2928
+ if (e.target.closest('.tg-rowdel-cell')) { e.preventDefault(); return; } // 削除列: 選択ドラッグを始めない(実削除は click で)。preventDefault で🗑のテキスト選択も抑止
2838
2929
  if (cls && cls.contains('tg-colgrip')) { this._startColResize(e); return; } // 列幅リサイズ
2839
2930
  if (cls && cls.contains('tg-rowgrip')) { this._startRowResize(e); return; } // 行高リサイズ
2840
2931
  if (cls && cls.contains('tg-rowmove') && !this.rowReorderWhole) { this._startRowMove(e); return; } // ⠿つまみドラッグ移動('header'モードはセル全体扱い=_onHeaderMouseDown 経由で選択も伴う)
@@ -2858,6 +2949,13 @@
2858
2949
  // ヘッダ全選択チェックは mousedown で処理済み。click のネイティブ・トグルを止めて二重反転を防ぐ
2859
2950
  this.table.addEventListener('click', (e) => {
2860
2951
  if (e.target.classList && e.target.classList.contains('tg-head-cb')) e.preventDefault();
2952
+ // 右端削除列の🗑クリック。通常は当該行を削除(remove_row と同一経路・1 undo で復活)。
2953
+ // 最後の1行(0行にはできない)は削除でなく「中身クリア(行は残す)」。minRows>1 のロック時は no-op(🗑 も無効表示)。
2954
+ const del = e.target.closest('.tg-rowdel-cell');
2955
+ if (del) {
2956
+ const tr = del.closest('tr[data-r]');
2957
+ 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); }
2958
+ }
2861
2959
  });
2862
2960
  this.table.addEventListener('dblclick', (e) => {
2863
2961
  // 列境界グリップのダブルクリック=オートフィット(Excel風)
@@ -3040,7 +3138,7 @@
3040
3138
  _openSelect() {
3041
3139
  const r = this.active.r, c = this.active.c, cfg = this.colCfg(c);
3042
3140
  if (this._isReadOnly(r, c)) return; // 読み取り専用は <select> を開かない
3043
- const opts = this._optList(c), values = opts.map(o => o.value), cur = this.data[r][c];
3141
+ const opts = this._optList(c, r), values = opts.map(o => o.value), cur = this.data[r][c];
3044
3142
  const allowEmpty = cfg.allowEmpty !== false; // 既定: クリア用の空オプションを先頭に付ける
3045
3143
  let html = allowEmpty ? '<option value=""></option>' : '';
3046
3144
  for (const o of opts) html += '<option value="' + TssGrid.esc(o.value) + '">' + TssGrid.esc(o.label) + '</option>'; // value↔label 分離