@threestarsoftware/tssgrid 0.0.8 → 0.0.9
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 +14 -0
- package/README.md +38 -5
- package/package.json +1 -1
- package/src/tssgrid.d.ts +380 -348
- package/src/tssgrid.js +198 -43
package/src/tssgrid.js
CHANGED
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
this.onEditEnd = opts.onEditEnd || null;
|
|
78
78
|
this.onSelectionChange = opts.onSelectionChange || null;
|
|
79
79
|
this.onHeaderClick = opts.onHeaderClick || null; // (c, e)=>false で列選択を抑止(ソートUI等を外部で組む用)
|
|
80
|
+
this.onHeaderRender = opts.onHeaderRender || null; // (th, c) 各列ヘッダthの生成後に呼ぶ。ソートアイコン等の要素差し込み用。再描画毎に自動で再実行される
|
|
80
81
|
this.onBeforePaste = opts.onBeforePaste || null;
|
|
81
82
|
// 確定キーの移動方向(実行時変更可)。'down'|'up'|'right'|'left'|'none'
|
|
82
83
|
this.enterMoves = opts.enterMoves || 'down';
|
|
@@ -119,6 +120,7 @@
|
|
|
119
120
|
const _cm = opts.contextMenu;
|
|
120
121
|
this.contextMenu = _cm !== false;
|
|
121
122
|
this._menuConfig = Array.isArray(_cm) ? _cm : (_cm && Array.isArray(_cm.items)) ? _cm.items : null;
|
|
123
|
+
this._menuCtx = null; // 直近の右クリック対象 { on:'col'|'row'|'cell'|'corner', r, c }(メニュー項目へ渡す)
|
|
122
124
|
this.allowInsertRows = opts.allowInsertRows !== false;
|
|
123
125
|
this.allowDeleteRows = opts.allowDeleteRows !== false;
|
|
124
126
|
this.allowInsertCols = opts.allowInsertCols !== false;
|
|
@@ -150,6 +152,9 @@
|
|
|
150
152
|
this.pendingCut = null;
|
|
151
153
|
this.headerDrag = null; this._colAnchor = 0; this._rowAnchor = 0; // 行/列ヘッダ選択
|
|
152
154
|
this._headerSel = null; // 'col'/'row'/'all'=列/行/全選択モード(結合に拡張せず実列/行にクランプ=Excel流)。null=通常セル選択
|
|
155
|
+
this._selCols = new Set(); // 飛び飛びの列選択(Ctrl+ヘッダクリック)。_headerSel==='col' の時だけ有効。連続選択は矩形と一致
|
|
156
|
+
this._selRows = new Set(); // 飛び飛びの行選択(Ctrl+行ヘッダクリック)。_headerSel==='row' の時だけ有効
|
|
157
|
+
this.disjointSelect = opts.disjointSelect !== false; // Ctrl+ヘッダで行/列を飛び飛び複数選択(false で無効=Ctrl+クリックは単一選択に戻る)
|
|
153
158
|
// リサイズ(列幅・行高)
|
|
154
159
|
// 列幅 colW[c]=px(未指定は既定)。指定方法は3通り、優先度=実行時リサイズ/明示colW > columns[c].width > 既定:
|
|
155
160
|
// 1) colWidths: [px, …] … 位置指定(従来)
|
|
@@ -349,10 +354,19 @@
|
|
|
349
354
|
this._moveBy(key === 'tab' ? this.tabMoves : this.enterMoves, reverse);
|
|
350
355
|
}
|
|
351
356
|
// CSV 文字列を返す(RFC4180 準拠のクォート)。opts: headers(既定true) / formatted(既定false=素の値, trueで表示形)
|
|
352
|
-
// / skipEmpty(既定true=空行除外) / eol(既定 '\r\n'=Excel向け)
|
|
357
|
+
// / skipEmpty(既定true=空行除外) / eol(既定 '\r\n'=Excel向け)
|
|
358
|
+
// / sanitizeFormula(既定true=数式インジェクション無害化。生値が要る用途は false でオプトアウト)。
|
|
359
|
+
// JSON は getRows()+JSON.stringify で足りるので非内蔵。
|
|
353
360
|
toCSV(opts) {
|
|
354
|
-
const o = Object.assign({ headers: true, formatted: false, skipEmpty: true, eol: '\r\n' }, opts);
|
|
355
|
-
const esc = s => {
|
|
361
|
+
const o = Object.assign({ headers: true, formatted: false, skipEmpty: true, eol: '\r\n', sanitizeFormula: true }, opts);
|
|
362
|
+
const esc = s => {
|
|
363
|
+
s = s == null ? '' : String(s);
|
|
364
|
+
// CSV数式インジェクション対策: 先頭 = @ タブ/CR は常に、+ - は「全体が普通の数値でない」時だけ ' で無害化。
|
|
365
|
+
// -100/-3.14 等の正当な数値は数値のまま/ =1+2・+1+HYPERLINK(..) 等の式ペイロードだけ Excel/Sheets で式解釈されないようにする。
|
|
366
|
+
if (o.sanitizeFormula !== false &&
|
|
367
|
+
(/^[=@\t\r]/.test(s) || (/^[+\-]/.test(s) && !/^[+\-]?\d+(\.\d+)?$/.test(s)))) s = "'" + s;
|
|
368
|
+
return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
369
|
+
};
|
|
356
370
|
const lines = [];
|
|
357
371
|
if (o.headers && this.headers) lines.push(this.headers.slice(0, this.COLS).map(esc).join(','));
|
|
358
372
|
for (let r = 0; r < this.ROWS; r++) {
|
|
@@ -412,6 +426,13 @@
|
|
|
412
426
|
}
|
|
413
427
|
// 初回ソート時に「入力順(行参照の並び)」を記録。編集ではズレず、clearSort() で戻せる。
|
|
414
428
|
_rememberOrder() { if (!this._unsortedData) this._unsortedData = this.data.slice(); }
|
|
429
|
+
// 行 index a,b を列 ci の値で比較(数値同士なら数値・でなければロケール文字列)。符号(asc/desc)は呼び側で掛ける。
|
|
430
|
+
// sortBy / sortByCols で共有=単一列と多段で並び規則を完全一致させる。
|
|
431
|
+
_cmpCell(a, b, ci) {
|
|
432
|
+
const va = this.data[a][ci], vb = this.data[b][ci];
|
|
433
|
+
const na = TssGrid.toNum(va), nb = TssGrid.toNum(vb);
|
|
434
|
+
return (na !== null && nb !== null) ? na - nb : String(va).localeCompare(String(vb));
|
|
435
|
+
}
|
|
415
436
|
// 列 c(index か data キー)で並べ替え。dir='asc'(既定)|'desc'。数値っぽければ数値比較、でなければ文字列比較。
|
|
416
437
|
sortBy(c, dir) {
|
|
417
438
|
if (this._sortLocked()) return; // 結合中はソート禁止(値による組み替えで結合帯が散らばる)
|
|
@@ -419,11 +440,22 @@
|
|
|
419
440
|
const sgn = dir === 'desc' ? -1 : 1;
|
|
420
441
|
this._rememberOrder();
|
|
421
442
|
const [full, empty] = this._sortBuckets();
|
|
443
|
+
full.sort((a, b) => this._cmpCell(a, b, ci) * sgn);
|
|
444
|
+
this._applyRowOrder(full.concat(empty));
|
|
445
|
+
}
|
|
446
|
+
// 多段(複数列)ソート。specs=[{col, dir}] を優先順(先頭=第1キー)に。col=index か data キー、dir='asc'(既定)|'desc'。
|
|
447
|
+
// 各段は sortBy と同じセル比較。前段が同値のときだけ次段で決める(安定ソート=残った同値は現在の並びを保つ)。空行は最下部固定。
|
|
448
|
+
sortByCols(specs) {
|
|
449
|
+
if (this._sortLocked()) return; // 結合中はソート禁止
|
|
450
|
+
const keys = (specs || [])
|
|
451
|
+
.map(s => ({ ci: this._resolveCol(s && s.col), sgn: (s && s.dir === 'desc') ? -1 : 1 }))
|
|
452
|
+
.filter(k => k.ci >= 0);
|
|
453
|
+
if (!keys.length) return;
|
|
454
|
+
this._rememberOrder();
|
|
455
|
+
const [full, empty] = this._sortBuckets();
|
|
422
456
|
full.sort((a, b) => {
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
const d = (na !== null && nb !== null) ? na - nb : String(va).localeCompare(String(vb));
|
|
426
|
-
return d * sgn;
|
|
457
|
+
for (const k of keys) { const d = this._cmpCell(a, b, k.ci); if (d) return d * k.sgn; }
|
|
458
|
+
return 0;
|
|
427
459
|
});
|
|
428
460
|
this._applyRowOrder(full.concat(empty));
|
|
429
461
|
}
|
|
@@ -1275,6 +1307,7 @@
|
|
|
1275
1307
|
}
|
|
1276
1308
|
html += '</tbody>';
|
|
1277
1309
|
this.table.innerHTML = html;
|
|
1310
|
+
if (this.onHeaderRender && this.colHeaders) this._applyHeaderRender(); // 外部がヘッダthを装飾(ソートアイコン等)。高さ確定前に走らせて幾何へ反映
|
|
1278
1311
|
const thd = this.table.querySelector('thead');
|
|
1279
1312
|
this._theadH = (this.colHeaders && thd) ? thd.offsetHeight : 0; // ヘッダ高をキャッシュ(クリップ計算用)
|
|
1280
1313
|
this._applyTableWidth();
|
|
@@ -1286,6 +1319,13 @@
|
|
|
1286
1319
|
this._buildGeomCache(); // 実測幾何キャッシュ(全 _apply 後=幅高が確定してから)
|
|
1287
1320
|
this.renderDump();
|
|
1288
1321
|
}
|
|
1322
|
+
// 各リーフ列ヘッダth(data-c 付き)を生成後に onHeaderRender へ渡す。ソートアイコン等の差し込み用。
|
|
1323
|
+
_applyHeaderRender() {
|
|
1324
|
+
for (let c = 0; c < this.COLS; c++) {
|
|
1325
|
+
const th = this.table.querySelector('thead th[data-c="' + c + '"]');
|
|
1326
|
+
if (th) { try { this.onHeaderRender(th, c); } catch (_) {} }
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1289
1329
|
// ---- checkbox 列の全選択(ヘッダ) ----
|
|
1290
1330
|
// c 以外の列が全部空=「追加用の空行」。全選択や状態判定はこの行を対象外にする
|
|
1291
1331
|
// (在庫だけ ON にして空行が中身ありに化け、空行が増殖するのを防ぐ)。
|
|
@@ -1478,6 +1518,21 @@
|
|
|
1478
1518
|
}
|
|
1479
1519
|
const el = this.cellEl(r, c); if (el) el.classList.add('sel');
|
|
1480
1520
|
}
|
|
1521
|
+
// 飛び飛びの列選択(Ctrl+ヘッダクリック): 集合の各列をヘッダ+セルまでハイライト(連続選択は矩形と重複=無害)
|
|
1522
|
+
if (this._headerSel === 'col' && this._selCols && this._selCols.size > 1) {
|
|
1523
|
+
for (const sc of this._selCols) {
|
|
1524
|
+
const th = this.table.querySelector('thead th[data-c="' + sc + '"]'); if (th) th.classList.add('selhdr');
|
|
1525
|
+
for (let r = hlo; r <= hhi; r++) { const el = this.cellEl(r, sc); if (el) el.classList.add('sel'); }
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
// 飛び飛びの行選択(Ctrl+行ヘッダクリック): 集合の各行を行ヘッダ+セルまでハイライト
|
|
1529
|
+
if (this._headerSel === 'row' && this._selRows && this._selRows.size > 1) {
|
|
1530
|
+
for (const sr of this._selRows) {
|
|
1531
|
+
if (this.virtual && (sr < this._vStart || sr >= this._vEnd)) continue; // 仮想: 窓外は描かない
|
|
1532
|
+
const th = this.table.querySelector('th.rowhead[data-r="' + sr + '"]'); if (th) th.classList.add('selhdr');
|
|
1533
|
+
for (let c = c0; c <= c1; c++) { const el = this.cellEl(sr, c); if (el) el.classList.add('sel'); }
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1481
1536
|
}
|
|
1482
1537
|
// 選択ヘッダの文字色を --tg-selhdr-bg の輝度から自動決定(暗背景→白/明背景→濃紺)。
|
|
1483
1538
|
// 既定の明るい選択色は従来どおり濃紺、暗い選択ヘッダ(slate等)でも文字が読める。buildTable でキャッシュ破棄。
|
|
@@ -1636,13 +1691,55 @@
|
|
|
1636
1691
|
}
|
|
1637
1692
|
|
|
1638
1693
|
// ---- 行 / 列 / 全体 の選択(ヘッダクリック) ----
|
|
1639
|
-
selectCol(c) { c = this.clampC(c); this._colAnchor = c; this.selectRect(0, c, this.ROWS - 1, c); this._headerSel = 'col'; this.updateSelectionUI(); }
|
|
1640
|
-
selectRow(r) { r = this.clampR(r); this._rowAnchor = r; this.selectRect(r, 0, r, this.COLS - 1); this._headerSel = 'row'; this.updateSelectionUI(); }
|
|
1694
|
+
selectCol(c) { c = this.clampC(c); this._colAnchor = c; this.selectRect(0, c, this.ROWS - 1, c); this._headerSel = 'col'; this._selCols = new Set([c]); this.updateSelectionUI(); }
|
|
1695
|
+
selectRow(r) { r = this.clampR(r); this._rowAnchor = r; this.selectRect(r, 0, r, this.COLS - 1); this._headerSel = 'row'; this._selRows = new Set([r]); this.updateSelectionUI(); }
|
|
1641
1696
|
selectAll() { this._colAnchor = 0; this._rowAnchor = 0; this.selectRect(0, 0, this.ROWS - 1, this.COLS - 1); this._headerSel = 'all'; this.updateSelectionUI(); }
|
|
1642
1697
|
_extendCol(c) { const a = { r: 0, c: this._colAnchor }, e = { r: this.ROWS - 1, c: this.clampC(c) }; if (!this._beforeSel(a, e)) return; this._headerSel = 'col'; this.active = a; this.extent = e;
|
|
1698
|
+
this._selCols = new Set(); for (let x = Math.min(this._colAnchor, a.c, e.c), hi = Math.max(this._colAnchor, e.c); x <= hi; x++) this._selCols.add(x);
|
|
1643
1699
|
this._syncEditorNav(); this.updateSelectionUI(); this._fireSelection(); }
|
|
1700
|
+
// Ctrl+ヘッダクリック: 列を飛び飛びに足す/外す。列モード外から入ると新規集合。
|
|
1701
|
+
_colSel(a, e) { if (!this._beforeSel(a, e)) return false; this._headerSel = 'col'; this.active = a; this.extent = e; this._syncEditorNav(); this.updateSelectionUI(); this._fireSelection(); return true; }
|
|
1702
|
+
_toggleCol(c) {
|
|
1703
|
+
c = this.clampC(c);
|
|
1704
|
+
if (this._headerSel !== 'col') this._selCols = new Set();
|
|
1705
|
+
const removed = this._selCols.has(c);
|
|
1706
|
+
if (removed) this._selCols.delete(c); else this._selCols.add(c);
|
|
1707
|
+
this._colAnchor = c;
|
|
1708
|
+
if (this._selCols.size === 0) { this.setActive(0, c); return; } // 全部外れた=列選択解除(アクティブだけ残す)
|
|
1709
|
+
const ac = removed ? Math.max.apply(null, [...this._selCols]) : c; // アクティブは必ず集合内の列(外した列を指さない)
|
|
1710
|
+
this._colSel({ r: 0, c: ac }, { r: this.ROWS - 1, c: ac });
|
|
1711
|
+
}
|
|
1712
|
+
_addColRange(c) { // Ctrl+Shift+ヘッダクリック: アンカー〜cの範囲を集合に追加
|
|
1713
|
+
c = this.clampC(c);
|
|
1714
|
+
if (this._headerSel !== 'col') this._selCols = new Set([this._colAnchor]);
|
|
1715
|
+
for (let x = Math.min(this._colAnchor, c), hi = Math.max(this._colAnchor, c); x <= hi; x++) this._selCols.add(x);
|
|
1716
|
+
this._colSel({ r: 0, c }, { r: this.ROWS - 1, c });
|
|
1717
|
+
}
|
|
1718
|
+
// 選択中の列インデックス(飛び飛び対応・昇順)。列選択モードでなければ空配列。
|
|
1719
|
+
getSelectedColumns() { return this._headerSel === 'col' ? [...this._selCols].sort((a, b) => a - b) : []; }
|
|
1644
1720
|
_extendRow(r) { const a = { r: this._rowAnchor, c: 0 }, e = { r: this.clampR(r), c: this.COLS - 1 }; if (!this._beforeSel(a, e)) return; this._headerSel = 'row'; this.active = a; this.extent = e;
|
|
1721
|
+
this._selRows = new Set(); for (let x = Math.min(this._rowAnchor, a.r, e.r), hi = Math.max(this._rowAnchor, e.r); x <= hi; x++) this._selRows.add(x);
|
|
1645
1722
|
this._syncEditorNav(); this.updateSelectionUI(); this._fireSelection(); }
|
|
1723
|
+
// Ctrl+行ヘッダクリック: 行を飛び飛びに足す/外す(列と対称)。
|
|
1724
|
+
_rowSel(a, e) { if (!this._beforeSel(a, e)) return false; this._headerSel = 'row'; this.active = a; this.extent = e; this._syncEditorNav(); this.updateSelectionUI(); this._fireSelection(); return true; }
|
|
1725
|
+
_toggleRow(r) {
|
|
1726
|
+
r = this.clampR(r);
|
|
1727
|
+
if (this._headerSel !== 'row') this._selRows = new Set();
|
|
1728
|
+
const removed = this._selRows.has(r);
|
|
1729
|
+
if (removed) this._selRows.delete(r); else this._selRows.add(r);
|
|
1730
|
+
this._rowAnchor = r;
|
|
1731
|
+
if (this._selRows.size === 0) { this.setActive(r, 0); return; }
|
|
1732
|
+
const ar = removed ? Math.max.apply(null, [...this._selRows]) : r; // アクティブは必ず集合内の行
|
|
1733
|
+
this._rowSel({ r: ar, c: 0 }, { r: ar, c: this.COLS - 1 });
|
|
1734
|
+
}
|
|
1735
|
+
_addRowRange(r) { // Ctrl+Shift+行ヘッダクリック: アンカー〜rの範囲を集合に追加
|
|
1736
|
+
r = this.clampR(r);
|
|
1737
|
+
if (this._headerSel !== 'row') this._selRows = new Set([this._rowAnchor]);
|
|
1738
|
+
for (let x = Math.min(this._rowAnchor, r), hi = Math.max(this._rowAnchor, r); x <= hi; x++) this._selRows.add(x);
|
|
1739
|
+
this._rowSel({ r, c: 0 }, { r, c: this.COLS - 1 });
|
|
1740
|
+
}
|
|
1741
|
+
// 選択中の行インデックス(飛び飛び対応・昇順)。行選択モードでなければ空配列。
|
|
1742
|
+
getSelectedRows() { return this._headerSel === 'row' ? [...this._selRows].sort((a, b) => a - b) : []; }
|
|
1646
1743
|
// ---- 列幅 / 行高 のリサイズ ----
|
|
1647
1744
|
setColWidth(c, w) {
|
|
1648
1745
|
w = Math.max(this.minColW, Math.round(w)); this.colW[c] = w;
|
|
@@ -1777,15 +1874,22 @@
|
|
|
1777
1874
|
if (th.dataset.c != null) { // 列ヘッダ
|
|
1778
1875
|
const c = +th.dataset.c;
|
|
1779
1876
|
if (this.onHeaderClick) { let r; try { r = this.onHeaderClick(c, e); } catch (_) {} if (r === false) { this.editor.focus(); return; } } // 外部ソートUI等
|
|
1780
|
-
|
|
1781
|
-
this.
|
|
1877
|
+
const ctrl = (e.ctrlKey || e.metaKey) && this.disjointSelect; // disjointSelect:false なら Ctrl は単一選択に落ちる
|
|
1878
|
+
if (ctrl && e.shiftKey) this._addColRange(c); // Ctrl+Shift=範囲を集合に追加
|
|
1879
|
+
else if (ctrl) this._toggleCol(c); // Ctrl=飛び飛びトグル
|
|
1880
|
+
else if (e.shiftKey) this._extendCol(c); // Shift=連続範囲
|
|
1881
|
+
else this.selectCol(c); // 素=単一
|
|
1882
|
+
this.editor.focus(); this.headerDrag = ctrl ? null : 'col'; // Ctrl中はドラッグ範囲選択を無効(飛び飛びを壊さない)
|
|
1782
1883
|
} else if (th.dataset.r != null) { // 行ヘッダ
|
|
1783
|
-
const r = +th.dataset.r;
|
|
1784
|
-
if (e.shiftKey) this.
|
|
1884
|
+
const r = +th.dataset.r, ctrl = (e.ctrlKey || e.metaKey) && this.disjointSelect; // disjointSelect:false なら単一選択に落ちる
|
|
1885
|
+
if (ctrl && e.shiftKey) this._addRowRange(r); // Ctrl+Shift=範囲を集合に追加
|
|
1886
|
+
else if (ctrl) this._toggleRow(r); // Ctrl=飛び飛びトグル
|
|
1887
|
+
else if (e.shiftKey) this._extendRow(r); // Shift=連続範囲
|
|
1888
|
+
else this.selectRow(r); // 素=単一
|
|
1785
1889
|
this.editor.focus();
|
|
1786
|
-
// 'header'モード: 行ヘッダーセル全体が移動ハンドル=行を選択した上で移動を arm
|
|
1787
|
-
if (this.rowReorderWhole && !e.shiftKey && !this._hasVMerge() && !this._allRows) this._startRowMove(e);
|
|
1788
|
-
else this.headerDrag = 'row'; // 通常:
|
|
1890
|
+
// 'header'モード: 行ヘッダーセル全体が移動ハンドル=行を選択した上で移動を armする(クリック=選択のまま/ドラッグで移動)。縦結合/フィルタ中・Ctrl中は不可。
|
|
1891
|
+
if (this.rowReorderWhole && !e.shiftKey && !ctrl && !this._hasVMerge() && !this._allRows) this._startRowMove(e);
|
|
1892
|
+
else this.headerDrag = ctrl ? null : 'row'; // 通常: ドラッグで複数行選択(Ctrl中は無効=飛び飛びを壊さない)
|
|
1789
1893
|
} else { // 左上の角 → 全選択
|
|
1790
1894
|
this.selectAll(); this.editor.focus();
|
|
1791
1895
|
}
|
|
@@ -1879,8 +1983,12 @@
|
|
|
1879
1983
|
if (wasEditing && this.onEditEnd) { try { this.onEditEnd({ r, c, oldValue, newValue: this.data[r][c], canceled: false }); } catch (_) {} }
|
|
1880
1984
|
}
|
|
1881
1985
|
clearRange() {
|
|
1882
|
-
const before = this.snapSel(), changes = []
|
|
1883
|
-
|
|
1986
|
+
const before = this.snapSel(), changes = [];
|
|
1987
|
+
const djCol = this._headerSel === 'col' && this._selCols && this._selCols.size > 1;
|
|
1988
|
+
const djRow = this._headerSel === 'row' && this._selRows && this._selRows.size > 1;
|
|
1989
|
+
if (djCol) { for (const c of this._selCols) for (let r = 0; r < this.ROWS; r++) this.setCell(r, c, '', changes); } // 飛び飛び列の内容クリア
|
|
1990
|
+
else if (djRow) { for (const r of this._selRows) for (let c = 0; c < this.COLS; c++) this.setCell(r, c, '', changes); } // 飛び飛び行の内容クリア
|
|
1991
|
+
else { const { r0, r1, c0, c1 } = this.rectRange(); for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) this.setCell(r, c, '', changes); }
|
|
1884
1992
|
this.pushCmd(changes, before, this.snapSel(), 'delete');
|
|
1885
1993
|
}
|
|
1886
1994
|
|
|
@@ -1907,7 +2015,7 @@
|
|
|
1907
2015
|
let empties = 0;
|
|
1908
2016
|
for (let r = this.ROWS - 1; r >= 0; r--) { if (this._rowEmpty(r)) empties++; else break; }
|
|
1909
2017
|
let added = 0;
|
|
1910
|
-
while (empties < this.minSpareRows) { this.data.push(new Array(this.COLS).fill('')); this.rowH.push(undefined); if (this._src) this._src.push({}); this.ROWS++; empties++; added++; }
|
|
2018
|
+
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 で天井打ち(スペア行も超えない)
|
|
1911
2019
|
if (added && this.table) { this.buildTable(); this.setActive(this.active.r, this.active.c); }
|
|
1912
2020
|
}
|
|
1913
2021
|
// 構造変更を実行+履歴に積む共通処理。doIt/undoIt は冪等(redo で再実行される)。
|
|
@@ -2028,6 +2136,31 @@
|
|
|
2028
2136
|
};
|
|
2029
2137
|
this._structCmd(doIt, undoIt, () => { const cc = Math.min(c0, grid.COLS - 1); return { active: { r, c: cc }, extent: { r, c: cc } }; }, before, { type: 'deleteCols', c0, c1 });
|
|
2030
2138
|
}
|
|
2139
|
+
// 飛び飛びの列/行をまとめて削除。高index側から順に消す=先の削除で後のindexがズレない。
|
|
2140
|
+
// N回の deleteCols/deleteRows を _batchHistory で1つのUndo/Redo段にまとめる(1発で戻る/やり直す)。
|
|
2141
|
+
deleteColsSet(cols) {
|
|
2142
|
+
const sorted = [...new Set(cols)].sort((a, b) => b - a);
|
|
2143
|
+
this._batchHistory(() => { for (const c of sorted) this.deleteCols(c, c); });
|
|
2144
|
+
this._selCols = new Set(); this._headerSel = null; this.updateSelectionUI();
|
|
2145
|
+
}
|
|
2146
|
+
deleteRowsSet(rows) {
|
|
2147
|
+
const sorted = [...new Set(rows)].sort((a, b) => b - a);
|
|
2148
|
+
this._batchHistory(() => { for (const r of sorted) this.deleteRows(r, r); });
|
|
2149
|
+
this._selRows = new Set(); this._headerSel = null; this.updateSelectionUI();
|
|
2150
|
+
}
|
|
2151
|
+
// 複数の構造変更を1つのUndo/Redo段にまとめる。history.push を一時横取りしてサブコマンドを集め、
|
|
2152
|
+
// 合成コマンドを1つ積む(apply=順に実行 / revert=逆順に実行)。各 deleteCols のロジックはそのまま再利用。
|
|
2153
|
+
_batchHistory(fn) {
|
|
2154
|
+
const h = this.history, orig = h.push, batch = [];
|
|
2155
|
+
h.push = (cmd) => { batch.push(cmd); };
|
|
2156
|
+
try { fn(); } finally { h.push = orig; }
|
|
2157
|
+
if (batch.length <= 1) { if (batch.length) orig.call(h, batch[0]); return; }
|
|
2158
|
+
orig.call(h, {
|
|
2159
|
+
label: this.name,
|
|
2160
|
+
apply() { for (const c of batch) c.apply(); },
|
|
2161
|
+
revert() { for (let i = batch.length - 1; i >= 0; i--) batch[i].revert(); },
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2031
2164
|
|
|
2032
2165
|
// ---- 右クリックメニュー ----
|
|
2033
2166
|
_onContextMenu(e) {
|
|
@@ -2036,12 +2169,16 @@
|
|
|
2036
2169
|
if (!th && !td) return;
|
|
2037
2170
|
e.preventDefault();
|
|
2038
2171
|
if (this.mode === 'edit') this._commitActive();
|
|
2039
|
-
//
|
|
2172
|
+
// 右クリック位置に選択を移す(既存選択の中ならそのまま=範囲削除に使える)+対象種別を記録(メニュー項目へ渡す)
|
|
2173
|
+
let ctx;
|
|
2040
2174
|
if (td) {
|
|
2041
2175
|
const r = +td.dataset.r, c = +td.dataset.c, s = this.rectRange();
|
|
2042
2176
|
if (!(r >= s.r0 && r <= s.r1 && c >= s.c0 && c <= s.c1)) this.setActive(r, c);
|
|
2043
|
-
|
|
2044
|
-
else if (th.dataset.
|
|
2177
|
+
ctx = { on: 'cell', r, c };
|
|
2178
|
+
} else if (th.dataset.c != null) { const c = +th.dataset.c, s = this.rectRange(); const inDj = this._headerSel === 'col' && this._selCols && this._selCols.has(c); if (!inDj && !(c >= s.c0 && c <= s.c1)) this.selectCol(c); ctx = { on: 'col', r: -1, c }; } // 既存選択(連続 or 飛び飛び)の中なら維持
|
|
2179
|
+
else if (th.dataset.r != null) { const r = +th.dataset.r, s = this.rectRange(); const inDj = this._headerSel === 'row' && this._selRows && this._selRows.has(r); if (!inDj && !(r >= s.r0 && r <= s.r1)) this.selectRow(r); ctx = { on: 'row', r, c: -1 }; } // 同上(飛び飛び行も維持)
|
|
2180
|
+
else ctx = { on: 'corner', r: -1, c: -1 }; // 隅(全体)ヘッダ
|
|
2181
|
+
this._menuCtx = ctx;
|
|
2045
2182
|
this._showMenu(e.clientX, e.clientY);
|
|
2046
2183
|
}
|
|
2047
2184
|
// 既定のメニュー項目キー(allow フラグ・copyPaste で出し分け)。
|
|
@@ -2066,10 +2203,14 @@
|
|
|
2066
2203
|
switch (key) {
|
|
2067
2204
|
case 'row_above': return this.allowInsertRows ? { label: '行を上に挿入', act: () => this.insertRow(s.r0, 'above') } : null;
|
|
2068
2205
|
case 'row_below': return this.allowInsertRows ? { label: '行を下に挿入', act: () => this.insertRow(s.r1, 'below') } : null;
|
|
2069
|
-
case 'remove_row':
|
|
2206
|
+
case 'remove_row': { if (!this.allowDeleteRows) return null;
|
|
2207
|
+
const dj = this._headerSel === 'row' && this._selRows && this._selRows.size > 1, set = dj ? [...this._selRows] : null, n = dj ? set.length : rows; // 飛び飛び行は集合を削除
|
|
2208
|
+
return { label: n > 1 ? n + ' 行を削除' : '行を削除', danger: true, disabled: this.ROWS - n < Math.max(1, this.minRows), act: dj ? () => this.deleteRowsSet(set) : () => this.deleteRows(s.r0, s.r1) }; }
|
|
2070
2209
|
case 'col_left': return this.allowInsertCols ? { label: '列を左に挿入', act: () => this.insertCol(s.c0, 'left') } : null;
|
|
2071
2210
|
case 'col_right': return this.allowInsertCols ? { label: '列を右に挿入', act: () => this.insertCol(s.c1, 'right') } : null;
|
|
2072
|
-
case 'remove_col':
|
|
2211
|
+
case 'remove_col': { if (!this.allowDeleteCols) return null;
|
|
2212
|
+
const dj = this._headerSel === 'col' && this._selCols && this._selCols.size > 1, set = dj ? [...this._selCols] : null, n = dj ? set.length : cols; // 飛び飛び列は集合を削除
|
|
2213
|
+
return { label: n > 1 ? n + ' 列を削除' : '列を削除', danger: true, disabled: this.COLS - n < Math.max(1, this.minCols), act: dj ? () => this.deleteColsSet(set) : () => this.deleteCols(s.c0, s.c1) }; }
|
|
2073
2214
|
case 'copy': return { label: 'コピー', disabled: !this.copyPaste, act: () => this.copy(false) };
|
|
2074
2215
|
case 'cut': return { label: '切り取り', disabled: !this.copyPaste, act: () => this.copy(true) };
|
|
2075
2216
|
case 'paste': return { label: '貼り付け', disabled: !this.copyPaste, act: () => this.paste() };
|
|
@@ -2082,30 +2223,32 @@
|
|
|
2082
2223
|
}
|
|
2083
2224
|
}
|
|
2084
2225
|
// メニュー構成(キー文字列/カスタム項目オブジェクト)→ 表示用記述子の配列。
|
|
2085
|
-
|
|
2226
|
+
// カスタム項目の name/disabled/hidden は (s, ctx) で呼ばれ、callback は { range, key, ctx } を受ける。
|
|
2227
|
+
// ctx=右クリック対象 { on:'col'|'row'|'cell'|'corner', r, c }(例: hidden:(s,ctx)=>ctx.on!=='col' で列ヘッダ限定表示)。
|
|
2228
|
+
_resolveMenuItems(config, s, ctx) {
|
|
2086
2229
|
const items = [];
|
|
2087
2230
|
for (const entry of config) {
|
|
2088
2231
|
if (typeof entry === 'string') { const it = this._builtinItem(entry, s); if (it) items.push(it); continue; }
|
|
2089
2232
|
if (!entry || typeof entry !== 'object') continue;
|
|
2090
|
-
const hidden = typeof entry.hidden === 'function' ? !!entry.hidden.call(this, s) : !!entry.hidden;
|
|
2233
|
+
const hidden = typeof entry.hidden === 'function' ? !!entry.hidden.call(this, s, ctx) : !!entry.hidden;
|
|
2091
2234
|
if (hidden) continue;
|
|
2092
|
-
const name = typeof entry.name === 'function' ? entry.name.call(this, s) : entry.name;
|
|
2235
|
+
const name = typeof entry.name === 'function' ? entry.name.call(this, s, ctx) : entry.name;
|
|
2093
2236
|
if (entry.separator || /^-+$/.test(String(name))) { items.push({ sep: true }); continue; }
|
|
2094
|
-
const disabled = typeof entry.disabled === 'function' ? !!entry.disabled.call(this, s) : !!entry.disabled;
|
|
2237
|
+
const disabled = typeof entry.disabled === 'function' ? !!entry.disabled.call(this, s, ctx) : !!entry.disabled;
|
|
2095
2238
|
const it = { label: String(name == null ? '' : name), disabled, danger: !!entry.danger };
|
|
2096
2239
|
if (Array.isArray(entry.submenu)) it.submenu = entry.submenu;
|
|
2097
|
-
else it.act = () => { if (typeof entry.callback === 'function') { try { entry.callback.call(this, { range: { ...s }, key: entry.key }); } catch (_) {} } };
|
|
2240
|
+
else it.act = () => { if (typeof entry.callback === 'function') { try { entry.callback.call(this, { range: { ...s }, key: entry.key, ctx }); } catch (_) {} } };
|
|
2098
2241
|
items.push(it);
|
|
2099
2242
|
}
|
|
2100
2243
|
return items;
|
|
2101
2244
|
}
|
|
2102
2245
|
// 記述子配列 → HTML。actionable 項目は data-mi で _menuActions に紐付け(submenu も再帰でフラット登録)。
|
|
2103
|
-
_renderMenuItems(items, s) {
|
|
2246
|
+
_renderMenuItems(items, s, ctx) {
|
|
2104
2247
|
let html = '';
|
|
2105
2248
|
for (const it of items) {
|
|
2106
2249
|
if (it.sep) { html += '<div class="tg-sep"></div>'; continue; }
|
|
2107
2250
|
if (it.submenu) {
|
|
2108
|
-
const sub = this._renderMenuItems(this._resolveMenuItems(it.submenu, s), s);
|
|
2251
|
+
const sub = this._renderMenuItems(this._resolveMenuItems(it.submenu, s, ctx), s, ctx);
|
|
2109
2252
|
html += '<div class="tg-mi tg-has-sub' + (it.disabled ? ' tg-disabled' : '') + '">' + TssGrid.esc(it.label) + '<div class="tg-submenu">' + sub + '</div></div>';
|
|
2110
2253
|
} else {
|
|
2111
2254
|
const idx = this._menuActions.push(it.act) - 1;
|
|
@@ -2115,12 +2258,12 @@
|
|
|
2115
2258
|
return html;
|
|
2116
2259
|
}
|
|
2117
2260
|
_showMenu(x, y) {
|
|
2118
|
-
const s = this.rectRange();
|
|
2261
|
+
const s = this.rectRange(), ctx = this._menuCtx;
|
|
2119
2262
|
const config = this._menuConfig || this._defaultMenuKeys();
|
|
2120
|
-
const items = this._resolveMenuItems(config, s);
|
|
2263
|
+
const items = this._resolveMenuItems(config, s, ctx);
|
|
2121
2264
|
if (!items.length) { this._hideMenu(); return; } // 該当項目なし=メニューを出さない(前回の残りも隠す)
|
|
2122
2265
|
this._menuActions = [];
|
|
2123
|
-
this.menuEl.innerHTML = this._renderMenuItems(items, s);
|
|
2266
|
+
this.menuEl.innerHTML = this._renderMenuItems(items, s, ctx);
|
|
2124
2267
|
this.menuEl.style.display = 'block';
|
|
2125
2268
|
// 画面外にはみ出さないよう調整
|
|
2126
2269
|
const mw = this.menuEl.offsetWidth, mh = this.menuEl.offsetHeight;
|
|
@@ -2205,19 +2348,26 @@
|
|
|
2205
2348
|
copy(cut) {
|
|
2206
2349
|
if (!this.copyPaste) return;
|
|
2207
2350
|
const sel = this.rectRange(); let buf = [];
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2351
|
+
// 飛び飛び選択(Ctrl+ヘッダ)は「選んだ列×全行」または「選んだ行×全列」=きれいな長方形になり Excel にそのまま貼れる。
|
|
2352
|
+
const djCol = this._headerSel === 'col' && this._selCols && this._selCols.size > 1;
|
|
2353
|
+
const djRow = this._headerSel === 'row' && this._selRows && this._selRows.size > 1;
|
|
2354
|
+
const disjoint = djCol || djRow;
|
|
2355
|
+
const cols = djCol ? this._visCols().filter(c => this._selCols.has(c)) // 選んだ可視列(昇順)
|
|
2356
|
+
: this._visCols().filter(c => c >= sel.c0 && c <= sel.c1); // 隠し列は除外
|
|
2357
|
+
const rowIdx = djRow ? [...this._selRows].sort((a, b) => a - b) : null;
|
|
2358
|
+
if (rowIdx) { for (const r of rowIdx) { const row = []; for (const c of cols) row.push(this.data[r][c]); buf.push(row); } }
|
|
2359
|
+
else { const r0 = djCol ? 0 : sel.r0, r1 = djCol ? this.ROWS - 1 : sel.r1;
|
|
2360
|
+
for (let r = r0; r <= r1; r++) { const row = []; for (const c of cols) row.push(this.data[r][c]); buf.push(row); } }
|
|
2213
2361
|
// 「前」フック(cut は onBeforeCut / copy は onBeforeCopy)。false で中止 / 配列を返すと差替え。
|
|
2214
2362
|
const info = { range: { ...sel }, cut: !!cut };
|
|
2215
2363
|
const before = cut ? this.onBeforeCut : this.onBeforeCopy;
|
|
2216
2364
|
if (before) { let r; try { r = before(buf, info); } catch (_) {} if (r === false) return; if (Array.isArray(r)) buf = r; }
|
|
2217
2365
|
sharedClip = buf;
|
|
2218
2366
|
this._writeClip(TssGrid.toTSV(buf));
|
|
2219
|
-
|
|
2220
|
-
this.
|
|
2367
|
+
// カットの消し込み対象を記録(paste時に消す)。飛び飛びは列/行の集合として保持。
|
|
2368
|
+
this.pendingCut = !cut ? null : djCol ? { cols: cols.slice() } : djRow ? { rows: rowIdx.slice() } : { ...sel };
|
|
2369
|
+
if (disjoint) this.copybox.style.display = 'none'; // 飛び飛びは単一枠で囲えない=マーキー無し
|
|
2370
|
+
else { this.placeRect(this.copybox, sel.r0, sel.c0, sel.r1, sel.c1); this.copybox.style.display = 'block'; }
|
|
2221
2371
|
if (this.onAfterCopy) { try { this.onAfterCopy(buf, info); } catch (_) {} }
|
|
2222
2372
|
}
|
|
2223
2373
|
_writeClip(text) {
|
|
@@ -2299,7 +2449,11 @@
|
|
|
2299
2449
|
}
|
|
2300
2450
|
after = { active: { r: r0, c: c0 }, extent: { r: Math.min(this.ROWS - 1, r0 + g.length - 1), c: lastC } };
|
|
2301
2451
|
}
|
|
2302
|
-
if (this.pendingCut) { const cs = this.pendingCut;
|
|
2452
|
+
if (this.pendingCut) { const cs = this.pendingCut;
|
|
2453
|
+
if (cs.cols) { for (const c of cs.cols) for (let r = 0; r < this.ROWS; r++) this.setCell(r, c, '', changes); } // 飛び飛び列カット
|
|
2454
|
+
else if (cs.rows) { for (const r of cs.rows) for (let c = 0; c < this.COLS; c++) this.setCell(r, c, '', changes); } // 飛び飛び行カット
|
|
2455
|
+
else { for (let r = cs.r0; r <= cs.r1; r++) for (let c = cs.c0; c <= cs.c1; c++) this.setCell(r, c, '', changes); } // 連続(従来)
|
|
2456
|
+
this.pendingCut = null; this.copybox.style.display = 'none'; }
|
|
2303
2457
|
this.pushCmd(changes, before, after, 'paste');
|
|
2304
2458
|
this.focusAndSelect(after);
|
|
2305
2459
|
if (this.onAfterPaste) {
|
|
@@ -2556,7 +2710,8 @@
|
|
|
2556
2710
|
// autoInsertRow: フィルハンドルをテーブル下端より下へドロップしたら、足りない行を追加(縦フィル時)
|
|
2557
2711
|
if (this.fillAutoInsertRow && this.fillDirection !== 'horizontal' && !this._tdAtPoint(e)) {
|
|
2558
2712
|
const tb = this.table.getBoundingClientRect();
|
|
2559
|
-
|
|
2713
|
+
let extra = Math.min(1000, Math.ceil((e.clientY - tb.bottom) / this.defRowH));
|
|
2714
|
+
if (this.maxRows) extra = Math.min(extra, this.maxRows - this.ROWS); // maxRows で天井打ち
|
|
2560
2715
|
if (extra > 0) {
|
|
2561
2716
|
for (let i = 0; i < extra; i++) { this.data.push(new Array(this.COLS).fill('')); this.rowH.push(undefined); if (this._src) this._src.push({}); this.ROWS++; }
|
|
2562
2717
|
this.buildTable();
|