kikuta@dev:~$cat ./notes/spex-sheets-dark-mode-canvas.md
·12 min·kikuta

Google Sheetsのセルまでダークモード化する:Canvas描画を追いかけた実装記録

Canvas描画されたSheets gridを、ユーザーが設定したセル色を壊さずダークモード化するまでの実装記録。

Google Sheets向けChrome拡張Spexには、キーボード操作とは別にダークモードがあります。

最初は「SheetsのUIにdark CSSを当てればよい」と考えていました。

しかしSpreadsheetでは画面の大半がセルです。周囲のツールバーだけ暗くして、セル領域が真っ白なままだと体験としてかなり中途半端でした。

ではセル領域も暗くすればよいかというと、Google Sheetsのgridは普通のDOM tableではなくCanvasに描画されています。

さらに、ユーザー自身が設定した黄色・赤・緑などのセル色には意味があります。

暗くしたいのは表示テーマであって、Spreadsheetそのものではない。

この区別が今回の設計の中心になりました。

CSS filter: invert() は簡単だが、意味まで反転する

最初に思いつくのは、grid canvasへCSS filterを掛ける方法です。

canvas {
  filter: invert(1);
}

見た目だけなら一瞬で暗くなります。

ただし、ユーザーが設定した色もすべて反転します。

yellow -> blue系
red    -> cyan系
blue   -> orange系

Spreadsheetでは色が、

  • 警告
  • 入力セル
  • 進捗
  • 区分
  • レビュー状態

などの意味を持っていることがあります。

テーマ切替の都合でその意味を変えるのは避けたいと考えました。

「白黒だけ反転」も実際には単純ではない

次に考えたのが、無彩色だけdark paletteへ変換し、有彩色はそのまま残す方法です。

方向性はこれでよかったのですが、R === G === B だけでneutral判定すると不十分でした。

UIで使われるgrayは、見た目はgrayでもRGB値にわずかな色味を含むことがあります。

一方で薄いblueやgreenをgrayと誤認すると、本来保持すべき色を壊します。

Spexでは色を知覚空間へ変換し、Chromaを使ってneutralかどうかを判定する方針にしています。

概念的には、

低Chroma
  -> neutralとしてdark paletteへmapping

十分なChroma
  -> authored colorとして原則保持

です。

色付きセルを「Original Color Island」として扱う

有彩色の背景は原則としてそのまま残します。

例えば、

yellow background + black text

なら、dark modeでもyellow backgroundはyellowのままです。

背景だけ暗くしないため、その中のblack textも十分読めるならblackのまま残します。

Spexの仕様ではこの種の領域を、Original Color Islandとして考えています。

Dark Modeだからといって画面上のすべてをDark Paletteへ統一しない、ということです。

foregroundはbackgroundとペアで判断する

背景色を保持するだけでは十分ではありません。

例えばwhite background上のdark blue hyperlinkは、背景をdark surfaceへ変えると読みにくくなる可能性があります。

そこでforegroundは単独で変換せず、backgroundとのcontrastを見ます。

優先順位は概ね次です。

  1. 元のforegroundで十分なcontrastがあれば保持
  2. neutral foregroundならdark/light側へremap
  3. chromatic foregroundならHue方向を維持してLightnessを必要最小限変更
  4. 必要な場合だけChromaを下げる
  5. 最後のfallbackとしてnear-light / near-darkへ寄せる

「Dark Mode用の固定色に塗り替える」のではなく、元の色の意味をできるだけ保存したまま読める状態へ救済する考え方です。

一番大きな設計変更:原本Canvasを書き換えない

色変換以上に重要だったのが、Canvas自体の扱いです。

SpexではGoogle Sheetsが描画する元のgrid canvasを、Light表示の正本として維持します。

Dark Modeのために元canvasを書き換えません。

代わりに、その直上へSpex専用のpresentation canvasを重ねます。

Google Sheets grid canvas
  -> original / light source of truth

Spex presentation canvas
  -> dark presentation only
  -> pointer-events: none

この構造にした理由は、表示テーマをGoogle Sheetsのデータや描画stateから分離したかったからです。

DarkからLightへ戻すときはpresentation canvasを破棄すればよく、Google Sheets側の再描画を待つ必要がありません。

途中でDark ModeをONにする問題

ページロード時からDark Modeなら、その後の描画operationをmirrorできます。

