— trackChangesとUndoStackを正しく分業し、ファイル選択も一手にする
先に用語をそろえる(平易 → 用語 → 以後は平易語)
-
変更の“差分一覧”を持つ仕組み → trackChanges(CollectionViewの機能)
追加・削除・編集をitemsAdded / itemsRemoved / itemsEditedに自動集計。保存時に便利。ただし元に戻す機能そのものではない。(MESCIUS) -
画面操作を“1手ずつ”積んで元に戻す仕組み → UndoStack(wijmo.undo)
指定したDOM配下のHTML入力と多くのWijmoコントロールの操作を監視し、undo()/redo()で往復。自作の一手(UndoableAction)をpushActionで積むこともできる。(MESCIUS) -
input type="file"は値をスクリプトで戻せない(設計上の制約)。Undoはアプリの状態+UI再描画で再現する。(MDN ウェブドキュメント)
この三つを混同しないと、設計がシンプルになる。
ゴールと前提
- ゴール:行追加/削除、セル編集、セレクト変更、ファイル/画像選択まで含めてUndo/Redo可能にする。
- 前提:プレーンJavaScript+Wijmo(FlexGrid, CollectionView, wijmo.undo)。
設計の地図(全体像)
-
差分管理は
CollectionView.trackChanges = true(保存用)。(MESCIUS) -
操作の履歴は
new UndoStack('#編集スコープ')(UI操作は自動捕捉)。(MESCIUS) -
コードで行を追加/削除するときは、明示的に“一手”を作る(
onRowAdded/onDeletingRowを叩く or カスタムAction)。(MESCIUS) -
ファイル/画像選択は、カスタムActionで前後状態を持ち、
pushActionで積む。(MESCIUS)
最小セットアップ
<form id="edit-scope">
<button type="button" id="undoBtn">Undo</button>
<button type="button" id="redoBtn">Redo</button>
<input id="fileInput" type="file" accept="image/*">
<img id="preview" alt="" style="display:none;max-height:120px" />
<div id="theGrid" style="height:320px"></div>
</form>
<script type="module">
import * as wjCore from '@mescius/wijmo';
import * as wjGrid from '@mescius/wijmo.grid';
import * as wjUndo from '@mescius/wijmo.undo';
// 1) データ+差分
const view = new wjCore.CollectionView([
{ id: 1, name: 'Apple', price: 120 },
{ id: 2, name: 'Banana', price: 80 },
], { trackChanges: true });
// 2) Grid
const grid = new wjGrid.FlexGrid('#theGrid', {
itemsSource: view,
allowAddNew: true,
allowDelete: true,
autoGenerateColumns: false,
columns: [
{ binding: 'id', header: 'ID', isReadOnly: true, width: 80 },
{ binding: 'name', header: 'Name', width: '*' },
{ binding: 'price', header: 'Price', format: 'n0', width: 120 },
]
});
// 3) UndoStack(このフォーム配下を監視)
const undoStack = new wjUndo.UndoStack('#edit-scope', {
maxActions: 200,
stateChanged: (s) => {
document.querySelector('#undoBtn').disabled = !s.canUndo;
document.querySelector('#redoBtn').disabled = !s.canRedo;
// デバッグ:件数を覗く
console.debug('[undo-state]', { count: s.actionCount, canUndo: s.canUndo, canRedo: s.canRedo });
},
addingAction: (s, e) => console.debug('[addingAction]', e.action?.constructor?.name || e.action?.name),
});
document.querySelector('#undoBtn').onclick = () => undoStack.undo();
document.querySelector('#redoBtn').onclick = () => undoStack.redo();
</script>
-
allowAddNew/allowDeleteを立てれば、UI経由の追加・削除は自動でUndo対象になる。(MESCIUS) -
trackChanges=trueで差分三兄弟が使える(保存時に便利)。(MESCIUS)
ファイル/画像選択を“1手”にする
やることはシンプル。前の状態と次の状態をスナップショット化し、
undo() と redo() で 状態+UIを再現する。
import { UndoableAction, UndoStack } from '@mescius/wijmo.undo';
const state = { file: null, url: null }; // 画像の“いま”
const input = document.querySelector('#fileInput');
const img = document.querySelector('#preview');
// UI反映+古いblobの掃除
function applyImage(next) {
if (state.url && state.url.startsWith('blob:')) URL.revokeObjectURL(state.url);
state.file = next.file || null;
state.url = next.url || null;
img.src = state.url || '';
img.style.display = state.url ? 'block' : 'none';
input.value = ''; // file要素の値は戻せないので常に空にする
}
// カスタム一手
class FileSelectAction extends UndoableAction {
constructor(target, prev, next) {
super(target);
this._prev = prev; // {file, url}
this._next = next;
this.name = '画像選択';
}
undo() { applyImage(this._prev); }
redo() { applyImage(this._next); }
}
// 変更ハンドラ:スナップショット→UI反映→pushAction
input.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0] ? e.target.files[0] : null;
const prev = { file: state.file, url: state.url };
const next = file ? { file, url: URL.createObjectURL(file) } : { file: null, url: null };
applyImage(next);
undoStack.pushAction(new FileSelectAction(document.querySelector('#edit-scope'), prev, next));
});
- 事実:
<input type="file">の値はスクリプトで戻せない。アプリ状態を戻してUIを再描画するのが正攻法。(MDN ウェブドキュメント) -
UndoableActionを継承し、pushActionで積むのが素直。(MESCIUS)
行追加/削除を“コードで”やるときのUndo
UI操作なら自動で積まれるが、コードで一括追加・削除する場合は明示的に“一手”を作る。
1行追加を1手にする(最小)
const v = grid.editableCollectionView;
function addOneItem() {
grid.focus(); // Undoが正しく積まれるための定石
const it = v.addNew();
it.id = Date.now();
it.name = 'New';
it.price = 0;
// ← ここでGridに「行が追加された」という一手を作らせる
grid.onRowAdded(new wjGrid.CellRangeEventArgs(grid.cells, grid.selection));
v.commitNew();
}
まとめてN行を“1手”にする(バッチ)
100行まとめて追加したらUndo 100回は苦行。親アクションを自作して1手に束ねる。
class BatchAddAction extends UndoableAction {
constructor(target, items) {
super(target);
this.items = items; // 追加したアイテム配列(ID等で同定できるように)
this.name = `行を${items.length}件追加`;
}
undo() {
const v = grid.editableCollectionView;
// 追加した行を全て削除
this.items.forEach(it => v.remove(it));
}
redo() {
const v = grid.editableCollectionView;
this.items.forEach(it => v.addNew() && Object.assign(v.currentAddItem, it) && v.commitNew());
// 1手としての痕跡だけ付けたいときは onRowAdded を1回だけ叩く設計でもOK
}
}
function addMany(itemsToAdd) {
itemsToAdd = itemsToAdd.map(it => ({ ...it })); // スナップショット
itemsToAdd.forEach(it => { grid.focus(); const it2 = v.addNew(); Object.assign(it2, it); grid.onRowAdded(new wjGrid.CellRangeEventArgs(grid.cells, grid.selection)); v.commitNew(); });
undoStack.pushAction(new BatchAddAction('#edit-scope', itemsToAdd));
}
-
onRowAdded/onDeletingRowを叩く実装は、公式フォーラム回答・デモでも推奨。(MESCIUS)
UndoStackの中身を“見える化”する
内部配列を直接覗くAPIはない。イベントで観測してミラー配列を作るのが現実解。(MESCIUS)
const timeline = [];
undoStack.addingAction.addHandler((s, e) => {
const name = e.action.name || e.action.constructor?.name || 'Unknown';
timeline.push({ type: 'add', name, t: Date.now() });
console.debug('[add->stack]', name);
});
undoStack.undoingAction.addHandler((s, e) => console.debug('[undoing]', e.action.name));
undoStack.redoingAction.addHandler((s, e) => console.debug('[redoing]', e.action.name));
-
actionCount / canUndo / canRedoでボタン活性やラベルも更新できる。(MESCIUS)
保存処理(差分の使い方)
保存ボタンで view.itemsAdded / itemsRemoved / itemsEdited を読み、サーバーへ反映。
Undo/RedoはUIの体験、trackChangesは保存のための材料。ここは常に分業。(MESCIUS)
function collectDiffs() {
const adds = view.itemsAdded.map(shallowCopy);
const dels = view.itemsRemoved.map(shallowCopy);
const edits = view.itemsEdited.map(shallowCopy);
return { adds, dels, edits };
}
function shallowCopy(x){ return {...x}; }
よくある落とし穴と回避
-
「trackChangesをtrueにしたのに戻らない」
役割が違う。戻すのはUndoStack、差分はtrackChanges。(MESCIUS) -
ファイル選択がUndoに載らない
ブラウザ仕様。カスタムActionをpushAction。Fileとblob URLを状態として持ち、revokeObjectURLを忘れない。(MDN ウェブドキュメント) -
コードでの大量追加が細切れのUndoになる
バッチActionで1手にまとめる。公式でも「追加・削除をコードでやるなら、Undo用の一手を作る」方針。(MESCIUS)
仕上げのチェックリスト
-
UndoStackをフォームや編集領域に張っている(#edit-scope) - UI編集は自動Undo、**コード編集は“一手を作る”**という方針が全体で一貫
-
ファイル/画像はカスタムActionで前後状態+
pushAction -
trackChangesは保存用に分業し、差分三兄弟を使っている -
addingAction / stateChangedを入れて、何がスタックに載ったかを観測できる
参考(一次情報)
- CollectionViewの差分管理(
trackChangesと三兄弟)— 公式Doc/デモ。(MESCIUS) - UndoStackのAPI(
pushAction、canUndoなど)— 公式API。(MESCIUS) - UndoStackの使い方(監視対象DOM、HTML入力やWijmoコントロールを自動追跡)— 公式デモ&ブログ。(MESCIUS)
- FlexGridの行追加/削除(UIで自動、コードはフックが必要)— 公式デモ&フォーラム回答。(MESCIUS)
- File入力はスクリプトで値を戻せない(設計上の制約)— MDN/Chromiumメーリングリスト等。(MDN ウェブドキュメント)
ラストメモ
Undo/Redoの“体験”と、サーバー保存の“帳尻”を、意識的に分ける。
GridのUI編集はUndoStackに寄せ、コード編集とファイル選択は自前の一手で包む。
これで、FlexGridの一括編集はだいぶ素直に回る。
補足
trackChanges を使わず、読み込み直後のスナップショットと 現在の CollectionView を突き合わせて itemsAdded / itemsRemoved / itemsEdited 相当を自力で出すやつ。計算量は O(n) に抑える。
方針(平易 → 用語 → 以後は平易語)
-
1件を一意に見分ける鍵 → 主キー(primary key)
例:id。なければ読み込み時に仮キーを発番して持たせる。 -
変更の判定は比較したい項目だけを見る
例:['name', 'price', 'stock']のように配列で指定。未指定なら全列比較でもよいが、更新対象列に絞る方が速くて安全。 -
スナップショットは
Map<key, { raw, pick, hash }>で持つ-
raw… 元オブジェクト(保存用に前後差を取りたいから) -
pick… 比較対象のフィールドだけ抜き出した軽量オブジェクト -
hash…pickの安いハッシュ(JSON化など)
こうしておけば、現在状態のpick/hashと照合して一発で added / removed / edited を出せる。
-
実装(プレーンJS)
// =========================
// 設定
// =========================
// 1件を識別するキーを返す関数。
// 既存の id が無いなら、初期化時に __uid を付ける。
function defaultKeySelector(item) {
return item.id ?? item.__uid;
}
// 比較対象フィールドを整形する
function projectFields(item, fields) {
if (!fields || fields.length === 0) return structuredClone(item); // 全項目
const out = {};
for (const f of fields) out[f] = item[f];
return out;
}
// オブジェクトを軽くハッシュ(順序安定のJSON)
// Dateや数値/文字列の扱いに注意。必要なら整形してから。
function stableHash(obj) {
// ソート付きJSON
const keys = Object.keys(obj).sort();
const parts = keys.map(k => `${k}:${normalize(obj[k])}`);
return parts.join('|');
}
function normalize(v) {
if (v == null) return '␀';
if (v instanceof Date) return `@date:${v.toISOString()}`;
if (typeof v === 'number' && Number.isNaN(v)) return '@nan';
if (typeof v === 'object') return stableHash(v); // ネスト対応(軽め)
return String(v);
}
// =========================
// スナップショット作成
// =========================
/**
* 初期状態のスナップショットを作る
* @param {wijmo.CollectionView} view
* @param {Object} opts
* - keySelector: (item)=>key
* - fields: string[] 比較対象のフィールド
* - assignUidIfMissing: boolean 主キーが無い行に __uid を振る
*/
function createBaseline(view, opts = {}) {
const {
keySelector = defaultKeySelector,
fields = [],
assignUidIfMissing = true,
} = opts;
let uidSeq = 1;
const map = new Map();
const order = [];
view.items.forEach((it) => {
// 仮キー付与
if (assignUidIfMissing && it.id == null && it.__uid == null) {
Object.defineProperty(it, '__uid', { value: `tmp_${uidSeq++}`, enumerable: true, writable: false });
}
const key = keySelector(it);
const pick = projectFields(it, fields);
const hash = stableHash(pick);
map.set(key, { raw: structuredClone(it), pick, hash });
order.push(key);
});
return { map, order, fields, keySelector };
}
// =========================
// 差分計算
// =========================
/**
* 現在のCollectionViewとbaselineを比較して差分を返す
* @param {wijmo.CollectionView} view
* @param {Object} baseline - createBaselineの戻り
* @returns {Object} { added, removed, edited }
* added: after
* removed: before
* edited: { key, before, after, changes }
*/
function computeDiff(view, baseline) {
const { map: baseMap, fields, keySelector } = baseline;
const currMap = new Map();
const added = [];
const removed = [];
const edited = [];
// 現在側を走査して added / edited を特定
view.items.forEach((it) => {
const key = keySelector(it);
const pick = projectFields(it, fields);
const hash = stableHash(pick);
currMap.set(key, { raw: it, pick, hash });
const baseHit = baseMap.get(key);
if (!baseHit) {
// 追加
added.push(structuredClone(it));
} else if (baseHit.hash !== hash) {
// 変更あり → どの項目が変わったか列挙
const changes = {};
const keys = fields.length ? fields : Array.from(new Set([...Object.keys(baseHit.pick), ...Object.keys(pick)]));
for (const k of keys) {
const a = baseHit.pick[k];
const b = pick[k];
if (normalize(a) !== normalize(b)) {
changes[k] = [a, b];
}
}
edited.push({
key,
before: structuredClone(baseHit.raw),
after: structuredClone(it),
changes
});
}
});
// 基準にあって現在に無い → removed
baseMap.forEach((val, key) => {
if (!currMap.has(key)) {
removed.push(structuredClone(val.raw));
}
});
return { added, removed, edited };
}
// =========================
// 運用ユーティリティ
// =========================
// 差分を取り終えて「きれいな状態」にしたいとき、現在を新しい基準にする
function resetBaseline(view, baseline) {
const next = createBaseline(view, {
keySelector: baseline.keySelector,
fields: baseline.fields,
assignUidIfMissing: false, // 既に __uid が付いているはず
});
baseline.map = next.map;
baseline.order = next.order;
}
// 例:差分をAPIに投げる形に整形
function collectPayload(diff, options = {}) {
const { includeChanges = true } = options;
const payload = {
itemsAdded: diff.added,
itemsRemoved: diff.removed,
itemsEdited: includeChanges ? diff.edited.map(e => ({ key: e.key, before: e.before, after: e.after, changes: e.changes }))
: diff.edited.map(e => e.after),
};
return payload;
}
使い方(FlexGrid + CollectionView)
// viewは既にある想定(例):
const view = new wijmo.CollectionView(data, { /* trackChanges: 使わない */ });
// 1) 初期スナップショットを作る(比較対象列を指定)
const baseline = createBaseline(view, {
keySelector: it => it.id ?? it.__uid,
fields: ['name', 'price', 'stock'],
});
// …ユーザーが編集・追加・削除…(UIでもコードでもOK)
// 2) 差分を計算
const diff = computeDiff(view, baseline);
// 3) 使いやすい形にする
const payload = collectPayload(diff);
console.log(payload.itemsAdded, payload.itemsRemoved, payload.itemsEdited);
// 4) 保存が通ったら今の状態を「正」とする
resetBaseline(view, baseline);
細かな実務ポイント
-
主キーが無いデータ
初期化時に__uidを振って不変にする。編集中にidが発番されたら、スナップショット更新時にキーセレクタを変えない(keySelectorは一貫させる)。どうしてもidに寄せたいなら、保存成功時にresetBaselineして以後はidを使う方針に切り替える。 -
比較フィールドの選び方
送信する可能性がある項目に絞るのが安全。createdAtやupdatedAtのようなシステム列は除外しないと毎回変更扱いになる。
日付や小数は正規化してから比較(上のnormalizeでISO化/文字列化済み)。 -
参照のまま比較しない
structuredCloneで before/after を凍結しておく。ビュー側で値がまた変わっても、変更時点のスナップショットが残る。 -
順序の変化を無視したい
今回は順序は無視(キー照合のみ)。「並び替え変更も差分にしたい」要件なら、baseline.orderと現在のキー順を比べてmovedを別に出す。 -
大規模データの速度
1万件級でも O(n) なら現実的。フィールド数が多いとstableHashが効く(等しければフィールドループに入らない)。さらに詰めたければ、- 比較対象を プリミティブ列だけに絞る
- ネストオブジェクトは事前に派生列へ潰しておく
といった前処理が効く。
これで何が手に入るか
-
trackChangesに頼らず、「差分をいつ計算するか」を自分で決められる。 - UIのUndo/Redo(
UndoStack)と絡めても、保存の帳尻は常にこの差分計算で確定できる。 - 「コードで一括編集したけど、追加/削除/編集を精密に出したい」要件にも強い。
いいね。セル内の <input type="file"> を “1手” にして Undo/Redo に載せる共通クラスを用意する。
前提はこう:
- FlexGrid は既にある(
grid)。 - そのセルに置くファイル入力のクラス名(例:
.file-cell-input)を決めておく。 -
UndoStackは外から渡す(フォーム全体などですでに使っている想定)。 - “値を戻す”のは input の value じゃなく データ項目(dataItem)のプロパティ。UI はそれを表示する(input は毎回クリア)。
使い方の絵
- ファイル列は、データに
{ name, size, type, lastModified }のメタを持たせる(未選択ならnull)。 - セルの表示は「ファイル名ラベル+file input」。ラベルは
dataItem[binding]?.nameを出す。 - 変更時に 前後状態をスナップショット → データに反映 → UndoStack に “1手” を push。
- Undo/Redo 時は キーで該当行を探し、その列の値を前/後に戻す。UI ラベルも同期。input は常に空に戻す。
クラス本体(プレーンJS)
<style>
.file-cell { display: flex; gap: .5rem; align-items: center; }
.file-cell-name { min-width: 8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>
<script>
// 依存:wijmo.grid, wijmo.undo が読み込まれていること
// grid: wjGrid.FlexGrid
// undoStack: wjUndo.UndoStack
// fileInputClass: 例 '.file-cell-input'
class GridFileUndoBinder {
/**
* @param {wjGrid.FlexGrid} grid
* @param {wjUndo.UndoStack} undoStack
* @param {string} fileInputClass - '.file-cell-input' のようなセレクタ
* @param {object} options
* - keySelector: (item)=>key 行識別子。既存の id か __uid など
* - bindingFilter: (col)=>boolean 対象列を絞りたい場合
*/
constructor(grid, undoStack, fileInputClass, options = {}) {
this.grid = grid;
this.undoStack = undoStack;
this.fileSel = fileInputClass;
this.keySelector = options.keySelector || ((it) => it.id ?? it.__uid);
this.bindingFilter = options.bindingFilter || (() => true);
this._onChange = this._onChange.bind(this);
}
attach() {
// セル内の change を捕まえる(キャプチャで早めに拾う)
this.grid.hostElement.addEventListener('change', this._onChange, true);
}
detach() {
this.grid.hostElement.removeEventListener('change', this._onChange, true);
}
// 変更ハンドラ:ファイル選択を “1手” にする
_onChange(e) {
const t = e.target;
if (!(t instanceof HTMLInputElement)) return;
if (t.type !== 'file') return;
if (!t.matches(this.fileSel)) return;
const hti = this.grid.hitTest(e); // どのセルか
if (!hti || hti.cellType !== wijmo.grid.CellType.Cell) return;
const rowObj = this.grid.rows[hti.row];
if (!rowObj) return;
const item = rowObj.dataItem;
const col = this.grid.columns[hti.col];
if (!col || !this.bindingFilter(col)) return;
const binding = col.binding;
const key = this.keySelector(item);
// 前後状態を作る(データは {name,size,type,lastModified} か null)
const prev = this._cloneValue(item[binding]);
const file = t.files && t.files[0] ? t.files[0] : null;
const next = file ? {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
} : null;
// 画面を先に更新(データとラベル)。input は毎回空に戻す
this._apply(key, binding, next);
// Undo/Redo の “1手” を積む
this.undoStack.pushAction(new FileCellAction(
this, key, binding, prev, next, `ファイル選択: ${this._label(next)}`
));
}
// 値適用:行キーとバインディングで特定 → データ更新 → ラベル更新 → inputは空
_apply(key, binding, val) {
const r = this._findRowIndexByKey(key);
if (r < 0) return;
const rowObj = this.grid.rows[r];
const item = rowObj?.dataItem;
if (!item) return;
item[binding] = this._cloneValue(val);
// ラベル更新(見つかったセル範囲から DOM を探す)
const cell = this._findCellDom(r, this._findColIndexByBinding(binding));
if (cell) {
const label = cell.querySelector('.file-cell-name');
if (label) label.textContent = this._label(val);
const input = cell.querySelector(this.fileSel);
if (input && input instanceof HTMLInputElement) input.value = ''; // クリア
}
// グリッド描画を促す(必要に応じて)
this.grid.invalidate();
}
_label(val) {
return val ? val.name : '(未選択)';
}
_cloneValue(v) {
return v ? { name: v.name, size: v.size, type: v.type, lastModified: v.lastModified } : null;
// File オブジェクトは保持しない。メタだけ持つ。
}
_findRowIndexByKey(key) {
for (let i = 0; i < this.grid.rows.length; i++) {
const it = this.grid.rows[i].dataItem;
if (it && this.keySelector(it) === key) return i;
}
return -1;
}
_findColIndexByBinding(binding) {
for (let c = 0; c < this.grid.columns.length; c++) {
if (this.grid.columns[c].binding === binding) return c;
}
return -1;
}
_findCellDom(rowIndex, colIndex) {
if (rowIndex < 0 || colIndex < 0) return null;
// 可視範囲にあるときだけ見つかる。見つからなくてもデータ更新は済む。
const panel = this.grid.cells;
const r = panel.getCellBoundingRect(rowIndex, colIndex);
if (!r) return null;
return document.elementFromPoint(r.left + 1, r.top + 1)?.closest('.wj-cell');
}
}
// UndoableAction 実体
class FileCellAction extends wijmo.undo.UndoableAction {
constructor(binder, key, binding, prev, next, name) {
super(binder.grid.hostElement);
this.binder = binder;
this.key = key;
this.binding = binding;
this.prev = binder._cloneValue(prev);
this.next = binder._cloneValue(next);
this.name = name || 'ファイル選択';
}
undo() { this.binder._apply(this.key, this.binding, this.prev); }
redo() { this.binder._apply(this.key, this.binding, this.next); }
}
</script>
セル側(テンプレート)例
ファイル名ラベルは データのメタから描く。input の中身に頼らない。
<script>
// grid 作成済みとする
// 例:ファイル列の binding は 'fileMeta'
const fileCol = grid.getColumn('fileMeta');
// セル内容を差し替える(表示・編集どちらでも同じ見た目にするなら formatItem)
grid.formatItem.addHandler((s, e) => {
if (e.panel !== s.cells) return;
const col = s.columns[e.col];
if (col.binding !== 'fileMeta') return;
const item = s.rows[e.row].dataItem;
const val = item?.fileMeta;
e.cell.innerHTML = `
<div class="file-cell">
<span class="file-cell-name">${val ? val.name : '(未選択)'}</span>
<input type="file" class="file-cell-input">
</div>
`;
});
// UndoStack と Binder を起動
const undoStack = new wijmo.undo.UndoStack('#edit-scope', { maxActions: 200 });
const binder = new GridFileUndoBinder(grid, undoStack, '.file-cell-input', {
keySelector: (it) => it.id ?? it.__uid
});
binder.attach();
// 参考:Undo/Redo ボタン
document.querySelector('#undoBtn').onclick = () => undoStack.undo();
document.querySelector('#redoBtn').onclick = () => undoStack.redo();
</script>
ここまでの要点
- input の value は戻さない。データ(メタ)だけ戻す。表示はラベルでやる。
- 行・列の位置は変わるので、行は key、列は binding で特定してから適用。
- DOM が見つからなくても、データを先に正しく戻す。再描画で追いつく。
必要なら、このクラスをモジュール化して import できる形にもする。列が複数ある場合は bindingFilter で限定すると事故りにくい。
了解。CellMaker.makeButtonで「ファイル選択」ボタンを作り、UndoStackに“1手”として積む最小クラスを用意する。
ポイントは2つだけ。
-
<input type="file">の値はスクリプトで戻せない → データにメタ情報を持つ(名前やサイズなど)。UIはそれを表示する。(developer.mozilla.org) - Undo/Redoは 前後のメタ情報をもとに、該当セルのデータを差し替えて再描画する。(MESCIUS)
下の実装はプレーンJS。画像プレビューは対象外。
(CellMaker.makeButton のクリックハンドラで、隠し<input type="file">を開く。クリック時のセル情報はctx.itemで取れる)(MESCIUS)
使い方イメージ
- データに
fileMeta({ name, size, type, lastModified })と、表示用のfileName(文字列)を持たせる。 - 表示は「ファイル名の列」+「選択ボタンの列」。ボタンは CellMaker.makeButton で作る。(MESCIUS)
- Undo/Redoはボタンを押したセルだけを前後に戻す。
コード
1) Binder本体(共通処理クラス)
<script type="module">
import * as wjGrid from '@mescius/wijmo.grid';
import { CellMaker } from '@mescius/wijmo.grid.cellmaker';
import * as wjUndo from '@mescius/wijmo.undo';
// --------- “ファイル選択→Undo/Redo” を束ねる ----------
class CellMakerFilePickerUndo {
/**
* @param {wjGrid.FlexGrid} grid
* @param {wjUndo.UndoStack} undoStack
* @param {{
* keySelector?: (item:any)=>string|number,
* accept?: string
* }} [opts]
*/
constructor(grid, undoStack, opts = {}) {
this.grid = grid;
this.undo = undoStack;
this.keyOf = opts.keySelector || (it => it.id ?? it.__uid);
this.accept = opts.accept || '';
this._hiddenInput = this._createHiddenInput();
this._pending = null; // { key, binding, prev }
}
// 対象の列に「選択」ボタンを仕込む(CellMaker)
decorateColumn(binding, { header = 'ファイル', width = 120 } = {}) {
const col = this.grid.getColumn(binding);
if (!col) throw new Error(`binding "${binding}" の列が見つからない`);
col.header = header;
col.width = width;
col.isReadOnly = true; // 編集はボタン経由に限定
col.cellTemplate = CellMaker.makeButton({
text: '選択…',
click: (e, ctx) => this._startPick(ctx.item, binding)
});
}
// クリック→隠し<input type=file>を開く
_startPick(item, binding) {
const key = this.keyOf(item);
const prev = this._cloneMeta(item[binding]); // 前の状態(null可)
this._pending = { key, binding, prev };
this._hiddenInput.value = ''; // 直前の選択をクリア
this._hiddenInput.click();
}
// inputのchangeで確定
_onPicked(file) {
const p = this._pending;
this._pending = null;
if (!p) return; // ありえないが安全策
const next = file ? {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
} : null;
// 画面を先に更新
this._apply(p.key, p.binding, next);
// 1手として積む
this.undo.pushAction(new FileCellAction(
this, p.key, p.binding, p.prev, next, `ファイル: ${next ? next.name : '未選択'}`
));
}
// 実際に値を適用(データ更新+再描画)
_apply(key, binding, meta) {
const r = this._findRowIndexByKey(key);
if (r < 0) return;
const item = this.grid.rows[r]?.dataItem;
if (!item) return;
// データ(メタ)を更新。表示用に fileName があれば同期。
item[binding] = this._cloneMeta(meta);
if ('fileName' in item) {
item.fileName = meta ? meta.name : '';
}
this.grid.invalidate(); // 再描画
}
_findRowIndexByKey(key) {
for (let i = 0; i < this.grid.rows.length; i++) {
const it = this.grid.rows[i].dataItem;
if (it && this.keyOf(it) === key) return i;
}
return -1;
}
_createHiddenInput() {
const input = document.createElement('input');
input.type = 'file';
input.style.position = 'fixed';
input.style.left = '-9999px';
input.accept = this.accept;
input.addEventListener('change', (e) => {
const f = e.target.files && e.target.files[0] ? e.target.files[0] : null;
this._onPicked(f);
});
document.body.appendChild(input);
return input;
}
_cloneMeta(v) {
return v ? { name: v.name, size: v.size, type: v.type, lastModified: v.lastModified } : null;
}
}
// Undo用アクション(前後のメタを差し替えるだけ)
class FileCellAction extends wjUndo.UndoableAction {
constructor(binder, key, binding, prev, next, name) {
super(binder.grid.hostElement);
this.binder = binder;
this.key = key;
this.binding = binding;
this.prev = binder._cloneMeta(prev);
this.next = binder._cloneMeta(next);
this.name = name || 'ファイル選択';
}
undo() { this.binder._apply(this.key, this.binding, this.prev); }
redo() { this.binder._apply(this.key, this.binding, this.next); }
}
// --- 以降はサンプル初期化(最小) ---
// items: id / name / fileMeta / fileName を持つ
const items = [
{ id: 1, name: 'A', fileMeta: null, fileName: '' },
{ id: 2, name: 'B', fileMeta: null, fileName: '' },
];
const view = new wijmo.CollectionView(items); // trackChangesは使わない
// Grid
const grid = new wjGrid.FlexGrid('#theGrid', {
itemsSource: view,
autoGenerateColumns: false,
columns: [
{ binding: 'id', header: 'ID', isReadOnly: true, width: 80 },
{ binding: 'name', header: 'Name', width: '*' },
{ binding: 'fileName', header: 'ファイル名', isReadOnly: true, width: 200 },
{ binding: 'fileMeta', header: '選択', width: 120 } // ← ボタンを載せる列
]
});
// UndoStack(フォームや画面のラッパー要素に張る)
const undo = new wjUndo.UndoStack('#edit-scope', {
maxActions: 100,
stateChanged: (s) => {
document.querySelector('#undoBtn').disabled = !s.canUndo;
document.querySelector('#redoBtn').disabled = !s.canRedo;
}
});
document.querySelector('#undoBtn').onclick = () => undo.undo();
document.querySelector('#redoBtn').onclick = () => undo.redo();
// Binderを適用(fileMeta列に “選択” ボタンを付ける)
const binder = new CellMakerFilePickerUndo(grid, undo, {
keySelector: (it) => it.id, // 行の識別子
accept: '' // 必要なら 'application/pdf' など
});
binder.decorateColumn('fileMeta', { header: '選択', width: 120 });
</script>
2) HTML(最小)
<form id="edit-scope">
<div style="margin:8px 0;">
<button type="button" id="undoBtn" disabled>Undo</button>
<button type="button" id="redoBtn" disabled>Redo</button>
</div>
<div id="theGrid" style="height:320px"></div>
</form>
補足と判断の根拠
-
CellMaker.makeButton のクリックで
ctx.itemが渡る。そこから行データを特定できる。(MESCIUS) -
UndoStack はHTML入力や多くのWijmoコントロールを自動追跡するが、
type="file"は仕様上そのまま扱えない。カスタムActionとして積むのが確実。(MESCIUS) - 行追加/削除をコードでやる場合の「一手化」は、公式デモでも
onRowAdded/onDeletingRowを呼んで実装している。今回のファイル選択も同じ発想で前後状態を1手にしている。(MESCIUS)
伸ばすなら(任意)
-
複数列対応:
decorateColumnを複数回呼ぶだけでOK。 -
表示文言の動的化:CellMakerの
textに${item.fileName}を入れてもよい(再描画で更新)。ただしシンプルさ重視なら、今の**「ファイル名列+選択ボタン列」**の分離が分かりやすい。(MESCIUS) -
サーバー送信:
fileMetaはメタだけ。実ファイルは別のアップロード処理で扱う(Undoはメタの往復のみ)。
この形なら、CellMakerの見た目の素直さとUndo/Redoの扱いやすさが両立する。必要なら、accept を列ごとに変える・beforePickフックを入れる等、軽く拡張できるよ。
やりたいことはこう整理できる。
- 初期表示でサーバーから Blob(実体) を受け取り、セルに「ファイル名+ダウンロード用リンク」を出す。
- ユーザーが選び直したら 前(初期Blob)と後(新しいFile/Blob)のスナップショットを1手として積む。
- Undo/Redoでは データ(メタ+Blob)を戻す → 表示を再描画 する。
-
input type="file"の値はスクリプトで戻せないので、inputは常に空にしておく(仕様)。(MDN ウェブドキュメント)
下は CellMaker.makeButton を使う、最小で迷いがない実装例。
(画像プレビューは省略。Blobはダウンロード/閲覧リンク用の Object URL に変換する。使い終えたら revoke して漏れを防ぐ)(MESCIUS)
データ構造(セルの値)
-
fileMeta:{ name, size, type, lastModified, blob } | nullを入れる(blobはBlobかFile)。 - 表示用に
fileName(文字列)と、リンク表示用のfileUrl(Object URL文字列)も持たせる。
事実:Blobはファイル名を持たない。名前が必要なら
Fileを作るか、別でnameを持たせる。(MDN ウェブドキュメント)
1. 初期表示:サーバーBlobをセルに入れる
// 例)サーバーからBlobと表示名を取って、Gridのitemsに流し込む
async function fetchInitialRows() {
// 例: /api/file/{id} が Blob を返す
const blob1 = await fetch('/api/file/1').then(r => r.blob());
const blob2 = await fetch('/api/file/2').then(r => r.blob());
return [
makeRow(1, 'A文書', blob1, 'A.pdf'),
makeRow(2, 'B文書', blob2, 'B.pdf')
];
}
// Blobをメタ付きで保持し、Object URLを作って表示名と紐付ける
function makeRow(id, name, blobOrNull, displayName) {
const meta = blobOrNull ? {
name: displayName || '(名称不明)',
size: blobOrNull.size,
type: blobOrNull.type || 'application/octet-stream',
lastModified: Date.now(),
blob: blobOrNull
} : null;
const url = meta ? URL.createObjectURL(meta.blob) : '';
return {
id, name,
fileMeta: meta, // ← セルの実体
fileName: meta ? meta.name : '',
fileUrl: url // ← aタグのhrefに使う
};
}
2. Grid と UndoStack、CellMaker ボタン列
<form id="edit-scope">
<div style="margin:8px 0;">
<button type="button" id="undoBtn" disabled>Undo</button>
<button type="button" id="redoBtn" disabled>Redo</button>
</div>
<div id="theGrid" style="height:320px"></div>
</form>
<script type="module">
import * as wjCore from '@mescius/wijmo';
import * as wjGrid from '@mescius/wijmo.grid';
import { CellMaker } from '@mescius/wijmo.grid.cellmaker';
import * as wjUndo from '@mescius/wijmo.undo';
// 初期データ
const items = await fetchInitialRows();
const view = new wjCore.CollectionView(items);
// Grid
const grid = new wjGrid.FlexGrid('#theGrid', {
itemsSource: view,
autoGenerateColumns: false,
columns: [
{ binding: 'id', header: 'ID', width: 60, isReadOnly: true },
{ binding: 'name', header: '名称', width: '*' },
// 表示用(ファイル名+リンク)
{ binding: 'fileName', header: 'ファイル名', width: 220, isReadOnly: true },
{ binding: 'fileUrl', header: 'リンク', width: 100, isReadOnly: true },
// ボタン用(後でCellMakerをあてる)
{ binding: 'fileMeta', header: '選択', width: 120, isReadOnly: true }
]
});
// リンク列をaタグ表示に(formatItem)
grid.formatItem.addHandler((s, e) => {
if (e.panel !== s.cells) return;
const col = s.columns[e.col];
if (col.binding === 'fileUrl') {
const it = s.rows[e.row].dataItem;
e.cell.innerHTML = it.fileUrl
? `<a href="${it.fileUrl}" target="_blank" rel="noopener">開く/保存</a>`
: '';
}
});
// UndoStack(ページ/フォーム配下を監視)
const undo = new wjUndo.UndoStack('#edit-scope', {
maxActions: 100,
stateChanged: s => {
document.querySelector('#undoBtn').disabled = !s.canUndo;
document.querySelector('#redoBtn').disabled = !s.canRedo;
}
});
document.querySelector('#undoBtn').onclick = () => undo.undo();
document.querySelector('#redoBtn').onclick = () => undo.redo();
// ファイル選択の共通バインダ(後述クラス)
const binder = new CellMakerFilePickerUndoWithBlob(grid, undo, {
keySelector: it => it.id, // 行の識別
metaBinding: 'fileMeta', // 実体(メタ+Blob)を持つ列
nameBinding: 'fileName', // 表示名
urlBinding: 'fileUrl', // Object URL
accept: '' // 例: 'application/pdf,image/*'
});
// CellMakerでボタン列を作る
const col = grid.getColumn('fileMeta');
col.cellTemplate = CellMaker.makeButton({
text: '選択…',
click: (e, ctx) => binder.pick(ctx.item) // ← ここだけでOK
});
</script>
事実:CellMakerのボタンは
cellTemplateに渡すだけで動く。クリック時に行データ(ctx.item)が取れる。(MESCIUS)
事実:UndoStackはフォーム等に張ると、一般的な入力は自動追跡してCtrl+Z/Ctrl+Yも効く。今回のファイルはカスタムの“1手”で積む。(MESCIUS)
3. 変更を1手にするクラス(Blob対応)
- 役割は3つ。
① 隠し<input type=file>を開いてBlobを得る。
② 前後スナップショット(prevとnext)を作る。
③ 適用(_apply) でfileMetaとfileNameとfileUrlを同期し、古いURLをrevoke する。(MDN ウェブドキュメント)
class CellMakerFilePickerUndoWithBlob {
constructor(grid, undoStack, opts = {}) {
this.grid = grid;
this.undo = undoStack;
this.keyOf = opts.keySelector || (it => it.id ?? it.__uid);
this.metaBinding = opts.metaBinding || 'fileMeta';
this.nameBinding = opts.nameBinding || 'fileName';
this.urlBinding = opts.urlBinding || 'fileUrl';
this.accept = opts.accept || '';
this._hidden = this._createHiddenInput();
this._pending = null; // { key, prevMeta }
}
// ボタンから呼ぶ
pick(item) {
const key = this.keyOf(item);
const prev = this._cloneMeta(item[this.metaBinding]); // {name,size,type,lastModified,blob}|null
this._pending = { key, prev };
this._hidden.value = '';
this._hidden.click();
}
// input change
_onPicked(file) {
const p = this._pending; this._pending = null;
if (!p) return;
const next = file ? {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
blob: file // FileはBlobを継承
} : null;
// 先に適用
this._apply(p.key, next);
// 1手として積む
this.undo.pushAction(new FileMetaAction(
this, p.key, this._cloneMeta(p.prev), this._cloneMeta(next),
`ファイル: ${next ? next.name : '未選択'}`
));
}
// keyで行を特定し、Meta/Name/URLを一括更新
_apply(key, meta) {
const r = this._findRowIndexByKey(key);
if (r < 0) return;
const it = this.grid.rows[r].dataItem;
if (!it) return;
// 古いURLを掃除
const oldUrl = it[this.urlBinding];
if (oldUrl) URL.revokeObjectURL(oldUrl);
// 新しいURLを作る(metaがあれば)
const url = meta ? URL.createObjectURL(meta.blob) : '';
// 反映
it[this.metaBinding] = this._cloneMeta(meta);
it[this.nameBinding] = meta ? meta.name : '';
it[this.urlBinding] = url;
// inputは値を戻せない → 常に空のまま使う(仕様)
// 再描画
this.grid.invalidate();
}
_findRowIndexByKey(key) {
for (let i = 0; i < this.grid.rows.length; i++) {
const it = this.grid.rows[i].dataItem;
if (it && this.keyOf(it) === key) return i;
}
return -1;
}
_createHiddenInput() {
const el = document.createElement('input');
el.type = 'file';
el.accept = this.accept;
el.style.position = 'fixed';
el.style.left = '-9999px';
el.addEventListener('change', (e) => {
const f = e.target.files && e.target.files[0] ? e.target.files[0] : null;
this._onPicked(f);
});
document.body.appendChild(el);
return el;
}
_cloneMeta(v) {
return v ? { name: v.name, size: v.size, type: v.type, lastModified: v.lastModified, blob: v.blob } : null;
}
}
// UndoableAction(前後のMetaを戻すだけ)
class FileMetaAction extends wijmo.undo.UndoableAction {
constructor(binder, key, prev, next, name) {
super(binder.grid.hostElement);
this.binder = binder;
this.key = key;
this.prev = binder._cloneMeta(prev);
this.next = binder._cloneMeta(next);
this.name = name || 'ファイル選択';
}
undo() { this.binder._apply(this.key, this.prev); }
redo() { this.binder._apply(this.key, this.next); }
}
-
URL.createObjectURL(blob)でリンク用のURLを作り、不要になったらURL.revokeObjectURL(url)で解放する。これを怠るとメモリを掴み続ける。(MDN ウェブドキュメント) -
input type="file"の値は戻せない。Undo/Redoは**アプリ側の状態(Meta+Blob)**で再現する。(MDN ウェブドキュメント)
4. Undoで初期表示のBlobに戻るか?
戻る。理由は簡単で、prev に初期Meta+Blobを丸ごと持たせているから。undo() は _apply(key, prev) を呼び、Object URLを再発行して再描画する。
inputは空のままだが、fileName と fileUrl が初期状態に戻るので、ユーザーから見た体験は「ちゃんと元に戻った」になる(仕様上、これが正しいやり方)。(MDN ウェブドキュメント)
よくある質問に先回り
-
「input.files をコードで差し替えたい」
できない。セキュリティ上の理由で禁止。アプリ状態で表現する。(MDN ウェブドキュメント) -
「Blobが重くてメモリが不安」
行数やファイルサイズが大きいときは、初期表示は 署名付きURL等の外部URL を使い、選択し直したときだけFileを抱える設計に切り替える。Object URLは使い回さず都度revoke する。(MDN ウェブドキュメント) -
「CellMakerとイベントの相性」
問題なし。makeButtonのclickでctx.itemが取れるので、上のbinder.pick(ctx.item)の1行でつながる。(MESCIUS)
根拠(一次情報)
-
input type="file"の仕様とFile API(プログラムで値をセットできない)— MDN。(MDN ウェブドキュメント) - Blob / Object URL(生成と解放の作法)— MDN。(MDN ウェブドキュメント)
- UndoStackとCellMakerの使い方— Wijmoデモ/ドキュメント。(MESCIUS)
この形にしておけば、初期Blob → ユーザー選択 → Undoで初期Blobに戻るが素直に動く。
既存の列名やデータ構造に合わせて、metaBinding/nameBinding/urlBinding だけ変更すればそのまま差し替えられる。