-SpreadJS Designerの活用方法とバグ解決記-
はじめに
プロジェクトに携わることになり、初めてSpreadJSライブラリに触れました。このライブラリは、画面上でexcelの参照や編集を可能にするために使用しました。本記事では、適用する過程で経験したことを整理します。具体的には、再読み込み・import時にサイドバーとバインディングパスが消えるバグと、顧客の要件により追加されたシート追加防止およびセル削除防止機能についてです。どちらの問題も、最終的にはSpreadJS Designerのコマンド体系を理解しなければ解決できないものでした。結果として、ドキュメントに記載されていない部分を自分で分析し、要件を実装する必要があった経験となりました。
再読み込み・再取得時にサイドバーとバインディングパスが消える現象
テンプレートを初めて取得したときは、Excel画面とともにサイドバーにもバインディングパスのツリーが正常に表示されます。しかし、再読み込みボタンを押したりファイルを再度開いたりすると、Excel画面にもバインディングパスが表示されず、サイドバーも消える現象が発生しました。初回の取得は成功、初回の再読み込みは失敗、2回目の再読み込みは再び成功するという状態です。正確に、奇数回と偶数回で交互に現れるパターンでした。
原因:チェックボックスタイプのコマンドを呼び出すたびに状態が反転する
サイドバーを描画する関数は workbook.open()の成功コールバック内で呼び出されていました。その中で、Designer.getCommand(TEMPLATE_DESIGN_MODE)によって取得したcommandオブジェクトをコンソールに出力してみると、次のフィールドがありました。
{
"commandName": "templateDesignMode",
"type": "checkbox"
}
"type": "checkbox"が核心でした。SpreadJS Designerのチェックボックスタイプのコマンドは、リボンの太字や斜体ボタンのように、呼び出すたびにon/off状態を反転させるよう設計されています。取得するたびにこのコマンドを実行していたため、実際には次のような流れが繰り返されていたのです。
再読み込みボタン自体とは関係なく、「このコマンドを何回目に呼び出したか」に正確に依存するバグでした。最初はレンダリングのタイミングの問題だと考え、setTimeoutやsuspendPaint/resumePaintを先に試してみましたが、原因とは無関係でした。症状が正確に奇数回と偶数回で分かれるという事実自体が、すでに「状態値が関係する問題」であることを示す手がかりでしたが、そのサインに早い段階で気づけなかったことは心残りです。
解決策
実行前にコマンドの現在の状態を確認し、すでにオンになっている場合は実行をスキップするようにしました。
return (designer) => {
const isAlreadyOn = command.getState?.(designer);
if (isAlreadyOn) return;
return execute(designer);
};
顧客の要件:シート追加の防止とバインディングセル削除の防止
機能の実装をある程度終えようとしていたところ、顧客から3つの追加要件が提示されました。
1. テンプレート設定画面ではシートを追加できないようにしてほしい(+ボタンを削除する、または別の方法で)
2. バインディングパスが設定されているセルは削除できないようにしてほしい。このセルの値は別の画面でそのまま利用する予定なので、削除されてはいけない。ただし、バインディングパスが設定されていないその他のセルは、これまでどおり自由に編集できる必要がある。あくまで「削除」だけを禁止すればよい。
3. シートを切り替えるたびに、そのシートにあるバインディングパスをサイドバーのフィールド一覧に反映してほしい。
シート切り替え時のサイドバー更新
3番目の要件は、ActiveSheetChangedイベントで比較的簡単に解決できました。シートが切り替わるたびに、現在のモード(ファイルのimportモードか、既存DBの取得モードか)に応じてバインディングパスを再抽出またはマッピングし、その後サイドバーツリーを再描画する関数を呼び出す方式です。
workbook.bind(Events.ActiveSheetChanged, function (sender, args) {
const currentSheet = args.newSheet;
if (!currentSheet) return;
const sheetName = currentSheet.name();
if (isImport) {
const bindingPathsFromSheet = extractBindingPathsFromSheet(currentSheet);
void setBindingPathToData(currentDesigner, currentWorkbook, bindingPathsFromSheet);
} else {
const currentSheetDatas = fieldsBySheetMap?.get(sheetName) || [];
void setBindingPathToData(currentDesigner, currentWorkbook, currentSheetDatas);
}
});
1番目と2番目は、アプローチを3回ほど大幅に変更して、ようやく適切な方法を見つけました。
アプローチ1. Cell Locking
まず、バインディングパスが設定されているセルだけをロックし、それ以外は編集可能な状態にしたうえで、シート保護を有効にする方法を試しました。
const unlockedStyle = new GC.Spread.Sheets.Style();
unlockedStyle.locked = false;
sheet.setDefaultStyle(unlockedStyle);
for (let r = 0; r < sheet.getRowCount(); r++) {
for (let c = 0; c < sheet.getColumnCount(); c++) {
if (sheet.getBindingPath(r, c)) {
sheet.getCell(r, c).locked(true);
}
}
}
sheet.options.isProtected = true;
実際に適用してみると、バインディングパスが設定されていないセルまで、すべて編集できなくなりました。setDefaultStyleを使っても、全範囲を強制的に上書きしても同じ結果でした。コンソールで確認すると、そもそも初期状態ではlocking自体が設定されていませんでした。原因を最後まで明確に突き止めることはできませんでしたが、テンプレート(.sjs)ファイル自体に、コードからはすぐに確認できない別の保護オプションがすでに設定されていた可能性を疑っています。原因とは別に、「バインディングされていないセルは完全に自由に編集でき、削除だけを禁止する」という要件と、シート保護機能自体がそもそも相性のよくない組み合わせでした。
アプローチ2. Commandのオーバーライド
チェックボックスタイプのコマンドを解決する中で知ったコマンド体系を、そのまま活用してみました。削除にも名前付きのコマンドがあるはずだと考え、deleteRowsのような名前のコマンドをcommandManager().getCommand()で検索し、元のコマンドを保存したうえで、条件付きでブロックする方式にオーバーライドしました。
const originalDeleteRows = commandManager.getCommand('deleteRows');
commandManager.register('deleteRows', {
canUndo: originalDeleteRows.canUndo,
execute: (context, options, isUndo) => {
// 대상 행에 바인딩패스가 있으면 차단하는 로직
return originalDeleteRows.execute(context, options, isUndo);
}
}, false, false, false, false);
ところが、右クリックメニューで行/列の削除をクリックしても、オーバーライドしたexecuteは実際には実行されませんでした。正確な理由を公式フォーラムで確認することはできませんでしたが、先ほどチェックボックスコマンドで確認したように、Designerはリボンであれ右クリックメニューであれ、独自のcommandMap体系で動作を連携しています。そのため、右クリックメニューの「行の削除」のクリックは、commandManagerに登録された名前付きコマンドをそのまま呼び出しているのではなく、Designer内部で別の経路により処理されているようです。つまり、外見上は同じ「削除」に見えても、Deleteキー(→ clear系コマンド)と右クリックメニューの行/列の削除では、異なる経路を通っていたということです。コマンドに固執する方法には限界があると判断し、アプローチそのものを変更しました。
アプローチ3. Context Menuの制御
コマンドの実行を阻止しようとするのではなく、右クリックメニューが開いたときに、その中の「削除」項目だけを無効化すればよいのではないかと考え、方向を変えました。「右クリックでメニューが開くたびに呼び出されるイベントは何か?」と問いを絞って探した結果、見つかったのが contextMenu.onOpenMenuでした。
ただし、これをそのまま設定してみると、コールバックは正常に呼び出されるものの、itemsDataForShown(実際に表示されるメニュー項目の一覧)が常に空の配列として出力されました。Designerはリボンと右クリックメニューを独自のcommandMap体系で管理しているため、プレーンなワークブックのcontextMenuに設定するだけでは、Designerが実際に描画する項目までは取得できないようでした。元のonOpenMenuをあらかじめ保存しておき、それをラップする方法で整理し直したところ、項目が正常に渡されるようになりました。
workbook.contextMenu.onOpenMenu = function (menuData, itemsDataForShown, hitInfo, spread) {
let result = true;
if (typeof originalOnOpenMenu === 'function') {
result = originalOnOpenMenu.apply(this, arguments);
}
const sheet = spread.getActiveSheet();
const info = hitInfo.worksheetHitInfo;
if (!info || hitInfo.hitTestType == null) {
// 시트 탭 영역: insertSheet 항목 비활성화
itemsDataForShown?.forEach((item) => {
if (item.name === 'insertSheet') item.disable = item.disabled = true;
});
} else if (info.row >= 0 && info.col >= 0 && sheet.getBindingPath(info.row, info.col)) {
// 셀 영역: 바인딩패스가 있으면 delete 관련 항목 비활성화
itemsDataForShown?.forEach((item) => {
const name = item.name?.toLowerCase() ?? '';
const text = item.text?.toLowerCase() ?? '';
if (name.includes('delete') || text.includes('delete')) item.disable = item.disabled = true;
});
}
return result;
};
このロジックにより、シートタブのinsertSheetとセルのdelete項目をそれぞれ無効化し、2つの要件を解決しました。リボンの+ボタンはこのオーバーライドとは別に、workbook.options.newTabVisible = false;という1つのオプションで、画面上から完全に非表示にしました。右クリックで新しいシートを追加する経路はonOpenMenuで、ボタン自体はこのオプションで無効化したというわけです。
おわりに
今回の作業を通じて感じたのは、SpreadJS Designerは一般的なSpreadJS Workbook APIだけを見てアプローチしても、解決できないケースが多いということでした。リボン、Context Menu、Commandが1つの体系として連携しているため、行き詰まったときは、関連するコマンドオブジェクトをまず確認するのが最も早いデバッグ方法でした。今回の経験を通じて、単にAPIを使うだけでなく、Designer内部の構造をより深く理解できました。
pong