難しいのは、すでにLightで描画済みのSpreadsheetを途中からDarkにする場合です。

このときは元canvasのvisible bitmapをsnapshotし、初回だけpixelベースで変換します。

ただしpixelだけ見ても、その色が

  • background
  • text
  • border
  • anti-alias pixel

のどれなのか分かりません。

例えばyellowセルの上にあるblack textを、「white系background上のblack text」と同じようにlight textへ反転すると壊れます。

そのためsnapshotでは周辺pixelも見ながら、solid surfaceかstrokeかを推定します。

一方向だけ均一でも背景とは限らない

ここで一つ面白い失敗がありました。

周囲と同じ色が横方向に続いているpixelをbackgroundとみなすと、細い横線や文字strokeまでbackground扱いされることがあります。

縦方向だけでも同じです。

そこで、solid fillとして確定するには、縦横の双方でsurfaceとして均一であることを要求するようにしました。

一方向だけ均一なものはborderやglyphの可能性があるのでforeground/stroke候補として残します。

画像処理として高度なsemantic segmentationをしているわけではありませんが、Spreadsheetの規則的なgridだからこそ、この程度の局所推定でもかなり意味があります。

初回snapshotの後はpixel inferenceを続けない

Dark Mode中にGoogle Sheetsが新しく描画するたび、毎回pixelから意味を推測するのは高コストです。

そこで初回snapshot以降は、Canvas APIのoperation semanticsを利用します。

例えば、

fillRect -> background候補
fillText -> foreground
stroke   -> stroke

として扱います。

fillText の位置に対して、直近で描かれた最も内側のbackground矩形を解決できれば、foreground/backgroundのpairでcontrast rescueできます。

つまり、

途中ON時
  -> bitmapしかないので推定

ON後の新規描画
  -> operationの意味を利用

と分けています。

Virtual ScrollでLight pixelが戻ってくる

Canvasの二重化でさらに厄介だったのが、Google Sheetsのvirtual scrollです。

gridがscrollされると、既存canvasの一部を drawImage でself-blitして再利用することがあります。

presentation側でこの処理のコピー元をoriginal canvasにすると、せっかくDark化した領域へLight pixelが再混入します。

そのためgrid self-blitの場合、presentation側ではpresentation canvas自身をコピー元として同じblitを再現します。

一方、chartや画像などuser-authored visual contentを描く drawImage は原色保持したいので、self-blitと外部画像描画を区別します。

このあたりは、単純なCSS themeでは出てこないCanvas特有の問題でした。

Dark Mode対象を広げすぎない

Canvasをhookできるようになると、ページ上のすべてのCanvasを変換したくなります。

しかしSpexではgrid canvasだけにscopeを限定しています。

対象外には、

  • chart
  • embedded image
  • drawing
  • artwork SVG
  • color palette swatch

などを置いています。

「暗くできる」ことと「暗くすべき」ことは別です。

特にchartの系列色まで勝手に変えると、ユーザーが作ったvisual semanticsを壊します。

Cell EditorはCanvasとは別だった

もう一つ実用上重要だったのが、セルをEnter等で編集するときのeditorです。

grid自体をdarkにしても、編集に入った瞬間だけ白いeditor rectangleが出るとかなり目立ちます。

これはCanvasとは別のDOM surfaceなので、別途theme化します。

ただしここでもSpreadsheet formatting自体は変更せず、editorのpresentationだけをdark themeへ寄せます。

Dark Modeは「保存された書式変更」ではなく、最後まで表示レイヤーの責務として扱います。

非破壊性を最優先にした

今回のDark Mode実装で最も重視したのは見た目の美しさより、次の3点でした。

  1. Google Sheetsの保存書式を変更しない
  2. ユーザーが付けた色の意味を可能な限り変えない
  3. Lightへ戻したとき元表示へ確実に戻れる

そのために、原本Canvasをsource of truthとして残し、presentation canvasを重ねる設計にしました。

テーマはpresentationであってdata mutationではない、という境界を守るためです。

Spexで実際に使っています

このCanvas Dark ModeはSpexのGoogle Sheetsテーマ機能として実装しています。

Canvasベースの既存Webアプリをtheme化する場合、CSS filterの次の選択肢として「原本とpresentationを分離する」設計はかなり使えると思います。