ReoGrid ReoGrid Web

スプレッドシートにレビューのメモを ― ReoGrid Web のセルコメントと自作のコメント UI

· unvell team
スプレッドシートにレビューのメモを ― ReoGrid Web のセルコメントと自作のコメント UI

フォームモードの記事では、人が入力するためのシートを作りました。今回はその次の段階、つまり誰かがそれを確認する場面の話です。Excel でのレビューは余白で行われます。確認する人がセルを選んでメモを付けると、そのセルの隅に赤い三角が付きます。カーソルを乗せると「経理: 62% 増。どの出張ですか?」と読めます。

この流れに慣れた人は、Web アプリにも同じことを期待します。メモが見えること。自分の名前でメモを書けること。百個のセルにカーソルを乗せて回らなくても、未解決の質問を全部見つけられること。そして Excel に戻したときに、メモが残っていることです。

ReoGrid Web は v1.4 から Excel のメモに対応し、v1.6 からはそれを xlsx に書き戻せるようになりました。1.7.1 では、レビュー用途に足りなかった最後の部品が加わりました。組み込みのホバー吹き出しを、自前の UI に置き換えるための 2 つのフックです。この記事では、レビューのメモが付いた予算シートを作り、その周りに小さなレビュー用 UI を組み立てます。メモ一覧のパネルと、赤い三角または右クリックから開くエディターです。

コードからメモを書くのは Pro の機能です。読むのは無料の Lite でもでき、この記事のレビュー UI のうちメモを表示するための部分、つまり赤いマーク、一覧、クリックイベントはすべて Lite で動きます。詳しくはエディションの節をご覧ください。


スレッドではなく「メモ」

Excel のセル注釈には 2 種類あるので、最初にどちらの話かをはっきりさせておきます。

  • メモは昔からある方です。黄色い付箋の箱、セルの赤い三角、作成者は 1 人、本文はプレーンテキスト。Office 365 より前の Excel では「コメント」と呼ばれていて、今は「校閲」タブの「メモ」→「新しいメモ」(Shift+F2)で作ります。
  • 今の Excel のコメントはスレッド形式です。紫のマークが付き、返信やメンションができます。

ReoGrid Web のコメントは前者で、API には Excel の昔の呼び名がそのまま残っています。メモはセルごとに 1 つの小さなオブジェクトです。

フィールド意味
idメモの作成時に割り当てられる数値の ID
row・colセルの位置(0 始まり)
textプレーンテキスト。\n で改行
author作成者名(任意)。吹き出しに表示され、xlsx にも保存される
visible「常に表示」フラグ(任意)。Excel 向けに保持される

スレッド形式のコメントには対応していません。利用者のファイルにスレッドが含まれている場合、Excel は旧バージョン向けに各スレッドのメモ版の写しも書き込んでいて、ReoGrid が読むのはその写しです。本文の前に、旧バージョン向けの定型の前置き文が付いた状態で表示されます。ReoGrid から保存するとメモだけが書き出されるので、スレッドそのものは残りません。


作例:レビュー中の予算申請

費目が 6 行、前期実績と今期申請、増減の列、そしてメモが 3 つ。2 つは経理からの質問で、残る 1 つは申請した部署からの説明です。

import { createReogrid } from '@reogrid/pro';

const grid = createReogrid({ workspace: '#grid', licenseKey: 'YOUR_LICENSE_KEY' });
const ws = grid.worksheet;

ws.suspendRender();
[150, 110, 110, 90].forEach((w, c) => ws.column(c).setWidth(w));

const head = { bold: true, backgroundColor: '#1e3a5f', color: '#ffffff' } as const;
['費目', '前期実績', '今期申請', '増減'].forEach((h, c) =>
  ws.cell(0, c).setValue(h).setStyle({ ...head, textAlign: c ? 'right' : 'left' }));

const LINES: [string, number, number][] = [
  ['人件費',       1820000, 1860000],
  ['業務委託費',    240000,  410000],
  ['旅費交通費',     98000,  159000],
  ['ソフトウェア',  124000,  129000],
  ['イベント費',     60000,  180000],
  ['研修費',         32000,   25000],
];
LINES.forEach(([item, actual, request], i) => {
  const r = i + 2;                                    // シートの 2〜7 行目
  ws.cell(`A${r}`).setValue(item);
  ws.cell(`B${r}`).setValue(actual);
  ws.cell(`C${r}`).setValue(request);
  ws.cell(`D${r}`).setValue(`=C${r}/B${r}-1`);
});
ws.cell('A8').setValue('合計');
ws.cell('B8').setValue('=SUM(B2:B7)');
ws.cell('C8').setValue('=SUM(C2:C7)');
ws.cell('D8').setValue('=C8/B8-1');
ws.range('A8:D8').setStyle({ bold: true, backgroundColor: '#eef5ff' });
ws.range('B2:C8').setFormat('#,##0');
ws.range('D2:D8').setFormat('0.0%');
ws.resumeRender();

