@threestarsoftware/tssgrid 0.0.1

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.
@@ -0,0 +1,146 @@
1
+ /*! TssFilter — ヘッダのオートフィルタUI(Excel風)。コアの非破壊 filter() を呼ぶだけ。
2
+ *
3
+ * 各リーフ見出しにロート(漏斗)アイコンを出し(ソートの▲▼と区別)、クリックで「値チェックリスト+絞り込み検索」ポップアップ。
4
+ * 複数列の条件は AND。適用は grid.filter(pred)/全解除で grid.clearFilter()。フィルタ済み列は印が付く。
5
+ *
6
+ * 使い方:
7
+ * const grid = new TssGrid(el, { plugins: [ TssFilter.plugin() ] }); // 全列にフィルタUI
8
+ * TssFilter.plugin({ columns: ['部署', 2] }); // 対象列を限定(data キー or index)
9
+ * grid.getPlugin('filter').clearAll(); // 全条件クリア
10
+ *
11
+ * opts: columns(対象列の配列・省略=全列) / caseSensitive(既定 false・値一致は文字列)。
12
+ * メモ: 値候補はマスタ全行(_allRows)から集計=絞り込み中でも全値が出る。grid.filter() は履歴クリア(コア仕様)。
13
+ */
14
+ (function (root) {
15
+ 'use strict';
16
+ function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
17
+ // ロート(漏斗=filter)アイコン。currentColor で .on の色替えに追従。ソートの▼と区別。
18
+ const FUNNEL = '<svg class="tg-filter-ico" viewBox="0 0 16 16" width="11" height="11" aria-hidden="true"><path d="M1.5 3h13l-5 6v4.2l-3-1.6V9z" fill="currentColor"/></svg>';
19
+
20
+ function plugin(opts) {
21
+ opts = opts || {};
22
+ return function (grid) {
23
+ const active = {}; // colIndex -> Set(許可する値文字列)
24
+ let pop = null, popCol = -1;
25
+
26
+ // 対象列 index の集合(省略時は全列)
27
+ const targets = (() => {
28
+ if (!opts.columns) return null; // null = 全列
29
+ const s = new Set();
30
+ opts.columns.forEach(k => { const ci = grid._resolveCol ? grid._resolveCol(k) : (typeof k === 'number' ? k : -1); if (ci >= 0) s.add(ci); });
31
+ return s;
32
+ })();
33
+ const isTarget = c => !targets || targets.has(c);
34
+
35
+ // マスタ全行(フィルタ中でも全件)から列 c の重複なし値(出現順)。
36
+ function distinctValues(c) {
37
+ const rows = grid._allRows || grid.data, seen = new Set(), out = [];
38
+ for (const row of rows) { const v = String(row[c]); if (!seen.has(v)) { seen.add(v); out.push(v); } }
39
+ return out;
40
+ }
41
+
42
+ function buildPredicate() {
43
+ const cols = Object.keys(active).map(Number).filter(c => active[c]);
44
+ if (!cols.length) return null;
45
+ return row => cols.every(c => active[c].has(String(row[c])));
46
+ }
47
+ function apply() {
48
+ const p = buildPredicate();
49
+ if (p) grid.filter(p); else grid.clearFilter();
50
+ decorate();
51
+ }
52
+ grid.clearAllFilters = function () { for (const k in active) delete active[k]; apply(); };
53
+
54
+ // ---- ヘッダ装飾(buildTable をラップして毎回付け直す) ----
55
+ function decorate() {
56
+ const ths = grid.table ? grid.table.querySelectorAll('thead th[data-c]') : [];
57
+ ths.forEach(th => {
58
+ const c = +th.dataset.c;
59
+ let btn = th.querySelector('.tg-filter-btn');
60
+ if (!isTarget(c)) { if (btn) btn.remove(); return; }
61
+ if (!btn) { btn = document.createElement('span'); btn.className = 'tg-filter-btn'; btn.innerHTML = FUNNEL; th.appendChild(btn); }
62
+ btn.dataset.fc = c;
63
+ btn.classList.toggle('on', !!active[c]);
64
+ });
65
+ }
66
+ const origBuild = grid.buildTable.bind(grid);
67
+ grid.buildTable = function () { origBuild(); decorate(); };
68
+
69
+ // ---- ポップアップ ----
70
+ function closePop() { if (pop) { pop.remove(); pop = null; popCol = -1; } }
71
+ function openPop(c, anchor) {
72
+ closePop();
73
+ popCol = c;
74
+ const vals = distinctValues(c);
75
+ const allowed = active[c] || new Set(vals); // 未設定なら全許可
76
+ pop = document.createElement('div');
77
+ pop.className = 'tg-filter-pop';
78
+ pop.innerHTML =
79
+ '<div class="tg-filter-search"><input type="text" placeholder="値を検索…"></div>' +
80
+ '<div class="tg-filter-tools"><a data-act="all">すべて</a> / <a data-act="none">解除</a></div>' +
81
+ '<div class="tg-filter-list">' +
82
+ vals.map(v => '<label><input type="checkbox" value="' + esc(v) + '"' + (allowed.has(v) ? ' checked' : '') + '><span>' + (v === '' ? '(空白)' : esc(v)) + '</span></label>').join('') +
83
+ '</div>' +
84
+ '<div class="tg-filter-btns"><button data-act="apply">適用</button><button data-act="clear">クリア</button></div>';
85
+ document.body.appendChild(pop);
86
+ // 位置(アンカーの下・はみ出しは画面内へ)
87
+ const r = anchor.getBoundingClientRect();
88
+ const pw = 220, ph = Math.min(320, pop.offsetHeight || 300);
89
+ let left = r.left, top = r.bottom + 2;
90
+ if (left + pw > window.innerWidth - 6) left = window.innerWidth - pw - 6;
91
+ if (top + ph > window.innerHeight - 6) top = Math.max(6, r.top - ph - 2);
92
+ pop.style.left = Math.max(6, left) + 'px'; pop.style.top = top + 'px';
93
+
94
+ const search = pop.querySelector('.tg-filter-search input');
95
+ const list = pop.querySelector('.tg-filter-list');
96
+ search.focus();
97
+ search.oninput = () => {
98
+ const q = search.value.trim().toLowerCase();
99
+ list.querySelectorAll('label').forEach(l => { l.style.display = (!q || l.textContent.toLowerCase().indexOf(q) >= 0) ? '' : 'none'; });
100
+ };
101
+ pop.querySelector('[data-act=all]').onclick = () => list.querySelectorAll('label:not([style*="none"]) input').forEach(cb => cb.checked = true);
102
+ pop.querySelector('[data-act=none]').onclick = () => list.querySelectorAll('label:not([style*="none"]) input').forEach(cb => cb.checked = false);
103
+ pop.querySelector('[data-act=apply]').onclick = () => {
104
+ const checked = [...list.querySelectorAll('input:checked')].map(cb => cb.value);
105
+ if (checked.length === vals.length) delete active[c]; // 全選択=この列は無条件
106
+ else active[c] = new Set(checked);
107
+ closePop(); apply();
108
+ };
109
+ pop.querySelector('[data-act=clear]').onclick = () => { delete active[c]; closePop(); apply(); };
110
+ }
111
+
112
+ function onHeadClick(e) {
113
+ const btn = e.target.closest && e.target.closest('.tg-filter-btn');
114
+ if (!btn) return;
115
+ e.stopPropagation(); e.preventDefault();
116
+ const c = +btn.dataset.fc;
117
+ if (pop && popCol === c) { closePop(); return; } // トグル
118
+ openPop(c, btn);
119
+ }
120
+ function onDocDown(e) { if (pop && !pop.contains(e.target) && !(e.target.closest && e.target.closest('.tg-filter-btn'))) closePop(); }
121
+
122
+ grid.table.addEventListener('mousedown', onHeadClick, true); // sort等より先に拾う(capture)
123
+ document.addEventListener('mousedown', onDocDown);
124
+ decorate();
125
+
126
+ return {
127
+ name: 'filter',
128
+ clearAll: () => grid.clearAllFilters(),
129
+ destroy: function () {
130
+ closePop();
131
+ grid.table.removeEventListener('mousedown', onHeadClick, true);
132
+ document.removeEventListener('mousedown', onDocDown);
133
+ grid.buildTable = origBuild;
134
+ delete grid.clearAllFilters;
135
+ for (const k in active) delete active[k];
136
+ if (grid.isFiltered && grid.isFiltered()) grid.clearFilter();
137
+ if (grid.table) grid.table.querySelectorAll('.tg-filter-btn').forEach(b => b.remove());
138
+ },
139
+ };
140
+ };
141
+ }
142
+
143
+ const TssFilter = { plugin: plugin };
144
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssFilter;
145
+ else root.TssFilter = TssFilter;
146
+ })(typeof self !== 'undefined' ? self : this);
@@ -0,0 +1,87 @@
1
+ /*! TssFormula — 行スコープの派生列(computed column)プラグイン。
2
+ *
3
+ * 「その行の値だけから計算する列」を columns 定義に formula 関数で書けるようにする。
4
+ * 例: 金額 = 数量 × 単価 / 税込 = 金額 × 1.1 / 勤怠の勤務時間 など。
5
+ *
6
+ * 使い方:
7
+ * columns: [
8
+ * { data:'quantity', type:'number' },
9
+ * { data:'price', type:'number' },
10
+ * { data:'amount', type:'number', formula: (row) => row.quantity * row.price }, // ← 派生列
11
+ * { data:'withTax', type:'number', formula: (row) => Math.round(row.amount * 1.1) }, // ← 前の formula 結果も使える
12
+ * ],
13
+ * plugins: [TssFormula] // または grid.usePlugin(TssFormula) / plugins:['formula']
14
+ *
15
+ * 仕様:
16
+ * - formula は **行スコープの純粋関数** `(row, ctx) => value`。row=その行の {dataキー:値}、ctx={r,c,grid}。
17
+ * - formula 列は **自動で readOnly**(計算結果=手入力不可。明示済みなら尊重)。
18
+ * - **同じ行のどれかが変わると自動再計算**(編集/フィル/貼付/Undo/Redo すべて追従。依存解析はせず「同じ行が変わったら回す」=単純で正しい)。
19
+ * - 書き込みは **setValueRaw**=undo/redo 履歴に積まない(派生値が独立した Undo ステップにならない)。
20
+ * - **columns 定義順に評価**=後ろの formula は前の formula 結果を参照できる。
21
+ * - 値は内部文字列。算術は JS が数値強制('1200' * '5' = 6000)。`+` で連結を避けたいときは Number(row.x) を。
22
+ *
23
+ * ※ 列方向の集計(縦の SUM/合計行)はこのプラグインの範囲外。合計は別の小さなグリッド+関数で(勤怠デモの合計表が好例)。
24
+ */
25
+ (function (root) {
26
+ 'use strict';
27
+ function TssFormula(grid, opts) {
28
+ opts = opts || {};
29
+
30
+ // formula を持つ列を収集(無ければ何もしない)。formula 列は自動 readOnly。
31
+ var fcols = [];
32
+ for (var c = 0; c < grid.COLS; c++) {
33
+ var cfg = grid.columns[c];
34
+ if (cfg && typeof cfg.formula === 'function') { fcols.push(c); if (cfg.readOnly == null) cfg.readOnly = true; }
35
+ }
36
+ if (!fcols.length) return { name: 'formula' };
37
+
38
+ // その行を {dataキー: 値} のオブジェクトに(formula へ渡す row)。
39
+ function rowObject(r) {
40
+ var o = {};
41
+ for (var c = 0; c < grid.COLS; c++) { var k = grid.columns[c] && grid.columns[c].data; if (k != null) o[k] = grid.data[r][c]; }
42
+ return o;
43
+ }
44
+ // 1行の formula 列を順に再計算(後続が前の結果を参照できるよう row を更新しながら)。
45
+ function recalcRow(r) {
46
+ if (r < 0 || r >= grid.ROWS) return;
47
+ var o = rowObject(r);
48
+ for (var i = 0; i < fcols.length; i++) {
49
+ var c = fcols[i], v;
50
+ try { v = grid.columns[c].formula(o, { r: r, c: c, grid: grid }); } catch (e) { v = ''; }
51
+ if (v == null || (typeof v === 'number' && !isFinite(v))) v = ''; // null/NaN/Infinity は空
52
+ grid.setValueRaw(r, c, v);
53
+ var k = grid.columns[c].data; if (k != null) o[k] = grid.data[r][c];
54
+ }
55
+ }
56
+ function recalcRows(rows) { rows.forEach(recalcRow); }
57
+ function recalcAll() { for (var r = 0; r < grid.ROWS; r++) recalcRow(r); }
58
+
59
+ // 変更フック: onAfterChange を包んで「変わった行」の formula を再計算。
60
+ // setValueRaw は履歴も onAfterChange も起こさないので、ここで書いても再入しない=ループしない。
61
+ var prevAfter = grid.onAfterChange;
62
+ grid.onAfterChange = function (changes, source) {
63
+ var rows = new Set();
64
+ for (var i = 0; i < changes.length; i++) rows.add(changes[i].r);
65
+ recalcRows(rows);
66
+ if (prevAfter) { try { prevAfter.call(grid, changes, source); } catch (e) {} }
67
+ };
68
+ // setData(全入替)後は全行再計算。
69
+ var prevSetData = grid.setData.bind(grid);
70
+ grid.setData = function (rows) { prevSetData(rows); recalcAll(); };
71
+ // 行/列の挿入・削除後も全行再計算(新規行の formula を埋める)。
72
+ var prevStruct = grid.onStructureChange;
73
+ grid.onStructureChange = function (info) { recalcAll(); if (prevStruct) { try { prevStruct.call(grid, info); } catch (e) {} } };
74
+
75
+ recalcAll(); // 初期計算
76
+
77
+ return {
78
+ name: 'formula',
79
+ recalc: recalcRow, // 手動で1行
80
+ recalcAll: recalcAll, // 手動で全行
81
+ destroy: function () { grid.onAfterChange = prevAfter; grid.setData = prevSetData; grid.onStructureChange = prevStruct; },
82
+ };
83
+ }
84
+ if (typeof TssGrid !== 'undefined' && TssGrid.registerPlugin) TssGrid.registerPlugin('formula', TssFormula);
85
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssFormula;
86
+ else root.TssFormula = TssFormula;
87
+ })(typeof self !== 'undefined' ? self : this);
@@ -0,0 +1,84 @@
1
+ /*! TssFurigana — 氏名→フリガナ 自動入力プラグイン。**辞書不要**。
2
+ *
3
+ * スマホの連絡先のように「漢字を入力すると よみ が別列に自動で入る」。仕組みは**辞書引きではなく**、
4
+ * IME変換の **composition イベントから「変換前の読み(かな)」を拾う**だけ。だから辞書データを持たない。
5
+ * TssGrid は**アクティブセルに本物の <input> を重ねる**設計(IME堅牢の核)なので composition が確実に取れる=相性抜群。
6
+ *
7
+ * 使い方:
8
+ * plugins: [ TssFurigana({ source: 'name', target: 'kana' }) ] // 氏名→フリガナ
9
+ * plugins: [ TssFurigana({ pairs: [{source:'sei',target:'seiKana'}, …], katakana:false }) ] // 複数ペア / ひらがな出力
10
+ *
11
+ * opts:
12
+ * source / target … 漢字を入れる列 / 読みを入れる列(dataキー)。target は手修正可(readOnlyにはしない)。
13
+ * pairs … 複数ペアを配列で(source/target の代わり / 併用可)。
14
+ * katakana … true(既定)=カタカナ出力 / false=ひらがな出力。
15
+ *
16
+ * 仕組みの限界(正直に): **手入力(IME変換)した時だけ**読みが取れる。貼り付け・既存値の漢字からは取れない。
17
+ * 変換のクセで拾い損ねる語もある=「自動入力+手直しできる」が現実解(target は編集可のまま)。
18
+ */
19
+ (function (root) {
20
+ 'use strict';
21
+ // ひらがな → カタカナ
22
+ function toKatakana(s) { return s.replace(/[ぁ-ゖ]/g, function (c) { return String.fromCharCode(c.charCodeAt(0) + 0x60); }); }
23
+ // 全部かな(ひら/カタ/長音/濁点)か=変換前の「読み」かどうか
24
+ function isKana(s) { return !!s && /^[ぁ-んァ-ヶー゛゜ー\s]+$/.test(s); }
25
+
26
+ function TssFurigana(opts) {
27
+ opts = opts || {};
28
+ var pairs = opts.pairs ? opts.pairs.slice() : [];
29
+ if (opts.source && opts.target) pairs.push({ source: opts.source, target: opts.target });
30
+ var toKata = opts.katakana !== false;
31
+
32
+ return function (grid) {
33
+ var ed = grid.editor;
34
+ if (!ed || !pairs.length) return { name: 'furigana' };
35
+ // source 列の index + target 列キー を解決
36
+ var map = [];
37
+ pairs.forEach(function (p) {
38
+ var si = -1; for (var c = 0; c < grid.COLS; c++) if (grid.columns[c] && grid.columns[c].data === p.source) { si = c; break; }
39
+ if (si >= 0) map.push({ srcIdx: si, targetKey: p.target });
40
+ });
41
+ if (!map.length) return { name: 'furigana' };
42
+
43
+ // acc = このセル編集で打った読みの累積(漢字変換を複数回しても連結)。seg = 変換中セグメントの読み。
44
+ var acc = '', seg = '', lastKey = null;
45
+ function onStart() {
46
+ var key = grid.active.r + ',' + grid.active.c;
47
+ if (key !== lastKey) { acc = ''; lastKey = key; } // 別セルに移ったら読みをリセット
48
+ seg = '';
49
+ }
50
+ function onUpdate(e) { if (isKana(e.data)) seg = e.data; } // 変換前の全かなを保持(漢字化したら更新せず最後のかなが残る)
51
+ function onEnd() { acc += seg; seg = ''; } // 1変換ぶんの読みを累積
52
+ ed.addEventListener('compositionstart', onStart);
53
+ ed.addEventListener('compositionupdate', onUpdate);
54
+ ed.addEventListener('compositionend', onEnd);
55
+
56
+ // セル確定(onAfterChange)で、source 列が変わっていたら読みを target へ。貼付/フィルは acc 空=何もしない。
57
+ var prevAfter = grid.onAfterChange;
58
+ grid.onAfterChange = function (changes, source) {
59
+ if (prevAfter) { try { prevAfter.call(grid, changes, source); } catch (e) {} }
60
+ if (source !== 'undo' && source !== 'redo' && acc) {
61
+ for (var i = 0; i < changes.length; i++) {
62
+ var ch = changes[i];
63
+ for (var m = 0; m < map.length; m++) if (ch.c === map[m].srcIdx) {
64
+ grid.setValueRaw(ch.r, map[m].targetKey, toKata ? toKatakana(acc) : acc);
65
+ }
66
+ }
67
+ }
68
+ acc = ''; seg = ''; lastKey = null; // コミットでリセット
69
+ };
70
+
71
+ return {
72
+ name: 'furigana',
73
+ destroy: function () {
74
+ ed.removeEventListener('compositionstart', onStart);
75
+ ed.removeEventListener('compositionupdate', onUpdate);
76
+ ed.removeEventListener('compositionend', onEnd);
77
+ grid.onAfterChange = prevAfter;
78
+ },
79
+ };
80
+ };
81
+ }
82
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssFurigana;
83
+ else root.TssFurigana = TssFurigana;
84
+ })(typeof self !== 'undefined' ? self : this);
@@ -0,0 +1,66 @@
1
+ /*! TssHeatmap / TssDataBar — セル背景の連続ビジュアライズ(cellStyle フック用)。
2
+ *
3
+ * コアの `cellStyle`(セルにインラインCSSを差すフック)に乗せる。cellClass(クラス=離散色)では出せない
4
+ * **値に応じた連続色(ヒートマップ)/背景データバー**を、**編集できる数値セルのまま**実現する。
5
+ *
6
+ * 使い方:
7
+ * // ヒートマップ: 値で背景色(低→高)。文字色は自動でコントラスト確保。
8
+ * { data:'m4', type:'number', cellStyle: TssHeatmap({ min:0, max:400000, colors:['#f8b4b4','#ffffff','#93c5fd'] }) }
9
+ * // データバー: 数値はそのまま見せつつ背景に棒(編集可)。
10
+ * { data:'score', type:'number', cellStyle: TssDataBar({ min:0, max:100, color:'rgba(13,148,136,.25)' }) }
11
+ *
12
+ * ※ min/max は明示(cellStyle はセル単位呼び出しで列全体の範囲を知らないため)。既定 0..100。
13
+ */
14
+ (function (root) {
15
+ 'use strict';
16
+ function r2(n) { return Math.round(n * 100) / 100; }
17
+ function clamp01(t) { return t < 0 ? 0 : t > 1 ? 1 : t; }
18
+ // '#abc'/'#aabbcc' → [r,g,b]
19
+ function hex2rgb(h) {
20
+ h = String(h).replace('#', '');
21
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
22
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
23
+ }
24
+ function rgb2hex(c) { return '#' + c.map(function (n) { return ('0' + Math.round(n).toString(16)).slice(-2); }).join(''); }
25
+ function lerp(a, b, t) { return a + (b - a) * t; }
26
+ // 複数ストップ([low, mid, high] 等)を t∈0..1 で補間
27
+ function lerpStops(stops, t) {
28
+ if (stops.length === 1) return stops[0];
29
+ var seg = t * (stops.length - 1), i = Math.min(stops.length - 2, Math.floor(seg)), f = seg - i;
30
+ var a = hex2rgb(stops[i]), b = hex2rgb(stops[i + 1]);
31
+ return rgb2hex([lerp(a[0], b[0], f), lerp(a[1], b[1], f), lerp(a[2], b[2], f)]);
32
+ }
33
+ // 背景色に対して読みやすい文字色(黒 or 白)
34
+ function textOn(hex) { var c = hex2rgb(hex); return (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) > 150 ? '#1f2733' : '#fff'; }
35
+
36
+ function TssHeatmap(opts) {
37
+ opts = opts || {};
38
+ var min = (opts.min != null) ? opts.min : 0, max = (opts.max != null) ? opts.max : 100;
39
+ var stops = opts.colors || [opts.low || '#e8f0fe', opts.high || '#1a73e8'];
40
+ var autoText = opts.text !== false;
41
+ return function (value) {
42
+ var s = String(value == null ? '' : value).trim();
43
+ if (s === '' || !isFinite(Number(s))) return null; // 空・非数値はスタイル無し
44
+ var t = clamp01((Number(s) - min) / ((max - min) || 1));
45
+ var bg = lerpStops(stops, t), out = { background: bg };
46
+ if (autoText) out.color = textOn(bg);
47
+ return out;
48
+ };
49
+ }
50
+
51
+ function TssDataBar(opts) {
52
+ opts = opts || {};
53
+ var min = (opts.min != null) ? opts.min : 0, max = (opts.max != null) ? opts.max : 100;
54
+ var color = opts.color || 'rgba(13,148,136,.25)';
55
+ var dir = (opts.align === 'right') ? 'to left' : 'to right';
56
+ return function (value) {
57
+ var s = String(value == null ? '' : value).trim();
58
+ if (s === '' || !isFinite(Number(s))) return null;
59
+ var pct = r2(Math.max(0, Math.min(100, ((Number(s) - min) / ((max - min) || 1)) * 100)));
60
+ return { background: 'linear-gradient(' + dir + ', ' + color + ' ' + pct + '%, transparent ' + pct + '%)' };
61
+ };
62
+ }
63
+
64
+ if (typeof module !== 'undefined' && module.exports) module.exports = { TssHeatmap: TssHeatmap, TssDataBar: TssDataBar };
65
+ else { root.TssHeatmap = TssHeatmap; root.TssDataBar = TssDataBar; }
66
+ })(typeof self !== 'undefined' ? self : this);
@@ -0,0 +1,144 @@
1
+ /*! TssMerge — 矩形セル結合(colspan×rowspan)の軽量プラグイン。
2
+ *
3
+ * アンカー=(r,c)=左上が値・編集・選択の対象。覆われた従属セルは描画スキップ+読取専用(貼付/フィルは弾く)。
4
+ * 行/列の挿入削除では結合座標がコア側で縦横対称に追従(undo 対応)。
5
+ * 並べ替え/行移動との関係: ソートは結合中つねにロック。行移動は「横結合のみ」なら可(行と一緒に移動)、
6
+ * 「縦結合(rowspan>1)」が在る間はロック(行順を崩すと縦結合が分断=auto-split を避ける割り切り)。
7
+ *
8
+ * 使い方:
9
+ * grid = new TssGrid(el, { plugins: [ TssMerge.plugin({ merges: [{r:0,c:1,colspan:3},{r:1,c:0,rowspan:2}] }) ] });
10
+ * grid.setMerge(0, 1, 3); // (0,1) を起点に3列を横結合(rowspan 省略=1)
11
+ * grid.setMerge(1, 0, 1, 2); // (1,0) を起点に2行を縦結合(colspan,rowspan)
12
+ * grid.setMerge(2, 2, 2, 2); // 2x2 の矩形結合
13
+ * grid.mergeSelection(); // 選択矩形を結合(既存結合を含めば飲み込んで1つに=Excel流)
14
+ * grid.setMerge(0, 0, 4, 1, true); // 第5引数 absorb=true で重なる既存結合を飲み込む
15
+ * grid.removeMerge(0, 1); // (0,1) を含む結合を解除
16
+ * grid.getMerges(); // [{r,c,colspan,rowspan}, ...]
17
+ * grid.destroyMerged(); // 全結合を解除
18
+ *
19
+ * opts(plugin): merges(初期結合の配列 [{r,c,colspan,rowspan?}])。
20
+ * Undo: setMerge/removeMerge/mergeSelection/destroyMerged は履歴に積む(結合と、結合時にクリアした従属
21
+ * セルの値の両方を復元)。初期 merges(opts)は履歴に積まない(設定扱い)。
22
+ * 注意: frozen 境界をまたぐ結合は想定外(ネストヘッダと同じ制約)。結合範囲のコピーは従属位置が空セル。
23
+ */
24
+ (function (root) {
25
+ 'use strict';
26
+
27
+ function TssMerge() { /* 名前空間。plugin を使う */ }
28
+
29
+ TssMerge.plugin = function (opts) {
30
+ opts = opts || {};
31
+ return function (grid) {
32
+ if (grid._tssMergeInstalled) return { name: 'merge' }; // 二重インストール防止
33
+ grid._tssMergeInstalled = true;
34
+
35
+ function cp(m) { return { r: m.r, c: m.c, colspan: m.colspan, rowspan: m.rowspan || 1 }; }
36
+ function snapshot() { return grid._merges ? grid._merges.map(cp) : null; }
37
+ function restore(snap) { grid._merges = snap ? snap.map(cp) : null; }
38
+ // 矩形の正規化(範囲外/1x1 は null)。重なり判定はせず、呼び出し側で扱う。
39
+ function rectOf(r, c, colspan, rowspan) {
40
+ r |= 0; c |= 0; colspan |= 0; rowspan = rowspan ? rowspan | 0 : 1;
41
+ if (r < 0 || r >= grid.ROWS || c < 0 || c >= grid.COLS) return null;
42
+ colspan = Math.min(colspan, grid.COLS - c);
43
+ rowspan = Math.min(rowspan, grid.ROWS - r);
44
+ if (colspan < 1 || rowspan < 1 || (colspan < 2 && rowspan < 2)) return null; // 1x1 は結合でない
45
+ return { r: r, c: c, colspan: colspan, rowspan: rowspan };
46
+ }
47
+ // 矩形 m と重なる既存結合の配列(飲み込み対象の特定に使う)。
48
+ function overlapping(m) {
49
+ return (grid._merges || []).filter(function (x) {
50
+ var xrs = x.rowspan || 1;
51
+ return !(m.c + m.colspan <= x.c || x.c + x.colspan <= m.c || m.r + m.rowspan <= x.r || x.r + xrs <= m.r);
52
+ });
53
+ }
54
+ // 結合矩形のうちアンカー以外(従属セル)を列挙。値クリア/復元に使う。
55
+ function coveredOf(m) {
56
+ var out = [];
57
+ for (var rr = m.r; rr < m.r + m.rowspan; rr++) for (var cc = m.c; cc < m.c + m.colspan; cc++) {
58
+ if (rr === m.r && cc === m.c) continue;
59
+ out.push({ r: rr, c: cc });
60
+ }
61
+ return out;
62
+ }
63
+ // do/undo を実行+履歴へ。コア _structCmd と同形(手動実行→push、apply=redo / revert=undo)。
64
+ function commit(doFn, undoFn, selR, selC) {
65
+ doFn(); grid.buildTable(); grid.setActive(selR, selC);
66
+ grid.history.push({
67
+ label: grid.name || 'merge',
68
+ apply: function () { doFn(); grid.buildTable(); grid.setActive(selR, selC); },
69
+ revert: function () { undoFn(); grid.buildTable(); grid.setActive(selR, selC); },
70
+ });
71
+ }
72
+
73
+ // (r,c) を起点に colspan×rowspan を結合。rowspan 省略=1(横結合)。成功で true。範囲外/1x1 は false。Undo 対応。
74
+ // 既存結合と重なる時: absorb=true なら飲み込んで1つに(Excel流)/false(既定)は拒否して false。
75
+ grid.setMerge = function (r, c, colspan, rowspan, absorb) {
76
+ var m = rectOf(r, c, colspan, rowspan); if (!m) return false;
77
+ if (overlapping(m).length && !absorb) return false; // 重なりは既定では拒否(従来挙動)
78
+ var before = snapshot(), cleared = []; // 従属セルの値クリア=undo で復元
79
+ coveredOf(m).forEach(function (p) { if (grid.data[p.r][p.c] !== '') cleared.push({ r: p.r, c: p.c, val: grid.data[p.r][p.c] }); });
80
+ commit(
81
+ function () {
82
+ var hh = overlapping(m); // 飲み込む結合は do の度に現在の _merges から再計算(undo→redo でも正しい)
83
+ if (hh.length) grid._merges = (grid._merges || []).filter(function (x) { return hh.indexOf(x) < 0; });
84
+ for (var k = 0; k < cleared.length; k++) grid.data[cleared[k].r][cleared[k].c] = '';
85
+ (grid._merges || (grid._merges = [])).push(cp(m));
86
+ },
87
+ function () { restore(before); for (var k = 0; k < cleared.length; k++) grid.data[cleared[k].r][cleared[k].c] = cleared[k].val; },
88
+ m.r, m.c);
89
+ return true;
90
+ };
91
+
92
+ // (r,c) を覆う結合を解除。解除したら true。Undo 対応(値は戻さない=結合時に消えたまま)。
93
+ grid.removeMerge = function (r, c) {
94
+ if (!grid._merges) return false;
95
+ var hit = null;
96
+ for (var i = 0; i < grid._merges.length; i++) { var m = grid._merges[i], mrs = m.rowspan || 1; if (r >= m.r && r < m.r + mrs && c >= m.c && c < m.c + m.colspan) { hit = m; break; } }
97
+ if (!hit) return false;
98
+ var before = snapshot(), key = cp(hit);
99
+ commit(
100
+ function () { grid._merges = (grid._merges || []).filter(function (m) { return !(m.r === key.r && m.c === key.c && m.colspan === key.colspan && (m.rowspan || 1) === key.rowspan); }); if (!grid._merges.length) grid._merges = null; },
101
+ function () { restore(before); },
102
+ r, c);
103
+ return true;
104
+ };
105
+
106
+ // 現在の選択範囲(矩形)を結合。既存結合を含む場合は飲み込んで1つに(Excel流)。1x1 のみの選択は false。
107
+ grid.mergeSelection = function () {
108
+ var rr = grid.rectRange();
109
+ return grid.setMerge(rr.r0, rr.c0, rr.c1 - rr.c0 + 1, rr.r1 - rr.r0 + 1, true);
110
+ };
111
+
112
+ grid.getMerges = function () { return (grid._merges || []).map(cp); };
113
+
114
+ // 全結合を解除。Undo 対応。
115
+ grid.destroyMerged = function () {
116
+ if (!grid._merges) return;
117
+ var before = snapshot();
118
+ commit(function () { grid._merges = null; }, function () { restore(before); }, grid.active.r, grid.active.c);
119
+ };
120
+
121
+ // 初期結合(履歴に積まない=設定扱い)。範囲検証し、重なるものは無視、従属セルの値はクリア。
122
+ (opts.merges || []).forEach(function (mm) {
123
+ var m = rectOf(mm.r, mm.c, mm.colspan, mm.rowspan); if (!m || overlapping(m).length) return;
124
+ coveredOf(m).forEach(function (p) { grid.data[p.r][p.c] = ''; });
125
+ (grid._merges || (grid._merges = [])).push(m);
126
+ });
127
+ if (grid._merges) grid.buildTable();
128
+
129
+ return {
130
+ name: 'merge',
131
+ destroy: function () {
132
+ grid._merges = null;
133
+ grid._tssMergeInstalled = false;
134
+ delete grid.setMerge; delete grid.removeMerge; delete grid.mergeSelection;
135
+ delete grid.getMerges; delete grid.destroyMerged;
136
+ if (grid.table) grid.buildTable();
137
+ },
138
+ };
139
+ };
140
+ };
141
+
142
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssMerge;
143
+ else root.TssMerge = TssMerge;
144
+ })(typeof self !== 'undefined' ? self : this);
@@ -0,0 +1,68 @@
1
+ /*! TssMessages — システム共通メッセージのカタログ作成ヘルパー(CSV/行配列 → grid.messages 用マップ)。
2
+ *
3
+ * エラー本文をコードで一元管理(トーン統一・多言語・差し替え)。validator は { code, params } を返すだけにし、
4
+ * 本文は外部の設定(CSV/JSON/XML→行配列)に置く。ここはその設定を grid.messages の形に変換する係。
5
+ *
6
+ * 形: grid.messages = { CODE: { level, text } }(text に {label}/{value}/任意params のプレースホルダ)。
7
+ *
8
+ * 使い方:
9
+ * // CSV(1行目ヘッダ: code,level,text)から
10
+ * const cat = TssMessages.fromCSV(`code,level,text
11
+ * E_REQUIRED,error,{label}は必須です
12
+ * E_RANGE,error,{label}は{min}〜{max}の範囲で
13
+ * W_LONG,warn,{label}が長めです`);
14
+ * new TssGrid(el, { messages: cat, columns:[{ data:'age', title:'年齢',
15
+ * validator: v => v===''||(+v>=0&&+v<=120) || { code:'E_RANGE', params:{min:0,max:120} } }] });
16
+ *
17
+ * // 既に配列(JSON等)なら: TssMessages.fromRows([{code,level,text}, …])
18
+ */
19
+ (function (root) {
20
+ 'use strict';
21
+ // 1行を CSV としてパース("..." クォート・"" エスケープ・カンマ/改行をフィールド内に許容)
22
+ function parseCSV(text) {
23
+ var rows = [], row = [], i = 0, f = '', q = false, s = String(text);
24
+ while (i < s.length) {
25
+ var ch = s[i];
26
+ if (q) {
27
+ if (ch === '"') { if (s[i + 1] === '"') { f += '"'; i += 2; continue; } q = false; i++; continue; }
28
+ f += ch; i++; continue;
29
+ }
30
+ if (ch === '"') { q = true; i++; continue; }
31
+ if (ch === ',') { row.push(f); f = ''; i++; continue; }
32
+ if (ch === '\r') { i++; continue; }
33
+ if (ch === '\n') { row.push(f); rows.push(row); row = []; f = ''; i++; continue; }
34
+ f += ch; i++;
35
+ }
36
+ if (f !== '' || row.length) { row.push(f); rows.push(row); }
37
+ return rows.filter(function (r) { return r.length && !(r.length === 1 && r[0] === ''); });
38
+ }
39
+
40
+ function fromRows(rows) {
41
+ var cat = {};
42
+ (rows || []).forEach(function (r) {
43
+ var code = (r.code != null ? r.code : r[0]); if (code == null || code === '') return;
44
+ var level = (r.level != null ? r.level : r[1]) || 'error';
45
+ var text = (r.text != null ? r.text : (r.message != null ? r.message : r[2])) || '';
46
+ cat[String(code).trim()] = { level: String(level).trim() || 'error', text: String(text) };
47
+ });
48
+ return cat;
49
+ }
50
+
51
+ function fromCSV(text) {
52
+ var rows = parseCSV(text); if (!rows.length) return {};
53
+ var head = rows[0].map(function (h) { return String(h).trim().toLowerCase(); });
54
+ var hasHeader = head.indexOf('code') >= 0 || head.indexOf('text') >= 0 || head.indexOf('message') >= 0;
55
+ var ic = head.indexOf('code'), il = head.indexOf('level'), it = head.indexOf('text');
56
+ if (it < 0) it = head.indexOf('message');
57
+ var body = hasHeader ? rows.slice(1) : rows;
58
+ return fromRows(body.map(function (r) {
59
+ return hasHeader
60
+ ? { code: r[ic < 0 ? 0 : ic], level: il < 0 ? 'error' : r[il], text: r[it < 0 ? 2 : it] }
61
+ : { code: r[0], level: r[1], text: r[2] };
62
+ }));
63
+ }
64
+
65
+ var TssMessages = { fromRows: fromRows, fromCSV: fromCSV, parseCSV: parseCSV };
66
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssMessages;
67
+ else root.TssMessages = TssMessages;
68
+ })(typeof self !== 'undefined' ? self : this);