@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.
package/src/tssgrid.js ADDED
@@ -0,0 +1,2440 @@
1
+ /*!
2
+ * TssGrid — 軽量・ノービルド・日本語IME堅牢な編集グリッド (vanilla JS)
3
+ * UMD: <script src> なら window.TssGrid / window.HistoryManager、Node なら require で取得。
4
+ * License: MIT
5
+ */
6
+ (function (root) {
7
+ 'use strict';
8
+
9
+ // 全 TssGrid 共有の内部クリップボード(file:// でもグリッド間コピペが効くように)
10
+ let sharedClip = null;
11
+
12
+ // ============================================================
13
+ // HistoryManager — グリッドから独立。1個を複数グリッドで共有できる。
14
+ // command = { apply(), revert(), label }
15
+ // ============================================================
16
+ class HistoryManager {
17
+ constructor(max = 200) { this.undoStack = []; this.redoStack = []; this.max = max; this.onChange = null; }
18
+ push(cmd) {
19
+ this.undoStack.push(cmd);
20
+ if (this.undoStack.length > this.max) this.undoStack.shift();
21
+ this.redoStack.length = 0;
22
+ this._fire();
23
+ }
24
+ canUndo() { return this.undoStack.length > 0; }
25
+ canRedo() { return this.redoStack.length > 0; }
26
+ undo() { const c = this.undoStack.pop(); if (!c) return; c.revert(); this.redoStack.push(c); this._fire(); }
27
+ redo() { const c = this.redoStack.pop(); if (!c) return; c.apply(); this.undoStack.push(c); this._fire(); }
28
+ clear() { this.undoStack.length = 0; this.redoStack.length = 0; this._fire(); }
29
+ _fire() { if (this.onChange) this.onChange(this); }
30
+ }
31
+
32
+ // ============================================================
33
+ // TssGrid
34
+ // new TssGrid(container, {
35
+ // headers: string[], data: string[][],
36
+ // history?: HistoryManager, // 省略時は自前生成(単体動作)
37
+ // name?: string, // 履歴ラベル / 識別用
38
+ // showDump?: boolean, // データ確認用の表示(既定 false)
39
+ // onChange?: (grid) => void, // データ変更時コールバック(後方互換)
40
+ // // ---- イベント(V1)。changes = [{ r, c, oldValue, newValue, source }] ----
41
+ // onBeforeChange?: (changes, source) => false | changes | void,
42
+ // // 確定直前。false で全取消 / change.newValue を書換えると整形。
43
+ // // 1セルだけ拒否したい時は、その change.newValue を oldValue に戻す。
44
+ // onAfterChange?: (changes, source) => void,
45
+ // // 確定後。source: 'edit'|'paste'|'fill'|'delete'|'undo'|'redo'
46
+ // onEditStart?: ({ r, c, value }) => void,
47
+ // onEditEnd?: ({ r, c, oldValue, newValue, canceled }) => void,
48
+ // onSelectionChange?: ({ range, active, extent }) => void,
49
+ // onBeforeSelectionChange?: ({ range, active, extent }) => false | void, // false で選択中止
50
+ // onBeforePaste?: (data) => false | string[][] | void, // data: 2次元文字列配列
51
+ // onAfterPaste?: (data, { range }) => void, // 貼り付け確定後
52
+ // onBeforeCopy?: (data, { range }) => false | string[][] | void, // 取消 / 差替え
53
+ // onAfterCopy?: (data, { range, cut }) => void,
54
+ // onBeforeCut?: (data, { range }) => false | string[][] | void,
55
+ // onBeforeStructureChange?: (info) => false | void, // 行/列の挿入・削除の前。false で中止
56
+ // onStructureChange?: (info) => void, // 挿入・削除の後。info.type / undo
57
+ // })
58
+ // ============================================================
59
+ class TssGrid {
60
+ constructor(container, opts = {}) {
61
+ this.name = opts.name || 'grid';
62
+ this.columns = opts.columns || [];
63
+ // 見出し: 明示 headers > columns の title/data 由来 > 既定 A,B,C
64
+ this.headers = opts.headers || (this.columns.length
65
+ ? this.columns.map((c, i) => (c && (c.title || c.data)) || TssGrid._colLabel(i))
66
+ : ['A', 'B', 'C']);
67
+ this.COLS = this.headers.length;
68
+ // データ取り込み: 配列(string[][]) と オブジェクト配列(object[]) を自動判別。
69
+ // オブジェクト時は columns[c].data をキーに 2次元へ展開し、元オブジェクトは _src に保持(非表示フィールド維持)。
70
+ this._ingest(opts.data); // → this.data(2D) / this._src / this.ROWS / this._objectMode
71
+ this.history = opts.history || new HistoryManager();
72
+ this.showDump = !!opts.showDump;
73
+ this.onChange = opts.onChange || null;
74
+ this.onBeforeChange = opts.onBeforeChange || null;
75
+ this.onAfterChange = opts.onAfterChange || null;
76
+ this.onEditStart = opts.onEditStart || null;
77
+ this.onEditEnd = opts.onEditEnd || null;
78
+ this.onSelectionChange = opts.onSelectionChange || null;
79
+ this.onHeaderClick = opts.onHeaderClick || null; // (c, e)=>false で列選択を抑止(ソートUI等を外部で組む用)
80
+ this.onBeforePaste = opts.onBeforePaste || null;
81
+ // 確定キーの移動方向(実行時変更可)。'down'|'up'|'right'|'left'|'none'
82
+ this.enterMoves = opts.enterMoves || 'down';
83
+ this.tabMoves = opts.tabMoves || 'right';
84
+ // 初期カーソル位置。[r,c] か {r,c}(c は index か data キー)。未指定は [0,0]。
85
+ this.initialCell = opts.initialCell || null;
86
+ // 入力フロー制御: Enter/Tab の移動先を上書き。({r,c,key,shift})=>{r,c}|null(null で既定方向)。
87
+ // 例: 出勤→退勤→所定外休→備考→次行の出勤、のように入力セルだけ巡れる(業務フォーム向け)。
88
+ this.nextCell = opts.nextCell || null;
89
+ // 列定義(任意): columns[c] = { type, options, validator, checked, unchecked, parse, format, picker }
90
+ // type: 'text'(既定) | 'dropdown' | 'checkbox' | 'date' | 'time' | 'number'
91
+ // ── CellFormat(表示書式。保存値は正規・表示だけ整形・getData は保存値のまま)──
92
+ // number: decimals(小数桁/四捨五入) | thousands(true でカンマ区切り) | prefix/suffix(前後綴り '¥' '%' 等)。
93
+ // 入力はグルーピングや前後綴り混じりでも parse で正規化('1,234.50'→'1234.5')。非数値は拒否。編集時は素の数値を表示。
94
+ // 全角数字・符号は既定で半角化('123'→'123')。zenkaku:false で無効化。
95
+ // date: format に**文字列パターン**を渡すと宣言的に表示('yyyy/mm/dd' / 'yyyy年m月d日')。トークン yyyy/yy/mm/m/dd/d。parse は組込。
96
+ // options: dropdown の選択肢。string[] または {value,label}[](保存値=value・表示=label=内部コードと表示名を分離)。
97
+ // クリア用の空オプションは自動で先頭に付く='' を入れない。getData/getRows は value で返る。
98
+ // allowEmpty: dropdown で空(クリア)を許すか(既定 true)。false=空オプションを出さず空値も検証で弾く=ラジオ的な必須選択
99
+ // validator: (value, {r,c}) => true|undefined(許可) | false|string(拒否, 文字列はエラー文)
100
+ // maxLength: 桁数(文字数)上限。編集中はそれ以上**打てない**+貼り付け/setValue は超過分を**切り詰め**(無制限を避ける)
101
+ // checked/unchecked: checkbox の ON/OFF 保存値(既定 '1' / '')
102
+ // parse: (input, {r,c}) => 保存値 | false/null(不正)。入力文字列→保存値の正規化。
103
+ // format: (stored, {r,c}) => 表示文字列。保存値→セル表示の整形(getData は保存値のまま)。
104
+ // picker: date/time でネイティブピッカーを使うか(既定 true)。false でテキスト入力+parse。
105
+ // hour12: time で 12時間制(AM/PM)表示・入力。保存値は常に 24h 'HH:MM'。実行時に切替→redraw()。
106
+ // readOnly: true(または (value,{r,c})=>bool)で読み取り専用。編集不可+全経路で変更を拒否し old へ戻す。
107
+ // html: true で表示だけ生HTML(値 or format の戻りを innerHTML。画像/SVG/バッジ等)。値はテキスト保存のまま=
108
+ // getData/コピペは無傷。エスケープを外す=XSS責任は format/値の提供側(外部入力をそのまま埋めない)。表示用途は readOnly 推奨。
109
+ // cellClass: (value,{r,c,row,src}) => クラス文字列/配列。条件付き書式(見た目は業務側CSS)。全体版は opts.cellClass(r,c,value,row,src)。
110
+ // src=元レコード(オブジェクトデータ時。非表示フィールドも参照可=休日区分など隠し列基準の色付けに)。
111
+ // ※ date は parse 既定で yyyymmdd / yyyy-mm-dd / yyyy/mm/dd を yyyy-mm-dd に正規化。
112
+ // parse/format を付けるか picker:false にするとテキスト入力(並び順を自分で決められる)。
113
+ // data: オブジェクト配列で渡す時の**バインド先キー**(例 {data:'品番'})。getRows/getValue(r,'品番') で使う。
114
+ this.onInvalid = opts.onInvalid || null; // (rejections, source) 検証で弾かれた時
115
+ // 読み取り専用: グリッド全体(opts.readOnly)/ 列単位(columns[c].readOnly)。編集と全変更経路を拒否。
116
+ this.readOnly = !!opts.readOnly;
117
+ this.onReadOnly = opts.onReadOnly || null; // (blocked, source) 読み取り専用セルへの変更を弾いた時
118
+ // 右クリックメニュー。true=既定項目 / false=無効 / 配列 or {items:[...]}=構成(組込キー+カスタム項目)。
119
+ const _cm = opts.contextMenu;
120
+ this.contextMenu = _cm !== false;
121
+ this._menuConfig = Array.isArray(_cm) ? _cm : (_cm && Array.isArray(_cm.items)) ? _cm.items : null;
122
+ this.allowInsertRows = opts.allowInsertRows !== false;
123
+ this.allowDeleteRows = opts.allowDeleteRows !== false;
124
+ this.allowInsertCols = opts.allowInsertCols !== false;
125
+ this.allowDeleteCols = opts.allowDeleteCols !== false;
126
+ this.onStructureChange = opts.onStructureChange || null; // (info) 行/列の挿入・削除後
127
+ this.onFilter = opts.onFilter || null; // ({count,total,cleared?}) 非破壊フィルタ適用/解除後
128
+ this.onBeforeStructureChange = opts.onBeforeStructureChange || null; // (info)=>false で挿入/削除を中止
129
+ this.onAfterPaste = opts.onAfterPaste || null; // (data, {range}) 貼り付け確定後
130
+ this.onBeforeCopy = opts.onBeforeCopy || null; // (data, {range})=>false 取消 / 配列で差替え
131
+ this.onAfterCopy = opts.onAfterCopy || null; // (data, {range, cut}) クリップボード書込後
132
+ this.onBeforeCut = opts.onBeforeCut || null; // (data, {range})=>false 取消 / 配列で差替え
133
+ this.onBeforeSelectionChange = opts.onBeforeSelectionChange || null; // ({range,active,extent})=>false で選択中止
134
+ // 検証 NG 時の挙動: 'revert'(既定)=元の値に戻す / 'keep'=入力値を残してセルを赤く警告
135
+ this.invalidMode = opts.invalidMode || 'revert';
136
+ this._marksInvalid = this.invalidMode === 'keep';
137
+ // keep モードの赤セルに validator メッセージを title= で出す(ホバーで理由)。false で無効。
138
+ // 全エラーをまとめて出したい時は getInvalidCells() で [{r,c,value,message}] を取得(プログラム側で集計表示)。
139
+ this.invalidTitle = opts.invalidTitle !== false;
140
+ // メッセージカタログ(システム共通メッセージの別管理): { code: 'text' | {level,text} } or (code,ctx)=>text。
141
+ // validator が { code, params?, level? } を返すと、ここから本文を解決({label}/{value}/params を差込)。本文を外部JSON/CSV/XMLに置ける。
142
+ this.messages = opts.messages || null;
143
+ // 貼り付けがグリッド範囲を超えた時: 'clip'(既定, はみ出し分を切り捨て) | 'error'(貼り付け中止)
144
+ this.pasteOverflow = opts.pasteOverflow || 'clip';
145
+ this.onPasteOverflow = opts.onPasteOverflow || null; // (info) => void。error 時に呼ぶ
146
+
147
+ this.active = { r: 0, c: 0 }; this.extent = { r: 0, c: 0 };
148
+ this.mode = 'nav'; this.dragging = false; this.filling = null;
149
+ this.pendingCut = null;
150
+ this.headerDrag = null; this._colAnchor = 0; this._rowAnchor = 0; // 行/列ヘッダ選択
151
+ // リサイズ(列幅・行高)
152
+ // 列幅 colW[c]=px(未指定は既定)。指定方法は3通り、優先度=実行時リサイズ/明示colW > columns[c].width > 既定:
153
+ // 1) colWidths: [px, …] … 位置指定(従来)
154
+ // 2) colWidths: { dataキー: px, … } … 列ID(data キー)指定=列を入れ替えても幅が追従する
155
+ // 3) columns[c].width: px … 列定義に同居(並べ替え・コピペしても幅が一緒に動く=最も壊れにくい)
156
+ this.colW = [];
157
+ const _cw = opts.colWidths;
158
+ if (Array.isArray(_cw)) this.colW = _cw.slice();
159
+ else if (_cw && typeof _cw === 'object') for (const k in _cw) { const ci = this._resolveCol(k); if (ci >= 0) this.colW[ci] = _cw[k]; }
160
+ for (let c = 0; c < this.COLS; c++) if (this.colW[c] == null && this.colCfg(c).width != null) this.colW[c] = this.colCfg(c).width;
161
+ this.rowH = (opts.rowHeights || []).slice(); // rowH[r] = px(未指定は既定)
162
+ this.defColW = opts.defaultColWidth || 120;
163
+ this.defRowH = opts.defaultRowHeight || 28;
164
+ this.minColW = opts.minColWidth || 30;
165
+ this.minRowH = opts.minRowHeight || 18;
166
+ this.resizing = null;
167
+ // リサイズの見せ方: 'preview'(既定, Excel風に線でプレビュー→離して確定) | 'live'(即時反映)
168
+ this.resizeMode = opts.resizeMode || 'preview';
169
+ // 列固定: 左から frozenCols 列を横スクロールしても固定(行ヘッダの sticky と同じ仕組み)
170
+ this.frozenCols = opts.frozenCols || 0;
171
+ // 行固定: 上から frozenRows 行を縦スクロールしても固定(ヘッダ行の sticky と同じ仕組み・行版)
172
+ this.frozenRows = opts.frozenRows || 0;
173
+ // ネストヘッダ(ヘッダセル結合): 複数段の見出し行。各段は配列で、要素は文字列 or {label,colspan}。
174
+ // 最終段がリーフ(実列に対応=並べ替え/全選択チェック等が効く)。例:
175
+ // nestedHeaders: [ ['基本', {label:'住所', colspan:2}], ['氏名','都道府県','市区町村'] ]
176
+ this.nestedHeaders = (Array.isArray(opts.nestedHeaders) && opts.nestedHeaders.length) ? opts.nestedHeaders : null;
177
+ // wrap の幅/高さ。固定列・縦スクロールのため指定推奨(数値=px / 文字列=そのまま)
178
+ this.width = opts.width != null ? (typeof opts.width === 'number' ? opts.width + 'px' : opts.width) : null;
179
+ this.height = opts.height != null ? (typeof opts.height === 'number' ? opts.height + 'px' : opts.height) : null;
180
+ // リサイズ可否。列は columns[c].resizable:false で個別ロックも可(幅固定)
181
+ this.resizeCols = opts.resizeCols !== false;
182
+ this.resizeRows = opts.resizeRows !== false;
183
+ // 行/列ヘッダの表示(false で行番号列・ヘッダ行を隠す)
184
+ this.rowHeaders = opts.rowHeaders !== false;
185
+ this.colHeaders = opts.colHeaders !== false;
186
+ // 行ヘッダの「つまみ」をドラッグして行を並べ替える(行番号列が必要)
187
+ this.rowReorder = !!opts.rowReorder;
188
+ // 選択カーソル(緑の選択枠・範囲・セルハイライト・フィルハンドル)を表示するか。
189
+ // false で表示専用グリッド向けに非表示(内部の選択は保持=コピーは可)。
190
+ this.cursor = opts.cursor !== false;
191
+ this.onAfterRowMove = opts.onAfterRowMove || null; // (from, to) 行移動の確定後
192
+ // 列ストレッチ: 'none'(既定) | 'last'(余白を最終列へ) | 'all'(全列で配分)。枠幅まで伸ばす。
193
+ this.stretchH = opts.stretchH || 'none';
194
+ // セル内折り返し(既定 nowrap)。grid 全体 or columns[c].wordWrap。
195
+ this.wordWrap = !!opts.wordWrap;
196
+ // 空セルのヒント表示(grid 全体 or columns[c].placeholder)。getData には入らない。
197
+ this.placeholder = opts.placeholder != null ? String(opts.placeholder) : null;
198
+ // 機能トグル
199
+ // fillHandle: false で無効 / true で既定 / {direction,autoInsertRow} で詳細指定
200
+ const _fh = opts.fillHandle;
201
+ this.fillHandle = _fh !== false;
202
+ this.fillDirection = (_fh && _fh.direction) || 'both'; // 'both'|'vertical'|'horizontal'
203
+ this.fillAutoInsertRow = !!(_fh && _fh.autoInsertRow); // 下へドラッグで行を自動追加
204
+ this.onBeforeAutofill = opts.onBeforeAutofill || null; // (src,target)=>false で取消
205
+ this.onAfterAutofill = opts.onAfterAutofill || null; // (src,target)
206
+ this.copyPaste = opts.copyPaste !== false; // false でコピー/カット/ペースト無効
207
+ // 列幅オートフィット(内容に合わせて自動調整)。境界ダブルクリック / autoSizeColumn でも。
208
+ this.autoColumnSize = !!opts.autoColumnSize;
209
+ // データ入力支援
210
+ this.minSpareRows = opts.minSpareRows | 0; // 末尾に常に確保する空行数
211
+ this.autoWrapRow = !!opts.autoWrapRow; // Tab で行末→次行頭に折り返し
212
+ this.autoWrapCol = !!opts.autoWrapCol; // Enter で列末→次列頭に折り返し
213
+ // 行/列数の下限・上限(挿入/削除の境界。min は初期パディングにも使う)
214
+ this.minRows = opts.minRows | 0; this.maxRows = opts.maxRows | 0;
215
+ this.minCols = opts.minCols | 0; this.maxCols = opts.maxCols | 0;
216
+ this.className = opts.className || null; // コンテナ(.tssgrid)へ付ける任意クラス
217
+ // 条件付き書式: 値/行/列を見てセルに付けるCSSクラスを返す。全体版 cellClass(r,c,value,row)(row=値配列)。
218
+ // 列版は columns[c].cellClass(value,{r,c,row})。見た目は業務側CSS(.tssgrid td.xxx{…})で。
219
+ this.cellClass = opts.cellClass || null;
220
+ // セル単位インラインスタイル: cellStyle(r,c,value,row,src)→{CSSプロパティ} or 'k:v;…'。列版 columns[c].cellStyle(value,{r,c,row,src})。
221
+ // クラス(離散)では出せない連続色・データバー幅(CSS変数)向け。例: cellStyle:(r,c,v)=>({background:heat(v)})
222
+ this.cellStyle = opts.cellStyle || null;
223
+ // 列の非表示(描画で畳む+移動/幅/コピーは隠し列をスキップ)。実行時は hideColumn/showColumn。
224
+ this.hiddenCols = new Set((opts.hiddenColumns || []).map(Number));
225
+ // セル単位の可変状態(キー "r,c")。整列 / 実行時 readOnly。構造変更で _structCmd がキー追従。
226
+ this._align = new Map(); // "r,c" → { h?, v? }(列既定は columns[c].align/valign)
227
+ this._cellRO = new Set(); // "r,c"(セル単位の読み取り専用)
228
+ // キーボード: グリッドより先に渡すフック / カスタムショートカット登録 / プラグイン
229
+ this.onBeforeKeyDown = opts.onBeforeKeyDown || null; // (e)=>false か preventDefault で既定を止める
230
+ this._shortcuts = []; // { keys, sig, handler, context, name }
231
+ this._plugins = []; // { name, handle }(handle.destroy で後始末)
232
+ this._merges = null; // セル結合(横): [{r,c,colspan}] or null。null=結合なし=全経路ゼロコスト
233
+ this._listeners = []; // destroy() 用に登録した document/window リスナを記録
234
+ this._destroyed = false;
235
+ (opts.shortcuts || []).forEach(s => this.addShortcut(s));
236
+
237
+ this._padToMin(); // minRows/minCols まで空行・空列を補う
238
+ this._build(container);
239
+ this._bind();
240
+ this.buildTable();
241
+ { const ic = this._resolveInitialCell(); this.setActive(ic[0], ic[1]); } // 初期カーソル
242
+ this._ensureSpare(); // minSpareRows を確保
243
+ if (this.autoColumnSize) this.autoSizeAllColumns();
244
+ (opts.plugins || []).forEach(p => this.usePlugin(p)); // プラグイン初期化(全構築後)
245
+ }
246
+
247
+ // ---- データモデル(配列 / オブジェクト配列の両対応) ----
248
+ _key(c) { const d = this.colCfg(c).data; return d != null ? d : null; } // 列 c のバインド先キー('a' / 'a.b.c' のネストパス可)
249
+ _cellStr(v) { return v == null ? '' : String(v); }
250
+ _colByKey(key) { for (let c = 0; c < this.COLS; c++) if (this._key(c) === key) return c; return -1; }
251
+ // 列指定を index に解決(数値はそのまま / 文字列は data キーとして探す)
252
+ _resolveCol(c) { return typeof c === 'number' ? c : this._colByKey(c); }
253
+ // ネストパス取得 'name.last' → o.name.last(途中が無ければ undefined)
254
+ _getPath(o, path) {
255
+ if (o == null) return undefined;
256
+ if (path.indexOf('.') < 0) return o[path];
257
+ const ks = path.split('.'); let v = o;
258
+ for (let i = 0; i < ks.length; i++) { if (v == null) return undefined; v = v[ks[i]]; }
259
+ return v;
260
+ }
261
+ // ネストパス設定(途中の階層を copy-on-write でクローンしつつ書く=元オブジェクトを汚さない)
262
+ _setPathCOW(o, path, val) {
263
+ if (path.indexOf('.') < 0) { o[path] = val; return; }
264
+ const ks = path.split('.'); let t = o;
265
+ for (let i = 0; i < ks.length - 1; i++) {
266
+ const cur = t[ks[i]];
267
+ t[ks[i]] = (cur && typeof cur === 'object') ? (Array.isArray(cur) ? cur.slice() : Object.assign({}, cur)) : {};
268
+ t = t[ks[i]];
269
+ }
270
+ t[ks[ks.length - 1]] = val;
271
+ }
272
+ // オブジェクト1件 → 行配列(キーのある列だけ値を引く。無キー列は ''。ネストパス対応)
273
+ _objToRow(o) { const row = []; for (let c = 0; c < this.COLS; c++) { const k = this._key(c); row.push(k != null ? this._cellStr(this._getPath(o, k)) : ''); } return row; }
274
+ // 入力の取り込み。配列 or オブジェクト配列を自動判別し this.data(2D文字列) を作る。
275
+ _ingest(rows) {
276
+ rows = rows || [];
277
+ const isObj = rows.length > 0 && !Array.isArray(rows[0]) && typeof rows[0] === 'object';
278
+ this._objectMode = isObj;
279
+ if (isObj) {
280
+ this._src = rows.map(o => Object.assign({}, o)); // 非表示フィールドも保持
281
+ this.data = rows.map(o => this._objToRow(o));
282
+ } else {
283
+ this._src = null;
284
+ this.data = (rows.length ? rows : [new Array(this.COLS).fill('')]).map(r => r.slice());
285
+ }
286
+ this.ROWS = this.data.length;
287
+ }
288
+
289
+ // ---- 取得用 API ----
290
+ getData() { return this.data.map(row => row.slice()); } // 常に 2次元配列(後方互換)
291
+ // オブジェクト配列で取り出す。_src(元の非表示フィールド)にキー列の現在値を上書きして返す。
292
+ getRows() {
293
+ return this.data.map((row, r) => {
294
+ const o = this._src ? Object.assign({}, this._src[r]) : {};
295
+ for (let c = 0; c < this.COLS; c++) { const k = this._key(c); if (k != null) this._setPathCOW(o, k, row[c]); }
296
+ return o;
297
+ });
298
+ }
299
+ getRow(r) { return this.getRows()[r]; }
300
+ // getRow の対(行→列)。1列分の値を全行ぶん配列で。列は index か data キー。
301
+ getColumn(c) { const ci = this._resolveCol(c); return ci < 0 ? undefined : this.data.map(row => row[ci]); }
302
+ // 全列を「列ごとの値配列」で。見出しキーは data キー > headers。getRows の列方向版。
303
+ getColumns() {
304
+ const out = {};
305
+ for (let c = 0; c < this.COLS; c++) { const k = this._key(c); out[k != null ? k : this.headers[c]] = this.data.map(row => row[c]); }
306
+ return out;
307
+ }
308
+ setData(rows) { this._align.clear(); this._cellRO.clear(); this._unsortedData = null; this._allRows = this._allSrc = this._meta = this._filterFn = null; if (this.history) this.history.clear(); this._ingest(rows); this._padToMin(); this.buildTable(); const ic = this._resolveInitialCell(); this.setActive(ic[0], ic[1]); this._ensureSpare(); }
309
+ // 初期カーソル位置を [r,c] に解決(c は index か data キー)。範囲内へクランプ。
310
+ _resolveInitialCell() {
311
+ const ic = this.initialCell; if (!ic) return [0, 0];
312
+ let r, c;
313
+ if (Array.isArray(ic)) { r = ic[0] | 0; c = ic[1]; } else { r = ic.r | 0; c = ic.c; }
314
+ c = (typeof c === 'string') ? this._resolveCol(c) : (c | 0);
315
+ return [this.clampR(r), this.clampC(c < 0 ? 0 : c)];
316
+ }
317
+ // Enter/Tab 確定後の移動。nextCell フックがセルを返せばそこへ、無ければ既定方向(_moveBy)。
318
+ _advance(key, reverse) {
319
+ if (typeof this.nextCell === 'function') {
320
+ let nx; try { nx = this.nextCell({ r: this.active.r, c: this.active.c, key, shift: !!reverse }); } catch (_) {}
321
+ if (nx && nx.r != null && nx.c != null) {
322
+ const c = (typeof nx.c === 'string') ? this._resolveCol(nx.c) : nx.c;
323
+ this.setActive(this.clampR(nx.r), this.clampC(c < 0 ? 0 : c)); return;
324
+ }
325
+ }
326
+ this._moveBy(key === 'tab' ? this.tabMoves : this.enterMoves, reverse);
327
+ }
328
+ // CSV 文字列を返す(RFC4180 準拠のクォート)。opts: headers(既定true) / formatted(既定false=素の値, trueで表示形)
329
+ // / skipEmpty(既定true=空行除外) / eol(既定 '\r\n'=Excel向け)。JSON は getRows()+JSON.stringify で足りるので非内蔵。
330
+ toCSV(opts) {
331
+ const o = Object.assign({ headers: true, formatted: false, skipEmpty: true, eol: '\r\n' }, opts);
332
+ const esc = s => { s = s == null ? '' : String(s); return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
333
+ const lines = [];
334
+ if (o.headers && this.headers) lines.push(this.headers.slice(0, this.COLS).map(esc).join(','));
335
+ for (let r = 0; r < this.ROWS; r++) {
336
+ if (o.skipEmpty && this._rowEmpty(r)) continue;
337
+ const cells = [];
338
+ for (let c = 0; c < this.COLS; c++) cells.push(esc(o.formatted ? this._displayValue(r, c) : this.data[r][c]));
339
+ lines.push(cells.join(','));
340
+ }
341
+ return lines.join(o.eol);
342
+ }
343
+ // CSV を即ダウンロード(UTF-8 BOM 付きで Excel(日本語)が文字化けしない)。
344
+ downloadCSV(filename, opts) {
345
+ const blob = new Blob(['\uFEFF' + this.toCSV(opts)], { type: 'text/csv;charset=utf-8;' }); // 先頭BOMでExcel(日本語)の文字化けを防ぐ
346
+ const url = URL.createObjectURL(blob), a = document.createElement('a');
347
+ a.href = url; a.download = filename || 'export.csv';
348
+ document.body.appendChild(a); a.click(); a.remove();
349
+ setTimeout(() => URL.revokeObjectURL(url), 0);
350
+ }
351
+ getValue(r, c) { const ci = this._resolveCol(c); return ci < 0 ? undefined : this.data[r][ci]; }
352
+ // 値を1セット(列は index か data キー)。履歴に積まれ undo 可。検証は通常経路どおり。
353
+ // force=true で読み取り専用セルにも書ける(計算列など、プログラムからの確定的な書き込み用)。
354
+ setValue(r, c, val, force) {
355
+ const ci = this._resolveCol(c); if (ci < 0) return;
356
+ const before = this.snapSel(), changes = [];
357
+ this.setCell(r, ci, this._cellStr(val), changes);
358
+ this.pushCmd(changes, before, this.snapSel(), 'edit', !!force);
359
+ }
360
+ // 履歴に積まず・検証も通さず値を直接セット(readOnly も貫通)。表示書式は描画時に適用。
361
+ // 計算列など「入力から導く派生値」向け。undo/redo では入力セルが履歴復元され、onAfterChange で
362
+ // これを呼んで再計算する流儀にする=派生値が独立した undo ステップにならず入力に追従する。
363
+ setValueRaw(r, c, val) {
364
+ const ci = this._resolveCol(c); if (ci < 0 || r < 0 || r >= this.ROWS) return;
365
+ const v = this._cellStr(val);
366
+ if (this.data[r][ci] === v) return;
367
+ this.data[r][ci] = v;
368
+ this._renderCell(r, ci);
369
+ }
370
+ // 列定義(format/hour12 等)を変えた後の再描画。データと選択は保持。
371
+ redraw() { this.buildTable(); this.setActive(this.active.r, this.active.c); }
372
+
373
+ // ---- 並べ替え(A方式: データ実体を並べ替え。Undo には積まない/状態は行に追従) ----
374
+ // 行を order(=新しい並びの「元index配列」)で再配置。data/rowH/_src と セル状態(整列/cellRO)も行ごと追従。
375
+ _applyRowOrder(order) {
376
+ this.data = order.map(r => this.data[r]);
377
+ this.rowH = order.map(r => this.rowH[r]);
378
+ if (this._src) this._src = order.map(r => this._src[r]);
379
+ const pos = new Map(); order.forEach((oldR, newR) => pos.set(oldR, newR));
380
+ this._align = this._mapCellKeys(this._align, (r, c) => pos.has(r) ? [pos.get(r), c] : null);
381
+ this._cellRO = this._mapCellKeys(this._cellRO, (r, c) => pos.has(r) ? [pos.get(r), c] : null);
382
+ if (this._merges && !this._hasVMerge()) this._merges.forEach(m => { m.r = pos.get(m.r); }); // 横結合のみ行順追従(縦結合時はそもそも move/sort をロック済み)
383
+ this.buildTable(); this.setActive(0, 0);
384
+ }
385
+ // 未入力(空)行はソート対象外=最下部へ固定。[並べ替える行index, 末尾固定する空行index]
386
+ _sortBuckets() {
387
+ const all = this.data.map((_, r) => r);
388
+ return [all.filter(r => !this._rowEmpty(r)), all.filter(r => this._rowEmpty(r))];
389
+ }
390
+ // 初回ソート時に「入力順(行参照の並び)」を記録。編集ではズレず、clearSort() で戻せる。
391
+ _rememberOrder() { if (!this._unsortedData) this._unsortedData = this.data.slice(); }
392
+ // 列 c(index か data キー)で並べ替え。dir='asc'(既定)|'desc'。数値っぽければ数値比較、でなければ文字列比較。
393
+ sortBy(c, dir) {
394
+ if (this._sortLocked()) return; // 結合中はソート禁止(値による組み替えで結合帯が散らばる)
395
+ const ci = this._resolveCol(c); if (ci < 0) return;
396
+ const sgn = dir === 'desc' ? -1 : 1;
397
+ this._rememberOrder();
398
+ const [full, empty] = this._sortBuckets();
399
+ full.sort((a, b) => {
400
+ const va = this.data[a][ci], vb = this.data[b][ci];
401
+ const na = TssGrid.toNum(va), nb = TssGrid.toNum(vb);
402
+ const d = (na !== null && nb !== null) ? na - nb : String(va).localeCompare(String(vb));
403
+ return d * sgn;
404
+ });
405
+ this._applyRowOrder(full.concat(empty));
406
+ }
407
+ // 任意比較で並べ替え。cmp(rowA, rowB) は行(配列 or オブジェクト)を受ける。空行は最下部固定。
408
+ sortRows(cmp) {
409
+ if (this._sortLocked()) return; // 結合中はソート禁止
410
+ this._rememberOrder();
411
+ const rows = this._src ? this.getRows() : this.data;
412
+ const [full, empty] = this._sortBuckets();
413
+ full.sort((a, b) => cmp(rows[a], rows[b]));
414
+ this._applyRowOrder(full.concat(empty));
415
+ }
416
+ // 並べ替えを解除して初期状態(入力順)に戻す。戻したら true。未ソートなら false。
417
+ // 行参照ベースなので、ソート後に追加した行は末尾へ、削除済みの行は無視(堅牢)。
418
+ clearSort() {
419
+ if (!this._unsortedData) return false;
420
+ const idxOf = new Map(this.data.map((row, i) => [row, i])), order = [];
421
+ for (const ref of this._unsortedData) { const i = idxOf.get(ref); if (i !== undefined) { order.push(i); idxOf.delete(ref); } }
422
+ this.data.forEach((row, i) => { if (idxOf.has(row)) order.push(i); }); // 後から増えた行は末尾へ
423
+ this._unsortedData = null;
424
+ this._applyRowOrder(order);
425
+ return true;
426
+ }
427
+ isSorted() { return !!this._unsortedData; }
428
+
429
+ // ---- 非破壊フィルタ(行インデックスマッパ) ----
430
+ // フィルタ中も全行は _allRows に保持。this.data は可視部分集合(同じ行参照)=編集は参照共有でマスタへ自動反映。
431
+ // 選択/ナビ/結合/幾何は this.data(ビュー)と ROWS のまま無改変=フィルタ未使用なら _allRows===null でゼロコスト。
432
+ // 行ごとの可変状態(rowH/整列/セルRO)は行参照キーの _meta に退避して非破壊に往復。
433
+ // フィルタ中も「行の挿入削除」は可(_allRows をマスタ追従+Undo・同一フィルタ内)。filter/clearFilter は履歴クリア
434
+ // (ビューindex基準の履歴が境界跨ぎで不整合になるため)。制限: 列の挿入削除・行移動・セル結合はロック。
435
+ isFiltered() { return !!this._allRows; }
436
+ // pred を関数化。関数はそのまま / {col: value|fn} は AND 条件(col は index か data キー) / falsy は解除。
437
+ _normFilter(pred) {
438
+ if (!pred) return null;
439
+ if (typeof pred === 'function') return pred;
440
+ if (typeof pred === 'object') {
441
+ const conds = Object.keys(pred).map(k => {
442
+ let ci = this._resolveCol(k); if (ci < 0) ci = this.headers.indexOf(k); // data キー → 見出し名でも解決
443
+ const want = pred[k]; return { ci, test: typeof want === 'function' ? want : (v => String(v) === String(want)) };
444
+ }).filter(x => x.ci >= 0);
445
+ return row => conds.every(cd => cd.test(row[cd.ci]));
446
+ }
447
+ return null;
448
+ }
449
+ // 現ビュー(this.data)の rowH/整列/セルRO を行参照キー(_meta)へ退避(ビューでの編集を保全)。
450
+ _metaSyncFromView() {
451
+ if (!this._meta) return;
452
+ for (let r = 0; r < this.data.length; r++) this._meta.set(this.data[r], { a: new Map(), ro: new Set(), h: this.rowH[r] });
453
+ this._align.forEach((v, k) => { const i = k.indexOf(','), ref = this.data[+k.slice(0, i)], m = ref && this._meta.get(ref); if (m) m.a.set(+k.slice(i + 1), v); });
454
+ this._cellRO.forEach(k => { const i = k.indexOf(','), ref = this.data[+k.slice(0, i)], m = ref && this._meta.get(ref); if (m) m.ro.add(+k.slice(i + 1)); });
455
+ }
456
+ // 行参照列を this.data に据え、rowH/整列/RO を _meta から復元(ビューindex へ展開)。
457
+ _metaApply(rows) {
458
+ this.data = rows; this.ROWS = rows.length;
459
+ this.rowH = rows.map(ref => { const m = this._meta.get(ref); return m ? m.h : undefined; });
460
+ this._align = new Map(); this._cellRO = new Set();
461
+ rows.forEach((ref, r) => { const m = this._meta.get(ref); if (!m) return; m.a.forEach((v, c) => this._align.set(r + ',' + c, v)); m.ro.forEach(c => this._cellRO.add(r + ',' + c)); });
462
+ }
463
+ // 絞り込み。pred=(row, masterIndex, src)=>bool / {col:value|fn} / falsy=解除。非破壊(編集は参照でマスタへ反映)。
464
+ filter(pred) {
465
+ if (this._merges && this._merges.length) return false; // v1: 結合中は不可
466
+ const fn = this._normFilter(pred);
467
+ if (!fn) return this.clearFilter();
468
+ if (this.mode === 'edit') this._commitActive();
469
+ if (!this._allRows) { this._allRows = this.data.slice(); this._allSrc = this._src ? this._src.slice() : null; this._meta = new Map(); }
470
+ this._metaSyncFromView(); // 既存ビューの編集(rowH/整列/RO)を ref へ退避
471
+ this._filterFn = fn;
472
+ const view = [], vsrc = this._allSrc ? [] : null;
473
+ for (let i = 0; i < this._allRows.length; i++) { const row = this._allRows[i], src = this._allSrc ? this._allSrc[i] : undefined; if (fn(row, i, src)) { view.push(row); if (vsrc) vsrc.push(src); } }
474
+ this._src = vsrc; this._metaApply(view); this._unsortedData = null;
475
+ this.history.clear(); // ビューindex基準の履歴がフィルタ境界を跨ぐと不整合=境界で履歴クリア(v1)
476
+ this.buildTable();
477
+ this._afterFilterSelect();
478
+ if (this.onFilter) { try { this.onFilter({ count: view.length, total: this._allRows.length }); } catch (_) {} }
479
+ return true;
480
+ }
481
+ // フィルタ解除=全行を master 順で復元(フィルタ中に編集/値変更した分も含め非破壊に戻る)。
482
+ clearFilter() {
483
+ if (!this._allRows) return false;
484
+ if (this.mode === 'edit') this._commitActive();
485
+ this._metaSyncFromView();
486
+ this._src = this._allSrc ? this._allSrc.slice() : null;
487
+ this._metaApply(this._allRows.slice());
488
+ this._allRows = this._allSrc = this._meta = this._filterFn = null; this._unsortedData = null;
489
+ this.history.clear(); // フィルタ境界で履歴クリア(境界跨ぎの undo 不整合を回避・v1)
490
+ this._padToMin(); this.buildTable(); this._ensureSpare();
491
+ this._afterFilterSelect();
492
+ if (this.onFilter) { try { this.onFilter({ count: this.ROWS, total: this.ROWS, cleared: true }); } catch (_) {} }
493
+ return true;
494
+ }
495
+ _afterFilterSelect() {
496
+ if (this.ROWS > 0) this.setActive(Math.min(this.active.r, this.ROWS - 1), this._snapVisCol(this.active.c));
497
+ else { this.active = { r: 0, c: 0 }; this.extent = { r: 0, c: 0 }; this.selbox.style.display = this.selrange.style.display = this.fillhandle.style.display = 'none'; }
498
+ }
499
+ // マスタ(全行)を getRows 形式で取得(フィルタ中でも全件)。未フィルタなら getRows と同じ。
500
+ getAllRows() {
501
+ const rows = this._allRows || this.data, src = this._allRows ? this._allSrc : this._src;
502
+ return rows.map((row, r) => { const o = src ? Object.assign({}, src[r]) : {}; for (let c = 0; c < this.COLS; c++) { const k = this._key(c); if (k != null) this._setPathCOW(o, k, row[c]); } return o; });
503
+ }
504
+
505
+ // ---- リスナ登録(destroy() 用に記録) / 後始末 ----
506
+ _on(target, type, fn, opts) { target.addEventListener(type, fn, opts); this._listeners.push({ target, type, fn, opts }); }
507
+ // グリッドを破棄: document/window リスナと ResizeObserver を外し、プラグインを destroy、DOM を空に。
508
+ destroy() {
509
+ if (this._destroyed) return;
510
+ this._destroyed = true;
511
+ for (const p of this._plugins) { if (p.handle && typeof p.handle.destroy === 'function') { try { p.handle.destroy(); } catch (_) {} } }
512
+ this._plugins = [];
513
+ if (this._ro) { try { this._ro.disconnect(); } catch (_) {} this._ro = null; }
514
+ for (const l of this._listeners) { try { l.target.removeEventListener(l.type, l.fn, l.opts); } catch (_) {} }
515
+ this._listeners = [];
516
+ if (this._customEditor && this._customEditor.close) { try { this._customEditor.close(); } catch (_) {} }
517
+ try { if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root); } catch (_) {}
518
+ }
519
+
520
+ // ---- カスタムショートカット(軽量 shortcut-manager / context) ----
521
+ // keys: 'Ctrl+S' 等の文字列 or 配列。context: 'grid'(ナビ) | 'editor'(編集中) | 'all'(既定)。
522
+ addShortcut(s) {
523
+ const keys = Array.isArray(s.keys) ? s.keys : [s.keys];
524
+ this._shortcuts.push({ name: s.name, context: s.context || 'all', handler: s.handler, sigs: keys.map(k => this._normSig(k)) });
525
+ }
526
+ removeShortcut(name) { this._shortcuts = this._shortcuts.filter(s => s.name !== name); }
527
+ // 'Ctrl+Shift+K' → 正規シグネチャ。meta/cmd は Ctrl 扱い。1文字キーは小文字化。
528
+ _normSig(str) {
529
+ const parts = String(str).split('+').map(p => p.trim()).filter(Boolean);
530
+ let ctrl = false, shift = false, alt = false, key = '';
531
+ for (const p of parts) {
532
+ const lp = p.toLowerCase();
533
+ if (lp === 'ctrl' || lp === 'control' || lp === 'cmd' || lp === 'meta') ctrl = true;
534
+ else if (lp === 'shift') shift = true;
535
+ else if (lp === 'alt' || lp === 'option') alt = true;
536
+ else key = p.length === 1 ? lp : p;
537
+ }
538
+ return (ctrl ? 'c' : '') + (shift ? 's' : '') + (alt ? 'a' : '') + ':' + key;
539
+ }
540
+ _evSig(e) {
541
+ const key = e.key && e.key.length === 1 ? e.key.toLowerCase() : e.key;
542
+ return ((e.ctrlKey || e.metaKey) ? 'c' : '') + (e.shiftKey ? 's' : '') + (e.altKey ? 'a' : '') + ':' + key;
543
+ }
544
+ // 登録ショートカットを照合。実行したら true(=グリッド既定を抑止)。
545
+ _runShortcuts(e) {
546
+ if (!this._shortcuts.length) return false;
547
+ const ctx = this.mode === 'edit' ? 'editor' : 'grid', sig = this._evSig(e);
548
+ for (const sc of this._shortcuts) {
549
+ if (sc.context !== 'all' && sc.context !== ctx) continue;
550
+ if (sc.sigs.includes(sig)) {
551
+ e.preventDefault();
552
+ let ret; try { ret = sc.handler(e, { grid: this, context: ctx }); } catch (_) {}
553
+ if (ret !== true) return true; // handler が true を返した時だけ既定を続行
554
+ }
555
+ }
556
+ return false;
557
+ }
558
+
559
+ // ---- プラグイン(軽量 base-plugin。init(grid)→任意の {destroy} を返す薄い契約) ----
560
+ usePlugin(plugin, opts) {
561
+ let factory = plugin, name;
562
+ if (typeof plugin === 'string') { factory = TssGrid._plugins && TssGrid._plugins[plugin]; name = plugin; }
563
+ if (!factory) return null;
564
+ const handle = (typeof factory === 'function') ? factory(this, opts) : (typeof factory.init === 'function' ? factory.init(this, opts) : null);
565
+ const entry = { name: name || (handle && handle.name) || (factory && factory.pluginName), handle: handle || {} };
566
+ this._plugins.push(entry);
567
+ return entry.handle;
568
+ }
569
+ getPlugin(name) { const p = this._plugins.find(p => p.name === name); return p ? p.handle : null; }
570
+ static registerPlugin(name, factory) { (TssGrid._plugins || (TssGrid._plugins = {}))[name] = factory; }
571
+
572
+ _build(container) {
573
+ container.innerHTML =
574
+ '<div class="tssgrid"><div class="tg-wrap">' +
575
+ '<table class="tg-table"></table>' +
576
+ '<div class="tg-selrange"></div><div class="tg-selbox"></div>' +
577
+ '<div class="tg-copybox"></div><div class="tg-fillpreview"></div><div class="tg-fillhandle"></div>' +
578
+ '<div class="tg-resizeguide"></div>' +
579
+ '<input class="tg-editor nav" type="text" autocomplete="off" spellcheck="false">' +
580
+ '<select class="tg-select" style="display:none"></select>' +
581
+ '</div>' + (this.showDump ? '<pre class="tg-dump"></pre>' : '') +
582
+ '<div class="tg-menu" style="display:none"></div></div>';
583
+ const q = s => container.querySelector(s);
584
+ this.wrap = q('.tg-wrap'); this.table = q('.tg-table');
585
+ this.selrange = q('.tg-selrange'); this.selbox = q('.tg-selbox');
586
+ this.copybox = q('.tg-copybox'); this.fillhandle = q('.tg-fillhandle'); this.fillpreview = q('.tg-fillpreview');
587
+ this.resizeGuide = q('.tg-resizeguide');
588
+ this.editor = q('.tg-editor'); this.select = q('.tg-select'); this.dumpEl = q('.tg-dump');
589
+ this.menuEl = q('.tg-menu');
590
+ this.root = q('.tssgrid');
591
+ if (this.className) this.root.className += ' ' + this.className; // 任意クラス(テーマ付け)
592
+ if (this.wordWrap) this.root.classList.add('tg-wordwrap'); // グリッド全体の折り返し
593
+ if (!this.cursor) this.root.classList.add('tg-no-cursor'); // 表示専用(選択枠なし+セルカーソル既定)
594
+ if (this.width) {
595
+ this.wrap.style.width = this.width; // 幅制約(横スクロール/固定列用)
596
+ if (this.dumpEl) this.dumpEl.style.width = this.width; // ダンプ欄も枠幅に揃える
597
+ }
598
+ if (this.height) this.wrap.style.maxHeight = this.height; // 高さ制約(縦スクロール用)
599
+ }
600
+
601
+ static esc(s) { return String(s).replace(/[&<>]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[m])); }
602
+ cellEl(r, c) { if (this._merges) { const a = this._anchorOf(r, c); r = a.r; c = a.c; } return this.table.querySelector('td[data-r="' + r + '"][data-c="' + c + '"]'); }
603
+ activeTd() { return this.cellEl(this.active.r, this.active.c); }
604
+ clampR(r) { return Math.max(0, Math.min(this.ROWS - 1, r)); }
605
+ clampC(c) { return Math.max(0, Math.min(this.COLS - 1, c)); }
606
+ renderDump() { if (this.dumpEl) this.dumpEl.textContent = JSON.stringify(this.data); if (this.onChange) this.onChange(this); }
607
+
608
+ // ---- 列定義(セルタイプ / バリデータ) ----
609
+ colCfg(c) { return this.columns[c] || {}; }
610
+ colType(c) { return this.colCfg(c).type || 'text'; }
611
+ // 読み取り専用判定(グリッド全体 / 列 readOnly: bool/関数 / セル単位 setCellReadOnly)。
612
+ _isReadOnly(r, c) {
613
+ if (this.readOnly) return true;
614
+ if (this._merges && this._isCovered(r, c)) return true; // 結合の従属セルは編集不可(貼付/フィルは既存の readOnly 経路で巻戻し)
615
+ if (this._cellRO.has(r + ',' + c)) return true; // セル単位の実行時トグル
616
+ const ro = this.colCfg(c).readOnly;
617
+ if (typeof ro === 'function') { try { return !!ro(this.data[r][c], { r, c }); } catch (_) { return false; } }
618
+ return !!ro;
619
+ }
620
+ _hasReadOnly() { return this.readOnly || (this._merges && this._merges.length > 0) || this._cellRO.size > 0 || this.columns.some(cfg => cfg && cfg.readOnly); }
621
+ // ---- セル単位の可変状態(整列 / 実行時 readOnly)。キーは "r,c"。 ----
622
+ _ck(r, c) { return r + ',' + c; }
623
+ // 列既定(align/valign) + セル上書きを合成した実効整列。
624
+ _cellAlign(r, c) {
625
+ const cell = this._align.get(this._ck(r, c)) || {}, col = this.colCfg(c);
626
+ return { h: cell.h || col.align || null, v: cell.v || col.valign || null };
627
+ }
628
+ _parseAlign(a) {
629
+ if (a && typeof a === 'object') return { h: a.h || null, v: a.v || null };
630
+ if (a === 'left' || a === 'center' || a === 'right') return { h: a };
631
+ if (a === 'top' || a === 'middle' || a === 'bottom') return { v: a };
632
+ return {};
633
+ }
634
+ getAlignment(r, c) { return this._cellAlign(r, c); }
635
+ // 整列を設定(range 省略時は現在の選択範囲)。align='left|center|right|top|middle|bottom'|{h,v}|'reset'。非 Undo。
636
+ setAlignment(align, range) {
637
+ range = range || this.rectRange();
638
+ const reset = align === 'reset' || align === 'none' || align == null;
639
+ const { h, v } = reset ? {} : this._parseAlign(align);
640
+ for (let r = range.r0; r <= range.r1; r++) for (let c = range.c0; c <= range.c1; c++) {
641
+ const k = this._ck(r, c);
642
+ if (reset) { this._align.delete(k); continue; }
643
+ const cur = this._align.get(k) || {};
644
+ if (h) cur.h = h; if (v) cur.v = v;
645
+ this._align.set(k, cur);
646
+ }
647
+ this.buildTable(); this.setActive(this.active.r, this.active.c);
648
+ }
649
+ // セル単位の読み取り専用を設定(range 省略時は選択範囲)。非 Undo。
650
+ setCellReadOnly(flag, range) {
651
+ range = range || this.rectRange();
652
+ for (let r = range.r0; r <= range.r1; r++) for (let c = range.c0; c <= range.c1; c++) {
653
+ const k = this._ck(r, c);
654
+ if (flag) this._cellRO.add(k); else this._cellRO.delete(k);
655
+ }
656
+ this.buildTable(); this.setActive(this.active.r, this.active.c);
657
+ }
658
+ // 構造変更でセル状態のキー("r,c")を追従。fn(r,c)→[r,c] | null(削除)。
659
+ _mapCellKeys(coll, fn) {
660
+ const isMap = coll instanceof Map, out = isMap ? new Map() : new Set();
661
+ for (const e of coll) {
662
+ const key = isMap ? e[0] : e, val = isMap ? e[1] : true;
663
+ const [r, c] = key.split(',').map(Number), nk = fn(r, c);
664
+ if (nk) { const k = nk[0] + ',' + nk[1]; isMap ? out.set(k, val) : out.add(k); }
665
+ }
666
+ return out;
667
+ }
668
+ _cloneCellState() { return { align: new Map(this._align), ro: new Set(this._cellRO) }; }
669
+ _restoreCellState(s) { this._align = s.align; this._cellRO = s.ro; }
670
+ _remapCellState(fn) { this._align = this._mapCellKeys(this._align, fn); this._cellRO = this._mapCellKeys(this._cellRO, fn); }
671
+ // info.type → キー追従関数(行/列の挿入・削除)。
672
+ _structRemapFn(info) {
673
+ switch (info.type) {
674
+ case 'insertRow': { const at = info.at; return (r, c) => [r >= at ? r + 1 : r, c]; }
675
+ case 'deleteRows': { const { r0, r1 } = info, n = r1 - r0 + 1; return (r, c) => (r >= r0 && r <= r1) ? null : [r > r1 ? r - n : r, c]; }
676
+ case 'insertCol': { const at = info.at; return (r, c) => [r, c >= at ? c + 1 : c]; }
677
+ case 'deleteCols': { const { c0, c1 } = info, m = c1 - c0 + 1; return (r, c) => (c >= c0 && c <= c1) ? null : [r, c > c1 ? c - m : c]; }
678
+ default: return null;
679
+ }
680
+ }
681
+ // 構造変更で結合(矩形)の座標を追従。行/列の挿入削除を縦横対称に処理: 結合範囲の外なら平行移動、
682
+ // 内側に挿入なら span 拡張、削除が範囲に重なれば縮小(端も削れたらアンカーを削除境界へ)、全消去なら解除。
683
+ // 1x1 に縮んだ結合は解除。
684
+ _remapMerges(info) {
685
+ if (!this._merges || !this._merges.length) return;
686
+ const out = [];
687
+ for (const m of this._merges) {
688
+ let { r, c, colspan } = m, rowspan = m.rowspan || 1;
689
+ if (info.type === 'insertRow') { const at = info.at; if (at <= r) r++; else if (at < r + rowspan) rowspan++; }
690
+ else if (info.type === 'deleteRows') {
691
+ const { r0, r1 } = info, n = r1 - r0 + 1, top = r, bot = r + rowspan - 1;
692
+ if (r0 <= top && r1 >= bot) continue; // 縦方向すべて消える
693
+ if (r1 < top) r -= n; // 全て上で削除=上シフト
694
+ else if (r0 > bot) { /* 全て下で削除=影響なし */ }
695
+ else { rowspan -= (Math.min(r1, bot) - Math.max(r0, top) + 1); if (r0 < top) r = r0; } // 部分重なり=縮小
696
+ }
697
+ else if (info.type === 'insertCol') { const at = info.at; if (at <= c) c++; else if (at < c + colspan) colspan++; }
698
+ else if (info.type === 'deleteCols') {
699
+ const { c0, c1 } = info, n = c1 - c0 + 1, left = c, right = c + colspan - 1;
700
+ if (c0 <= left && c1 >= right) continue; // 横方向すべて消える
701
+ if (c1 < left) c -= n; // 全て左で削除=左シフト
702
+ else if (c0 > right) { /* 全て右で削除=影響なし */ }
703
+ else { colspan -= (Math.min(c1, right) - Math.max(c0, left) + 1); if (c0 < left) c = c0; } // 部分重なり=縮小
704
+ }
705
+ if (colspan >= 2 || rowspan >= 2) out.push({ r, c, colspan, rowspan }); // 1x1 に縮んだら結合解除
706
+ }
707
+ this._merges = out.length ? out : null;
708
+ }
709
+ _cbChecked(c) { const cfg = this.colCfg(c); return 'checked' in cfg ? String(cfg.checked) : '1'; }
710
+ _cbUnchecked(c) { const cfg = this.colCfg(c); return 'unchecked' in cfg ? String(cfg.unchecked) : ''; }
711
+ _isCheckedVal(c, v) {
712
+ const cfg = this.colCfg(c);
713
+ if ('checked' in cfg) return v === String(cfg.checked);
714
+ return v === '1' || v === 'true' || v === 'TRUE' || v === 'yes' || v === '✓';
715
+ }
716
+ _hasColRules() { return this.columns.some(cfg => cfg && ((cfg.type && cfg.type !== 'text') || typeof cfg.validator === 'function' || typeof cfg.parse === 'function' || cfg.maxLength > 0)); }
717
+ static _isISODate(s) {
718
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
719
+ const [y, m, d] = s.split('-').map(Number);
720
+ if (m < 1 || m > 12 || d < 1 || d > 31) return false;
721
+ const dt = new Date(Date.UTC(y, m - 1, d));
722
+ return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
723
+ }
724
+ static _isTime(s) {
725
+ const m = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(s);
726
+ if (!m) return false;
727
+ return +m[1] <= 23 && +m[2] <= 59 && (m[3] == null || +m[3] <= 59);
728
+ }
729
+ static _pad2(n) { return String(n).padStart(2, '0'); }
730
+ // 列の既定見出し(Excel 風 A,B,…,Z,AA)。列挿入時の新規見出しに使う。
731
+ static _colLabel(n) { let s = ''; n = n | 0; do { s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26) - 1; } while (n >= 0); return s; }
732
+ // 入力文字列 → 'yyyy-mm-dd'(不正は null)。yyyymmdd / yyyy-mm-dd / yyyy/mm/dd / yyyy.mm.dd を許容。
733
+ static _parseDate(s) {
734
+ s = String(s).trim(); if (s === '') return null;
735
+ let y, m, d;
736
+ const sep = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/.exec(s);
737
+ if (sep) { y = +sep[1]; m = +sep[2]; d = +sep[3]; }
738
+ else if (/^\d{8}$/.test(s)) { y = +s.slice(0, 4); m = +s.slice(4, 6); d = +s.slice(6, 8); }
739
+ else return null;
740
+ const iso = y + '-' + TssGrid._pad2(m) + '-' + TssGrid._pad2(d);
741
+ return TssGrid._isISODate(iso) ? iso : null;
742
+ }
743
+ // 入力文字列 → 24時間制 'HH:MM' / 'HH:MM:SS'(不正は null)。24h と 12h(AM/PM) の両方を受理。
744
+ static _parseTime(s) {
745
+ const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][.]?[Mm][.]?)?$/.exec(String(s).trim());
746
+ if (!m) return null;
747
+ let h = +m[1]; const mi = +m[2], se = m[3] == null ? null : +m[3];
748
+ const ap = m[4] ? m[4][0].toLowerCase() : null; // 'a' | 'p' | null
749
+ if (ap) { if (h < 1 || h > 12) return null; h = ap === 'a' ? (h === 12 ? 0 : h) : (h === 12 ? 12 : h + 12); }
750
+ if (h > 23 || mi > 59 || (se != null && se > 59)) return null;
751
+ return TssGrid._pad2(h) + ':' + TssGrid._pad2(mi) + (se != null ? ':' + TssGrid._pad2(se) : '');
752
+ }
753
+ // 24時間制 'HH:MM[:SS]' → 12時間制 'h:MM[:SS] AM/PM' 表示。
754
+ static _fmtTime12(s) {
755
+ const m = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(String(s));
756
+ if (!m) return s;
757
+ let h = +m[1]; const ap = h < 12 ? 'AM' : 'PM';
758
+ h = h % 12; if (h === 0) h = 12;
759
+ return h + ':' + m[2] + (m[3] != null ? ':' + m[3] : '') + ' ' + ap;
760
+ }
761
+ // CellFormat: 入力(グルーピング/前後綴り混じり) → 正規化した数値文字列(保存値)。不正は null。
762
+ // 全角→半角(数値文脈)。数字・符号・小数点・カンマ・%・¥・空白を半角化。業務フォームの IME 誤入力対策。
763
+ static _zen2han(s) {
764
+ return String(s)
765
+ .replace(/[0-9]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xFEE0))
766
+ .replace(/+/g, '+').replace(/[-−ー―]/g, '-')
767
+ .replace(/./g, '.').replace(/,/g, ',')
768
+ .replace(/%/g, '%').replace(/¥/g, '¥').replace(/ /g, ' ');
769
+ }
770
+ static _parseNumber(v, cfg) {
771
+ let s = String(v).trim();
772
+ if (cfg.zenkaku !== false) s = TssGrid._zen2han(s); // 既定で全角→半角(cfg.zenkaku:false で無効化)
773
+ if (cfg.prefix) s = s.split(cfg.prefix).join('');
774
+ if (cfg.suffix) s = s.split(cfg.suffix).join('');
775
+ s = s.replace(/,/g, '').replace(/\s/g, '');
776
+ if (s === '') return null;
777
+ if (!/^[-+]?(\d+\.?\d*|\.\d+)$/.test(s)) return null;
778
+ const n = Number(s);
779
+ return Number.isFinite(n) ? String(n) : null; // 正規('1,234.50' → '1234.5')
780
+ }
781
+ // CellFormat: 正規化数値文字列 → 表示(decimals/thousands/prefix/suffix)。getData は保存値のまま。
782
+ static _formatNumber(v, cfg) {
783
+ const n = Number(String(v).replace(/,/g, '').trim());
784
+ if (!Number.isFinite(n)) return v;
785
+ let s = cfg.decimals != null ? Math.abs(n).toFixed(cfg.decimals) : String(Math.abs(n));
786
+ let [int, frac = ''] = s.split('.');
787
+ if (cfg.thousands) int = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
788
+ const body = int + (frac ? '.' + frac : '');
789
+ return (n < 0 ? '-' : '') + (cfg.prefix || '') + body + (cfg.suffix || '');
790
+ }
791
+ // CellFormat: 保存 ISO 'yyyy-mm-dd' をパターン表示。トークン: yyyy/yy/mm/m/dd/d(パターン文字は数字に置換=衝突しない)。
792
+ static _applyDatePattern(iso, pattern) {
793
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso));
794
+ if (!m) return iso; // ISO でなければそのまま(誤用時の保険)
795
+ const y = m[1], mo = m[2], d = m[3];
796
+ return String(pattern)
797
+ .replace(/yyyy/g, y).replace(/yy/g, y.slice(2))
798
+ .replace(/mm/g, mo).replace(/m/g, String(+mo))
799
+ .replace(/dd/g, d).replace(/d/g, String(+d));
800
+ }
801
+ // 1セルの型強制 / 検証。{ ok, value, message } を返す。入力→保存値の正規化(parse)もここで。
802
+ _coerceCell(r, c, value) {
803
+ const cfg = this.colCfg(c); let v = value;
804
+ const t = cfg.type || 'text';
805
+ // parse: 入力文字列 → 保存値(カスタム優先 / 無ければ date・time の組込)
806
+ if (v !== '') {
807
+ if (typeof cfg.parse === 'function') {
808
+ let p; try { p = cfg.parse(v, { r, c }); } catch (_) { p = null; }
809
+ if (p == null || p === false) return { ok: false };
810
+ v = String(p);
811
+ } else if (t === 'date') {
812
+ const p = TssGrid._parseDate(v); if (p == null) return { ok: false }; v = p;
813
+ } else if (t === 'time') {
814
+ const p = TssGrid._parseTime(v); if (p == null) return { ok: false }; v = p;
815
+ } else if (t === 'number') {
816
+ const p = TssGrid._parseNumber(v, cfg); if (p == null) return { ok: false }; v = p;
817
+ }
818
+ }
819
+ if (t === 'dropdown') {
820
+ if (v === '') { if (cfg.allowEmpty === false) return { ok: false }; } // allowEmpty:false=必須選択(空は不可)
821
+ else if (!this._optList(c).some(o => o.value === v)) return { ok: false }; // 保存値は option の value と照合
822
+ } else if (t === 'checkbox') {
823
+ v = this._isCheckedVal(c, v) ? this._cbChecked(c) : this._cbUnchecked(c);
824
+ }
825
+ // 桁数(文字数)制限: maxLength 超過分を切り詰め(貼り付け/setValue もここで効く。入力中の打鍵は editor の maxlength でブロック)
826
+ if (cfg.maxLength > 0 && typeof v === 'string' && v.length > cfg.maxLength) v = v.slice(0, cfg.maxLength);
827
+ if (typeof cfg.validator === 'function') {
828
+ let res; try { res = cfg.validator(v, { r, c }); } catch (_) { res = true; }
829
+ if (res === false) return { ok: false, level: 'error' };
830
+ if (typeof res === 'string') return { ok: false, level: 'error', message: res }; // 文字列=そのまま本文(従来互換)
831
+ if (res && typeof res === 'object') { // { code, params?, level?, message? }=コード管理。本文は messages から解決
832
+ const m = this._resolveMessage(res.code, res.params, r, c, v);
833
+ return { ok: false, code: res.code || null, level: res.level || m.level || 'error', message: res.message || m.message };
834
+ }
835
+ }
836
+ return { ok: true, value: v };
837
+ }
838
+ // メッセージカタログ解決: code → { level, message }({label}/{value}/params を差込)。カタログ未設定/未登録は code をそのまま。
839
+ _resolveMessage(code, params, r, c, v) {
840
+ const ctx = Object.assign({ label: (this.colCfg(c).title || this._key(c) || ''), value: v }, params || {});
841
+ const interp = t => String(t).replace(/\{(\w+)\}/g, (_, k) => (ctx[k] != null ? ctx[k] : '{' + k + '}'));
842
+ const cat = this.messages;
843
+ if (typeof cat === 'function') { let t; try { t = cat(code, ctx); } catch (_) {} return { level: 'error', message: t != null ? String(t) : (code || '') }; }
844
+ if (cat && typeof cat === 'object' && code != null) {
845
+ const e = cat[code];
846
+ if (e == null) return { level: 'error', message: String(code) };
847
+ if (typeof e === 'object') return { level: e.level || 'error', message: interp(e.text != null ? e.text : (e.message != null ? e.message : code)) };
848
+ return { level: 'error', message: interp(e) };
849
+ }
850
+ return { level: 'error', message: code || '' };
851
+ }
852
+ // 現在の全セルを検証し、NG セルを [{r,c,value,message}] で返す(空セルは検証対象=必須dropdown等のみ拾う)。
853
+ // 複数エラーをプログラム側でまとめて表示したい時に。invalidMode に依存せず「今の値」を判定する。
854
+ getInvalidCells() {
855
+ const out = [];
856
+ for (let r = 0; r < this.ROWS; r++) for (let c = 0; c < this.COLS; c++) {
857
+ if (this._isHidden(c)) continue;
858
+ const v = this.data[r][c];
859
+ const res = this._coerceCell(r, c, v);
860
+ if (!res.ok) out.push({ r, c, key: this._key(c), value: v, code: res.code || null, level: res.level || 'error', message: res.message || null });
861
+ }
862
+ return out;
863
+ }
864
+ // ネイティブ入力(date/time ピッカー)を使う列か。使わない=テキスト入力。
865
+ _nativeInputType(c) {
866
+ const cfg = this.colCfg(c), t = cfg.type;
867
+ if ((t === 'date' || t === 'time') && cfg.picker !== false && !cfg.format && typeof cfg.parse !== 'function' && !cfg.hour12) return t;
868
+ return null;
869
+ }
870
+ _usesTextEditor(c) {
871
+ if (this.colCfg(c).editor) return false; // カスタムエディタは共有 input を使わない
872
+ const t = this.colType(c);
873
+ return t !== 'checkbox' && t !== 'dropdown' && !this._nativeInputType(c);
874
+ }
875
+ // 保存値 → セル表示文字列(format 適用)。getData は保存値のまま。
876
+ _displayValue(r, c) {
877
+ const cfg = this.colCfg(c), v = this.data[r][c];
878
+ if (v === '') return v;
879
+ if (typeof cfg.format === 'function') {
880
+ try { const out = cfg.format(v, { r, c }); return out == null ? '' : String(out); } catch (_) { return v; }
881
+ }
882
+ if (typeof cfg.format === 'string') return TssGrid._applyDatePattern(v, cfg.format); // 日付パターン表示
883
+ if (cfg.type === 'number') return TssGrid._formatNumber(v, cfg); // 数値書式(カンマ/小数/前後綴り)
884
+ if (cfg.type === 'time' && cfg.hour12) return TssGrid._fmtTime12(v); // 組込 12時間表示
885
+ if (cfg.type === 'dropdown') return this._optLabel(c, v); // options が {value,label} なら表示はラベル
886
+ return v;
887
+ }
888
+ // dropdown の options を [{value,label}] に正規化(文字列なら value=label)。値↔表示を分けられる。
889
+ _optList(c) {
890
+ return (this.colCfg(c).options || []).map(o =>
891
+ (o && typeof o === 'object') ? { value: String(o.value), label: String(o.label != null ? o.label : o.value) }
892
+ : { value: String(o), label: String(o) });
893
+ }
894
+ _optLabel(c, value) { const o = this._optList(c).find(x => x.value === value); return o ? o.label : value; }
895
+ // 編集開始時にエディタへ入れる文字列。number は Excel風に「素の数値」を出す(書式は表示専用)。
896
+ // ネイティブ(date/time picker)は保存値、それ以外のテキスト編集は表示形。
897
+ _editText(r, c) {
898
+ if (this._nativeInputType(c)) return this.data[r][c];
899
+ if (this.colType(c) === 'number') return this.data[r][c];
900
+ return this._displayValue(r, c);
901
+ }
902
+ // td の中身を型に応じて描画。data は確定済み前提。空セルは placeholder(あれば)を表示。
903
+ _cellHTML(r, c) {
904
+ if (this.colType(c) === 'checkbox')
905
+ return '<input type="checkbox" class="tg-cb" tabindex="-1"' + (this._isReadOnly(r, c) ? ' disabled' : '') + (this._isCheckedVal(c, this.data[r][c]) ? ' checked' : '') + '>';
906
+ if (this.data[r][c] === '' && this._placeholder(c) != null) return TssGrid.esc(this._placeholder(c));
907
+ // html:true の列は表示だけ生HTML(値はテキスト保存のまま)。エスケープを外す=XSS責任は format/値の提供側。
908
+ if (this.colCfg(c).html) return this._displayValue(r, c);
909
+ return TssGrid.esc(this._displayValue(r, c));
910
+ }
911
+ // 条件付き書式: 列版 columns[c].cellClass(value,{r,c,row,src}) + 全体版 cellClass(r,c,value,row,src)。
912
+ // src = 元レコード(オブジェクトデータ時。非表示フィールドも見える=休日区分など隠し列基準の色付けに)。
913
+ _cellClasses(r, c) {
914
+ const out = [], v = this.data[r][c], src = this._src ? this._src[r] : null;
915
+ const push = x => { if (x) out.push(...(Array.isArray(x) ? x : String(x).split(/\s+/))); };
916
+ const cc = this.colCfg(c).cellClass;
917
+ if (typeof cc === 'function') { try { push(cc(v, { r, c, row: this.data[r], src })); } catch (_) {} }
918
+ if (typeof this.cellClass === 'function') { try { push(this.cellClass(r, c, v, this.data[r], src)); } catch (_) {} } // row=値配列, src=元レコード
919
+ return out.filter(Boolean);
920
+ }
921
+ // 条件付きクラスをセルに反映(前回分 data-ccls を外してから付け直す=値変更で追従)。
922
+ _applyCellClass(td, r, c) {
923
+ const prev = td.dataset.ccls ? td.dataset.ccls.split(' ') : [];
924
+ for (const k of prev) if (k) td.classList.remove(k);
925
+ const next = this._cellClasses(r, c);
926
+ for (const k of next) td.classList.add(k);
927
+ if (next.length) td.dataset.ccls = next.join(' '); else delete td.dataset.ccls;
928
+ }
929
+ // セル単位のインラインスタイル: 列版 columns[c].cellStyle(value,{r,c,row,src}) + 全体版 cellStyle(r,c,value,row,src)。
930
+ // 返り値は CSSプロパティのオブジェクト {'background':'#fde','--pct':'42%'} か文字列 'background:#fde;--pct:42%'。
931
+ // cellClass(クラス=離散)では出せない**連続値の背景色・データバー幅(CSS変数)**などに(プロパティ名は kebab/標準名 or --var)。
932
+ _cellStyleObj(r, c) {
933
+ const out = {}, v = this.data[r][c], src = this._src ? this._src[r] : null;
934
+ const merge = res => {
935
+ if (!res) return;
936
+ if (typeof res === 'string') res.split(';').forEach(d => { const i = d.indexOf(':'); if (i > 0) out[d.slice(0, i).trim()] = d.slice(i + 1).trim(); });
937
+ else if (typeof res === 'object') for (const k in res) out[k] = res[k];
938
+ };
939
+ const cs = this.colCfg(c).cellStyle;
940
+ if (typeof cs === 'function') { try { merge(cs(v, { r, c, row: this.data[r], src })); } catch (_) {} }
941
+ if (typeof this.cellStyle === 'function') { try { merge(this.cellStyle(r, c, v, this.data[r], src)); } catch (_) {} }
942
+ return out;
943
+ }
944
+ // インラインスタイルをセルに反映(前回分 data-cstyle のプロパティを消してから付け直す=値変更で追従)。
945
+ _applyCellStyle(td, r, c) {
946
+ const prev = td.dataset.cstyle ? td.dataset.cstyle.split(' ') : [];
947
+ for (const k of prev) if (k) td.style.removeProperty(k);
948
+ const obj = this._cellStyleObj(r, c), keys = Object.keys(obj);
949
+ for (const k of keys) td.style.setProperty(k, obj[k]);
950
+ if (keys.length) td.dataset.cstyle = keys.join(' '); else delete td.dataset.cstyle;
951
+ }
952
+ _renderCell(r, c) {
953
+ if (this._merges && this._isCovered(r, c)) return; // 従属セルは描画対象なし(cellEl はアンカーへ解決=誤って上書きしない)
954
+ const td = this.cellEl(r, c); if (!td) return;
955
+ const ph = (this.data[r][c] === '' && this.colType(c) !== 'checkbox') ? this._placeholder(c) : null;
956
+ if (this.colType(c) === 'checkbox' || (this.colCfg(c).html && ph == null)) td.innerHTML = this._cellHTML(r, c);
957
+ else td.textContent = ph != null ? ph : this._displayValue(r, c);
958
+ td.classList.toggle('tg-placeholder', ph != null);
959
+ if (this._marksInvalid) {
960
+ const res = this._coerceCell(r, c, this.data[r][c]);
961
+ td.classList.toggle('tg-invalid', !res.ok);
962
+ if (!res.ok) td.dataset.errLevel = res.level || 'error'; else delete td.dataset.errLevel; // CSS で level 別トーン可
963
+ if (this.invalidTitle) { if (!res.ok) td.title = res.message || '入力値が不正です'; else td.removeAttribute('title'); }
964
+ }
965
+ if (this.cellClass || this.colCfg(c).cellClass) this._applyCellClass(td, r, c); // 条件付き書式
966
+ if (this.cellStyle || this.colCfg(c).cellStyle) this._applyCellStyle(td, r, c); // セル単位インラインスタイル
967
+ }
968
+
969
+ _colWidth(c) { return this.colW[c] != null ? this.colW[c] : this.defColW; }
970
+ _rowHeight(r) { return this.rowH[r] != null ? this.rowH[r] : this.defRowH; }
971
+ _rhW() { return this.rowHeaders ? 40 : 0; } // 行ヘッダ列の幅(非表示なら 0)
972
+ // ---- 列の非表示 ----
973
+ _isHidden(c) { return this.hiddenCols.has(c); }
974
+ _visCols() { const a = []; for (let c = 0; c < this.COLS; c++) if (!this.hiddenCols.has(c)) a.push(c); return a; }
975
+ _firstVisCol() { for (let c = 0; c < this.COLS; c++) if (!this.hiddenCols.has(c)) return c; return 0; }
976
+ _lastVisCol() { for (let c = this.COLS - 1; c >= 0; c--) if (!this.hiddenCols.has(c)) return c; return this.COLS - 1; }
977
+ // c から方向 dir(+1/-1) の次の可視列。無ければ c のまま(端)。
978
+ _stepVisCol(c, dir) { for (let n = c + dir; n >= 0 && n < this.COLS; n += dir) if (!this.hiddenCols.has(n)) return n; return c; }
979
+ // c が隠れていれば最寄りの可視列へスナップ。
980
+ _snapVisCol(c) {
981
+ if (!this.hiddenCols.has(c)) return c;
982
+ for (let d = 1; d < this.COLS; d++) {
983
+ if (c + d < this.COLS && !this.hiddenCols.has(c + d)) return c + d;
984
+ if (c - d >= 0 && !this.hiddenCols.has(c - d)) return c - d;
985
+ }
986
+ return c;
987
+ }
988
+ // ---- セル結合(矩形: colspan×rowspan)。アンカー=(r,c)=左上が値・編集・選択の対象。覆われたセルは描画スキップ+読取専用。
989
+ // 解決ヘルパは描画/ナビ/選択で共有。実際の merge/unmerge API と値クリア意味論は plugins/tss-merge が担う。
990
+ // _merges が null の間は全分岐をショートサーキット=結合未使用なら一切コスト無し。横結合は rowspan=1。
991
+ _mergeAt(r, c) { // (r,c) を覆う結合(アンカー自身も含む)。無ければ null
992
+ if (!this._merges) return null;
993
+ for (const m of this._merges) { const rs = m.rowspan || 1; if (r >= m.r && r < m.r + rs && c >= m.c && c < m.c + m.colspan) return m; }
994
+ return null;
995
+ }
996
+ _isCovered(r, c) { const m = this._mergeAt(r, c); return !!m && !(m.r === r && m.c === c); } // アンカー以外=従属セル
997
+ _anchorOf(r, c) { const m = this._mergeAt(r, c); return m ? { r: m.r, c: m.c } : { r, c }; }
998
+ _colspanAt(r, c) { const m = this._mergeAt(r, c); return (m && m.r === r && m.c === c) ? m.colspan : 1; }
999
+ _rowspanAt(r, c) { const m = this._mergeAt(r, c); return (m && m.r === r && m.c === c) ? (m.rowspan || 1) : 1; }
1000
+ // c から方向 dir の次セル(結合幅を跨ぐ)。アンカー/従属なら結合の端から踏み出す=merge 内で詰まらない。
1001
+ _stepColM(r, c, dir) {
1002
+ if (!this._merges) return this._stepVisCol(c, dir);
1003
+ const m = this._mergeAt(r, c);
1004
+ return this._stepVisCol(m ? (dir > 0 ? m.c + m.colspan - 1 : m.c) : c, dir);
1005
+ }
1006
+ // r から方向 dir の次行(結合高を跨ぐ)。行は非表示概念が無いので素直に ±1(端は setActive の clampR 任せ)。
1007
+ _stepRowM(r, c, dir) {
1008
+ if (!this._merges) return r + dir;
1009
+ const m = this._mergeAt(r, c);
1010
+ return (m ? (dir > 0 ? m.r + (m.rowspan || 1) - 1 : m.r) : r) + dir;
1011
+ }
1012
+ // ソートは結合中ロック(値で行順を組み替えると結合帯が散らばり分かりにくい=既定の方針)。
1013
+ _sortLocked() { return !!(this._merges && this._merges.length); }
1014
+ // 縦結合(rowspan>1)が在ると行移動は禁止(行順を崩すと結合が分断=Handsontable の auto-split 相当を避ける)。
1015
+ // 横結合のみなら _applyRowOrder が座標追従するので移動可。
1016
+ _hasVMerge() { return !!(this._merges && this._merges.some(m => (m.rowspan || 1) > 1)); }
1017
+ isColumnHidden(c) { return this.hiddenCols.has(this.clampC(c)); }
1018
+ getHiddenColumns() { return [...this.hiddenCols].sort((a, b) => a - b); }
1019
+ hideColumn(c) {
1020
+ c = this.clampC(c);
1021
+ if (this.hiddenCols.has(c) || this._visCols().length <= 1) return; // 最低1列は残す
1022
+ this.hiddenCols.add(c);
1023
+ this.buildTable(); this.setActive(this.active.r, this._snapVisCol(this.active.c));
1024
+ }
1025
+ showColumn(c) {
1026
+ c = this.clampC(c);
1027
+ if (!this.hiddenCols.has(c)) return;
1028
+ this.hiddenCols.delete(c);
1029
+ this.buildTable(); this.setActive(this.active.r, this.active.c);
1030
+ }
1031
+ toggleColumn(c) { this.hiddenCols.has(this.clampC(c)) ? this.showColumn(c) : this.hideColumn(c); }
1032
+ _placeholder(c) { const p = this.colCfg(c).placeholder; return p != null ? String(p) : this.placeholder; }
1033
+ _totalWidth() { let w = this._rhW(); for (let c = 0; c < this.COLS; c++) if (!this._isHidden(c)) w += this._colWidth(c); return w; }
1034
+ // テーブルに合計幅を明示。これが無いと狭い枠で fixed レイアウトが列を比例圧縮し、
1035
+ // 保存値(colW)と実描画幅がズレてリサイズのプレビュー線が外れる。
1036
+ _applyTableWidth() { this.table.style.width = this._totalWidth() + 'px'; }
1037
+ // 固定列の sticky left オフセットを設定(rowhead 40px + 先行列幅の累積)。幅変更後も呼ぶ。
1038
+ _applyFrozen() {
1039
+ if (!this.frozenCols) return;
1040
+ const leftOf = [];
1041
+ let left = this._rhW();
1042
+ for (let c = 0; c < this.COLS; c++) {
1043
+ leftOf[c] = left; // 列 c の sticky-left オフセット
1044
+ if (this._isHidden(c)) continue; // 隠し列は幅0・固定対象外
1045
+ if (c < this.frozenCols) {
1046
+ const th = this.table.querySelector('thead th[data-c="' + c + '"]');
1047
+ if (th) th.style.left = left + 'px';
1048
+ this.table.querySelectorAll('td[data-c="' + c + '"]').forEach(td => td.style.left = left + 'px');
1049
+ }
1050
+ left += this._colWidth(c);
1051
+ }
1052
+ // ネストヘッダ: 固定範囲のグループ見出しも先頭列のオフセットで sticky-left に
1053
+ this.table.querySelectorAll('thead th.tg-grouphdr.tg-frozen').forEach(th => {
1054
+ const c0 = +th.dataset.c0;
1055
+ if (c0 >= 0 && leftOf[c0] != null) th.style.left = leftOf[c0] + 'px';
1056
+ });
1057
+ }
1058
+ // 左から n 列を固定(実行時変更可)
1059
+ freezeCols(n) { this.frozenCols = Math.max(0, Math.min(this.COLS, n | 0)); this.buildTable(); this.setActive(this.active.r, this.active.c); }
1060
+ // 行固定: 上から frozenRows 行を sticky top で固定。top はヘッダ高+上の固定行高の累積。
1061
+ _applyFrozenRows() {
1062
+ if (!this.frozenRows) return;
1063
+ let top = this.colHeaders ? (this._theadH || 0) : 0;
1064
+ for (let r = 0; r < this.frozenRows && r < this.ROWS; r++) {
1065
+ const tr = this.table.querySelector('tr[data-r="' + r + '"]');
1066
+ if (tr) tr.querySelectorAll('th,td').forEach(cell => cell.style.top = top + 'px');
1067
+ top += this._rowHeight(r);
1068
+ }
1069
+ }
1070
+ // 上から n 行を固定(実行時変更可)
1071
+ freezeRows(n) { this.frozenRows = Math.max(0, Math.min(this.ROWS, n | 0)); this.buildTable(); this.setActive(this.active.r, this.active.c); }
1072
+ // リーフ(実列に対応する)ヘッダ行を生成。labels 指定時はその文字列/ {label} を見出しに使う。
1073
+ _leafHeaderTr(withCorner, labels) {
1074
+ let html = '<tr>' + (withCorner ? '<th class="rowhead corner"></th>' : '');
1075
+ for (let c = 0; c < this.COLS; c++) {
1076
+ const cls = [];
1077
+ if (c < this.frozenCols) { cls.push('tg-frozen'); if (c === this.frozenCols - 1) cls.push('tg-frozen-edge'); }
1078
+ if (this._isHidden(c)) cls.push('tg-hidden');
1079
+ const a = cls.length ? ' class="' + cls.join(' ') + '"' : '';
1080
+ const grip = (this.resizeCols && this.colCfg(c).resizable !== false) ? '<div class="tg-colgrip"></div>' : '';
1081
+ const headCb = (this.colType(c) === 'checkbox' && this.colCfg(c).headerCheckbox !== false) ? '<input type="checkbox" class="tg-cb tg-head-cb" tabindex="-1">' : '';
1082
+ let lbl = labels ? labels[c] : this.headers[c];
1083
+ if (lbl && typeof lbl === 'object') lbl = lbl.label != null ? lbl.label : '';
1084
+ html += '<th data-c="' + c + '"' + a + '>' + headCb + TssGrid.esc(lbl != null ? lbl : '') + grip + '</th>';
1085
+ }
1086
+ return html + '</tr>';
1087
+ }
1088
+ // ネストヘッダ: 各ヘッダ行を異なる top で sticky(段が重ならないように積む)。
1089
+ _applyHeaderSticky() {
1090
+ if (!this.nestedHeaders) return;
1091
+ let top = 0;
1092
+ this.table.querySelectorAll('thead tr').forEach(tr => {
1093
+ tr.querySelectorAll('th').forEach(th => { th.style.top = top + 'px'; });
1094
+ top += tr.offsetHeight;
1095
+ });
1096
+ }
1097
+ buildTable() {
1098
+ this._selhdrFg = null; // 選択ヘッダ文字色キャッシュ破棄(テーマ変更に追従)
1099
+ const rh = this.rowHeaders;
1100
+ let html = '<colgroup>' + (rh ? '<col style="width:40px">' : '');
1101
+ for (let c = 0; c < this.COLS; c++) html += '<col data-c="' + c + '"' + (this._isHidden(c) ? ' class="tg-hidden"' : '') + ' style="width:' + (this._isHidden(c) ? 0 : this._colWidth(c)) + 'px">';
1102
+ html += '</colgroup>';
1103
+ if (this.colHeaders) {
1104
+ html += '<thead>';
1105
+ if (this.nestedHeaders) {
1106
+ const nRows = this.nestedHeaders.length, leaf = this.nestedHeaders[nRows - 1];
1107
+ this.nestedHeaders.slice(0, -1).forEach((row, ri) => { // 上段グループ行(colspan)
1108
+ html += '<tr class="tg-group-row">' + (rh && ri === 0 ? '<th class="rowhead corner" rowspan="' + nRows + '"></th>' : '');
1109
+ let c = 0;
1110
+ for (const spec of row) {
1111
+ const isObj = spec && typeof spec === 'object';
1112
+ const span = Math.max(1, (isObj && spec.colspan) ? spec.colspan : 1);
1113
+ const label = isObj ? (spec.label != null ? spec.label : '') : (spec != null ? spec : '');
1114
+ const c1 = c + span - 1;
1115
+ // 固定列の範囲に収まるグループ見出しは固定列に追従(left は _applyFrozen が設定)。境界をまたぐ群は固定しない。
1116
+ const froz = (c1 < this.frozenCols) ? (' tg-frozen' + (c1 === this.frozenCols - 1 ? ' tg-frozen-edge' : '')) : '';
1117
+ html += '<th class="tg-grouphdr' + froz + '" colspan="' + span + '" data-c0="' + c + '" data-c1="' + c1 + '">' + TssGrid.esc(label) + '</th>';
1118
+ c += span;
1119
+ }
1120
+ html += '</tr>';
1121
+ });
1122
+ html += this._leafHeaderTr(false, leaf); // リーフ段(角は上で rowspan 済み)
1123
+ } else {
1124
+ html += this._leafHeaderTr(rh, null);
1125
+ }
1126
+ html += '</thead>';
1127
+ }
1128
+ html += '<tbody>';
1129
+ for (let r = 0; r < this.ROWS; r++) {
1130
+ const rgrip = (rh && this.resizeRows) ? '<div class="tg-rowgrip"></div>' : '';
1131
+ const dgrip = (rh && this.rowReorder && !this._hasVMerge()) ? '<span class="tg-rowmove" title="ドラッグで行を移動">⠿</span>' : ''; // 横結合は行と一緒に移動可/縦結合中は不可
1132
+ const rcls = (r < this.frozenRows) ? (' tg-frozen-row' + (r === this.frozenRows - 1 ? ' tg-frozen-row-edge' : '')) : ''; // 行固定
1133
+ html += '<tr data-r="' + r + '" class="tg-row' + rcls + '" style="height:' + this._rowHeight(r) + 'px">' + (rh ? '<th class="rowhead" data-r="' + r + '">' + dgrip + (r + 1) + rgrip + '</th>' : '');
1134
+ for (let c = 0; c < this.COLS; c++) {
1135
+ if (this._merges && this._isCovered(r, c)) continue; // 結合に覆われた従属セルは td を出さない(アンカーが colspan で覆う)
1136
+ const cs = this._merges ? this._colspanAt(r, c) : 1; // 結合アンカーは colspan で従属列ぶんを覆う
1137
+ const rs = this._merges ? this._rowspanAt(r, c) : 1; // 縦結合は rowspan で従属行ぶんを覆う
1138
+ const cls = [];
1139
+ if (cs > 1 || rs > 1) cls.push('tg-merged'); // 結合アンカー(CSS で中央寄せ等のフック)
1140
+ if (c < this.frozenCols) { cls.push('tg-frozen'); if (c === this.frozenCols - 1) cls.push('tg-frozen-edge'); }
1141
+ if (this._isHidden(c)) cls.push('tg-hidden'); // 非表示列
1142
+ if (this.colType(c) === 'dropdown') cls.push('tg-dropdown'); // ▾ キャレット表示用
1143
+ if (this.colType(c) === 'number') cls.push('tg-num'); // 数値は右寄せ
1144
+ if (this._isReadOnly(r, c)) cls.push('tg-readonly'); // 読み取り専用(淡色+カーソル既定)
1145
+ if (this.colCfg(c).wordWrap) cls.push('tg-wrap-cell'); // 列単位の折り返し
1146
+ if (this.data[r][c] === '' && this._placeholder(c) != null) cls.push('tg-placeholder'); // 空セルのヒント
1147
+ const al = this._cellAlign(r, c); // 整列(列既定+セル上書き)
1148
+ if (al.h) cls.push('tg-h-' + al.h); if (al.v) cls.push('tg-v-' + al.v);
1149
+ let titleAttr = '';
1150
+ if (this._marksInvalid) { const _iv = this._coerceCell(r, c, this.data[r][c]); if (!_iv.ok) { cls.push('tg-invalid'); titleAttr = ' data-err-level="' + TssGrid.esc(_iv.level || 'error') + '"'; if (this.invalidTitle) titleAttr += ' title="' + TssGrid.esc(_iv.message || '入力値が不正です') + '"'; } }
1151
+ const ed = this.colCfg(c).editor; // カスタムエディタが icon を申告していれば右端に表示
1152
+ const ico = (ed && ed.icon) ? ' data-ico="' + TssGrid.esc(ed.icon) + '"' : '';
1153
+ if (ico) cls.push('tg-haspicker');
1154
+ const ccls = (this.cellClass || this.colCfg(c).cellClass) ? this._cellClasses(r, c) : []; // 条件付き書式
1155
+ for (const k of ccls) cls.push(k);
1156
+ const dccls = ccls.length ? ' data-ccls="' + TssGrid.esc(ccls.join(' ')) + '"' : '';
1157
+ let sty = ''; // セル単位インラインスタイル(連続色・データバー幅などCSS変数も)
1158
+ if (this.cellStyle || this.colCfg(c).cellStyle) {
1159
+ const so = this._cellStyleObj(r, c), sk = Object.keys(so);
1160
+ if (sk.length) { let css = ''; for (const k of sk) css += k + ':' + so[k] + ';'; sty = ' style="' + TssGrid.esc(css) + '" data-cstyle="' + TssGrid.esc(sk.join(' ')) + '"'; }
1161
+ }
1162
+ const a = cls.length ? ' class="' + cls.join(' ') + '"' : '';
1163
+ const csAttr = (cs > 1 ? ' colspan="' + cs + '"' : '') + (rs > 1 ? ' rowspan="' + rs + '"' : '');
1164
+ html += '<td' + a + ico + dccls + sty + titleAttr + csAttr + ' data-r="' + r + '" data-c="' + c + '">' + this._cellHTML(r, c) + '</td>';
1165
+ }
1166
+ html += '</tr>';
1167
+ }
1168
+ html += '</tbody>';
1169
+ this.table.innerHTML = html;
1170
+ const thd = this.table.querySelector('thead');
1171
+ this._theadH = (this.colHeaders && thd) ? thd.offsetHeight : 0; // ヘッダ高をキャッシュ(クリップ計算用)
1172
+ this._applyTableWidth();
1173
+ this._applyStretch();
1174
+ this._applyHeaderSticky();
1175
+ this._applyFrozen();
1176
+ this._applyFrozenRows();
1177
+ this._syncHeaderCheckboxes();
1178
+ this._buildGeomCache(); // 実測幾何キャッシュ(全 _apply 後=幅高が確定してから)
1179
+ this.renderDump();
1180
+ }
1181
+ // ---- checkbox 列の全選択(ヘッダ) ----
1182
+ // c 以外の列が全部空=「追加用の空行」。全選択や状態判定はこの行を対象外にする
1183
+ // (在庫だけ ON にして空行が中身ありに化け、空行が増殖するのを防ぐ)。
1184
+ _rowBlankExcept(r, c) {
1185
+ for (let cc = 0; cc < this.COLS; cc++) { if (cc === c) continue; if (this.data[r][cc] !== '') return false; }
1186
+ return true;
1187
+ }
1188
+ _columnCheckState(c) {
1189
+ let checked = 0, total = 0;
1190
+ for (let r = 0; r < this.ROWS; r++) {
1191
+ if (this._rowBlankExcept(r, c)) continue;
1192
+ total++; if (this._isCheckedVal(c, this.data[r][c])) checked++;
1193
+ }
1194
+ return total === 0 ? 'none' : (checked === total ? 'all' : (checked === 0 ? 'none' : 'some'));
1195
+ }
1196
+ // ヘッダの全選択チェックボックスの見た目を現在の列状態に同期(some は indeterminate)。
1197
+ _syncHeaderCheckboxes() {
1198
+ if (!this.colHeaders) return;
1199
+ for (let c = 0; c < this.COLS; c++) {
1200
+ if (this.colType(c) !== 'checkbox' || this.colCfg(c).headerCheckbox === false) continue;
1201
+ const cb = this.table.querySelector('thead th[data-c="' + c + '"] .tg-head-cb');
1202
+ if (!cb) continue;
1203
+ const st = this._columnCheckState(c);
1204
+ cb.checked = st === 'all'; cb.indeterminate = st === 'some';
1205
+ }
1206
+ }
1207
+ // 列 c の checkbox を全 ON/OFF(readOnly はスキップ)。1コマンドで履歴に積む=Undo 一発。
1208
+ setColumnChecked(c, flag) {
1209
+ if (this.colType(c) !== 'checkbox') return;
1210
+ const val = flag ? this._cbChecked(c) : this._cbUnchecked(c);
1211
+ const before = this.snapSel(), changes = [];
1212
+ for (let r = 0; r < this.ROWS; r++) { if (this._isReadOnly(r, c) || this._rowBlankExcept(r, c)) continue; this.setCell(r, c, val, changes); }
1213
+ this.pushCmd(changes, before, this.snapSel(), 'edit');
1214
+ this._syncHeaderCheckboxes();
1215
+ }
1216
+ // ヘッダのチェックボックスクリック: 全 ON or 全 OFF をトグル(全部 ON なら OFF、それ以外は ON)。
1217
+ _toggleColumnAll(c) {
1218
+ if (this.colType(c) !== 'checkbox') return;
1219
+ this.setColumnChecked(c, this._columnCheckState(c) !== 'all');
1220
+ }
1221
+ // 列ストレッチ: 余白を埋めるよう col 幅を伸ばす(枠に幅制約がある時だけ効く)。colW は変えない。
1222
+ _applyStretch() {
1223
+ if (this.stretchH === 'none' || !this.stretchH) return;
1224
+ const avail = this.wrap ? this.wrap.clientWidth : 0;
1225
+ if (!avail) return;
1226
+ const base = this._rhW(), widths = [];
1227
+ let sum = 0;
1228
+ for (let c = 0; c < this.COLS; c++) { const wd = this._colWidth(c); widths.push(wd); sum += wd; }
1229
+ const extra = avail - base - sum;
1230
+ if (extra <= 0) return; // 余白なし → 通常(_applyTableWidth のまま)
1231
+ if (this.stretchH === 'last') widths[this.COLS - 1] += extra;
1232
+ else { // 'all': ほぼ均等に配分し、端数は最終列へ
1233
+ const add = Math.floor(extra / this.COLS);
1234
+ for (let c = 0; c < this.COLS; c++) widths[c] += add;
1235
+ widths[this.COLS - 1] += extra - add * this.COLS;
1236
+ }
1237
+ for (let c = 0; c < this.COLS; c++) { const col = this.table.querySelector('colgroup col[data-c="' + c + '"]'); if (col) col.style.width = widths[c] + 'px'; }
1238
+ this.table.style.width = avail + 'px';
1239
+ }
1240
+
1241
+ // セル(r,c)の幾何を「overlay 座標系(wrap padding box 基準・スクロール込みの content 座標)」で返す唯一の窓口。
1242
+ // place/placeRect/コピー枠/フィル枠 すべてここを通す=座標変換式を一元化(散らばると frozen/overlay のズレ温床)。
1243
+ // 現状は DOM 実測(getBoundingClientRect)。border 分(clientLeft/Top)を引いて枠に正確に合わせる
1244
+ // (引かないと罫線の太さ分だけ右下にズレ、最終列/行で 1px はみ出してスクロールバーが出る)。
1245
+ // 将来 step2: ビルド時に実測キャッシュした幾何へ切替(DOM非依存=仮想スクロールの土台。stretch/wordWrap/border で
1246
+ // 純モデルは合わないので "一から計算" でなく "実測キャッシュ"。切替時は実Chrome で現状とのピクセル一致を確認)。
1247
+ _cellRect(r, c) {
1248
+ const w = this.wrap.getBoundingClientRect(), t = this.cellEl(r, c).getBoundingClientRect();
1249
+ const left = t.left - w.left - this.wrap.clientLeft + this.wrap.scrollLeft;
1250
+ const top = t.top - w.top - this.wrap.clientTop + this.wrap.scrollTop;
1251
+ return { left, top, width: t.width, height: t.height };
1252
+ }
1253
+ // ハイブリッド: 固定セル(frozen行/列)は常に描画されている=DOM実測(sticky×スクロールをタダで正しく処理)。
1254
+ // 非固定セルはモデル(キャッシュ)=DOM非依存(画面外でも座標が出る=仮想スクロールの土台)。
1255
+ // 非固定セルは静止時もスクロール中も model==DOM を実機実証済み(temp/hybrid-test.js)=全DOMとピクセル不変。
1256
+ _cellRectAuto(r, c) {
1257
+ return (r < this.frozenRows || c < this.frozenCols) ? this._cellRect(r, c) : this._cellRectModel(r, c);
1258
+ }
1259
+ // 明示的に幾何キャッシュを取り直す公開API(画像/フォント等の遅延レイアウト後に呼ぶ保険。
1260
+ // 通常は buildTable/_reflow(ResizeObserver が table/wrap を監視)で自動更新される)。
1261
+ refreshGeometry() { this._buildGeomCache(); this.updateSelectionUI(); }
1262
+ place(elem, td) {
1263
+ const r = +td.dataset.r, c = +td.dataset.c, R = this._cellRectAuto(r, c);
1264
+ elem.style.left = R.left + 'px'; elem.style.top = R.top + 'px';
1265
+ elem.style.width = R.width + 'px'; elem.style.height = R.height + 'px';
1266
+ this._clipOverlay(elem, R.left, R.top, c, r);
1267
+ }
1268
+ placeRect(elem, r0, c0, r1, c1) {
1269
+ const A = this._cellRectAuto(r0, c0), B = this._cellRectAuto(r1, c1);
1270
+ elem.style.left = A.left + 'px'; elem.style.top = A.top + 'px';
1271
+ elem.style.width = (B.left + B.width - A.left) + 'px'; elem.style.height = (B.top + B.height - A.top) + 'px';
1272
+ this._clipOverlay(elem, A.left, A.top, c0, r0);
1273
+ }
1274
+ // ---- 幾何キャッシュ(step2: DOM非依存幾何の土台)----
1275
+ // buildTable / _reflow で1回だけ実測して列左・行上の累積を作る。
1276
+ // 重要: sticky(ヘッダ/固定行列)はスクロール時に実位置がズレるので「位置」を実測しない。
1277
+ // 代わりに「行高・列幅(スクロール不変)」を実測し、原点(_theadH / _rhW)から累積=スクロール非依存。
1278
+ // 行高は wordWrap で、列幅は stretch の DOM 改変で可変なので、モデル計算でなく実測が要る。
1279
+ _buildGeomCache() {
1280
+ if (!this.wrap || !this.table) { this._rowTops = this._colLefts = null; return; }
1281
+ const trs = this.table.querySelectorAll('tbody tr');
1282
+ const RT = new Array(this.ROWS + 1); RT[0] = this._theadH || 0;
1283
+ for (let r = 0; r < this.ROWS; r++) { const tr = trs[r]; RT[r + 1] = RT[r] + (tr ? tr.getBoundingClientRect().height : this._rowHeight(r)); }
1284
+ this._rowTops = RT;
1285
+ // 列幅: リーフヘッダ th(マージ無しで確実)→ 無ければ colgroup col。stretch 後の実幅を拾う。
1286
+ const colEl = c => (this.colHeaders ? this.table.querySelector('thead th[data-c="' + c + '"]') : null) || this.table.querySelector('colgroup col[data-c="' + c + '"]');
1287
+ const CL = new Array(this.COLS + 1); CL[0] = this._rhW();
1288
+ for (let c = 0; c < this.COLS; c++) { const el = colEl(c); CL[c + 1] = CL[c] + (el ? el.getBoundingClientRect().width : (this._isHidden(c) ? 0 : this._colWidth(c))); }
1289
+ this._colLefts = CL;
1290
+ }
1291
+ // キャッシュからセル幾何を返す(_cellRect と同座標系・DOM非依存)。covered はアンカーの矩形に解決。
1292
+ _cellRectModel(r, c) {
1293
+ const L = this._colLefts, T = this._rowTops;
1294
+ if (!L || !T) return this._cellRect(r, c); // 未構築なら DOM 実測へフォールバック
1295
+ const a = this._merges ? this._anchorOf(r, c) : { r, c };
1296
+ const cs = this._merges ? this._colspanAt(a.r, a.c) : 1, rs = this._merges ? this._rowspanAt(a.r, a.c) : 1;
1297
+ const left = L[a.c], top = T[a.r];
1298
+ return { left, top, width: L[Math.min(a.c + cs, this.COLS)] - left, height: T[Math.min(a.r + rs, this.ROWS)] - top };
1299
+ }
1300
+ // 開発用検証: モデル幾何(キャッシュ) vs DOM実測 の最大ズレ(px)と最悪セル。
1301
+ // 実Chrome で grid._geomDelta() を各デモ(frozen / nestedHeaders / merge / wordWrap)で実行し max≈0 を確認 →
1302
+ // place/placeRect を _cellRectModel へ切替(border-collapse 由来のズレが出たら補正項を入れる)。
1303
+ _geomDelta() {
1304
+ let max = 0, worst = null;
1305
+ for (let r = 0; r < this.ROWS; r++) for (let c = 0; c < this.COLS; c++) {
1306
+ if (this._merges && this._isCovered(r, c)) continue;
1307
+ const d = this._cellRect(r, c), m = this._cellRectModel(r, c);
1308
+ const e = Math.max(Math.abs(d.left - m.left), Math.abs(d.top - m.top), Math.abs(d.width - m.width), Math.abs(d.height - m.height));
1309
+ if (e > max) { max = e; worst = { r, c, dom: d, model: m }; }
1310
+ }
1311
+ return { max, worst };
1312
+ }
1313
+ // 固定ペイン: 左幅(行ヘッダ+frozenCols) と 上高(ヘッダ行+frozenRows)。上は buildTable でキャッシュした
1314
+ // ヘッダ高(_theadH)を使う(スクロール毎の offsetHeight 再読込を避ける)。
1315
+ _frozenPaneW() { let w = this._rhW(); for (let c = 0; c < this.frozenCols && c < this.COLS; c++) if (!this._isHidden(c)) w += this._colWidth(c); return w; }
1316
+ _frozenPaneH() { let h = this.colHeaders ? (this._theadH || 0) : 0; for (let r = 0; r < this.frozenRows && r < this.ROWS; r++) h += this._rowHeight(r); return h; }
1317
+ // 非固定セルのオーバーレイが固定領域の下に潜った分をクリップ(枠の漏れ防止)。
1318
+ // 左の固定領域=行番号列+固定列(_frozenPaneW)/上の固定領域=ヘッダ行+固定行(_frozenPaneH)。
1319
+ // どちらも「常時 sticky な行番号列/ヘッダ」を含むので、frozenCols/frozenRows が 0 でもクリップが効く(縦横対称)。
1320
+ // 固定セル自身(c0<frozenCols / r0<frozenRows)はその辺をクリップしない=枠を見せる従来挙動を維持。
1321
+ _clipOverlay(elem, left, top, c0, r0) {
1322
+ let cl = 0, ct = 0;
1323
+ const leftFixed = this._frozenPaneW(); // 行番号列 + 固定列
1324
+ if (leftFixed > 0 && c0 >= this.frozenCols) cl = Math.max(0, (this.wrap.scrollLeft + leftFixed) - left);
1325
+ const topFixed = this._frozenPaneH(); // ヘッダ行 + 固定行
1326
+ if (topFixed > 0 && r0 >= this.frozenRows) ct = Math.max(0, (this.wrap.scrollTop + topFixed) - top);
1327
+ elem.style.clipPath = (cl || ct) ? ('inset(' + ct + 'px 0 0 ' + cl + 'px)') : '';
1328
+ }
1329
+ rectRange() {
1330
+ return {
1331
+ r0: Math.min(this.active.r, this.extent.r), r1: Math.max(this.active.r, this.extent.r),
1332
+ c0: Math.min(this.active.c, this.extent.c), c1: Math.max(this.active.c, this.extent.c),
1333
+ };
1334
+ }
1335
+ highlightRange() {
1336
+ this.table.querySelectorAll('td.sel, th.selhdr').forEach(el => el.classList.remove('sel', 'selhdr'));
1337
+ const { r0, r1, c0, c1 } = this.rectRange();
1338
+ // 選択範囲が及ぶ行/列ヘッダをハイライト(Excel 風のアフォーダンス)
1339
+ for (let c = c0; c <= c1; c++) { const th = this.table.querySelector('thead th[data-c="' + c + '"]'); if (th) th.classList.add('selhdr'); }
1340
+ for (let r = r0; r <= r1; r++) { const th = this.table.querySelector('th.rowhead[data-r="' + r + '"]'); if (th) th.classList.add('selhdr'); }
1341
+ this.wrap.style.setProperty('--tg-selhdr-fg', this._selhdrTextColor()); // 選択ヘッダ文字色を背景輝度から自動(暗背景→白)
1342
+ if (r0 === r1 && c0 === c1) return;
1343
+ for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) this.cellEl(r, c).classList.add('sel');
1344
+ }
1345
+ // 選択ヘッダの文字色を --tg-selhdr-bg の輝度から自動決定(暗背景→白/明背景→濃紺)。
1346
+ // 既定の明るい選択色は従来どおり濃紺、暗い選択ヘッダ(slate等)でも文字が読める。buildTable でキャッシュ破棄。
1347
+ _selhdrTextColor() {
1348
+ if (this._selhdrFg != null) return this._selhdrFg;
1349
+ let bg = '', m, r, g, b;
1350
+ try { bg = getComputedStyle(this.wrap).getPropertyValue('--tg-selhdr-bg').trim(); } catch (_) {}
1351
+ if (m = bg.match(/^#([0-9a-f]{3})$/i)) { r = parseInt(m[1][0] + m[1][0], 16); g = parseInt(m[1][1] + m[1][1], 16); b = parseInt(m[1][2] + m[1][2], 16); }
1352
+ else if (m = bg.match(/^#([0-9a-f]{6})$/i)) { r = parseInt(m[1].slice(0, 2), 16); g = parseInt(m[1].slice(2, 4), 16); b = parseInt(m[1].slice(4, 6), 16); }
1353
+ else if (m = bg.match(/rgba?\(([^)]+)\)/i)) { const p = m[1].split(',').map(parseFloat); r = p[0]; g = p[1]; b = p[2]; }
1354
+ const L = (r == null) ? 1 : (0.299 * r + 0.587 * g + 0.114 * b) / 255; // 知覚輝度(簡易)
1355
+ return (this._selhdrFg = L < 0.55 ? '#fff' : '#1a3a6b');
1356
+ }
1357
+ updateSelectionUI() {
1358
+ if (!this.cursor) { // 表示専用: 選択枠・範囲・フィルハンドルを出さない(選択自体は内部に保持)
1359
+ this.selbox.style.display = 'none'; this.selrange.style.display = 'none'; this.fillhandle.style.display = 'none';
1360
+ return;
1361
+ }
1362
+ this.highlightRange(); this._placeOverlays();
1363
+ }
1364
+ // 枠の位置合わせだけ(クラス操作なし)。スクロール時はこちらだけ呼ぶ=ちらつかない。
1365
+ _placeOverlays() {
1366
+ const { r0, r1, c0, c1 } = this.rectRange();
1367
+ this.placeRect(this.selrange, r0, c0, r1, c1); this.selrange.style.display = 'block';
1368
+ this.place(this.selbox, this.activeTd()); this.selbox.style.display = 'block';
1369
+ // フィルハンドルは nav か、ドロップダウンを開いている時に表示(テキスト等の編集中は隠す)
1370
+ const selOpen = this.select && this.select.style.display !== 'none';
1371
+ if (this.fillHandle && (this.mode === 'nav' || selOpen || (this._customEditor && this._customKeepHandle))) {
1372
+ const R = this._cellRectAuto(r1, c1); // 選択末尾セルの右下角(ハイブリッド経由)
1373
+ const fw = this.fillhandle.offsetWidth || 8;
1374
+ // ハンドルは角に半分はみ出す配置だが、最終列/行ではテーブル端を超えないようクランプ
1375
+ // (超えると overflow:auto の枠を押し広げてスクロールバーが出てしまう)。テーブルは
1376
+ // 枠(padding box)の原点 0 に置かれるので、上限は offsetWidth/Height - ハンドル幅。
1377
+ const maxL = this.table.offsetWidth - fw, maxT = this.table.offsetHeight - fw;
1378
+ const fhL = Math.min(R.left + R.width - 5, maxL);
1379
+ const fhT = Math.min(R.top + R.height - 5, maxT);
1380
+ this.fillhandle.style.left = fhL + 'px'; this.fillhandle.style.top = fhT + 'px';
1381
+ this.fillhandle.style.display = 'block';
1382
+ this._clipOverlay(this.fillhandle, fhL, fhT, c1, r1); // 固定ペインに潜ったら隠す
1383
+ } else this.fillhandle.style.display = 'none';
1384
+ }
1385
+ toNav() {
1386
+ this.mode = 'nav'; this.editor.className = 'tg-editor nav';
1387
+ this.editor.type = 'text'; this.editor.value = '';
1388
+ if (this.select) this.select.style.display = 'none';
1389
+ }
1390
+ toEdit(value, caretEnd) {
1391
+ if (this._isReadOnly(this.active.r, this.active.c)) return; // 読み取り専用は編集モードに入らない
1392
+ const starting = this.mode !== 'edit';
1393
+ if (starting) this.clearCopyMarquee(); // 編集開始でコピー/カットのマーキー(点線)を消す(Excel 流)
1394
+ this.mode = 'edit'; this.editor.className = 'tg-editor edit'; this.fillhandle.style.display = 'none';
1395
+ this.editor.type = this._nativeInputType(this.active.c) || 'text';
1396
+ const _ml = this.colCfg(this.active.c).maxLength; // 桁数(文字数)上限=打鍵をブロック(貼付/投入は _coerceCell で切り詰め)
1397
+ if (typeof _ml === 'number' && _ml > 0) this.editor.maxLength = _ml; else this.editor.removeAttribute('maxlength');
1398
+ if (value != null) this.editor.value = value;
1399
+ this.editor.focus();
1400
+ // setSelectionRange は text 系のみ対応(date/time input は例外を投げる)
1401
+ if (caretEnd && this.editor.type === 'text') { const l = this.editor.value.length; this.editor.setSelectionRange(l, l); }
1402
+ if (starting && this.onEditStart) { try { this.onEditStart({ r: this.active.r, c: this.active.c, value: this.editor.value }); } catch (_) {} }
1403
+ }
1404
+ _fireSelection() {
1405
+ if (!this.onSelectionChange) return;
1406
+ try { this.onSelectionChange({ range: this.rectRange(), active: { ...this.active }, extent: { ...this.extent } }); } catch (_) {}
1407
+ }
1408
+ // 選択変更の「前」フック。false を返すと選択を変えない(ナビゲーション/クリック/範囲拡張に効く)。
1409
+ _beforeSel(a, e) {
1410
+ if (!this.onBeforeSelectionChange) return true;
1411
+ const range = { r0: Math.min(a.r, e.r), c0: Math.min(a.c, e.c), r1: Math.max(a.r, e.r), c1: Math.max(a.c, e.c) };
1412
+ let r; try { r = this.onBeforeSelectionChange({ range, active: { ...a }, extent: { ...e } }); } catch (_) {}
1413
+ return r !== false;
1414
+ }
1415
+ setActive(r, c) {
1416
+ const a = { r: this.clampR(r), c: this._snapVisCol(this.clampC(c)) }; // 隠し列にはカーソルを置かない
1417
+ if (this._merges) { const an = this._anchorOf(a.r, a.c); a.r = an.r; a.c = an.c; } // 結合内はアンカーへ
1418
+ if (!this._beforeSel(a, a)) return;
1419
+ this.active = a; this.extent = { ...a };
1420
+ this.place(this.editor, this.activeTd()); this.toNav(); this.editor.focus(); this.updateSelectionUI();
1421
+ this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' });
1422
+ this._fireSelection();
1423
+ }
1424
+ setExtent(r, c) {
1425
+ const e = { r: this.clampR(r), c: this.clampC(c) };
1426
+ if (!this._beforeSel(this.active, e)) return;
1427
+ this.extent = e; this.updateSelectionUI();
1428
+ this.cellEl(this.extent.r, this.extent.c).scrollIntoView({ block: 'nearest', inline: 'nearest' });
1429
+ this._fireSelection();
1430
+ }
1431
+ selectRect(r0, c0, r1, c1) {
1432
+ const a = { r: r0, c: c0 }, e = { r: r1, c: c1 };
1433
+ if (!this._beforeSel(a, e)) return;
1434
+ this.active = a; this.extent = e;
1435
+ this.place(this.editor, this.activeTd()); this.toNav(); this.editor.focus(); this.updateSelectionUI();
1436
+ this._fireSelection();
1437
+ }
1438
+ focusAndSelect(sel) {
1439
+ this.active = { ...sel.active }; this.extent = { ...sel.extent };
1440
+ this.place(this.editor, this.activeTd()); this.toNav(); this.editor.focus(); this.updateSelectionUI();
1441
+ this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' });
1442
+ this._fireSelection();
1443
+ }
1444
+
1445
+ // ---- 行 / 列 / 全体 の選択(ヘッダクリック) ----
1446
+ selectCol(c) { c = this.clampC(c); this._colAnchor = c; this.selectRect(0, c, this.ROWS - 1, c); }
1447
+ selectRow(r) { r = this.clampR(r); this._rowAnchor = r; this.selectRect(r, 0, r, this.COLS - 1); }
1448
+ selectAll() { this._colAnchor = 0; this._rowAnchor = 0; this.selectRect(0, 0, this.ROWS - 1, this.COLS - 1); }
1449
+ _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.active = a; this.extent = e;
1450
+ this.place(this.editor, this.activeTd()); this.toNav(); this.updateSelectionUI(); this._fireSelection(); }
1451
+ _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.active = a; this.extent = e;
1452
+ this.place(this.editor, this.activeTd()); this.toNav(); this.updateSelectionUI(); this._fireSelection(); }
1453
+ // ---- 列幅 / 行高 のリサイズ ----
1454
+ setColWidth(c, w) {
1455
+ w = Math.max(this.minColW, Math.round(w)); this.colW[c] = w;
1456
+ const col = this.table.querySelector('colgroup col[data-c="' + c + '"]');
1457
+ if (col) col.style.width = w + 'px';
1458
+ this._applyTableWidth(); // 合計幅も更新(圧縮防止)
1459
+ this._applyStretch(); // ストレッチ時は余白を再配分
1460
+ this._applyFrozen(); // 固定列の left オフセットも更新
1461
+ this._applyFrozenRows(); // 固定行の top オフセットも更新
1462
+ this._reflow();
1463
+ }
1464
+ // ---- 列幅オートフィット ----
1465
+ _measureText(text, font) {
1466
+ if (this._mctx === undefined) { const cv = document.createElement('canvas'); this._mctx = cv.getContext ? cv.getContext('2d') : null; }
1467
+ if (!this._mctx) { const m = /(\d+)px/.exec(font); return String(text).length * (m ? +m[1] : 13) * 0.6; } // canvas 非対応(jsdom等)は概算
1468
+ this._mctx.font = font;
1469
+ return this._mctx.measureText(String(text)).width;
1470
+ }
1471
+ _colFont(bold) {
1472
+ const el = this.table.querySelector(bold ? 'thead th' : 'tbody td') || this.table;
1473
+ const view = this.table.ownerDocument.defaultView || window, cs = view.getComputedStyle(el);
1474
+ return (bold ? '600 ' : (cs.fontWeight || '400') + ' ') + (cs.fontSize || '13px') + ' ' + (cs.fontFamily || 'sans-serif');
1475
+ }
1476
+ // 列 c を内容に合わせて自動幅調整(min/max でクランプ)。checkbox は最小固定。
1477
+ autoSizeColumn(c) {
1478
+ if (this._isHidden(c) || this.colCfg(c).resizable === false) return;
1479
+ const cellFont = this._colFont(false), hdrFont = this._colFont(true);
1480
+ let max = (this.colHeaders && this.colType(c) !== 'checkbox') ? this._measureText(this.headers[c] || '', hdrFont) : 0;
1481
+ if (this.colType(c) !== 'checkbox') {
1482
+ for (let r = 0; r < this.ROWS; r++) {
1483
+ const v = this._displayValue(r, c);
1484
+ if (v) max = Math.max(max, this._measureText(v, cellFont));
1485
+ }
1486
+ }
1487
+ let extra = 14; // padding 6+6 + slack 2
1488
+ if (this.colType(c) === 'dropdown') extra += 14; // ▾ キャレット
1489
+ const w = this.colType(c) === 'checkbox' ? this.minColW : Math.max(this.minColW, Math.ceil(max + extra));
1490
+ const cap = this.autoColumnSizeMax || (typeof this.autoColumnSize === 'number' ? this.autoColumnSize : 0);
1491
+ this.setColWidth(c, cap ? Math.min(w, cap) : w);
1492
+ }
1493
+ autoSizeAllColumns() { for (let c = 0; c < this.COLS; c++) if (!this._isHidden(c)) this.autoSizeColumn(c); }
1494
+ setRowHeight(r, h) {
1495
+ h = Math.max(this.minRowH, Math.round(h)); this.rowH[r] = h;
1496
+ const tr = this.table.querySelector('tbody tr[data-r="' + r + '"]');
1497
+ if (tr) tr.style.height = h + 'px';
1498
+ this._reflow();
1499
+ }
1500
+ _startColResize(e) {
1501
+ e.preventDefault(); e.stopPropagation();
1502
+ const th = e.target.closest('th[data-c]'); if (!th) return;
1503
+ const c = +th.dataset.c;
1504
+ const wr = this.wrap.getBoundingClientRect(), tr = th.getBoundingClientRect();
1505
+ const edge = tr.left - wr.left + this.wrap.scrollLeft; // 列の左端(content 座標)
1506
+ this.resizing = { type: 'col', c, start: e.clientX, base: this._colWidth(c), edge };
1507
+ if (this.resizeMode !== 'live') this._showGuide('col', edge + this._colWidth(c));
1508
+ }
1509
+ _startRowResize(e) {
1510
+ e.preventDefault(); e.stopPropagation();
1511
+ const th = e.target.closest('th[data-r]'); if (!th) return;
1512
+ const r = +th.dataset.r;
1513
+ const wr = this.wrap.getBoundingClientRect(), tr = th.getBoundingClientRect();
1514
+ const edge = tr.top - wr.top + this.wrap.scrollTop; // 行の上端(content 座標)
1515
+ this.resizing = { type: 'row', r, start: e.clientY, base: this._rowHeight(r), edge };
1516
+ if (this.resizeMode !== 'live') this._showGuide('row', edge + this._rowHeight(r));
1517
+ }
1518
+ // ---- 行のドラッグ移動(つまみ) ----
1519
+ _startRowMove(e) {
1520
+ e.preventDefault(); e.stopPropagation();
1521
+ const th = e.target.closest('th[data-r]'); if (!th) return;
1522
+ if (this.mode === 'edit') this._commitActive();
1523
+ const from = +th.dataset.r, sel = this.rectRange();
1524
+ let r0 = from, r1 = from;
1525
+ if (sel.r1 > sel.r0 && from >= sel.r0 && from <= sel.r1) { r0 = sel.r0; r1 = sel.r1; } // 範囲選択内を掴んだら範囲ごと
1526
+ this.rowMove = { r0, r1, to: from };
1527
+ if (typeof document !== 'undefined') document.body.style.cursor = 'grabbing';
1528
+ }
1529
+ // ドロップ位置(行 to の上端)にガイド線を出す。to===ROWS は最下端。
1530
+ _showRowDropLine(to) {
1531
+ let pos;
1532
+ if (to >= this.ROWS) pos = this.table.offsetHeight;
1533
+ else {
1534
+ const tr = this.table.querySelector('tr[data-r="' + to + '"]');
1535
+ const wr = this.wrap.getBoundingClientRect(), b = tr.getBoundingClientRect();
1536
+ pos = b.top - wr.top + this.wrap.scrollTop;
1537
+ }
1538
+ this._showGuide('row', pos);
1539
+ }
1540
+ // 1行を to(0..ROWS の「挿入境界」)へ移動。moveRows の単一版。
1541
+ moveRow(from, to) { this.moveRows(from, from, to); }
1542
+ // 連続する複数行 [r0..r1] を to(0..ROWS の挿入境界)へまとめて移動。data/状態は _applyRowOrder が追従。
1543
+ // Undo/Redo 対応: 適用した順列の「逆順列」を履歴に積む(行移動は意図的な編集なので戻せる)。
1544
+ moveRows(r0, r1, to) {
1545
+ if (this._hasVMerge() || this._allRows) return; // 縦結合中/フィルタ中は行移動禁止(v1)。
1546
+ r0 = Math.max(0, r0 | 0); r1 = Math.min(this.ROWS - 1, r1 | 0); // 横結合は _applyRowOrder が座標追従
1547
+ if (r1 < r0) return;
1548
+ if (to >= r0 && to <= r1 + 1) return; // ブロック内/直後=何もしない
1549
+ if (this.mode === 'edit') this._commitActive();
1550
+ const block = [], rest = [];
1551
+ for (let r = 0; r < this.ROWS; r++) (r >= r0 && r <= r1 ? block : rest).push(r);
1552
+ const ins = rest.filter(r => r < to).length; // to より前に残る行数=挿入位置
1553
+ const order = rest.slice(); order.splice(ins, 0, ...block); // 適用順列 order[newRow]=oldRow
1554
+ const inv = new Array(order.length); order.forEach((o, n) => inv[o] = n); // 逆順列(undo 用)
1555
+ const grid = this, n = block.length, selBefore = this.snapSel();
1556
+ const fire = () => { if (typeof grid.onAfterRowMove === 'function') { try { grid.onAfterRowMove(r0, ins, n); } catch (_) {} } };
1557
+ const doMove = () => { grid._applyRowOrder(order); grid.selectRect(ins, 0, ins + n - 1, grid.COLS - 1); };
1558
+ doMove();
1559
+ this.history.push({
1560
+ label: this.name,
1561
+ apply() { doMove(); fire(); }, // redo
1562
+ revert() { grid._applyRowOrder(inv); grid.focusAndSelect(selBefore); }, // undo
1563
+ });
1564
+ fire();
1565
+ }
1566
+ // リサイズのプレビュー線(content 座標で配置)
1567
+ _showGuide(type, pos) {
1568
+ const g = this.resizeGuide;
1569
+ if (type === 'col') { g.style.left = pos + 'px'; g.style.top = '0px'; g.style.width = '2px'; g.style.height = this.table.offsetHeight + 'px'; }
1570
+ else { g.style.top = pos + 'px'; g.style.left = '0px'; g.style.height = '2px'; g.style.width = this.table.offsetWidth + 'px'; }
1571
+ g.style.display = 'block';
1572
+ }
1573
+
1574
+ _onHeaderMouseDown(e, th) {
1575
+ // ヘッダの全選択チェックボックス: 列選択ではなく全 ON/OFF トグル
1576
+ if (e.target.classList && e.target.classList.contains('tg-head-cb')) { e.preventDefault(); if (th.dataset.c != null) this._toggleColumnAll(+th.dataset.c); return; }
1577
+ e.preventDefault();
1578
+ if (this.mode === 'edit') this._commitActive();
1579
+ if (th.dataset.c0 != null) { // ネストヘッダのグループ → 範囲列を選択
1580
+ const c0 = +th.dataset.c0, c1 = Math.min(this.COLS - 1, +th.dataset.c1);
1581
+ this.selectRect(0, c0, this.ROWS - 1, c1); this._colAnchor = c0; this.editor.focus(); this.headerDrag = 'col';
1582
+ return;
1583
+ }
1584
+ if (th.dataset.c != null) { // 列ヘッダ
1585
+ const c = +th.dataset.c;
1586
+ if (this.onHeaderClick) { let r; try { r = this.onHeaderClick(c, e); } catch (_) {} if (r === false) { this.editor.focus(); return; } } // 外部ソートUI等
1587
+ if (e.shiftKey) this._extendCol(c); else this.selectCol(c);
1588
+ this.editor.focus(); this.headerDrag = 'col';
1589
+ } else if (th.dataset.r != null) { // 行ヘッダ
1590
+ const r = +th.dataset.r;
1591
+ if (e.shiftKey) this._extendRow(r); else this.selectRow(r);
1592
+ this.editor.focus(); this.headerDrag = 'row';
1593
+ } else { // 左上の角 → 全選択
1594
+ this.selectAll(); this.editor.focus();
1595
+ }
1596
+ }
1597
+
1598
+ // ---- 変更 → コマンド化して history に積む ----
1599
+ snapSel() { return { active: { ...this.active }, extent: { ...this.extent } }; }
1600
+ setCell(r, c, val, changes) {
1601
+ if (this.data[r][c] === val) return;
1602
+ changes.push({ r, c, old: this.data[r][c], neu: val });
1603
+ this.data[r][c] = val; this._renderCell(r, c);
1604
+ }
1605
+ _applyCells(changes, key, source) {
1606
+ for (const ch of changes) { this.data[ch.r][ch.c] = ch[key]; this._renderCell(ch.r, ch.c); }
1607
+ this._syncHeaderCheckboxes(); // undo/redo でもヘッダ全選択を同期
1608
+ this.renderDump();
1609
+ if (source && this.onAfterChange) {
1610
+ const fromKey = key === 'neu' ? 'old' : 'neu';
1611
+ try { this.onAfterChange(changes.map(ch => ({ r: ch.r, c: ch.c, oldValue: ch[fromKey], newValue: ch[key], source })), source); } catch (_) {}
1612
+ }
1613
+ }
1614
+ _pubChanges(changes, source) { return changes.map(ch => ({ r: ch.r, c: ch.c, oldValue: ch.old, newValue: ch.neu, source })); }
1615
+ pushCmd(changes, selBefore, selAfter, source, force) {
1616
+ if (!changes.length) return;
1617
+ // 読み取り専用: 変更不可セルは invalidMode を問わず常に old へ巻き戻し、履歴にも積まない。
1618
+ // force=true(setValue の計算列書き込み等)は貫通させる=ユーザー編集だけ守る。
1619
+ if (this._hasReadOnly() && !force) {
1620
+ const blocked = [];
1621
+ for (let i = changes.length - 1; i >= 0; i--) {
1622
+ const ch = changes[i];
1623
+ if (this._isReadOnly(ch.r, ch.c)) {
1624
+ blocked.push({ r: ch.r, c: ch.c, value: ch.neu });
1625
+ this.data[ch.r][ch.c] = ch.old; this._renderCell(ch.r, ch.c);
1626
+ changes.splice(i, 1);
1627
+ }
1628
+ }
1629
+ if (blocked.length && this.onReadOnly) { try { this.onReadOnly(blocked.reverse(), source); } catch (_) {} }
1630
+ if (!changes.length) { this.renderDump(); this.focusAndSelect(selBefore); return; }
1631
+ }
1632
+ // 列ルール(型強制 / バリデータ): onBeforeChange より前に効かせる。値は適用済みなので巻き戻す。
1633
+ if (this._hasColRules()) {
1634
+ const rejections = [];
1635
+ for (const ch of changes) {
1636
+ const res = this._coerceCell(ch.r, ch.c, ch.neu);
1637
+ if (!res.ok) {
1638
+ rejections.push({ r: ch.r, c: ch.c, value: ch.neu, code: res.code || null, level: res.level || 'error', message: res.message });
1639
+ // 'keep': 入力値を残してセルを赤く(_renderCell が判定)。'revert': 元の値へ巻き戻す。
1640
+ if (!this._marksInvalid) { ch.neu = ch.old; this.data[ch.r][ch.c] = ch.old; }
1641
+ this._renderCell(ch.r, ch.c);
1642
+ } else if (res.value !== ch.neu) {
1643
+ ch.neu = res.value; this.data[ch.r][ch.c] = res.value; this._renderCell(ch.r, ch.c);
1644
+ }
1645
+ }
1646
+ for (let i = changes.length - 1; i >= 0; i--) if (changes[i].neu === changes[i].old) changes.splice(i, 1);
1647
+ if (rejections.length && this.onInvalid) { try { this.onInvalid(rejections, source); } catch (_) {} }
1648
+ if (!changes.length) { this.renderDump(); this.focusAndSelect(selBefore); return; }
1649
+ }
1650
+ // onBeforeChange: 検証 / 整形 / 取消。値は既に適用済みなので、取消は old へ巻き戻す。
1651
+ if (this.onBeforeChange) {
1652
+ const pub = this._pubChanges(changes, source);
1653
+ let ret; try { ret = this.onBeforeChange(pub, source); } catch (_) { ret = undefined; }
1654
+ if (ret === false) { // 全取消 → data/DOM を old へ戻す
1655
+ for (const ch of changes) { this.data[ch.r][ch.c] = ch.old; this._renderCell(ch.r, ch.c); }
1656
+ this.renderDump(); this.focusAndSelect(selBefore); return;
1657
+ }
1658
+ const fin = Array.isArray(ret) ? ret : pub; // 整形後の newValue を反映
1659
+ for (let i = 0; i < changes.length; i++) {
1660
+ const nv = (fin[i] && 'newValue' in fin[i]) ? fin[i].newValue : changes[i].neu;
1661
+ if (nv !== changes[i].neu) { changes[i].neu = nv; this.data[changes[i].r][changes[i].c] = nv; this._renderCell(changes[i].r, changes[i].c); }
1662
+ }
1663
+ for (let i = changes.length - 1; i >= 0; i--) if (changes[i].neu === changes[i].old) changes.splice(i, 1); // 整形で無変更化したものは除外
1664
+ if (!changes.length) { this.focusAndSelect(selBefore); return; }
1665
+ }
1666
+ this.renderDump();
1667
+ const grid = this;
1668
+ this.history.push({
1669
+ label: this.name,
1670
+ apply() { grid._applyCells(changes, 'neu', 'redo'); grid.focusAndSelect(selAfter); },
1671
+ revert() { grid._applyCells(changes, 'old', 'undo'); grid.focusAndSelect(selBefore); },
1672
+ });
1673
+ if (this.onAfterChange) { try { this.onAfterChange(this._pubChanges(changes, source), source); } catch (_) {} }
1674
+ this._syncHeaderCheckboxes(); // ヘッダ全選択チェックの見た目を同期
1675
+ this._ensureSpare(); // 末尾の空行確保(minSpareRows)
1676
+ }
1677
+ commit() {
1678
+ const wasEditing = this.mode === 'edit';
1679
+ const r = this.active.r, c = this.active.c, oldValue = this.data[r][c];
1680
+ const before = this.snapSel(), changes = [];
1681
+ this.setCell(r, c, this.editor.value, changes);
1682
+ this.pushCmd(changes, before, this.snapSel(), 'edit');
1683
+ if (wasEditing && this.onEditEnd) { try { this.onEditEnd({ r, c, oldValue, newValue: this.data[r][c], canceled: false }); } catch (_) {} }
1684
+ }
1685
+ clearRange() {
1686
+ const before = this.snapSel(), changes = [], { r0, r1, c0, c1 } = this.rectRange();
1687
+ for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) this.setCell(r, c, '', changes);
1688
+ this.pushCmd(changes, before, this.snapSel(), 'delete');
1689
+ }
1690
+
1691
+ // ---- 行/列の挿入・削除(構造変更。Undo に1コマンドで積む) ----
1692
+ // 並列配列(columns/colW は疎なことがある)を現在の COLS/ROWS 長に揃えてから splice する。
1693
+ _normalizeArrays() {
1694
+ while (this.columns.length < this.COLS) this.columns.push(undefined);
1695
+ while (this.colW.length < this.COLS) this.colW.push(undefined);
1696
+ while (this.rowH.length < this.ROWS) this.rowH.push(undefined);
1697
+ }
1698
+ _rowEmpty(r) { for (let c = 0; c < this.COLS; c++) if (this.data[r][c] !== '') return false; return true; }
1699
+ // minRows/minCols まで空行・空列を補う(初期化・setData 時。レンダ前提なし)。
1700
+ _padToMin() {
1701
+ while (this.ROWS < this.minRows) { this.data.push(new Array(this.COLS).fill('')); if (this._src) this._src.push({}); this.ROWS++; }
1702
+ while (this.COLS < this.minCols) {
1703
+ this.headers.push(TssGrid._colLabel(this.COLS)); this.columns.push(undefined);
1704
+ for (const row of this.data) row.push('');
1705
+ this.COLS++;
1706
+ }
1707
+ }
1708
+ // 末尾に常に minSpareRows 行の空行を確保(足りなければ追加。履歴には積まない)。
1709
+ _ensureSpare() {
1710
+ if (!this.minSpareRows || this._allRows) return; // フィルタ中は末尾の自動空行を足さない(マスタ desync 回避)
1711
+ let empties = 0;
1712
+ for (let r = this.ROWS - 1; r >= 0; r--) { if (this._rowEmpty(r)) empties++; else break; }
1713
+ let added = 0;
1714
+ 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++; }
1715
+ if (added && this.table) { this.buildTable(); this.setActive(this.active.r, this.active.c); }
1716
+ }
1717
+ // 構造変更を実行+履歴に積む共通処理。doIt/undoIt は冪等(redo で再実行される)。
1718
+ _structCmd(doIt, undoIt, selAfterFn, selBefore, info) {
1719
+ // 挿入/削除の「前」フック。false で中止(履歴にも積まない)。redo では呼ばれない(確定済みの再実行)。
1720
+ if (this.onBeforeStructureChange) { let r; try { r = this.onBeforeStructureChange(info); } catch (_) {} if (r === false) return; }
1721
+ const grid = this, remap = this._structRemapFn(info);
1722
+ const cellBefore = this._cloneCellState(); // セル状態(整列/RO)の undo 用スナップショット
1723
+ const mergeBefore = this._merges ? this._merges.map(m => ({ ...m })) : null; // 結合の undo 用スナップショット
1724
+ const run = () => { doIt(); if (remap) grid._remapCellState(remap); grid._remapMerges(info); }; // 構造変更+セル状態キー+結合座標を追従
1725
+ run(); this.buildTable(); const selAfter = selAfterFn();
1726
+ this.focusAndSelect(selAfter);
1727
+ this._ensureSpare(); // 末尾の空行確保(minSpareRows)
1728
+ if (this.onStructureChange) { try { this.onStructureChange(info); } catch (_) {} }
1729
+ this.history.push({
1730
+ label: this.name,
1731
+ apply() { run(); grid.buildTable(); grid.focusAndSelect(selAfter); if (grid.onStructureChange) { try { grid.onStructureChange(info); } catch (_) {} } },
1732
+ revert() { undoIt(); grid._restoreCellState(cellBefore); grid._merges = mergeBefore ? mergeBefore.map(m => ({ ...m })) : null; grid.buildTable(); grid.focusAndSelect(selBefore); if (grid.onStructureChange) { try { grid.onStructureChange({ ...info, undo: true }); } catch (_) {} } },
1733
+ });
1734
+ }
1735
+ // フィルタ中の挿入位置をマスタ(_allRows)へ写像。at=挿入後のビュー位置(data[at]=新行)。
1736
+ // 次の可視行(data[at+1])の手前へ=「その行の上に挿入」がマスタでも自然。無ければ前の可視行の直後/末尾。
1737
+ _masterInsertIndex(at) {
1738
+ if (at + 1 < this.data.length) { const i = this._allRows.indexOf(this.data[at + 1]); if (i >= 0) return i; }
1739
+ if (at - 1 >= 0) { const i = this._allRows.indexOf(this.data[at - 1]); if (i >= 0) return i + 1; }
1740
+ return this._allRows.length;
1741
+ }
1742
+ insertRow(ri, where = 'above') {
1743
+ if (!this.allowInsertRows) return;
1744
+ if (this.maxRows && this.ROWS >= this.maxRows) return; // 上限
1745
+ if (this.mode === 'edit') this._commitActive();
1746
+ this._normalizeArrays();
1747
+ const at = Math.max(0, Math.min(this.ROWS, where === 'below' ? ri + 1 : ri));
1748
+ const before = this.snapSel(), grid = this, c = this.active.c, frozenBump = at < this.frozenRows;
1749
+ let ref; // 挿入した行参照(フィルタ中の _allRows 追従・undo 用に保持)
1750
+ const doIt = () => {
1751
+ ref = new Array(grid.COLS).fill('');
1752
+ grid.data.splice(at, 0, ref); grid.rowH.splice(at, 0, undefined); if (grid._src) grid._src.splice(at, 0, {}); grid.ROWS++;
1753
+ if (grid._allRows) { const mi = grid._masterInsertIndex(at); grid._allRows.splice(mi, 0, ref); if (grid._allSrc) grid._allSrc.splice(mi, 0, {}); } // マスタにも挿入
1754
+ if (frozenBump) grid.frozenRows++;
1755
+ };
1756
+ const undoIt = () => {
1757
+ grid.data.splice(at, 1); grid.rowH.splice(at, 1); if (grid._src) grid._src.splice(at, 1); grid.ROWS--;
1758
+ if (grid._allRows && ref) { const i = grid._allRows.indexOf(ref); if (i >= 0) { grid._allRows.splice(i, 1); if (grid._allSrc) grid._allSrc.splice(i, 1); } }
1759
+ if (frozenBump) grid.frozenRows--;
1760
+ };
1761
+ this._structCmd(doIt, undoIt, () => ({ active: { r: at, c }, extent: { r: at, c } }), before, { type: 'insertRow', at });
1762
+ }
1763
+ deleteRows(r0, r1) {
1764
+ if (!this.allowDeleteRows) return;
1765
+ if (r1 == null) r1 = r0;
1766
+ const n = r1 - r0 + 1;
1767
+ if (this.ROWS - n < Math.max(1, this.minRows)) return; // 下限(既定: 最低1行)
1768
+ if (this.mode === 'edit') this._commitActive();
1769
+ this._normalizeArrays();
1770
+ const before = this.snapSel(), grid = this, c = this.active.c;
1771
+ const frozenDelta = Math.max(0, Math.min(r1, this.frozenRows - 1) - r0 + 1); // 固定行内で消える行数
1772
+ let saved;
1773
+ const doIt = () => {
1774
+ saved = { rows: grid.data.splice(r0, n), hs: grid.rowH.splice(r0, n), src: grid._src ? grid._src.splice(r0, n) : null }; grid.ROWS -= n; grid.frozenRows -= frozenDelta;
1775
+ if (grid._allRows) { // マスタからも参照で除去(master index を undo 用に記録)
1776
+ saved.mis = []; saved.msrc = grid._allSrc ? [] : null;
1777
+ for (const ref of saved.rows) { const i = grid._allRows.indexOf(ref); if (i >= 0) { saved.mis.push(i); grid._allRows.splice(i, 1); if (grid._allSrc) { saved.msrc.push(grid._allSrc[i]); grid._allSrc.splice(i, 1); } } else saved.mis.push(-1); }
1778
+ }
1779
+ };
1780
+ const undoIt = () => {
1781
+ grid.data.splice(r0, 0, ...saved.rows); grid.rowH.splice(r0, 0, ...saved.hs); if (grid._src) grid._src.splice(r0, 0, ...saved.src); grid.ROWS += n; grid.frozenRows += frozenDelta;
1782
+ if (grid._allRows && saved.mis) { // 記録 index へ逆順で戻す(前から戻すと index がズレるため)
1783
+ for (let k = saved.mis.length - 1; k >= 0; k--) { const mi = saved.mis[k]; if (mi < 0) continue; grid._allRows.splice(mi, 0, saved.rows[k]); if (grid._allSrc) grid._allSrc.splice(mi, 0, saved.msrc[k]); }
1784
+ }
1785
+ };
1786
+ this._structCmd(doIt, undoIt, () => { const rr = Math.min(r0, grid.ROWS - 1); return { active: { r: rr, c }, extent: { r: rr, c } }; }, before, { type: 'deleteRows', r0, r1 });
1787
+ }
1788
+ insertCol(ci, where = 'left') {
1789
+ if (!this.allowInsertCols) return;
1790
+ if (this._allRows) return; // v1: フィルタ中は構造変更ロック
1791
+ if (this.maxCols && this.COLS >= this.maxCols) return; // 上限
1792
+ if (this.mode === 'edit') this._commitActive();
1793
+ this._normalizeArrays();
1794
+ const at = Math.max(0, Math.min(this.COLS, where === 'right' ? ci + 1 : ci));
1795
+ const before = this.snapSel(), grid = this, r = this.active.r;
1796
+ const label = TssGrid._colLabel(this.COLS), frozenBump = at < this.frozenCols;
1797
+ const doIt = () => {
1798
+ grid.headers.splice(at, 0, label); grid.columns.splice(at, 0, undefined); grid.colW.splice(at, 0, undefined);
1799
+ for (const row of grid.data) row.splice(at, 0, '');
1800
+ grid.COLS++; if (frozenBump) grid.frozenCols++;
1801
+ grid.hiddenCols = new Set([...grid.hiddenCols].map(h => h >= at ? h + 1 : h)); // 隠し列インデックスをずらす
1802
+ };
1803
+ const undoIt = () => {
1804
+ grid.headers.splice(at, 1); grid.columns.splice(at, 1); grid.colW.splice(at, 1);
1805
+ for (const row of grid.data) row.splice(at, 1);
1806
+ grid.COLS--; if (frozenBump) grid.frozenCols--;
1807
+ grid.hiddenCols = new Set([...grid.hiddenCols].map(h => h > at ? h - 1 : h));
1808
+ };
1809
+ this._structCmd(doIt, undoIt, () => ({ active: { r, c: at }, extent: { r, c: at } }), before, { type: 'insertCol', at });
1810
+ }
1811
+ deleteCols(c0, c1) {
1812
+ if (!this.allowDeleteCols) return;
1813
+ if (this._allRows) return; // v1: フィルタ中は構造変更ロック
1814
+ if (c1 == null) c1 = c0;
1815
+ const m = c1 - c0 + 1;
1816
+ if (this.COLS - m < Math.max(1, this.minCols)) return; // 下限(既定: 最低1列)
1817
+ if (this.mode === 'edit') this._commitActive();
1818
+ this._normalizeArrays();
1819
+ const before = this.snapSel(), grid = this, r = this.active.r;
1820
+ const frozenDelta = Math.max(0, Math.min(c1, this.frozenCols - 1) - c0 + 1);
1821
+ let saved;
1822
+ const doIt = () => {
1823
+ saved = { headers: grid.headers.splice(c0, m), cfgs: grid.columns.splice(c0, m), ws: grid.colW.splice(c0, m), cells: grid.data.map(row => row.splice(c0, m)), hidden: [...grid.hiddenCols] };
1824
+ grid.COLS -= m; grid.frozenCols -= frozenDelta;
1825
+ grid.hiddenCols = new Set(saved.hidden.filter(h => h < c0 || h > c1).map(h => h > c1 ? h - m : h)); // 削除分を除き残りをずらす
1826
+ };
1827
+ const undoIt = () => {
1828
+ grid.headers.splice(c0, 0, ...saved.headers); grid.columns.splice(c0, 0, ...saved.cfgs); grid.colW.splice(c0, 0, ...saved.ws);
1829
+ grid.data.forEach((row, i) => row.splice(c0, 0, ...saved.cells[i]));
1830
+ grid.COLS += m; grid.frozenCols += frozenDelta;
1831
+ grid.hiddenCols = new Set(saved.hidden); // 隠し状態を完全復元
1832
+ };
1833
+ 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 });
1834
+ }
1835
+
1836
+ // ---- 右クリックメニュー ----
1837
+ _onContextMenu(e) {
1838
+ if (!this.contextMenu) return; // 無効時はブラウザ既定メニュー
1839
+ const th = e.target.closest('th'), td = e.target.closest('td[data-r]');
1840
+ if (!th && !td) return;
1841
+ e.preventDefault();
1842
+ if (this.mode === 'edit') this._commitActive();
1843
+ // 右クリック位置に選択を移す(既存選択の中ならそのまま=範囲削除に使える)
1844
+ if (td) {
1845
+ const r = +td.dataset.r, c = +td.dataset.c, s = this.rectRange();
1846
+ if (!(r >= s.r0 && r <= s.r1 && c >= s.c0 && c <= s.c1)) this.setActive(r, c);
1847
+ } else if (th.dataset.c != null) this.selectCol(+th.dataset.c);
1848
+ else if (th.dataset.r != null) this.selectRow(+th.dataset.r);
1849
+ this._showMenu(e.clientX, e.clientY);
1850
+ }
1851
+ // 既定のメニュー項目キー(allow フラグ・copyPaste で出し分け)。
1852
+ _defaultMenuKeys() {
1853
+ const k = [];
1854
+ if (this.allowInsertRows) k.push('row_above', 'row_below');
1855
+ if (this.allowDeleteRows) k.push('remove_row');
1856
+ const colOps = this.allowInsertCols || this.allowDeleteCols;
1857
+ if (k.length && colOps) k.push('---');
1858
+ if (this.allowInsertCols) k.push('col_left', 'col_right');
1859
+ if (this.allowDeleteCols) k.push('remove_col');
1860
+ const clip = [];
1861
+ if (this.copyPaste) clip.push('copy', 'cut', 'paste');
1862
+ clip.push('clear');
1863
+ if (k.length && clip.length) k.push('---');
1864
+ k.push(...clip);
1865
+ return k;
1866
+ }
1867
+ // 組込項目キー → 項目記述子({label, act, danger, disabled} / {sep:true})。不可なら null。
1868
+ _builtinItem(key, s) {
1869
+ const rows = s.r1 - s.r0 + 1, cols = s.c1 - s.c0 + 1;
1870
+ switch (key) {
1871
+ case 'row_above': return this.allowInsertRows ? { label: '行を上に挿入', act: () => this.insertRow(s.r0, 'above') } : null;
1872
+ case 'row_below': return this.allowInsertRows ? { label: '行を下に挿入', act: () => this.insertRow(s.r1, 'below') } : null;
1873
+ case 'remove_row': return this.allowDeleteRows ? { label: rows > 1 ? rows + ' 行を削除' : '行を削除', danger: true, disabled: this.ROWS - rows < Math.max(1, this.minRows), act: () => this.deleteRows(s.r0, s.r1) } : null;
1874
+ case 'col_left': return this.allowInsertCols ? { label: '列を左に挿入', act: () => this.insertCol(s.c0, 'left') } : null;
1875
+ case 'col_right': return this.allowInsertCols ? { label: '列を右に挿入', act: () => this.insertCol(s.c1, 'right') } : null;
1876
+ case 'remove_col': return this.allowDeleteCols ? { label: cols > 1 ? cols + ' 列を削除' : '列を削除', danger: true, disabled: this.COLS - cols < Math.max(1, this.minCols), act: () => this.deleteCols(s.c0, s.c1) } : null;
1877
+ case 'copy': return { label: 'コピー', disabled: !this.copyPaste, act: () => this.copy(false) };
1878
+ case 'cut': return { label: '切り取り', disabled: !this.copyPaste, act: () => this.copy(true) };
1879
+ case 'paste': return { label: '貼り付け', disabled: !this.copyPaste, act: () => this.paste() };
1880
+ case 'clear': return { label: '内容をクリア', act: () => this.clearRange() };
1881
+ case 'check_column': return this.colType(s.c0) === 'checkbox' ? { label: 'すべてチェック', act: () => this.setColumnChecked(s.c0, true) } : null;
1882
+ case 'uncheck_column': return this.colType(s.c0) === 'checkbox' ? { label: 'すべて外す', act: () => this.setColumnChecked(s.c0, false) } : null;
1883
+ case 'undo': return { label: '元に戻す', disabled: !this.history.canUndo(), act: () => this.history.undo() };
1884
+ case 'redo': return { label: 'やり直し', disabled: !this.history.canRedo(), act: () => this.history.redo() };
1885
+ default: return /^-+$/.test(key) || key === 'separator' ? { sep: true } : null;
1886
+ }
1887
+ }
1888
+ // メニュー構成(キー文字列/カスタム項目オブジェクト)→ 表示用記述子の配列。
1889
+ _resolveMenuItems(config, s) {
1890
+ const items = [];
1891
+ for (const entry of config) {
1892
+ if (typeof entry === 'string') { const it = this._builtinItem(entry, s); if (it) items.push(it); continue; }
1893
+ if (!entry || typeof entry !== 'object') continue;
1894
+ const hidden = typeof entry.hidden === 'function' ? !!entry.hidden.call(this, s) : !!entry.hidden;
1895
+ if (hidden) continue;
1896
+ const name = typeof entry.name === 'function' ? entry.name.call(this, s) : entry.name;
1897
+ if (entry.separator || /^-+$/.test(String(name))) { items.push({ sep: true }); continue; }
1898
+ const disabled = typeof entry.disabled === 'function' ? !!entry.disabled.call(this, s) : !!entry.disabled;
1899
+ const it = { label: String(name == null ? '' : name), disabled, danger: !!entry.danger };
1900
+ if (Array.isArray(entry.submenu)) it.submenu = entry.submenu;
1901
+ else it.act = () => { if (typeof entry.callback === 'function') { try { entry.callback.call(this, { range: { ...s }, key: entry.key }); } catch (_) {} } };
1902
+ items.push(it);
1903
+ }
1904
+ return items;
1905
+ }
1906
+ // 記述子配列 → HTML。actionable 項目は data-mi で _menuActions に紐付け(submenu も再帰でフラット登録)。
1907
+ _renderMenuItems(items, s) {
1908
+ let html = '';
1909
+ for (const it of items) {
1910
+ if (it.sep) { html += '<div class="tg-sep"></div>'; continue; }
1911
+ if (it.submenu) {
1912
+ const sub = this._renderMenuItems(this._resolveMenuItems(it.submenu, s), s);
1913
+ html += '<div class="tg-mi tg-has-sub' + (it.disabled ? ' tg-disabled' : '') + '">' + TssGrid.esc(it.label) + '<div class="tg-submenu">' + sub + '</div></div>';
1914
+ } else {
1915
+ const idx = this._menuActions.push(it.act) - 1;
1916
+ html += '<div class="tg-mi' + (it.danger ? ' tg-danger' : '') + (it.disabled ? ' tg-disabled' : '') + '" data-mi="' + idx + '">' + TssGrid.esc(it.label) + '</div>';
1917
+ }
1918
+ }
1919
+ return html;
1920
+ }
1921
+ _showMenu(x, y) {
1922
+ const s = this.rectRange();
1923
+ const config = this._menuConfig || this._defaultMenuKeys();
1924
+ const items = this._resolveMenuItems(config, s);
1925
+ if (!items.length) { this._hideMenu(); return; } // 該当項目なし=メニューを出さない(前回の残りも隠す)
1926
+ this._menuActions = [];
1927
+ this.menuEl.innerHTML = this._renderMenuItems(items, s);
1928
+ this.menuEl.style.display = 'block';
1929
+ // 画面外にはみ出さないよう調整
1930
+ const mw = this.menuEl.offsetWidth, mh = this.menuEl.offsetHeight;
1931
+ const vw = window.innerWidth, vh = window.innerHeight;
1932
+ this.menuEl.style.left = Math.min(x, vw - mw - 4) + 'px';
1933
+ this.menuEl.style.top = Math.min(y, vh - mh - 4) + 'px';
1934
+ }
1935
+ _hideMenu() { if (this.menuEl) this.menuEl.style.display = 'none'; }
1936
+
1937
+ // ---- フィル ----
1938
+ fillRectFromPointer(pr, pc, src, twoD) {
1939
+ if (pr >= src.r0 && pr <= src.r1 && pc >= src.c0 && pc <= src.c1) return null;
1940
+ if (this.fillDirection !== 'both') twoD = false; // 方向制限時は矩形(2D)フィルを無効
1941
+ if (twoD) return { r0: Math.min(src.r0, pr), r1: Math.max(src.r1, pr), c0: Math.min(src.c0, pc), c1: Math.max(src.c1, pc) };
1942
+ const dDown = pr - src.r1, dUp = src.r0 - pr, dRight = pc - src.c1, dLeft = src.c0 - pc;
1943
+ let vOver = Math.max(dDown, dUp, 0), hOver = Math.max(dRight, dLeft, 0);
1944
+ if (this.fillDirection === 'vertical') hOver = 0; // 縦のみ
1945
+ if (this.fillDirection === 'horizontal') vOver = 0; // 横のみ
1946
+ if (vOver === 0 && hOver === 0) return null;
1947
+ if (vOver >= hOver) return (dUp > dDown)
1948
+ ? { r0: this.clampR(pr), r1: src.r1, c0: src.c0, c1: src.c1 }
1949
+ : { r0: src.r0, r1: this.clampR(pr), c0: src.c0, c1: src.c1 };
1950
+ return (dLeft > dRight)
1951
+ ? { r0: src.r0, r1: src.r1, c0: this.clampC(pc), c1: src.c1 }
1952
+ : { r0: src.r0, r1: src.r1, c0: src.c0, c1: this.clampC(pc) };
1953
+ }
1954
+ static toNum(v) { const t = String(v).trim(); if (t === '') return null; const n = Number(t); return Number.isFinite(n) ? n : null; }
1955
+ static fmtNum(n) { return (Math.round(n * 1e10) / 1e10).toString(); }
1956
+ lineFill(srcVals, count, ctrl) {
1957
+ const nums = srcVals.map(TssGrid.toNum), numeric = nums.every(n => n !== null);
1958
+ let base = false, step = 0;
1959
+ if (numeric && srcVals.length >= 2) {
1960
+ step = nums[1] - nums[0]; base = true;
1961
+ for (let i = 2; i < nums.length; i++) if (Math.abs((nums[i] - nums[i - 1]) - step) > 1e-9) { base = false; break; }
1962
+ }
1963
+ let series = false, st = 0;
1964
+ if (numeric) { if (base) { series = !ctrl; st = step; } else { series = !!ctrl; st = 1; } }
1965
+ const out = [];
1966
+ if (series) { const last = nums[nums.length - 1]; for (let k = 1; k <= count; k++) out.push(TssGrid.fmtNum(last + st * k)); }
1967
+ else { for (let k = 0; k < count; k++) out.push(srcVals[k % srcVals.length]); }
1968
+ return out;
1969
+ }
1970
+ tileFill(src, fr, changes) {
1971
+ const sH = src.r1 - src.r0 + 1, sW = src.c1 - src.c0 + 1;
1972
+ for (let r = fr.r0; r <= fr.r1; r++) for (let c = fr.c0; c <= fr.c1; c++) {
1973
+ if (this._isHidden(c)) continue; // 隠し列は埋めない
1974
+ const sr = src.r0 + ((((r - src.r0) % sH) + sH) % sH), sc = src.c0 + ((((c - src.c0) % sW) + sW) % sW);
1975
+ if (r === sr && c === sc) continue;
1976
+ this.setCell(r, c, this.data[sr][sc], changes);
1977
+ }
1978
+ }
1979
+ fillVertical(src, fr, changes, ctrl) {
1980
+ const down = fr.r1 > src.r1;
1981
+ for (let c = src.c0; c <= src.c1; c++) {
1982
+ if (this._isHidden(c)) continue; // 隠し列は埋めない
1983
+ const sv = []; for (let r = src.r0; r <= src.r1; r++) sv.push(this.data[r][c]);
1984
+ if (down) { const n = fr.r1 - src.r1, out = this.lineFill(sv, n, ctrl); for (let k = 0; k < n; k++) this.setCell(src.r1 + 1 + k, c, out[k], changes); }
1985
+ else { const n = src.r0 - fr.r0, L = sv.length; for (let k = 0; k < n; k++) this.setCell(src.r0 - 1 - k, c, sv[L - 1 - (k % L)], changes); }
1986
+ }
1987
+ }
1988
+ fillHorizontal(src, fr, changes, ctrl) {
1989
+ const right = fr.c1 > src.c1;
1990
+ for (let r = src.r0; r <= src.r1; r++) {
1991
+ const sv = []; for (let c = src.c0; c <= src.c1; c++) if (!this._isHidden(c)) sv.push(this.data[r][c]);
1992
+ if (right) { const n = fr.c1 - src.c1, out = this.lineFill(sv, n, ctrl); for (let k = 0; k < n; k++) { const tc = src.c1 + 1 + k; if (!this._isHidden(tc)) this.setCell(r, tc, out[k], changes); } }
1993
+ else { const n = src.c0 - fr.c0, L = sv.length; for (let k = 0; k < n; k++) { const tc = src.c0 - 1 - k; if (!this._isHidden(tc)) this.setCell(r, tc, sv[L - 1 - (k % L)], changes); } }
1994
+ }
1995
+ }
1996
+ doFill(src, fr, ctrl) {
1997
+ if (this.onBeforeAutofill) { let r; try { r = this.onBeforeAutofill({ ...src }, { ...fr }); } catch (_) {} if (r === false) return; }
1998
+ const before = { active: { r: src.r0, c: src.c0 }, extent: { r: src.r1, c: src.c1 } };
1999
+ const changes = [];
2000
+ const vertical = fr.r0 < src.r0 || fr.r1 > src.r1, horizontal = fr.c0 < src.c0 || fr.c1 > src.c1;
2001
+ if (vertical && horizontal) this.tileFill(src, fr, changes);
2002
+ else if (vertical) this.fillVertical(src, fr, changes, ctrl);
2003
+ else this.fillHorizontal(src, fr, changes, ctrl);
2004
+ this.pushCmd(changes, before, { active: { r: fr.r0, c: fr.c0 }, extent: { r: fr.r1, c: fr.c1 } }, 'fill');
2005
+ if (this.onAfterAutofill) { try { this.onAfterAutofill({ ...src }, { ...fr }); } catch (_) {} }
2006
+ }
2007
+
2008
+ // ---- コピペ ----
2009
+ copy(cut) {
2010
+ if (!this.copyPaste) return;
2011
+ const sel = this.rectRange(); let buf = [];
2012
+ const cols = this._visCols().filter(c => c >= sel.c0 && c <= sel.c1); // 隠し列は除外
2013
+ for (let r = sel.r0; r <= sel.r1; r++) {
2014
+ const row = []; for (const c of cols) row.push(this.data[r][c]);
2015
+ buf.push(row);
2016
+ }
2017
+ // 「前」フック(cut は onBeforeCut / copy は onBeforeCopy)。false で中止 / 配列を返すと差替え。
2018
+ const info = { range: { ...sel }, cut: !!cut };
2019
+ const before = cut ? this.onBeforeCut : this.onBeforeCopy;
2020
+ if (before) { let r; try { r = before(buf, info); } catch (_) {} if (r === false) return; if (Array.isArray(r)) buf = r; }
2021
+ sharedClip = buf;
2022
+ this._writeClip(buf.map(row => row.join('\t')).join('\n'));
2023
+ this.pendingCut = cut ? { ...sel } : null;
2024
+ this.placeRect(this.copybox, sel.r0, sel.c0, sel.r1, sel.c1); this.copybox.style.display = 'block';
2025
+ if (this.onAfterCopy) { try { this.onAfterCopy(buf, info); } catch (_) {} }
2026
+ }
2027
+ _writeClip(text) {
2028
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).catch(() => this._execCopy(text));
2029
+ else this._execCopy(text);
2030
+ }
2031
+ _execCopy(text) {
2032
+ const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px';
2033
+ document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (_) {} document.body.removeChild(ta); this.editor.focus();
2034
+ }
2035
+ static parseTSV(t) { t = t.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); if (t.endsWith('\n')) t = t.slice(0, -1); return t.split('\n').map(l => l.split('\t')); }
2036
+ async paste() {
2037
+ if (!this.copyPaste) return;
2038
+ let text = null;
2039
+ try { if (navigator.clipboard && navigator.clipboard.readText) text = await navigator.clipboard.readText(); } catch (_) {}
2040
+ const g = (text && text.length) ? TssGrid.parseTSV(text) : sharedClip;
2041
+ if (!g || !g.length) return;
2042
+ this.pasteGrid(g);
2043
+ }
2044
+ pasteGrid(g) {
2045
+ if (this.onBeforePaste) {
2046
+ let ret; try { ret = this.onBeforePaste(g); } catch (_) { ret = undefined; }
2047
+ if (ret === false) return;
2048
+ if (Array.isArray(ret)) g = ret;
2049
+ if (!g || !g.length) return;
2050
+ }
2051
+ const before = this.snapSel(), changes = [], sel = this.rectRange();
2052
+ const visAll = this._visCols();
2053
+ let after;
2054
+ if (g.length === 1 && g[0].length === 1 && !(sel.r0 === sel.r1 && sel.c0 === sel.c1)) {
2055
+ const v = g[0][0];
2056
+ for (let r = sel.r0; r <= sel.r1; r++) for (let c = sel.c0; c <= sel.c1; c++) if (!this._isHidden(c)) this.setCell(r, c, v, changes);
2057
+ after = { active: { r: sel.r0, c: sel.c0 }, extent: { r: sel.r1, c: sel.c1 } };
2058
+ } else {
2059
+ const r0 = this.active.r, c0 = this.active.c;
2060
+ let maxw = 0; for (let i = 0; i < g.length; i++) maxw = Math.max(maxw, g[i].length);
2061
+ const p0 = visAll.indexOf(c0), availCols = visAll.length - p0; // 貼り付けは可視列へ(隠し列をスキップ)
2062
+ // 範囲超過チェック(オプトイン): はみ出すなら貼り付けを中止してエラー通知
2063
+ if (this.pasteOverflow === 'error' && (r0 + g.length > this.ROWS || maxw > availCols)) {
2064
+ if (this.onPasteOverflow) {
2065
+ try {
2066
+ this.onPasteOverflow({
2067
+ anchor: { r: r0, c: c0 }, height: g.length, width: maxw, rows: this.ROWS, cols: this.COLS,
2068
+ overRows: Math.max(0, r0 + g.length - this.ROWS), overCols: Math.max(0, maxw - availCols),
2069
+ });
2070
+ } catch (_) {}
2071
+ }
2072
+ return; // 何も変更しない
2073
+ }
2074
+ let lastC = c0;
2075
+ for (let i = 0; i < g.length; i++) for (let j = 0; j < g[i].length; j++) {
2076
+ const r = r0 + i, c = visAll[p0 + j]; if (r < this.ROWS && c != null) { this.setCell(r, c, g[i][j], changes); lastC = c; }
2077
+ }
2078
+ after = { active: { r: r0, c: c0 }, extent: { r: Math.min(this.ROWS - 1, r0 + g.length - 1), c: lastC } };
2079
+ }
2080
+ if (this.pendingCut) { const cs = this.pendingCut; for (let r = cs.r0; r <= cs.r1; r++) for (let c = cs.c0; c <= cs.c1; c++) this.setCell(r, c, '', changes); this.pendingCut = null; this.copybox.style.display = 'none'; }
2081
+ this.pushCmd(changes, before, after, 'paste');
2082
+ this.focusAndSelect(after);
2083
+ if (this.onAfterPaste) {
2084
+ const range = { r0: after.active.r, c0: after.active.c, r1: after.extent.r, c1: after.extent.c };
2085
+ try { this.onAfterPaste(g, { range }); } catch (_) {}
2086
+ }
2087
+ }
2088
+ clearCopyMarquee() { this.copybox.style.display = 'none'; this.pendingCut = null; }
2089
+
2090
+ _tdAtPoint(e) { const el = document.elementFromPoint(e.clientX, e.clientY); return el && el.closest ? el.closest('td[data-r]') : null; }
2091
+
2092
+ // スクロール / リサイズでセル位置が動いた時、エディタと選択枠を貼り直す
2093
+ _reflow() {
2094
+ if (this.stretchH !== 'none') this._applyStretch(); // 枠幅が変わったら余白を再配分
2095
+ this._buildGeomCache(); // 幅/枠が変わったので実測幾何を取り直す(リサイズ/ResizeObserver/window resize)
2096
+ const td = this.activeTd(); if (!td) return;
2097
+ this.place(this.editor, td);
2098
+ if (this.select && this.select.style.display !== 'none') this.place(this.select, td);
2099
+ this.updateSelectionUI();
2100
+ }
2101
+
2102
+ _bind() {
2103
+ this.editor.addEventListener('keydown', (e) => this._onKey(e));
2104
+ this.editor.addEventListener('compositionstart', () => {
2105
+ if (this.mode !== 'nav') return;
2106
+ if (this._usesTextEditor(this.active.c)) this.toEdit(null, false);
2107
+ });
2108
+ this.editor.addEventListener('beforeinput', (e) => {
2109
+ if (this.mode !== 'nav' || !(e.inputType || '').startsWith('insert')) return;
2110
+ const c = this.active.c, t = this.colType(c);
2111
+ if (this._usesTextEditor(c)) { this.toEdit(null, false); return; } // text / parse・format 付き date・time
2112
+ e.preventDefault();
2113
+ if (this.colCfg(c).editor) this._openCustomEditor(this.active.r, c); // カスタムエディタ
2114
+ else if (t === 'dropdown') this._openSelect();
2115
+ else if (t === 'date' || t === 'time') this.toEdit(this.data[this.active.r][c], true); // ネイティブピッカー
2116
+ // checkbox は文字入力を無視(Space / クリックでトグル)
2117
+ });
2118
+ this.editor.addEventListener('blur', () => {
2119
+ if (this._customEditor) return; // カスタムエディタが奪ったフォーカスでの blur は無視
2120
+ if (this.mode === 'edit' && (!this.select || this.select.style.display === 'none')) { this.commit(); this.toNav(); this.updateSelectionUI(); }
2121
+ });
2122
+ this.select.addEventListener('keydown', (e) => {
2123
+ if (e.key === 'Enter') { e.preventDefault(); this._commitSelect(); this._advance('enter', e.shiftKey); }
2124
+ else if (e.key === 'Tab') { e.preventDefault(); this._commitSelect(); this._advance('tab', e.shiftKey); }
2125
+ else if (e.key === 'Escape') { e.preventDefault(); this._cancelSelect(); }
2126
+ });
2127
+ this.select.addEventListener('blur', () => { if (this.mode === 'edit' && this.select.style.display !== 'none') this._commitSelect(); });
2128
+ // 選択肢を選んだら即確定(移動はしない=Excel と同様にそのセルに留まる)。
2129
+ // ネイティブの一覧で Enter/クリック確定すると change のみ発火し keydown(Enter) が来ないため、
2130
+ // ここでエディタへフォーカスを戻さないと以降キー操作が効かなくなる(カーソルキーがブラウザ既定に流れる)。
2131
+ this.select.addEventListener('change', () => {
2132
+ if (this.mode === 'edit' && this.select.style.display !== 'none') { this._commitSelect(); this.editor.focus(); }
2133
+ });
2134
+
2135
+ this.table.addEventListener('mousedown', (e) => {
2136
+ if (e.button !== 0) return; // 右/中クリックは選択ドラッグを開始しない(右は contextmenu で処理)
2137
+ const cls = e.target.classList;
2138
+ if (cls && cls.contains('tg-colgrip')) { this._startColResize(e); return; } // 列幅リサイズ
2139
+ if (cls && cls.contains('tg-rowgrip')) { this._startRowResize(e); return; } // 行高リサイズ
2140
+ if (cls && cls.contains('tg-rowmove')) { this._startRowMove(e); return; } // 行つまみドラッグ移動
2141
+ const th = e.target.closest('th');
2142
+ if (th) { this._onHeaderMouseDown(e, th); return; } // 行/列/全体 選択
2143
+ const td = e.target.closest('td[data-r]'); if (!td) return;
2144
+ e.preventDefault();
2145
+ const r = +td.dataset.r, c = +td.dataset.c;
2146
+ // 同じセルでカスタムエディタが開いていたら、再クリックは「閉じるだけ」(トグル)
2147
+ const wasOpenHere = this._customEditor && this._customCell && this._customCell.r === r && this._customCell.c === c;
2148
+ if (this.mode === 'edit') this._commitActive();
2149
+ if (e.shiftKey) { this.toNav(); this.setExtent(r, c); this.editor.focus(); return; }
2150
+ this.setActive(r, c); this.dragging = true;
2151
+ // チェックボックスを直接クリックしたらトグル(範囲選択は阻害しない)
2152
+ if (e.target.classList && e.target.classList.contains('tg-cb')) { this.dragging = false; this._toggleCheckbox(r, c); return; }
2153
+ // ドロップダウンはシングルクリックで即開く
2154
+ if (this.colType(c) === 'dropdown') { this.dragging = false; this._openSelect(); }
2155
+ // カスタムエディタが openOnClick を申告していれば、ドロップダウン同様シングルクリックで開く(再クリックは開かない=トグル)
2156
+ else { const ed = this.colCfg(c).editor; if (ed && ed.openOnClick && !wasOpenHere) { this.dragging = false; this._openCustomEditor(r, c); } }
2157
+ });
2158
+ // ヘッダ全選択チェックは mousedown で処理済み。click のネイティブ・トグルを止めて二重反転を防ぐ
2159
+ this.table.addEventListener('click', (e) => {
2160
+ if (e.target.classList && e.target.classList.contains('tg-head-cb')) e.preventDefault();
2161
+ });
2162
+ this.table.addEventListener('dblclick', (e) => {
2163
+ // 列境界グリップのダブルクリック=オートフィット(Excel風)
2164
+ if (e.target.classList && e.target.classList.contains('tg-colgrip')) {
2165
+ const th = e.target.closest('th[data-c]'); if (th) this.autoSizeColumn(+th.dataset.c); return;
2166
+ }
2167
+ const td = e.target.closest('td[data-r]'); if (!td) return;
2168
+ this.setActive(+td.dataset.r, +td.dataset.c); this._beginEdit(this.active.r, this.active.c);
2169
+ });
2170
+ this.fillhandle.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); this.filling = { src: this.rectRange() }; });
2171
+ // 右クリックメニュー(行/列の挿入・削除)
2172
+ this.table.addEventListener('contextmenu', (e) => this._onContextMenu(e));
2173
+ this.menuEl.addEventListener('mousedown', (e) => {
2174
+ e.preventDefault(); // メニュー内クリックでエディタの blur 確定を防ぐ
2175
+ const mi = e.target.closest('.tg-mi');
2176
+ if (!mi || mi.classList.contains('tg-disabled') || mi.classList.contains('tg-has-sub')) return; // 親項目は開くだけ
2177
+ const idx = mi.getAttribute('data-mi'); if (idx == null) return;
2178
+ this._hideMenu();
2179
+ const act = this._menuActions && this._menuActions[+idx];
2180
+ if (typeof act === 'function') act();
2181
+ });
2182
+ // メニュー外クリック / Esc / スクロールで閉じる
2183
+ this._docDown = (e) => { if (this.menuEl.style.display !== 'none' && !this.menuEl.contains(e.target)) this._hideMenu(); };
2184
+ this._on(document, 'mousedown', this._docDown, true);
2185
+ this._on(document, 'keydown', (e) => { if (e.key === 'Escape') this._hideMenu(); });
2186
+ this.wrap.addEventListener('scroll', () => this._hideMenu());
2187
+ // スクロール時は枠の位置合わせのみ(highlightRange を呼ばない=ちらつき防止。
2188
+ // 枠はネイティブにスクロール追従するので、これは主に固定列の補正用)
2189
+ this.wrap.addEventListener('scroll', () => {
2190
+ const td = this.activeTd(); if (!td) return;
2191
+ this.place(this.editor, td);
2192
+ if (this.select && this.select.style.display !== 'none') this.place(this.select, td);
2193
+ this._placeOverlays();
2194
+ });
2195
+ // リサイズ追従: テーブルの寸法が変わったら選択枠を貼り直す(ウィンドウ/コンテナ幅変更など)
2196
+ if (typeof ResizeObserver !== 'undefined') {
2197
+ this._ro = new ResizeObserver(() => this._reflow());
2198
+ this._ro.observe(this.table); this._ro.observe(this.wrap);
2199
+ } else if (typeof window !== 'undefined' && window.addEventListener) {
2200
+ this._on(window, 'resize', () => this._reflow());
2201
+ }
2202
+
2203
+ // document: 自分が掴んでる時だけ反応(複数インスタンスでも衝突しない)
2204
+ this._on(document, 'mousemove', (e) => {
2205
+ if (this.resizing) {
2206
+ const rz = this.resizing;
2207
+ if (rz.type === 'col') {
2208
+ rz.size = Math.max(this.minColW, rz.base + (e.clientX - rz.start));
2209
+ if (this.resizeMode === 'live') this.setColWidth(rz.c, rz.size);
2210
+ else this._showGuide('col', rz.edge + rz.size);
2211
+ } else {
2212
+ rz.size = Math.max(this.minRowH, rz.base + (e.clientY - rz.start));
2213
+ if (this.resizeMode === 'live') this.setRowHeight(rz.r, rz.size);
2214
+ else this._showGuide('row', rz.edge + rz.size);
2215
+ }
2216
+ return;
2217
+ }
2218
+ if (this.rowMove) {
2219
+ const rm = this.rowMove;
2220
+ const el = document.elementFromPoint(e.clientX, e.clientY);
2221
+ const tr = el && el.closest ? el.closest('tr[data-r]') : null;
2222
+ let to = rm.to;
2223
+ if (tr) { const r = +tr.dataset.r, b = tr.getBoundingClientRect(); to = (e.clientY > b.top + b.height / 2) ? r + 1 : r; }
2224
+ rm.to = to;
2225
+ if (to >= rm.r0 && to <= rm.r1 + 1) this.resizeGuide.style.display = 'none'; // 自分の中=移動無し
2226
+ else this._showRowDropLine(to);
2227
+ return;
2228
+ }
2229
+ if (this.headerDrag) {
2230
+ const el = document.elementFromPoint(e.clientX, e.clientY);
2231
+ const th = el && el.closest ? el.closest('th') : null;
2232
+ if (th) {
2233
+ if (this.headerDrag === 'col' && th.dataset.c != null) this._extendCol(+th.dataset.c);
2234
+ else if (this.headerDrag === 'row' && th.dataset.r != null) this._extendRow(+th.dataset.r);
2235
+ }
2236
+ return;
2237
+ }
2238
+ if (this.dragging) { const td = this._tdAtPoint(e); if (td) this.setExtent(+td.dataset.r, +td.dataset.c); }
2239
+ else if (this.filling) {
2240
+ const td = this._tdAtPoint(e); if (!td) return;
2241
+ const fr = this.fillRectFromPointer(+td.dataset.r, +td.dataset.c, this.filling.src, e.altKey);
2242
+ if (fr) { this.placeRect(this.fillpreview, fr.r0, fr.c0, fr.r1, fr.c1); this.fillpreview.style.display = 'block'; }
2243
+ else this.fillpreview.style.display = 'none';
2244
+ }
2245
+ });
2246
+ this._on(document, 'mouseup', (e) => {
2247
+ if (this.resizing) {
2248
+ const rz = this.resizing; this.resizing = null; this.resizeGuide.style.display = 'none';
2249
+ if (this.resizeMode !== 'live' && rz.size != null) { // 離した時に確定
2250
+ if (rz.type === 'col') this.setColWidth(rz.c, rz.size); else this.setRowHeight(rz.r, rz.size);
2251
+ }
2252
+ this._reflow(); return;
2253
+ }
2254
+ if (this.rowMove) {
2255
+ const rm = this.rowMove; this.rowMove = null;
2256
+ if (typeof document !== 'undefined') document.body.style.cursor = '';
2257
+ this.resizeGuide.style.display = 'none';
2258
+ this.moveRows(rm.r0, rm.r1, rm.to);
2259
+ return;
2260
+ }
2261
+ if (this.headerDrag) { this.headerDrag = null; return; }
2262
+ if (this.dragging) { this.dragging = false; return; }
2263
+ if (this.filling) {
2264
+ // autoInsertRow: フィルハンドルをテーブル下端より下へドロップしたら、足りない行を追加(縦フィル時)
2265
+ if (this.fillAutoInsertRow && this.fillDirection !== 'horizontal' && !this._tdAtPoint(e)) {
2266
+ const tb = this.table.getBoundingClientRect();
2267
+ const extra = Math.min(1000, Math.ceil((e.clientY - tb.bottom) / this.defRowH));
2268
+ if (extra > 0) {
2269
+ 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++; }
2270
+ this.buildTable();
2271
+ }
2272
+ }
2273
+ const td = this._tdAtPoint(e) || this.cellEl(this.filling.src.r1, this.filling.src.c1);
2274
+ const fr = this.fillRectFromPointer(+td.dataset.r, +td.dataset.c, this.filling.src, e.altKey);
2275
+ this.fillpreview.style.display = 'none';
2276
+ const src = this.filling.src; this.filling = null;
2277
+ if (fr) { this.doFill(src, fr, e.ctrlKey || e.metaKey); this.selectRect(fr.r0, fr.c0, fr.r1, fr.c1); }
2278
+ }
2279
+ });
2280
+ }
2281
+
2282
+ // ---- 型に応じた編集の入口 ----
2283
+ _beginEdit(r, c) {
2284
+ this.clearCopyMarquee(); // 編集開始でコピー/カットのマーキー(点線)を消す(dropdown/checkbox/カスタム含む)
2285
+ if (this.colCfg(c).editor) { this._openCustomEditor(r, c); return; } // カスタムエディタ
2286
+ const t = this.colType(c);
2287
+ if (t === 'checkbox') { this._toggleCheckbox(r, c); return; }
2288
+ if (t === 'dropdown') { this._openSelect(); return; }
2289
+ this.toEdit(this._editText(r, c), true);
2290
+ }
2291
+ // カスタムエディタ口(base-editor 相当)。columns[c].editor = オブジェクト or ()=>オブジェクト。
2292
+ // 契約: editor.open({ grid, r, c, value, td, commit(value), cancel() }) / 任意で editor.close()。
2293
+ // 確定は commit→pushCmd(関所)を通すので検証/整形/読取専用が効く。
2294
+ _openCustomEditor(r, c) {
2295
+ if (this._isReadOnly(r, c)) return;
2296
+ const def = this.colCfg(c).editor, ed = typeof def === 'function' ? def() : def;
2297
+ if (!ed || typeof ed.open !== 'function') return;
2298
+ this._customEditor = ed; this._customCell = { r, c }; this.mode = 'edit';
2299
+ // openOnClick のエディタ(プルダウン同様)は開いている間もフィルハンドルを出す=そのまま埋められる
2300
+ this._customKeepHandle = !!ed.openOnClick;
2301
+ if (this._customKeepHandle) this._placeOverlays(); else this.fillhandle.style.display = 'none';
2302
+ let done = false;
2303
+ const finish = () => { done = true; if (ed.close) { try { ed.close(); } catch (_) {} } this._customEditor = null; this._customCell = null; this._customKeepHandle = false; this.mode = 'nav'; };
2304
+ const commit = (val) => {
2305
+ if (done) return; finish();
2306
+ const before = this.snapSel(), changes = [];
2307
+ this.setCell(r, c, this._cellStr(val), changes);
2308
+ this.pushCmd(changes, before, this.snapSel(), 'edit');
2309
+ if (this.onEditEnd) { try { this.onEditEnd({ r, c, oldValue: this.data[r][c], newValue: this.data[r][c], canceled: false }); } catch (_) {} }
2310
+ };
2311
+ const cancel = () => { if (done) return; finish(); this.setActive(r, c); if (this.onEditEnd) { try { this.onEditEnd({ r, c, oldValue: this.data[r][c], newValue: this.data[r][c], canceled: true }); } catch (_) {} } };
2312
+ this._customCancel = cancel;
2313
+ try { ed.open({ grid: this, r, c, value: this.data[r][c], td: this.cellEl(r, c), commit, cancel }); } catch (_) { finish(); }
2314
+ if (this.onEditStart) { try { this.onEditStart({ r, c, value: this.data[r][c] }); } catch (_) {} }
2315
+ }
2316
+ _commitActive() {
2317
+ if (this._customEditor) { if (this._customCancel) this._customCancel(); return; } // カスタム編集中の外側操作は取消
2318
+ if (this.select && this.select.style.display !== 'none') this._commitSelect();
2319
+ else this.commit();
2320
+ }
2321
+ // checkbox: ON/OFF をトグルして関所を通す
2322
+ _toggleCheckbox(r, c) {
2323
+ if (this._isReadOnly(r, c)) return; // 読み取り専用はトグル不可
2324
+ const cur = this.data[r][c];
2325
+ const nv = this._isCheckedVal(c, cur) ? this._cbUnchecked(c) : this._cbChecked(c);
2326
+ const before = this.snapSel(), changes = [];
2327
+ this.setCell(r, c, nv, changes);
2328
+ this.pushCmd(changes, before, this.snapSel(), 'edit');
2329
+ }
2330
+ // dropdown: <select> を開く
2331
+ _openSelect() {
2332
+ const r = this.active.r, c = this.active.c, cfg = this.colCfg(c);
2333
+ if (this._isReadOnly(r, c)) return; // 読み取り専用は <select> を開かない
2334
+ const opts = this._optList(c), values = opts.map(o => o.value), cur = this.data[r][c];
2335
+ const allowEmpty = cfg.allowEmpty !== false; // 既定: クリア用の空オプションを先頭に付ける
2336
+ let html = allowEmpty ? '<option value=""></option>' : '';
2337
+ for (const o of opts) html += '<option value="' + TssGrid.esc(o.value) + '">' + TssGrid.esc(o.label) + '</option>'; // value↔label 分離
2338
+ this.select.innerHTML = html;
2339
+ this.select.value = values.includes(cur) ? cur : (allowEmpty ? '' : (values[0] || '')); // 保存は value。空不可なら先頭候補
2340
+ this.mode = 'edit';
2341
+ this.place(this.select, this.activeTd());
2342
+ this.select.style.display = 'block'; this.select.focus();
2343
+ this.updateSelectionUI(); // ドロップダウンを開いてもフィルハンドルは出したまま
2344
+ // ネイティブの選択肢リストまで開く(.focus() だけでは開かない)。未対応/非ユーザー操作時は黙って無視。
2345
+ if (typeof this.select.showPicker === 'function') { try { this.select.showPicker(); } catch (_) {} }
2346
+ if (this.onEditStart) { try { this.onEditStart({ r, c, value: cur }); } catch (_) {} }
2347
+ }
2348
+ _commitSelect() {
2349
+ const r = this.active.r, c = this.active.c, oldValue = this.data[r][c], v = this.select.value;
2350
+ this.select.style.display = 'none'; this.mode = 'nav';
2351
+ const before = this.snapSel(), changes = [];
2352
+ this.setCell(r, c, v, changes);
2353
+ this.pushCmd(changes, before, this.snapSel(), 'edit');
2354
+ if (this.onEditEnd) { try { this.onEditEnd({ r, c, oldValue, newValue: this.data[r][c], canceled: false }); } catch (_) {} }
2355
+ }
2356
+ _cancelSelect() {
2357
+ const r = this.active.r, c = this.active.c, v = this.data[r][c];
2358
+ this.select.style.display = 'none';
2359
+ if (this.onEditEnd) { try { this.onEditEnd({ r, c, oldValue: v, newValue: v, canceled: true }); } catch (_) {} }
2360
+ this.setActive(r, c);
2361
+ }
2362
+
2363
+ // 確定キーの移動。dir は enterMoves/tabMoves の値、reverse は Shift 押下。
2364
+ _moveBy(dir, reverse) {
2365
+ const D = { down: [1, 0], up: [-1, 0], right: [0, 1], left: [0, -1], none: [0, 0] };
2366
+ let [dr, dc] = D[dir] || D.down;
2367
+ if (reverse) { dr = -dr; dc = -dc; }
2368
+ let r = this.active.r, c = this.active.c;
2369
+ if (dc !== 0) { // 横移動は隠し列をスキップ+結合幅を跨ぐ
2370
+ const nc = this._stepColM(r, c, dc > 0 ? 1 : -1);
2371
+ if (nc !== c) c = nc;
2372
+ else if (this.autoWrapRow) { c = dc > 0 ? this._firstVisCol() : this._lastVisCol(); r += dc > 0 ? 1 : -1; }
2373
+ }
2374
+ if (dr !== 0) {
2375
+ r = this._stepRowM(r, c, dr > 0 ? 1 : -1); // 縦移動は結合高を跨ぐ
2376
+ if (this.autoWrapCol) { // 縦の端で列送り(送り先も可視列)
2377
+ if (r >= this.ROWS) { r = 0; c = this._stepVisCol(c, 1); }
2378
+ else if (r < 0) { r = this.ROWS - 1; c = this._stepVisCol(c, -1); }
2379
+ }
2380
+ }
2381
+ this.setActive(r, c);
2382
+ }
2383
+
2384
+ _onKey(e) {
2385
+ const composing = e.isComposing || e.keyCode === 229;
2386
+ // onBeforeKeyDown: グリッドより先にユーザーへ。false / preventDefault で既定を止める。
2387
+ if (this.onBeforeKeyDown) { let r; try { r = this.onBeforeKeyDown(e); } catch (_) {} if (r === false || e.defaultPrevented) return; }
2388
+ // カスタムショートカット(IME 変換中は除く)
2389
+ if (!composing && this._runShortcuts(e)) return;
2390
+ if (this.mode === 'edit') {
2391
+ if (composing) return;
2392
+ if (e.key === 'Enter') { e.preventDefault(); this.commit(); this._advance('enter', e.shiftKey); return; }
2393
+ if (e.key === 'Tab') { e.preventDefault(); this.commit(); this._advance('tab', e.shiftKey); return; }
2394
+ if (e.key === 'Escape') {
2395
+ e.preventDefault();
2396
+ const r = this.active.r, c = this.active.c, v = this.data[r][c];
2397
+ if (this.onEditEnd) { try { this.onEditEnd({ r, c, oldValue: v, newValue: v, canceled: true }); } catch (_) {} }
2398
+ this.setActive(r, c); return;
2399
+ }
2400
+ return;
2401
+ }
2402
+ if (composing) return;
2403
+ const ctrl = e.ctrlKey || e.metaKey;
2404
+ if (ctrl && !e.shiftKey && e.key.toLowerCase() === 'z') { e.preventDefault(); this.history.undo(); return; }
2405
+ if (ctrl && (e.key.toLowerCase() === 'y' || (e.shiftKey && e.key.toLowerCase() === 'z'))) { e.preventDefault(); this.history.redo(); return; }
2406
+ if (ctrl && e.key.toLowerCase() === 'c') { e.preventDefault(); this.copy(false); return; }
2407
+ if (ctrl && e.key.toLowerCase() === 'x') { e.preventDefault(); this.copy(true); return; }
2408
+ if (ctrl && e.key.toLowerCase() === 'v') { e.preventDefault(); this.paste(); return; }
2409
+ if (e.shiftKey && e.key.startsWith('Arrow')) {
2410
+ e.preventDefault();
2411
+ let { r, c } = this.extent;
2412
+ if (e.key === 'ArrowUp') r = this._stepRowM(r, c, -1); else if (e.key === 'ArrowDown') r = this._stepRowM(r, c, 1);
2413
+ else if (e.key === 'ArrowLeft') c = this._stepColM(r, c, -1); else c = this._stepColM(r, c, 1); // 隠し列スキップ+結合跨ぎ
2414
+ this.setExtent(r, c); return;
2415
+ }
2416
+ // Space: チェックボックス列ならトグル(テキスト列はそのまま入力に流す)
2417
+ if (e.key === ' ' && this.colType(this.active.c) === 'checkbox') { e.preventDefault(); this._toggleCheckbox(this.active.r, this.active.c); return; }
2418
+ switch (e.key) {
2419
+ case 'ArrowUp': e.preventDefault(); this.setActive(this._stepRowM(this.active.r, this.active.c, -1), this.active.c); return;
2420
+ case 'ArrowDown': e.preventDefault(); this.setActive(this._stepRowM(this.active.r, this.active.c, 1), this.active.c); return;
2421
+ case 'ArrowLeft': e.preventDefault(); this.setActive(this.active.r, this._stepColM(this.active.r, this.active.c, -1)); return;
2422
+ case 'ArrowRight': e.preventDefault(); this.setActive(this.active.r, this._stepColM(this.active.r, this.active.c, 1)); return;
2423
+ case 'Enter': e.preventDefault(); this._advance('enter', e.shiftKey); return;
2424
+ case 'Tab': e.preventDefault(); this._advance('tab', e.shiftKey); return;
2425
+ case 'F2': e.preventDefault(); this._beginEdit(this.active.r, this.active.c); return;
2426
+ case 'Delete': e.preventDefault(); this.clearRange(); return;
2427
+ case 'Backspace': e.preventDefault();
2428
+ if (this.colType(this.active.c) === 'text') this.toEdit('', true); else this.clearRange();
2429
+ return;
2430
+ case 'Escape': this.clearCopyMarquee(); return;
2431
+ }
2432
+ }
2433
+ }
2434
+
2435
+ // ---- UMD export ----
2436
+ const api = { TssGrid, HistoryManager };
2437
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
2438
+ else { root.TssGrid = TssGrid; root.HistoryManager = HistoryManager; }
2439
+
2440
+ })(typeof self !== 'undefined' ? self : this);