// ── レビューのメモ ──────────────────────────────────────────────────
ws.setComment(2, 3, '代理店の月額契約ですか、増員ですか?\n月額契約なら外注費に移してください。', { author: '経理' });
ws.setComment(3, 3, '62% 増。どの出張ですか?\nイベント一覧を添付してください。', { author: '経理' });
ws.cell('C6').setComment('3 月の展示会ブース\n(120,000)を含みます。', { author: 'マーケティング' });
実行結果
メモが 3 つ、赤い三角も 3 つ(D3・D4・C6)。D4 にカーソルを乗せると組み込みの吹き出しが開き、1 行目に作成者、その下に本文が表示されます。
メモが 3 つ、赤い三角も 3 つ(D3・D4・C6)。D4 にカーソルを乗せると組み込みの吹き出しが開き、1 行目に作成者、その下に本文が表示されます。

メモを付けているのは最後の 3 行だけです。setComment(row, col, text, options) の座標は 0 始まりなので、(3, 3) は D4 です。セルハンドル版の ws.cell('C6').setComment(…) は、同じことを A1 形式のアドレスで行います。ws.range('B2:C4').setComment(…) もあり、こちらは範囲の左上のセルにメモを付けます。メモは必ず 1 つのセルに属するからです。

メモはセルごとに 1 つです。メモのあるセルにもう一度 setComment を呼ぶと置き換わります。ID はそのままで、本文と作成者が上書きされます。

標準の UI はこれだけです。メモのあるセルの右上に赤い三角が付き、カーソルを乗せると黄色い吹き出しに作成者と本文が出ます。\n の改行もそのまま反映されます。メモを読むだけならこれで十分です。ただ、レビューには足りません。それが次の節です。


レビュー用の UI を自作する

組み込みの吹き出しは読むだけのもので、グリッド自身には「新しいメモ」のコマンドがありません。メモを書く操作はアプリ側に任されています。これは理にかなっています。誰がログインしているか、つまりメモに誰の名前を付けるかを知っているのはアプリだからです。

v1.7.1 から、そのためのフックが 2 つあります。

  • showCommentBubble: false でホバーの吹き出しを止めます。赤い三角は残ります。
  • onCommentClick は、セルの赤い三角が押されたときに発火し、どのメモか、ポインターがどこにあるかを教えてくれます。

この 2 つを組み合わせれば、組み込みの吹き出しが出るはずだった位置に自前のエディターを出せます。メモのないセル用に右クリックを足し、シート上の全メモの一覧を付ければ、レビューツールになります。ページにはそれぞれの置き場所を用意します。

<div class="review">
  <div id="grid"></div>
  <aside class="notes">
    <h3>メモ</h3>
    <ol id="note-list"></ol>
  </aside>
</div>

<div id="note-editor" hidden>
  <div class="note-ref"></div>
  <textarea rows="3"></textarea>
  <div class="note-actions">
    <button data-act="delete">削除</button>
    <button data-act="cancel">キャンセル</button>
    <button data-act="save">保存</button>
  </div>
</div>

見た目は自由ですが、大事なルールが 2 つあります。イベントが返すのはビューポート座標なので、エディターは position: fixed にすること。そして z-index でグリッドの内部レイヤーより上に出すこと。これがないと、エディターのボタンへのクリックをグリッドが受け取ってしまいます。

#note-editor { position: fixed; z-index: 100; width: 250px; }

グリッドは作例の予算シートを、吹き出しを止めた状態で作ります。

const ME = '経理';               // ログイン中のレビュー担当者

const grid = createReogrid({
  workspace: '#grid',
  licenseKey: 'YOUR_LICENSE_KEY',
  showCommentBubble: false,      // ホバーの吹き出しの代わりに下のエディターを使う
});
const ws = grid.worksheet;
// …作例の予算シートと 3 つのメモ(前述のとおり)

メモ一覧のパネル

パネルは、レビューする人が最初に知りたいこと、つまり「このシートで未解決なのはどれか」に答えます。

const a1 = (row: number, col: number) => String.fromCharCode(65 + col) + (row + 1);

