Excel の「右下を掴んで引っぱる」を軽量グリッドで実装する
Excel で 1 と 2 を選んで、選択枠の右下の小さな四角を下へ引っぱると
3, 4, 5… と連番が流れていく――あの操作を「フィルハンドル」と呼びます。
毎日 Excel を触っている人にとっては息をするように使う機能なので、
業務向けの Web グリッドでこれが無いと、それだけで「使えない」と判断されます。
この記事は、TssGrid がこのフィルハンドルを依存ゼロ・コア数十行でどう組んだか、
そして地味に効いてくる設計の勘所の記録です。
フィルが「やっていること」を分解する
ドラッグ操作はリッチに見えますが、確定の瞬間にやることは2つしかありません。
- どこを埋めるか=元の選択範囲(src)と、いまポインタがある位置から、塗りつぶす矩形(target)を決める。
- 何で埋めるか=src のセル値を見て、連番にするか・そのままコピーするか・パターンを繰り返すかを決め、target に流し込む。
この2つを別々の関数にしておくと、ドラッグ中のプレビュー枠(どこが塗られるかを薄く見せる箱)は 前者だけを呼べばよく、確定時は前者+後者を呼ぶ、という形に綺麗に分かれます。
解法①:方向は「はみ出し量が大きい方」へスナップする
いちばん最初に悩むのが方向です。右下のハンドルを斜め下に引っぱったとき、 ユーザーは「下に連番を入れたい」のか「右に入れたい」のか。Excel は斜めには塗らず、 ドラッグ方向の支配的な軸へスナップします。TssGrid も同じで、 縦のはみ出し量と横のはみ出し量を比べ、大きい方の軸だけを採用します。
// src=元の選択範囲, (pr,pc)=ポインタのいるセル。塗る矩形を返す。 fillRectFromPointer(pr, pc, src) { if (insideSrc(pr, pc)) return null; // 範囲の中=何もしない const dDown = pr - src.r1, dUp = src.r0 - pr; // 下/上へのはみ出し const dRight = pc - src.c1, dLeft = src.c0 - pc; // 右/左へのはみ出し const vOver = Math.max(dDown, dUp, 0); const hOver = Math.max(dRight, dLeft, 0); if (vOver >= hOver) // ★ 縦のはみ出しが勝てば縦フィル、横が勝てば横フィル return (dUp > dDown) ? rect(pr, src.r1, src.c0, src.c1) : rect(src.r0, pr, src.c0, src.c1); return (dLeft > dRight) ? rect(src.r0, src.r1, pc, src.c1) : rect(src.r0, src.r1, src.c0, pc); }
この「はみ出し量で軸を選ぶ」だけで、斜めドラッグでも自然に縦/横へ吸い付きます。
分岐の中で dUp > dDown(上へ伸ばすのか下へ伸ばすのか)も決めているので、
上方向フィル・左方向フィルも同じ1関数で扱えます。
fillDirection:'both' のときに Alt を押しながらドラッグで発動――
誤爆を避けるため、わざと「明示の操作」にしてあります。
解法②:連番か、コピーか――値から推測する
埋める矩形が決まったら、次は中身です。ここが Excel らしさの本体。判断はシンプルで、
- 元の値がすべて数値で、2つ以上あり、差が一定(等差)なら → 連番として外挿(
10,20→30,40,50)。 - 数値だけど等差でない/1つだけ → 既定はコピー。
- 文字や日付混じりなら → 元のパターンを周期で繰り返す(
A,B→A,B,A,B)。
lineFill(srcVals, count, ctrl) {
const nums = srcVals.map(toNum), numeric = nums.every(n => n !== null);
let isSeries = false, step = 0;
if (numeric && srcVals.length >= 2) { // 等差かどうか調べる
step = nums[1] - nums[0]; isSeries = true;
for (let i = 2; i < nums.length; i++)
if (nums[i] - nums[i - 1] !== step) { isSeries = false; break; }
}
// Ctrl は意味を反転:連番上では「コピー」、ただの数値上では「強制連番(+1)」
if (numeric) isSeries = isSeries ? !ctrl : (ctrl ? (step = 1, true) : false);
const out = [];
if (isSeries) { const last = nums[nums.length - 1];
for (let k = 1; k <= count; k++) out.push(fmt(last + step * k)); }
else for (let k = 0; k < count; k++) out.push(srcVals[k % srcVals.length]); // 周期コピー
return out;
}
ポイントは Ctrl の扱いです。Excel と同じく、Ctrl は意味を反転させます。
連番になる組み合わせの上で Ctrl を押せば「連番化せずそのままコピー」、
逆に 5 という1つの数値の上で Ctrl を押せば「6,7,8… と強制連番」。
修飾キー1つで“ユーザーが今ほしい方”に倒せるのが、地味ですが効きます。
解法③:タイルフィルは「剰余」で素直に書く
斜めに広げる 2D フィルは、元矩形をタイルのように敷き詰めるだけ。 負のインデックスにも耐えるよう剰余を正規化しておけば、上方向・左方向にも同じ式で効きます。
// 元の高さ sH・幅 sW のブロックを、target 全域にタイル配置 const sr = src.r0 + (((r - src.r0) % sH) + sH) % sH; const sc = src.c0 + (((c - src.c0) % sW) + sW) % sW; setCell(r, c, data[sr][sc]); // (r,c) に対応する元セルを写す
いちばん効く設計:フィルは「1回の Undo」で戻る
どれだけ賢く連番を作っても、1回ドラッグした結果が Undo 1発で消えないと業務では使えません。 TssGrid では、フィルで書き換わる全セルを1つの配列にためて、たった1回だけ履歴に積みます。
doFill(src, target, ctrl) {
if (onBeforeAutofill(src, target) === false) return; // 拒否できるフック
const changes = []; // ← 変更を全部ここに集める
if (vertical && horizontal) tileFill(src, target, changes);
else if (vertical) fillVertical(src, target, changes, ctrl);
else fillHorizontal(src, target, changes, ctrl);
pushCmd(changes, before, after, 'fill'); // ★ ここが唯一の“関所”=1コマンド=1Undo
onAfterAutofill(src, target);
}
この pushCmd は、TssGrid のすべての値変更(手入力・貼り付け・削除・フィル)が必ず通る1つの関所です。
=フィルも特別扱いせず同じ通り道を使うので、onBeforeChange での検証も、
source:'fill' 付きの onAfterChange 通知も、Undo/Redo の粒度も、
全部タダで揃います。この「関所を1本に絞る」話は
別記事の IME 設計とも通じる、TssGrid の背骨です。
細部に宿るもの
- ドラッグ中のプレビュー枠:確定前に「ここが塗られる」を薄い箱で見せる。中身は
fillRectFromPointerを呼ぶだけ。 - 隠し列はまたぐ:フィルタや非表示列があっても、見えている列だけに値を流す(隠し列は素通り)。
- 表の下までドラッグで行追加:
autoInsertRowを付けると、ハンドルを最下行より下へ落としたぶんだけ行が増える(伝票入力で効く)。 - 画面外まで伸ばせる:端へドラッグするとビューが自動スクロールし、窓の外のセルもモデル幾何から逆算して塗る。仮想スクロールとも両立。
触ってみる
下のグリッドで試せます。セルの選択枠の右下にある小さな四角(フィルハンドル)を掴んでドラッグしてください。
- 「数量」列で
10と20の2セルを選び、下へ引っぱる →30, 40, 50…と連番。 - 「担当」列の1セルを下へ引っぱる → 同じ値がコピー。
- 連番を引っぱるとき Ctrl を押しっぱなし → 連番化せずコピーに切り替わる(逆も同じ)。