0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlexGridで“Undo/Redo”を実装する

Last updated at Posted at 2025-10-08

— 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)。

設計の地図(全体像)

  1. 差分管理CollectionView.trackChanges = true(保存用)。(MESCIUS)
  2. 操作の履歴new UndoStack('#編集スコープ')(UI操作は自動捕捉)。(MESCIUS)
  3. コードで行を追加/削除するときは、明示的に“一手”を作るonRowAdded/onDeletingRow を叩く or カスタムAction)。(MESCIUS)
  4. ファイル/画像選択は、カスタム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を pushActionFileblob URL を状態として持ち、revokeObjectURL を忘れない。(MDN ウェブドキュメント)

  • コードでの大量追加が細切れのUndoになる
    バッチActionで1手にまとめる。公式でも「追加・削除をコードでやるなら、Undo用の一手を作る」方針。(MESCIUS)


仕上げのチェックリスト

  • UndoStackフォームや編集領域に張っている(#edit-scope
  • UI編集は自動Undo、**コード編集は“一手を作る”**という方針が全体で一貫
  • ファイル/画像はカスタムActionで前後状態+pushAction
  • trackChanges保存用に分業し、差分三兄弟を使っている
  • addingAction / stateChanged を入れて、何がスタックに載ったかを観測できる

参考(一次情報)

  • CollectionViewの差分管理(trackChanges と三兄弟)— 公式Doc/デモ。(MESCIUS)
  • UndoStackのAPI(pushActioncanUndo など)— 公式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 … 比較対象のフィールドだけ抜き出した軽量オブジェクト
    • hashpick の安いハッシュ(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 を使う方針に切り替える。

  • 比較フィールドの選び方
    送信する可能性がある項目に絞るのが安全。createdAtupdatedAt のようなシステム列は除外しないと毎回変更扱いになる。
    日付や小数は正規化してから比較(上の 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 は毎回クリア)。

使い方の絵

  1. ファイル列は、データに { name, size, type, lastModified } のメタを持たせる(未選択なら null)。
  2. セルの表示は「ファイル名ラベル+file input」。ラベルは dataItem[binding]?.name を出す。
  3. 変更時に 前後状態をスナップショットデータに反映UndoStack に “1手” を push
  4. 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は BlobFile)。
  • 表示用に 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を得る。
    前後スナップショットprevnext)を作る。
    適用(_apply)fileMetafileNamefileUrl を同期し、古い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は空のままだが、fileNamefileUrl が初期状態に戻るので、ユーザーから見た体験は「ちゃんと元に戻った」になる(仕様上、これが正しいやり方)。(MDN ウェブドキュメント)


よくある質問に先回り

  • 「input.files をコードで差し替えたい」
    できない。セキュリティ上の理由で禁止。アプリ状態で表現する。(MDN ウェブドキュメント)

  • 「Blobが重くてメモリが不安」
    行数やファイルサイズが大きいときは、初期表示は 署名付きURL等の外部URL を使い、選択し直したときだけ File を抱える設計に切り替える。Object URLは使い回さず都度revoke する。(MDN ウェブドキュメント)

  • 「CellMakerとイベントの相性」
    問題なし。makeButtonclickctx.item が取れるので、上の binder.pick(ctx.item) の1行でつながる。(MESCIUS)


根拠(一次情報)


この形にしておけば、初期Blob → ユーザー選択 → Undoで初期Blobに戻るが素直に動く。
既存の列名やデータ構造に合わせて、metaBinding/nameBinding/urlBinding だけ変更すればそのまま差し替えられる。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?