// メモ一覧:シート上のメモを上から順に
const list = document.getElementById('note-list')!;
const el = (tag: string, text: string) =>
  Object.assign(document.createElement(tag), { textContent: text });

function renderList() {
  const notes = [...ws.getComments()].sort((a, b) => a.row - b.row || a.col - b.col);
  list.replaceChildren(...notes.map((n) => {
    const li = document.createElement('li');
    li.append(el('b', a1(n.row, n.col)), el('span', n.author ?? ''), el('p', n.text));
    li.addEventListener('click', () => {
      ws.selection.moveTo(a1(n.row, n.col));
      grid.focus();
    });
    return li;
  }));
}
renderList();
ws.onStructureChange(renderList);   // 行・列の挿入/削除
ws.onAfterRangeMove(renderList);    // ドラッグで移動した範囲がメモを連れていった

getComments() はシート上の全メモを順不同で返すので、パネル側で読み順に並べ替えています。返ってくるのはコピーなので、配列を並べ替えたり書き換えたりしてもメモには影響しません。一覧の項目をクリックすると、そのセルに選択が移り、キーボードがグリッドに戻ります。

メモの本文と作成者名は textContent で入れていて、innerHTML は使っていません。シートをファイルから読み込むようになると、これが効いてきます。アップロードされた xlsx のメモは、他人が書いたテキストです。

「メモが変わった」というイベントはないので、一覧は手で最新に保つ必要があります。自分の編集は簡単で、エディターが書き込むたびに renderList() を呼べば済みます。厄介なのは、メモに触れずにメモを動かす操作です。D4 の上に行を挿入すると、そのメモは D5 に移ります。パネルが onStructureChange(行・列の挿入と削除)と onAfterRangeMove(ドラッグで動いた範囲はメモも連れていく)も購読しているのはそのためです。アプリで sortColumn を使って並べ替える場合は、その後にも renderList() を呼んでください。並べ替えはメモを行と一緒に動かしますが、どちらのイベントも発火しません。

エディター

const editor = document.getElementById('note-editor')!;
const textarea = editor.querySelector('textarea')!;
let target: { row: number; col: number } | null = null;

function openEditor(row: number, col: number, x: number, y: number) {
  target = { row, col };
  const note = ws.getComment(row, col);
  editor.querySelector('.note-ref')!.textContent =
    `${a1(row, col)} · ${note ? note.author ?? '作成者なし' : '新しいメモ'}`;
  textarea.value = note?.text ?? '';
  editor.hidden = false;
  editor.style.left = `${Math.max(8, x - editor.offsetWidth)}px`;   // 右端をポインター位置に
  editor.style.top = `${y + 12}px`;
  setTimeout(() => textarea.focus());   // グリッドが押下を処理し終えてから
}

function closeEditor() {
  editor.hidden = true;
  target = null;
  grid.focus();
}

editor.addEventListener('click', (e) => {
  const act = (e.target as HTMLElement).dataset.act;
  if (!act || !target) return;
  const { row, col } = target;
  const text = textarea.value.trim();
  if (act === 'save' && text) ws.setComment(row, col, text, { author: ME });
  if (act === 'delete' || (act === 'save' && !text)) ws.removeComment(row, col);
  closeEditor();
  renderList();
});

ここには、間違えやすい点が 3 つあります。

空のまま保存したら削除する。 setComment(row, col, '') は何も消しません。本文のないメモを保存するので、セルには何も言わない赤い三角が残ります。そこで、テキストエリアが空なら removeComment に回しています。

保存したメモは自分のものになる。 setComment は作成者も含めてメモ全体を置き換えます。マーケティングが書いたメモを経理が編集すると、保存されたメモの作成者は「経理」です。これは Excel のメモと同じ「作成者 1 人」の考え方で、レビュー担当者がメモに追記する場面ではたいていこれで構いません。元の名前を残したい場合は、代わりに { author: note.author } を渡します。

フォーカスは 1 拍遅らせる。 エディターは押した位置に出ますが、textarea.focus() は setTimeout で包んでいます。クリックイベントは押下の始まり、つまりグリッドがその押下を処理し終える前に発火します。そしてグリッドは処理の最後に、選択したセルのためにキーボードフォーカスを取ります。すぐにテキストエリアへフォーカスを移しても、グリッドに取り返されます。キャレットはエディターで点滅しているのに、打った文字はグリッドに入ってしまうのです。1 拍待てば、グリッドの処理が先に終わります。

入口は 2 つ

