@threestarsoftware/tssgrid 0.0.7 → 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 +26 -0
- package/README.md +38 -5
- package/package.json +1 -1
- package/src/tssgrid.css +9 -0
- package/src/tssgrid.d.ts +380 -340
- package/src/tssgrid.js +334 -78
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, …] … 位置指定(従来)
|
|
@@ -199,6 +204,12 @@
|
|
|
199
204
|
this.stretchH = opts.stretchH || 'none';
|
|
200
205
|
// セル内折り返し(既定 nowrap)。grid 全体 or columns[c].wordWrap。
|
|
201
206
|
this.wordWrap = !!opts.wordWrap;
|
|
207
|
+
// 複数行(改行)編集の Enter 挙動: 'commit'(既定・Excel風: Enter=確定 / Alt+Enter=改行) | 'newline'(メモ帳風: Enter=改行 / Ctrl+Enter=確定)。列は columns[c].multiline:true で有効化。
|
|
208
|
+
this.multilineEnter = opts.multilineEnter === 'newline' ? 'newline' : 'commit';
|
|
209
|
+
// multiline セル右上の「複数行」ヒント(既定 ON)。色は CSS 変数 --tg-ml-mark で。false で消す。
|
|
210
|
+
this.multilineMark = opts.multilineMark !== false;
|
|
211
|
+
// multiline 編集時に伸びる箱の最大行数(既定 10)。超えた分はボックス内スクロール。
|
|
212
|
+
this.multilineEditMaxLines = opts.multilineEditMaxLines > 0 ? opts.multilineEditMaxLines : 10;
|
|
202
213
|
// 空セルのヒント表示(grid 全体 or columns[c].placeholder)。getData には入らない。
|
|
203
214
|
this.placeholder = opts.placeholder != null ? String(opts.placeholder) : null;
|
|
204
215
|
// 機能トグル
|
|
@@ -343,10 +354,19 @@
|
|
|
343
354
|
this._moveBy(key === 'tab' ? this.tabMoves : this.enterMoves, reverse);
|
|
344
355
|
}
|
|
345
356
|
// CSV 文字列を返す(RFC4180 準拠のクォート)。opts: headers(既定true) / formatted(既定false=素の値, trueで表示形)
|
|
346
|
-
// / skipEmpty(既定true=空行除外) / eol(既定 '\r\n'=Excel向け)
|
|
357
|
+
// / skipEmpty(既定true=空行除外) / eol(既定 '\r\n'=Excel向け)
|
|
358
|
+
// / sanitizeFormula(既定true=数式インジェクション無害化。生値が要る用途は false でオプトアウト)。
|
|
359
|
+
// JSON は getRows()+JSON.stringify で足りるので非内蔵。
|
|
347
360
|
toCSV(opts) {
|
|
348
|
-
const o = Object.assign({ headers: true, formatted: false, skipEmpty: true, eol: '\r\n' }, opts);
|
|
349
|
-
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
|
+
};
|
|
350
370
|
const lines = [];
|
|
351
371
|
if (o.headers && this.headers) lines.push(this.headers.slice(0, this.COLS).map(esc).join(','));
|
|
352
372
|
for (let r = 0; r < this.ROWS; r++) {
|
|
@@ -406,6 +426,13 @@
|
|
|
406
426
|
}
|
|
407
427
|
// 初回ソート時に「入力順(行参照の並び)」を記録。編集ではズレず、clearSort() で戻せる。
|
|
408
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
|
+
}
|
|
409
436
|
// 列 c(index か data キー)で並べ替え。dir='asc'(既定)|'desc'。数値っぽければ数値比較、でなければ文字列比較。
|
|
410
437
|
sortBy(c, dir) {
|
|
411
438
|
if (this._sortLocked()) return; // 結合中はソート禁止(値による組み替えで結合帯が散らばる)
|
|
@@ -413,11 +440,22 @@
|
|
|
413
440
|
const sgn = dir === 'desc' ? -1 : 1;
|
|
414
441
|
this._rememberOrder();
|
|
415
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();
|
|
416
456
|
full.sort((a, b) => {
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
const d = (na !== null && nb !== null) ? na - nb : String(va).localeCompare(String(vb));
|
|
420
|
-
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;
|
|
421
459
|
});
|
|
422
460
|
this._applyRowOrder(full.concat(empty));
|
|
423
461
|
}
|
|
@@ -596,6 +634,7 @@
|
|
|
596
634
|
'<div class="tg-copybox"></div><div class="tg-fillpreview"></div><div class="tg-fillhandle"></div>' +
|
|
597
635
|
'<div class="tg-resizeguide"></div>' +
|
|
598
636
|
'<input class="tg-editor nav" type="text" autocomplete="off" spellcheck="false">' +
|
|
637
|
+
'<textarea class="tg-editor tg-editor-multi nav" autocomplete="off" spellcheck="false" style="display:none"></textarea>' +
|
|
599
638
|
'<select class="tg-select" style="display:none"></select>' +
|
|
600
639
|
'</div>' + (this.showDump ? '<pre class="tg-dump"></pre>' : '') +
|
|
601
640
|
'<div class="tg-menu" style="display:none"></div></div>';
|
|
@@ -604,11 +643,13 @@
|
|
|
604
643
|
this.selrange = q('.tg-selrange'); this.selbox = q('.tg-selbox');
|
|
605
644
|
this.copybox = q('.tg-copybox'); this.fillhandle = q('.tg-fillhandle'); this.fillpreview = q('.tg-fillpreview');
|
|
606
645
|
this.resizeGuide = q('.tg-resizeguide');
|
|
607
|
-
this.
|
|
646
|
+
this._edInput = q('input.tg-editor'); this._edMulti = q('.tg-editor-multi'); this.editor = this._edInput;
|
|
647
|
+
this.select = q('.tg-select'); this.dumpEl = q('.tg-dump');
|
|
608
648
|
this.menuEl = q('.tg-menu');
|
|
609
649
|
this.root = q('.tssgrid');
|
|
610
650
|
if (this.className) this.root.className += ' ' + this.className; // 任意クラス(テーマ付け)
|
|
611
651
|
if (this.wordWrap) this.root.classList.add('tg-wordwrap'); // グリッド全体の折り返し
|
|
652
|
+
if (!this.multilineMark) this.root.classList.add('tg-no-ml-mark'); // multiline の右上ヒントを消す
|
|
612
653
|
if (!this.cursor) this.root.classList.add('tg-no-cursor'); // 表示専用(選択枠なし+セルカーソル既定)
|
|
613
654
|
if (this.width) {
|
|
614
655
|
this.wrap.style.width = this.width; // 幅制約(横スクロール/固定列用)
|
|
@@ -887,7 +928,7 @@
|
|
|
887
928
|
return null;
|
|
888
929
|
}
|
|
889
930
|
_usesTextEditor(c) {
|
|
890
|
-
if (this.colCfg(c).editor) return false; // カスタムエディタは共有 input
|
|
931
|
+
if (this.colCfg(c).editor && !this.colCfg(c).multiline) return false; // カスタムエディタは共有 input を使わない(multiline は textarea を優先)
|
|
891
932
|
const t = this.colType(c);
|
|
892
933
|
return t !== 'checkbox' && t !== 'dropdown' && !this._nativeInputType(c);
|
|
893
934
|
}
|
|
@@ -925,7 +966,10 @@
|
|
|
925
966
|
if (this.data[r][c] === '' && this._placeholder(c) != null) return TssGrid.esc(this._placeholder(c));
|
|
926
967
|
// html:true の列は表示だけ生HTML(値はテキスト保存のまま)。エスケープを外す=XSS責任は format/値の提供側。
|
|
927
968
|
if (this.colCfg(c).html) return this._displayValue(r, c);
|
|
928
|
-
|
|
969
|
+
const _h = TssGrid.esc(this._displayValue(r, c));
|
|
970
|
+
// 複数行セルは絶対配置のクリップ箱で包む=行を押し広げない(行高一定・仮想スクロール維持/行高ぶんだけ表示)
|
|
971
|
+
if (this.colCfg(c).multiline) return '<div class="tg-ml-clip">' + _h + '</div>';
|
|
972
|
+
return _h;
|
|
929
973
|
}
|
|
930
974
|
// 条件付き書式: 列版 columns[c].cellClass(value,{r,c,row,src}) + 全体版 cellClass(r,c,value,row,src)。
|
|
931
975
|
// src = 元レコード(オブジェクトデータ時。非表示フィールドも見える=休日区分など隠し列基準の色付けに)。
|
|
@@ -972,7 +1016,7 @@
|
|
|
972
1016
|
if (this._merges && this._isCovered(r, c)) return; // 従属セルは描画対象なし(cellEl はアンカーへ解決=誤って上書きしない)
|
|
973
1017
|
const td = this.cellEl(r, c); if (!td) return;
|
|
974
1018
|
const ph = (this.data[r][c] === '' && this.colType(c) !== 'checkbox') ? this._placeholder(c) : null;
|
|
975
|
-
if (this.colType(c) === 'checkbox' || (this.colCfg(c).html && ph == null)) td.innerHTML = this._cellHTML(r, c);
|
|
1019
|
+
if (this.colType(c) === 'checkbox' || ((this.colCfg(c).html || this.colCfg(c).multiline) && ph == null)) td.innerHTML = this._cellHTML(r, c);
|
|
976
1020
|
else td.textContent = ph != null ? ph : this._displayValue(r, c);
|
|
977
1021
|
td.classList.toggle('tg-placeholder', ph != null);
|
|
978
1022
|
if (this._marksInvalid) {
|
|
@@ -1145,6 +1189,7 @@
|
|
|
1145
1189
|
if (this.colType(c) === 'number') cls.push('tg-num'); // 数値は右寄せ
|
|
1146
1190
|
if (this._isReadOnly(r, c)) cls.push('tg-readonly'); // 読み取り専用(淡色+カーソル既定)
|
|
1147
1191
|
if (this.colCfg(c).wordWrap) cls.push('tg-wrap-cell'); // 列単位の折り返し
|
|
1192
|
+
if (this.colCfg(c).multiline) cls.push('tg-multiline-cell'); // 複数行(改行保持=pre-wrap)
|
|
1148
1193
|
if (this.data[r][c] === '' && this._placeholder(c) != null) cls.push('tg-placeholder'); // 空セルのヒント
|
|
1149
1194
|
const al = this._cellAlign(r, c); // 整列(列既定+セル上書き)
|
|
1150
1195
|
if (al.h) cls.push('tg-h-' + al.h); if (al.v) cls.push('tg-v-' + al.v);
|
|
@@ -1262,6 +1307,7 @@
|
|
|
1262
1307
|
}
|
|
1263
1308
|
html += '</tbody>';
|
|
1264
1309
|
this.table.innerHTML = html;
|
|
1310
|
+
if (this.onHeaderRender && this.colHeaders) this._applyHeaderRender(); // 外部がヘッダthを装飾(ソートアイコン等)。高さ確定前に走らせて幾何へ反映
|
|
1265
1311
|
const thd = this.table.querySelector('thead');
|
|
1266
1312
|
this._theadH = (this.colHeaders && thd) ? thd.offsetHeight : 0; // ヘッダ高をキャッシュ(クリップ計算用)
|
|
1267
1313
|
this._applyTableWidth();
|
|
@@ -1273,6 +1319,13 @@
|
|
|
1273
1319
|
this._buildGeomCache(); // 実測幾何キャッシュ(全 _apply 後=幅高が確定してから)
|
|
1274
1320
|
this.renderDump();
|
|
1275
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
|
+
}
|
|
1276
1329
|
// ---- checkbox 列の全選択(ヘッダ) ----
|
|
1277
1330
|
// c 以外の列が全部空=「追加用の空行」。全選択や状態判定はこの行を対象外にする
|
|
1278
1331
|
// (在庫だけ ON にして空行が中身ありに化け、空行が増殖するのを防ぐ)。
|
|
@@ -1360,6 +1413,8 @@
|
|
|
1360
1413
|
elem.style.left = R.left + 'px'; elem.style.top = R.top + 'px';
|
|
1361
1414
|
elem.style.width = R.width + 'px'; elem.style.height = R.height + 'px';
|
|
1362
1415
|
this._clipOverlay(elem, R.left, R.top, c, r);
|
|
1416
|
+
// multiline を編集中に置き直した時は grow をやり直す(_reflow/ResizeObserver が高さを戻すのを打ち消す)
|
|
1417
|
+
if (elem === this._edMulti && this.mode === 'edit') this._growMultiEditor();
|
|
1363
1418
|
}
|
|
1364
1419
|
placeRect(elem, r0, c0, r1, c1) {
|
|
1365
1420
|
const L = this._colLefts, T = this._rowTops;
|
|
@@ -1463,6 +1518,21 @@
|
|
|
1463
1518
|
}
|
|
1464
1519
|
const el = this.cellEl(r, c); if (el) el.classList.add('sel');
|
|
1465
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
|
+
}
|
|
1466
1536
|
}
|
|
1467
1537
|
// 選択ヘッダの文字色を --tg-selhdr-bg の輝度から自動決定(暗背景→白/明背景→濃紺)。
|
|
1468
1538
|
// 既定の明るい選択色は従来どおり濃紺、暗い選択ヘッダ(slate等)でも文字が読める。buildTable でキャッシュ破棄。
|
|
@@ -1504,23 +1574,73 @@
|
|
|
1504
1574
|
this._clipOverlay(this.fillhandle, fhL, fhT, c1, r1); // 固定ペインに潜ったら隠す
|
|
1505
1575
|
} else this.fillhandle.style.display = 'none';
|
|
1506
1576
|
}
|
|
1577
|
+
_isMultiline(c) { return !!this.colCfg(c).multiline; }
|
|
1578
|
+
// アクティブ列に応じて this.editor を input↔textarea に張り替える(非アクティブ側は隠す)。
|
|
1579
|
+
_pickEditor(c) {
|
|
1580
|
+
const next = this._isMultiline(c) ? this._edMulti : this._edInput;
|
|
1581
|
+
if (next === this.editor) return;
|
|
1582
|
+
this.editor.style.display = 'none'; this.editor.value = '';
|
|
1583
|
+
this.editor = next; next.style.display = '';
|
|
1584
|
+
}
|
|
1585
|
+
// 「アクティブ editor を貼り直して nav へ」= 各選択メソッド共通の choke point(張り替えを place より前に)。
|
|
1586
|
+
_syncEditorNav() { this._pickEditor(this.active.c); this.place(this.editor, this.activeTd()); this.toNav(); }
|
|
1587
|
+
// カーソル位置に文字列を差し込む(multiline の改行挿入用)。
|
|
1588
|
+
_insertText(s) {
|
|
1589
|
+
const el = this.editor, st = el.selectionStart, en = el.selectionEnd, v = el.value;
|
|
1590
|
+
el.value = v.slice(0, st) + s + v.slice(en);
|
|
1591
|
+
const p = st + s.length; el.setSelectionRange(p, p);
|
|
1592
|
+
if (this.editor === this._edMulti) this._growMultiEditor();
|
|
1593
|
+
}
|
|
1594
|
+
// multiline 編集中: textarea を内容ぶん下へ伸ばす(浮いてる箱だけ=行高/スクロールには不干渉・確定で元に戻る)。
|
|
1595
|
+
_growMultiEditor() {
|
|
1596
|
+
const el = this._edMulti;
|
|
1597
|
+
if (this.editor !== el || this.mode !== 'edit' || !this.wrap) return;
|
|
1598
|
+
const td = this.activeTd(); if (!td) return;
|
|
1599
|
+
const tdRect = td.getBoundingClientRect(), cellH = Math.round(tdRect.height);
|
|
1600
|
+
el.style.height = 'auto'; // 一旦リセットして内容高さを測る
|
|
1601
|
+
const content = el.scrollHeight;
|
|
1602
|
+
// 行数上限(既定 multilineEditMaxLines)→ px。超えた分はボックス内スクロール。
|
|
1603
|
+
const cs = getComputedStyle(el);
|
|
1604
|
+
let lineH = parseFloat(cs.lineHeight); if (!lineH || isNaN(lineH)) lineH = (parseFloat(cs.fontSize) || 13) * 1.35;
|
|
1605
|
+
const vpad = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0) + 4;
|
|
1606
|
+
const capByLines = Math.round(this.multilineEditMaxLines * lineH + vpad);
|
|
1607
|
+
// 収める枠 = wrap の可視域 ∩ ビューポート(下優先/足りなければ上へフリップ/それでも足りなければ広い方+内部スクロール)=ブラウザからはみ出さない
|
|
1608
|
+
const wr = this.wrap.getBoundingClientRect(), vh = (typeof window !== 'undefined' && window.innerHeight) || 800, m = 6;
|
|
1609
|
+
const boundTop = Math.max(wr.top, 0), boundBot = Math.min(wr.bottom, vh);
|
|
1610
|
+
const spaceDown = boundBot - tdRect.top - m, spaceUp = tdRect.bottom - boundTop - m;
|
|
1611
|
+
let want = Math.max(cellH, Math.min(content, capByLines)), h, up = false;
|
|
1612
|
+
if (want <= spaceDown) h = want; // 下にそのまま収まる
|
|
1613
|
+
else if (want <= spaceUp) { h = want; up = true; } // 上に開けば収まる
|
|
1614
|
+
else if (spaceDown >= spaceUp) h = Math.max(cellH, spaceDown); // 下が広い→下+内部スクロール
|
|
1615
|
+
else { h = Math.max(cellH, spaceUp); up = true; } // 上が広い→上+内部スクロール
|
|
1616
|
+
el.style.height = h + 'px';
|
|
1617
|
+
el.style.overflowY = content > h ? 'auto' : 'hidden';
|
|
1618
|
+
const baseTop = parseFloat(el.style.top) || 0; // place がセル上端(content座標)を入れている
|
|
1619
|
+
el.style.top = (up ? baseTop + cellH - h : baseTop) + 'px'; // up なら下端をセル下端に合わせて上へ伸ばす
|
|
1620
|
+
}
|
|
1507
1621
|
toNav() {
|
|
1508
|
-
this.mode = 'nav';
|
|
1509
|
-
this.editor.
|
|
1622
|
+
this.mode = 'nav';
|
|
1623
|
+
this.editor.className = (this.editor === this._edMulti) ? 'tg-editor tg-editor-multi nav' : 'tg-editor nav';
|
|
1624
|
+
if (this.editor === this._edInput) this.editor.type = 'text'; // type は input 専用(textarea は readonly で throw する)
|
|
1625
|
+
this.editor.value = '';
|
|
1510
1626
|
if (this.select) this.select.style.display = 'none';
|
|
1511
1627
|
}
|
|
1512
1628
|
toEdit(value, caretEnd) {
|
|
1513
1629
|
if (this._isReadOnly(this.active.r, this.active.c)) return; // 読み取り専用は編集モードに入らない
|
|
1514
1630
|
const starting = this.mode !== 'edit';
|
|
1515
1631
|
if (starting) this.clearCopyMarquee(); // 編集開始でコピー/カットのマーキー(点線)を消す(Excel 流)
|
|
1516
|
-
this.mode = 'edit';
|
|
1517
|
-
this.editor.
|
|
1632
|
+
this.mode = 'edit';
|
|
1633
|
+
this.editor.className = (this.editor === this._edMulti) ? 'tg-editor tg-editor-multi edit' : 'tg-editor edit';
|
|
1634
|
+
this.fillhandle.style.display = 'none';
|
|
1635
|
+
if (this.editor === this._edInput) this.editor.type = this._nativeInputType(this.active.c) || 'text'; // type は input 専用
|
|
1518
1636
|
const _ml = this.colCfg(this.active.c).maxLength; // 桁数(文字数)上限=打鍵をブロック(貼付/投入は _coerceCell で切り詰め)
|
|
1519
1637
|
if (typeof _ml === 'number' && _ml > 0) this.editor.maxLength = _ml; else this.editor.removeAttribute('maxlength');
|
|
1520
1638
|
if (value != null) this.editor.value = value;
|
|
1521
1639
|
this.editor.focus();
|
|
1522
|
-
// setSelectionRange は text
|
|
1523
|
-
|
|
1640
|
+
// setSelectionRange は text / textarea のみ(date/time/number input は例外を投げる)
|
|
1641
|
+
const _t = this.editor.type;
|
|
1642
|
+
if (caretEnd && _t !== 'date' && _t !== 'time' && _t !== 'number') { const l = this.editor.value.length; this.editor.setSelectionRange(l, l); }
|
|
1643
|
+
if (this.editor === this._edMulti) this._growMultiEditor(); // multiline は編集開始時に内容ぶん伸ばす
|
|
1524
1644
|
if (starting && this.onEditStart) { try { this.onEditStart({ r: this.active.r, c: this.active.c, value: this.editor.value }); } catch (_) {} }
|
|
1525
1645
|
}
|
|
1526
1646
|
_fireSelection() {
|
|
@@ -1541,7 +1661,7 @@
|
|
|
1541
1661
|
this._headerSel = null;
|
|
1542
1662
|
this.active = a; this.extent = { ...a };
|
|
1543
1663
|
if (this.virtual) this._vEnsureVisible(a.r); // 窓に入れてから placement(窓外なら activeTd=null になるため)
|
|
1544
|
-
this.
|
|
1664
|
+
this._syncEditorNav(); this.editor.focus(); this.updateSelectionUI();
|
|
1545
1665
|
if (!this.virtual) this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' }); // 仮想は _vEnsureVisible が可視化済み
|
|
1546
1666
|
this._fireSelection();
|
|
1547
1667
|
}
|
|
@@ -1559,25 +1679,67 @@
|
|
|
1559
1679
|
if (!this._beforeSel(a, e)) return;
|
|
1560
1680
|
this._headerSel = null;
|
|
1561
1681
|
this.active = a; this.extent = e;
|
|
1562
|
-
this.
|
|
1682
|
+
this._syncEditorNav(); this.editor.focus(); this.updateSelectionUI();
|
|
1563
1683
|
this._fireSelection();
|
|
1564
1684
|
}
|
|
1565
1685
|
focusAndSelect(sel) {
|
|
1566
1686
|
this.active = { ...sel.active }; this.extent = { ...sel.extent };
|
|
1567
1687
|
if (this.virtual) this._vEnsureVisible(this.active.r);
|
|
1568
|
-
this.
|
|
1688
|
+
this._syncEditorNav(); this.editor.focus(); this.updateSelectionUI();
|
|
1569
1689
|
if (!this.virtual) this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1570
1690
|
this._fireSelection();
|
|
1571
1691
|
}
|
|
1572
1692
|
|
|
1573
1693
|
// ---- 行 / 列 / 全体 の選択(ヘッダクリック) ----
|
|
1574
|
-
selectCol(c) { c = this.clampC(c); this._colAnchor = c; this.selectRect(0, c, this.ROWS - 1, c); this._headerSel = 'col'; this.updateSelectionUI(); }
|
|
1575
|
-
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(); }
|
|
1576
1696
|
selectAll() { this._colAnchor = 0; this._rowAnchor = 0; this.selectRect(0, 0, this.ROWS - 1, this.COLS - 1); this._headerSel = 'all'; this.updateSelectionUI(); }
|
|
1577
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;
|
|
1578
|
-
this.
|
|
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);
|
|
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) : []; }
|
|
1579
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;
|
|
1580
|
-
this.
|
|
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);
|
|
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) : []; }
|
|
1581
1743
|
// ---- 列幅 / 行高 のリサイズ ----
|
|
1582
1744
|
setColWidth(c, w) {
|
|
1583
1745
|
w = Math.max(this.minColW, Math.round(w)); this.colW[c] = w;
|
|
@@ -1712,15 +1874,22 @@
|
|
|
1712
1874
|
if (th.dataset.c != null) { // 列ヘッダ
|
|
1713
1875
|
const c = +th.dataset.c;
|
|
1714
1876
|
if (this.onHeaderClick) { let r; try { r = this.onHeaderClick(c, e); } catch (_) {} if (r === false) { this.editor.focus(); return; } } // 外部ソートUI等
|
|
1715
|
-
|
|
1716
|
-
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中はドラッグ範囲選択を無効(飛び飛びを壊さない)
|
|
1717
1883
|
} else if (th.dataset.r != null) { // 行ヘッダ
|
|
1718
|
-
const r = +th.dataset.r;
|
|
1719
|
-
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); // 素=単一
|
|
1720
1889
|
this.editor.focus();
|
|
1721
|
-
// 'header'モード: 行ヘッダーセル全体が移動ハンドル=行を選択した上で移動を arm
|
|
1722
|
-
if (this.rowReorderWhole && !e.shiftKey && !this._hasVMerge() && !this._allRows) this._startRowMove(e);
|
|
1723
|
-
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中は無効=飛び飛びを壊さない)
|
|
1724
1893
|
} else { // 左上の角 → 全選択
|
|
1725
1894
|
this.selectAll(); this.editor.focus();
|
|
1726
1895
|
}
|
|
@@ -1814,8 +1983,12 @@
|
|
|
1814
1983
|
if (wasEditing && this.onEditEnd) { try { this.onEditEnd({ r, c, oldValue, newValue: this.data[r][c], canceled: false }); } catch (_) {} }
|
|
1815
1984
|
}
|
|
1816
1985
|
clearRange() {
|
|
1817
|
-
const before = this.snapSel(), changes = []
|
|
1818
|
-
|
|
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); }
|
|
1819
1992
|
this.pushCmd(changes, before, this.snapSel(), 'delete');
|
|
1820
1993
|
}
|
|
1821
1994
|
|
|
@@ -1842,7 +2015,7 @@
|
|
|
1842
2015
|
let empties = 0;
|
|
1843
2016
|
for (let r = this.ROWS - 1; r >= 0; r--) { if (this._rowEmpty(r)) empties++; else break; }
|
|
1844
2017
|
let added = 0;
|
|
1845
|
-
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 で天井打ち(スペア行も超えない)
|
|
1846
2019
|
if (added && this.table) { this.buildTable(); this.setActive(this.active.r, this.active.c); }
|
|
1847
2020
|
}
|
|
1848
2021
|
// 構造変更を実行+履歴に積む共通処理。doIt/undoIt は冪等(redo で再実行される)。
|
|
@@ -1963,6 +2136,31 @@
|
|
|
1963
2136
|
};
|
|
1964
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 });
|
|
1965
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
|
+
}
|
|
1966
2164
|
|
|
1967
2165
|
// ---- 右クリックメニュー ----
|
|
1968
2166
|
_onContextMenu(e) {
|
|
@@ -1971,12 +2169,16 @@
|
|
|
1971
2169
|
if (!th && !td) return;
|
|
1972
2170
|
e.preventDefault();
|
|
1973
2171
|
if (this.mode === 'edit') this._commitActive();
|
|
1974
|
-
//
|
|
2172
|
+
// 右クリック位置に選択を移す(既存選択の中ならそのまま=範囲削除に使える)+対象種別を記録(メニュー項目へ渡す)
|
|
2173
|
+
let ctx;
|
|
1975
2174
|
if (td) {
|
|
1976
2175
|
const r = +td.dataset.r, c = +td.dataset.c, s = this.rectRange();
|
|
1977
2176
|
if (!(r >= s.r0 && r <= s.r1 && c >= s.c0 && c <= s.c1)) this.setActive(r, c);
|
|
1978
|
-
|
|
1979
|
-
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;
|
|
1980
2182
|
this._showMenu(e.clientX, e.clientY);
|
|
1981
2183
|
}
|
|
1982
2184
|
// 既定のメニュー項目キー(allow フラグ・copyPaste で出し分け)。
|
|
@@ -2001,10 +2203,14 @@
|
|
|
2001
2203
|
switch (key) {
|
|
2002
2204
|
case 'row_above': return this.allowInsertRows ? { label: '行を上に挿入', act: () => this.insertRow(s.r0, 'above') } : null;
|
|
2003
2205
|
case 'row_below': return this.allowInsertRows ? { label: '行を下に挿入', act: () => this.insertRow(s.r1, 'below') } : null;
|
|
2004
|
-
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) }; }
|
|
2005
2209
|
case 'col_left': return this.allowInsertCols ? { label: '列を左に挿入', act: () => this.insertCol(s.c0, 'left') } : null;
|
|
2006
2210
|
case 'col_right': return this.allowInsertCols ? { label: '列を右に挿入', act: () => this.insertCol(s.c1, 'right') } : null;
|
|
2007
|
-
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) }; }
|
|
2008
2214
|
case 'copy': return { label: 'コピー', disabled: !this.copyPaste, act: () => this.copy(false) };
|
|
2009
2215
|
case 'cut': return { label: '切り取り', disabled: !this.copyPaste, act: () => this.copy(true) };
|
|
2010
2216
|
case 'paste': return { label: '貼り付け', disabled: !this.copyPaste, act: () => this.paste() };
|
|
@@ -2017,30 +2223,32 @@
|
|
|
2017
2223
|
}
|
|
2018
2224
|
}
|
|
2019
2225
|
// メニュー構成(キー文字列/カスタム項目オブジェクト)→ 表示用記述子の配列。
|
|
2020
|
-
|
|
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) {
|
|
2021
2229
|
const items = [];
|
|
2022
2230
|
for (const entry of config) {
|
|
2023
2231
|
if (typeof entry === 'string') { const it = this._builtinItem(entry, s); if (it) items.push(it); continue; }
|
|
2024
2232
|
if (!entry || typeof entry !== 'object') continue;
|
|
2025
|
-
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;
|
|
2026
2234
|
if (hidden) continue;
|
|
2027
|
-
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;
|
|
2028
2236
|
if (entry.separator || /^-+$/.test(String(name))) { items.push({ sep: true }); continue; }
|
|
2029
|
-
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;
|
|
2030
2238
|
const it = { label: String(name == null ? '' : name), disabled, danger: !!entry.danger };
|
|
2031
2239
|
if (Array.isArray(entry.submenu)) it.submenu = entry.submenu;
|
|
2032
|
-
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 (_) {} } };
|
|
2033
2241
|
items.push(it);
|
|
2034
2242
|
}
|
|
2035
2243
|
return items;
|
|
2036
2244
|
}
|
|
2037
2245
|
// 記述子配列 → HTML。actionable 項目は data-mi で _menuActions に紐付け(submenu も再帰でフラット登録)。
|
|
2038
|
-
_renderMenuItems(items, s) {
|
|
2246
|
+
_renderMenuItems(items, s, ctx) {
|
|
2039
2247
|
let html = '';
|
|
2040
2248
|
for (const it of items) {
|
|
2041
2249
|
if (it.sep) { html += '<div class="tg-sep"></div>'; continue; }
|
|
2042
2250
|
if (it.submenu) {
|
|
2043
|
-
const sub = this._renderMenuItems(this._resolveMenuItems(it.submenu, s), s);
|
|
2251
|
+
const sub = this._renderMenuItems(this._resolveMenuItems(it.submenu, s, ctx), s, ctx);
|
|
2044
2252
|
html += '<div class="tg-mi tg-has-sub' + (it.disabled ? ' tg-disabled' : '') + '">' + TssGrid.esc(it.label) + '<div class="tg-submenu">' + sub + '</div></div>';
|
|
2045
2253
|
} else {
|
|
2046
2254
|
const idx = this._menuActions.push(it.act) - 1;
|
|
@@ -2050,12 +2258,12 @@
|
|
|
2050
2258
|
return html;
|
|
2051
2259
|
}
|
|
2052
2260
|
_showMenu(x, y) {
|
|
2053
|
-
const s = this.rectRange();
|
|
2261
|
+
const s = this.rectRange(), ctx = this._menuCtx;
|
|
2054
2262
|
const config = this._menuConfig || this._defaultMenuKeys();
|
|
2055
|
-
const items = this._resolveMenuItems(config, s);
|
|
2263
|
+
const items = this._resolveMenuItems(config, s, ctx);
|
|
2056
2264
|
if (!items.length) { this._hideMenu(); return; } // 該当項目なし=メニューを出さない(前回の残りも隠す)
|
|
2057
2265
|
this._menuActions = [];
|
|
2058
|
-
this.menuEl.innerHTML = this._renderMenuItems(items, s);
|
|
2266
|
+
this.menuEl.innerHTML = this._renderMenuItems(items, s, ctx);
|
|
2059
2267
|
this.menuEl.style.display = 'block';
|
|
2060
2268
|
// 画面外にはみ出さないよう調整
|
|
2061
2269
|
const mw = this.menuEl.offsetWidth, mh = this.menuEl.offsetHeight;
|
|
@@ -2140,19 +2348,26 @@
|
|
|
2140
2348
|
copy(cut) {
|
|
2141
2349
|
if (!this.copyPaste) return;
|
|
2142
2350
|
const sel = this.rectRange(); let buf = [];
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
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); } }
|
|
2148
2361
|
// 「前」フック(cut は onBeforeCut / copy は onBeforeCopy)。false で中止 / 配列を返すと差替え。
|
|
2149
2362
|
const info = { range: { ...sel }, cut: !!cut };
|
|
2150
2363
|
const before = cut ? this.onBeforeCut : this.onBeforeCopy;
|
|
2151
2364
|
if (before) { let r; try { r = before(buf, info); } catch (_) {} if (r === false) return; if (Array.isArray(r)) buf = r; }
|
|
2152
2365
|
sharedClip = buf;
|
|
2153
|
-
this._writeClip(
|
|
2154
|
-
|
|
2155
|
-
this.
|
|
2366
|
+
this._writeClip(TssGrid.toTSV(buf));
|
|
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'; }
|
|
2156
2371
|
if (this.onAfterCopy) { try { this.onAfterCopy(buf, info); } catch (_) {} }
|
|
2157
2372
|
}
|
|
2158
2373
|
_writeClip(text) {
|
|
@@ -2163,7 +2378,33 @@
|
|
|
2163
2378
|
const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px';
|
|
2164
2379
|
document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (_) {} document.body.removeChild(ta); this.editor.focus();
|
|
2165
2380
|
}
|
|
2166
|
-
|
|
2381
|
+
// 2次元配列 → クリップボード TSV。セル内に タブ/改行/引用符 を含む値は "…" で囲み、内部の " は "" に(Excel/Sheets 互換)。
|
|
2382
|
+
static toTSV(rows) {
|
|
2383
|
+
return rows.map(row => row.map(v => {
|
|
2384
|
+
const s = v == null ? '' : String(v);
|
|
2385
|
+
return /[\t\n\r"]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
2386
|
+
}).join('\t')).join('\n');
|
|
2387
|
+
}
|
|
2388
|
+
// クリップボード TSV → 2次元配列。引用符で囲まれたフィールド内の タブ/改行 はセルの中身として保持(=Excel の複数行セルが崩れない)。
|
|
2389
|
+
static parseTSV(t) {
|
|
2390
|
+
t = String(t).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
2391
|
+
const rows = []; let row = [], f = '', inQ = false, i = 0;
|
|
2392
|
+
const endField = () => { row.push(f); f = ''; };
|
|
2393
|
+
const endRow = () => { endField(); rows.push(row); row = []; };
|
|
2394
|
+
while (i < t.length) {
|
|
2395
|
+
const ch = t[i];
|
|
2396
|
+
if (inQ) {
|
|
2397
|
+
if (ch === '"') { if (t[i + 1] === '"') { f += '"'; i += 2; continue; } inQ = false; i++; continue; } // "" → "/単独 " で引用終了
|
|
2398
|
+
f += ch; i++; continue;
|
|
2399
|
+
}
|
|
2400
|
+
if (ch === '"' && f === '') { inQ = true; i++; continue; } // フィールド先頭の " のみ引用開始
|
|
2401
|
+
if (ch === '\t') { endField(); i++; continue; }
|
|
2402
|
+
if (ch === '\n') { endRow(); i++; continue; }
|
|
2403
|
+
f += ch; i++;
|
|
2404
|
+
}
|
|
2405
|
+
if (f !== '' || row.length > 0 || inQ) endRow(); // 末尾行を確定(\n 終端の空行は足さない)
|
|
2406
|
+
return rows.length ? rows : [['']];
|
|
2407
|
+
}
|
|
2167
2408
|
async paste() {
|
|
2168
2409
|
if (!this.copyPaste) return;
|
|
2169
2410
|
let text = null;
|
|
@@ -2208,7 +2449,11 @@
|
|
|
2208
2449
|
}
|
|
2209
2450
|
after = { active: { r: r0, c: c0 }, extent: { r: Math.min(this.ROWS - 1, r0 + g.length - 1), c: lastC } };
|
|
2210
2451
|
}
|
|
2211
|
-
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'; }
|
|
2212
2457
|
this.pushCmd(changes, before, after, 'paste');
|
|
2213
2458
|
this.focusAndSelect(after);
|
|
2214
2459
|
if (this.onAfterPaste) {
|
|
@@ -2293,25 +2538,29 @@
|
|
|
2293
2538
|
|
|
2294
2539
|
_bind() {
|
|
2295
2540
|
if (this.virtual) this.wrap.addEventListener('scroll', () => this._vSync(), { passive: true }); // 仮想スクロール: 窓再描画
|
|
2296
|
-
|
|
2297
|
-
this.
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2541
|
+
// input と textarea(multiline) の両方に同じ編集入口リスナを張る(this.editor はアクティブ列で張り替わる)。
|
|
2542
|
+
[this._edInput, this._edMulti].forEach((ed) => {
|
|
2543
|
+
ed.addEventListener('keydown', (e) => this._onKey(e));
|
|
2544
|
+
ed.addEventListener('compositionstart', () => {
|
|
2545
|
+
if (this.mode !== 'nav') return;
|
|
2546
|
+
if (this._usesTextEditor(this.active.c)) this.toEdit(null, false);
|
|
2547
|
+
});
|
|
2548
|
+
ed.addEventListener('beforeinput', (e) => {
|
|
2549
|
+
if (this.mode !== 'nav' || !(e.inputType || '').startsWith('insert')) return;
|
|
2550
|
+
const c = this.active.c, t = this.colType(c);
|
|
2551
|
+
if (this._usesTextEditor(c)) { this.toEdit(null, false); return; } // text / multiline / parse・format 付き date・time
|
|
2552
|
+
e.preventDefault();
|
|
2553
|
+
if (this.colCfg(c).editor) this._openCustomEditor(this.active.r, c); // カスタムエディタ
|
|
2554
|
+
else if (t === 'dropdown') this._openSelect();
|
|
2555
|
+
else if (t === 'date' || t === 'time') this.toEdit(this.data[this.active.r][c], true); // ネイティブピッカー
|
|
2556
|
+
// checkbox は文字入力を無視(Space / クリックでトグル)
|
|
2557
|
+
});
|
|
2558
|
+
ed.addEventListener('blur', () => {
|
|
2559
|
+
if (this._customEditor) return; // カスタムエディタが奪ったフォーカスでの blur は無視
|
|
2560
|
+
if (this.mode === 'edit' && (!this.select || this.select.style.display === 'none')) { this.commit(); this.toNav(); this.updateSelectionUI(); }
|
|
2561
|
+
});
|
|
2314
2562
|
});
|
|
2563
|
+
this._edMulti.addEventListener('input', () => this._growMultiEditor()); // 入力で内容ぶん伸ばす(multiline のみ)
|
|
2315
2564
|
this.select.addEventListener('keydown', (e) => {
|
|
2316
2565
|
if (e.key === 'Enter') { e.preventDefault(); this._commitSelect(); this._advance('enter', e.shiftKey); }
|
|
2317
2566
|
else if (e.key === 'Tab') { e.preventDefault(); this._commitSelect(); this._advance('tab', e.shiftKey); }
|
|
@@ -2461,7 +2710,8 @@
|
|
|
2461
2710
|
// autoInsertRow: フィルハンドルをテーブル下端より下へドロップしたら、足りない行を追加(縦フィル時)
|
|
2462
2711
|
if (this.fillAutoInsertRow && this.fillDirection !== 'horizontal' && !this._tdAtPoint(e)) {
|
|
2463
2712
|
const tb = this.table.getBoundingClientRect();
|
|
2464
|
-
|
|
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 で天井打ち
|
|
2465
2715
|
if (extra > 0) {
|
|
2466
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++; }
|
|
2467
2717
|
this.buildTable();
|
|
@@ -2598,6 +2848,12 @@
|
|
|
2598
2848
|
if (!composing && this._runShortcuts(e)) return;
|
|
2599
2849
|
if (this.mode === 'edit') {
|
|
2600
2850
|
if (composing) return;
|
|
2851
|
+
if (this.editor === this._edMulti && e.key === 'Enter') { // multiline: 案1 Enter=改行/Ctrl+Enter=確定, 案2 Enter=確定/Alt+Enter=改行
|
|
2852
|
+
const commit = this.multilineEnter === 'newline' ? (e.ctrlKey || e.metaKey) : !e.altKey;
|
|
2853
|
+
e.preventDefault();
|
|
2854
|
+
if (commit) { this.commit(); this._advance('enter', e.shiftKey); } else this._insertText('\n');
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2601
2857
|
if (e.key === 'Enter') { e.preventDefault(); this.commit(); this._advance('enter', e.shiftKey); return; }
|
|
2602
2858
|
if (e.key === 'Tab') { e.preventDefault(); this.commit(); this._advance('tab', e.shiftKey); return; }
|
|
2603
2859
|
// Esc/編集中の Ctrl+Z = この編集をキャンセルして元値に戻す(nav へ)。
|