// 入口は 2 つ:メモのあるセルの赤い三角と、任意のセルの右クリック
grid.onCommentClick(({ row, column, clientX, clientY }) =>
  openEditor(row, column, clientX, clientY));

grid.onContextMenu((e) => {
  if (e.area === 'cell') openEditor(e.row, e.column, e.clientX, e.clientY);
});
実行結果
D4 の赤い三角を押した後のレビュー UI。経理のメモがエディターに開き、右のパネルにはシート上の全メモが並んでいます。ホバーの吹き出しはオフです。
D4 の赤い三角を押した後のレビュー UI。経理のメモがエディターに開き、右のパネルにはシート上の全メモが並んでいます。ホバーの吹き出しはオフです。

onCommentClick は、赤い三角(セル右上の小さな正方形)が押されると発火します。イベントには、セルの位置(row・column)、メモのコピー(comment)、ポインターの位置(clientX・clientY)が入っています。グリッドはいつもどおりそのセルも選択するので、選択位置とエディターの位置が食い違うことはありません。結合セルでは、メモがある左上のセルが報告されます。セルにセル型が設定されていても、三角を押したことでチェックボックスが切り替わったり、リストが開いたりはしません。

インスタンスの grid.onCommentClick はアクティブなシートを追いかけるので、複数シートのブックでも購読は 1 回で済みます(1 枚のワークシートに限定した ws.onCommentClick もあります)。

onContextMenu は、まだメモのないセルへの入口です。このイベントが発火した時点で、グリッドはすでにそのセルを選択し、ブラウザー標準のメニューを抑止しています。何が右クリックされたかは e.area でわかります('cell'・'row-header'・'column-header'・'corner')。行と列を持つのは 'cell' だけで、この判定で TypeScript の型も絞り込まれます。この例ではコードを短くするためにエディターを直接開いていますが、実際のアプリでは、自前のコンテキストメニューの中の「新しいメモ」という 1 項目にするのが自然です。


メモはセルについていく

メモは画面上の位置ではなくセルに付いていて、シートが変わってもグリッドがその関係を保ちます。

利用者の操作メモ
上や左に行・列を挿入/削除するセルと一緒に動く
メモのある行・列を削除する一緒に削除される
並べ替える(sortColumn、またはフィルターのドロップダウン)行と一緒に動く
範囲をドラッグして移動する範囲と一緒に動く
セルをコピーして貼り付ける元の場所に残る。貼り付けで運ばれるのは値・表示形式・スタイルで、メモは含まれない

メモの id はこれらの操作を通じて変わらないので、セッション中なら、アプリがメモごとに持つ情報(「解決済み」のチェックなど)のキーとして使えます。ただし、xlsx にも JSON にも保存されません。読み込みのたびに新しい ID が振られるので、後で突き合わせる目的でデータベースに保存しないでください。


保存:xlsx と JSON

xlsx。 v1.6 から、saveAsXlsx() は Excel と同じ形でメモを書き出します。本文と作成者を入れたコメントのパーツと、各メモの箱を描くためのレガシーな図形パーツの 2 つです。Excel には両方が必要で、後者がないと修復しないと開けません。Excel で開くと、どのメモも普通の Excel のメモとして表示されます。赤い三角、作成者、本文です。visible: true で保存したメモは、開いた状態で表示されます。呼び出す API は特になく、保存すればそうなります。

往復で残らないものが 2 つあります。1 つは箱の形、つまり付箋の大きさと位置です。これはモデルに含まれていないので、ファイルから読んだメモでは失われ、保存したメモにはセルの横に Excel の既定の箱が付きます。もう 1 つはメモ内の書式で、太字の語などはプレーンテキストとして読まれます。

JSON。 ReoGrid JSON はメモをシートごとに素のデータとして持つので、レビュー途中の状態を自前のバックエンドに保存するには扱いやすい形式です。

"comments": [
  { "row": 2, "col": 3, "text": "代理店の月額契約ですか、増員ですか?\n月額契約なら外注費に移してください。", "author": "経理" },
  { "row": 3, "col": 3, "text": "62% 増。どの出張ですか?\nイベント一覧を添付してください。", "author": "経理" },
  { "row": 5, "col": 2, "text": "3 月の展示会ブース\n(120,000)を含みます。", "author": "マーケティング" }
]

loadXlsx・loadFromUrl・loadJson の後は、手元の参照を使い回さずに grid.worksheet を取り直してから renderList() を呼んでください。読み込むとそのファイルのメモがシートに載りますが、パネルは教えてもらうまでそれを知りません。


Lite はどこまで、Pro はどこから

機能Lite(無料)Pro
xlsx や JSON から読み込んだメモと赤いマーク✅✅
メモの読み取り(getComment・getComments・hasComment)✅✅
onCommentClick・showCommentBubble・onContextMenu✅✅
メモの書き込み(setComment・removeComment・clearComments・setCommentVisible)—✅
メモ付きの xlsx エクスポート(saveAsXlsx)—✅
組み込み関数(合計行の SUM)—✅

Lite では書き込み系のメソッドはスタブで、コンソールに警告を出すだけで何も変えません。作例が @reogrid/pro を import しているのはそのためです。

それでも Lite で作れる便利なものがあります。読み取り専用のレビュー閲覧画面です。誰かが Excel で予算をレビューしてファイルを戻してきたら、アプリはそれを表示し、すべてのメモを一覧にして読めるようにします。Pro のライセンスは要りません。

import { createReogrid } from '@reogrid/lite';

const grid = createReogrid({ workspace: '#grid', showCommentBubble: false });
await grid.loadFromUrl('/reviews/budget-reviewed.xlsx');
const ws = grid.worksheet;

// …前述のパネルのコードをそのまま置き、続けて:
renderList();
grid.onCommentClick(({ row, column, clientX, clientY }) =>
  openEditor(row, column, clientX, clientY));   // 保存・削除を外したエディター

パネルのコードはそのまま使えます。エディターは閲覧用にします。保存と削除のボタンを外し、テキストエリアを readOnly にするだけです。Lite の上限は 100 行 × 26 列で、たいていのレビュー用シートと、a1 ヘルパーが扱える A〜Z 列の範囲に収まります。


まとめ

メモは、スプレッドシートを使う人がシートを変えずにシートについて話すための手段です。ReoGrid Web は Excel のメモの考え方をそのまま持っています。セルごとに 1 つ、名前とプレーンテキスト、そして赤い三角です。setComment で書き、getComments で一覧にでき、メモは挿入・削除・並べ替え・移動を経てもセルに付いたまま、xlsx や JSON に保存しても残ります。v1.7.1 からは showCommentBubble: false と onCommentClick で組み込みの吹き出しを自前のエディターに置き換えられ、そこからレビューツールが始まります。どのメモにもレビュー担当者の名前が付き、空で保存すれば消え、未解決の質問がセルの中に埋もれないパネルがあります。

メモはセルコメントのデモで試せます。API の全体はセルコメントのドキュメントにあります。シートと一緒に保存されないホバーのヒントについてはセルツールチップを、レビューの前段階である入力用のシートについてはフォームモードの記事をご覧ください。

ReoGrid Web を試してみる

React/Vue 向けの Canvas ベース Excel 互換スプレッドシートコンポーネント。 Lite は無料 — npm install 一発で始められます。

関連記事

キーボードだけで埋められるスプレッドシート帳票 ― ReoGrid Web のフォームモード

業務アプリでは、スプレッドシートを「表計算」ではなく「フォーム」として使う場面がよくあります。レイアウトは固定、入力欄は数か所、それ以外は保護。フォームモードを使えば、そうしたシートがフォームらしく振る舞います。Enter / Tab で入力欄を順に移動し、1 クリックで入力が始まり、プレースホルダーで書式を案内し、ドロップダウンもキーボードで選べます。経費精算書のフルコード、独自のタブ順、入力値の取り出しまで解説します。

Excel からの貼り付けで、実際にクリップボードを渡ってくるもの

業務アプリで一番速い取り込み経路は、たいてい誰も設計していない Ctrl+V です。表計算ソフトがクリップボードに何を書き出しているのか、そのうち何が ReoGrid Web まで届くのか、そして「届いているのに見た目が違う」2つの列と、その後始末までを実コードで。

JavaScriptスプレッドシートの VLOOKUP・XLOOKUP・SUMIFS ― 単価も集計も自動で追随する受注明細表

商品コードから単価を引き、区分ごとに集計する受注明細表をブラウザで組み立てる。VLOOKUP と XLOOKUP の違い、マスタに無いコードの扱い(if_not_found・IFNA・IFERROR)、SUMIF / SUMIFS / COUNTIFS の criteria 記法と引数順の落とし穴、そして ReoGrid Web に「無い」2つの参照関数まで。

ニュースレター

開発の最新情報をお届けします

新しいリリース・機能追加・お知らせをいち早く受け取るには、
メーリングリストにご登録ください。