本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

背景

CODH のサービス停止に伴い、vdiff.js を Wayback Machine から取り出して自サイトの static/vdiff/ にミラー配置する作業(前々回の記事)と、その後 Nuxt 2 の generate crawler に上書きされる問題と SW 退役の対応(前回の記事)まで終わったところで、サービスとしては復旧しました。

ここまではミラーした vdiff.js のデモページをそのまま使う形でしたが、本サイトの他ページは Vuetify ベースで作っているため、

  • 同サイト内の他ページとデザインの統一感を持たせたい(ヘッダ、Material 風のカラー、ダークモード対応など)
  • 自サイトでの主な利用シーン(書写本どうしの照合)でよく使う「左右スライダー」モードを初期表示にしたい
  • 縦長の IIIF 切り出しを与えたときに、画像の凡例フッタが画面内に常に見えていてほしい

といった調整をしておきたくなりました。本記事では、vdiff.js 本体(圧縮済みバンドル)にはほとんど手を加えず、外側のラッパでサイト全体のスタイルに合わせた記録を残します。最終的な見た目は次のようになりました(左右スライダーモード、デスクトップ表示)。

完成形: vdiff モダン版(左右スライダーモード、デスクトップ)

引数なしで開いたときのフォーム画面はこちらです。

URL/ファイル選択フォーム画面

構成

ミラー先は前回までで以下の構成になっています。

static/vdiff/
├── index.html
├── vdiffjs/
│   ├── vdiff.bundle.css
│   └── vdiff.bundle.min.js
├── opencv/opencv-4.5.1.js
├── vdiffjs-wrapper.css
└── vdiffjs-wrapper.js

vdiff.bundle.min.js がコア(jQuery UI ベースの本体)、vdiffjs-wrapper.js が CODH 提供のラッパ(URL パラメータから初期化、エディタモード切替、Spinner 表示などを担当)です。

ここに、外側のスタイルとロジックだけを足す形で vdiff-modern.cssvdiff-modern.js を追加し、index.html を新しいレイアウトに差し替えました。

static/vdiff/
├── index.html              # 入れ替え
├── vdiff-modern.css        # ← 追加
├── vdiff-modern.js         # ← 追加
└── ...                     # 以下、本体は触らず

やったこと(先に結論)

  • アプリバー(Material風、言語/テーマ/全画面トグル)を上に重ねる
  • 上下端に vdiff の元のツールバー / 凡例を貼り付くようにレイアウト(flex column + sticky)
  • フッタが画面内に残るよう、画像コンテナに max-height だけ効かせて、はみ出しは container 側の overflow: hidden に任せる(無理にスケールして合わせない)
  • 編集ボタン、? ヘルプ、ヘッダの i、CODH リンクなど、リンク先が現状ない UI を非表示
  • ja/en 切替、prefers-color-scheme + 明示トグルでのダークモード
  • デフォルト比較モードを「左右スライダー」に切替(バンドルに 1 バイトパッチ)

参考までに、ダークモードに切り替えた状態は次のようになります。

ダークモード(テーマトグルで data-theme=dark を設定)

下二つを順に書きます。

レイアウト: flex column + max-height

vdiff.js が生成する DOM のうち、可視範囲に出るのはおおよそ以下の構造です。

.vdiffjs-container               ← flex column の親
├── .toolbar-container.top       ← 比較モード選択など(上端)
├── canvas                       ← 強調 / 赤青 などモード時の出力
├── .images-compare-container    ← 左右スライダーモード時のコンテナ
└── .legend-container.bottom     ← 「画像1 / 画像2」凡例(下端)

ここに、元の CSS(display: inline-block のコンテナ等)を温存しつつ、外側で flex column を組みます。

.vdiff-modern .vdiffjs-container {
  display: flex;
  flex-direction: column;
  height: calc(100vh - var(--vm-appbar-h));
  overflow: hidden;
}

/* 上端ツールバー(フル幅 sticky) */
.vdiff-modern .vdiffjs-container > .toolbar-container {
  position: sticky;
  top: 0;
  flex: 0 0 auto;
  margin: 0 !important;
}

/* 下端凡例(flex で押し下げ + sticky) */
.vdiff-modern .vdiffjs-container > .legend-container.bottom {
  margin-top: auto !important;
  position: sticky;
  bottom: 0;
  flex: 0 0 auto;
}

/* 中央の画像領域 — はみ出しは container の overflow:hidden に任せる */
.vdiff-modern .vdiffjs-container > .images-compare-container {
  align-self: center;
  flex: 0 1 auto;
  min-height: 0;
  margin-top: auto !important;
  margin-bottom: auto !important;
  max-height: calc(
    100vh - var(--vm-appbar-h) - var(--vm-toolbar-h) - var(--vm-legend-h)
  ) !important;
  max-width: 100vw !important;
}

flex: 0 1 auto; min-height: 0; をペアで指定するのがコツで、これがないと flex の子は内容物以下に縮まないので、画像が縦に大きい IIIF 切り出しを与えると legend-container が viewport 外に押し出されます(いわゆる "footer disappears" 状態)。

はみ出した画像の下部はコンテナの overflow: hidden でクリップされて見えなくなりますが、無理にスケールして全部見せようとすると後述のように本体ライブラリと延々と争うはめになります。フッタが消えないことを最優先に、画像の見切れは許容します。

不要 UI の非表示

CODH のサイトが停止中なので、リンク先がそこに向いているボタンは見せません。あわせて、選択中モードによっては JS が visibility: hidden にする補助設定パネルがあり、空きスペースが残るので display: none に強制します。

/* 編集ボタン(編集モードを使わない方針) */
.vdiff-modern [id$="-editor_link"] { display: none !important; }

/* ヘルプ「?」と外部リンク(CODH へ) */
.vdiff-modern .vdiffjs-container .ui-button:has(.fa-question),
.vdiff-modern .vdiffjs-container .ui-button:has(.fa-external-link-alt) {
  display: none !important;
}

/* 強調モード以外で visibility:hidden にされる補助パネルを畳む */
.vdiff-modern .compare-methods-additional-setting-container[style*="visibility: hidden"],
.vdiff-modern .compare-methods-additional-setting-container[style*="visibility:hidden"] {
  display: none !important;
}

CSS の属性セレクタ [style*="visibility: hidden"] で空白あり・なしの 2 パターンに当てているのは、ライブラリが el.style.visibility = 'hidden' を内部で叩いた結果 style="visibility: hidden;" の形式(空白あり)になることが多いものの、念のため両方カバーするためです。

デフォルトモードを「左右スライダー」に切替

ここからが本題です。

Step 1: JS で起動直後にラジオボタンをクリックする (失敗)

vdiff.js の比較モードはトップに並んだラジオ群(ID は radio0radio3)で、radio1(強調)にチェックが入ったまま起動します。最初は素朴に「起動後にラジオを切り替えればいい」と考えました。

// vdiff-modern.js (素朴な版 — 動かなかった)
function applyDefaultView() {
  if (sliderApplied) return
  const sliderInput = document.querySelector(
    '.compare-methods-selecter-container input[type="radio"][value="0"]'
  )
  if (!sliderInput || sliderInput.checked) return
  // 念のため canvas 出現後に
  if (
    !document.querySelector('.vdiffjs-container > canvas')?.offsetHeight
  )
    return
  window.jQuery(sliderInput).click() // ← jQuery UI の checkboxradio パイプライン
  sliderApplied = true
}

Playwright で 200ms ごとにアクティブモードを観測すると、最初は左右モードに切り替わるものの約 800ms 後に強調モードに戻る現象が出ました。

t=    0ms: Juxtapose          ← 左右
t=  200ms: Juxtapose
t=  400ms: Juxtapose
t=  600ms: Juxtapose
t=  800ms: Highlight          ← ここで vdiff の初期化が走り強調に戻される
t= 1000ms: Highlight
...

vdiff のラッパは画像読み込みなど非同期処理を経てから最終的に radio1prop('checked', true) する処理を持っており、こちらの初期クリックを上書きしてきます。

リトライ + 一定時間後に降りる方式("ユーザーが他モードを能動的に押すまでスライダーを強制" + 10 秒で諦める)でも一応動きますが、起動直後にチラつくのは隠せません。MutationObserver で食らいついても、本体の最終 set は意外と遅いタイミングで来るためです。

Step 2: 圧縮済みバンドルを 1 バイト書き換え

「そもそも本体の初期値を変えれば良い」という当たり前の方針に戻りました。vdiff.bundle.min.js を grep してみると、比較モード初期化の周辺はおおむね次の形になっています。

// 圧縮済みなので変数名は短い
ut = jt('<div>').addClass('compare-methods-selecter-container'),
p = e + 'radio',
ct = U + ' input[name="' + p + '"]',
Pt = G ? 3 : 4,            // モード数(G が真なら 3、偽なら 4)
Ot = G ? 0 : 1,            // ★ デフォルト index(強調=1)
kt = (0 < t && t <= Pt && (Ot = t - 1), [])  // 引数 t で上書きできる

つまり「Ot がデフォルトのモード index、G が真なら 0、偽なら 1(=強調)。引数 t が 1〜Pt の範囲なら t-1 で上書きされる」という構造で、外から指定する手段が一応ありますが、t をどの設定キーから受けるかは圧縮で潰れていて見つけづらい。

一方で Ot=G?0:1ファイル中で 1 箇所だけに出現するので、ここを書き換えるのが一番確実です。

# 1 バイトパッチ
import sys
p = 'static/vdiff/vdiffjs/vdiff.bundle.min.js'
with open(p, 'rb') as f:
    data = f.read()
needle = b'Ot=G?0:1'
assert data.count(needle) == 1, 'pattern is not unique anymore'
with open(p, 'wb') as f:
    f.write(data.replace(needle, b'Ot=G?0:0'))

これで G=true / G=false どちらの分岐でも Ot=0 になり、初期選択が左右スライダー(value=0)になります。

「G」が何を意味するかは圧縮済みでは追えませんが、Pt = G ? 3 : 4(モード数)から推測するに、おそらくエディタモードや一部機能を切ったコンパクト版のフラグで、index 0 が同じく左右スライダーに当たる構造です。なので Ot=G?0:0 にしても、現状ある両方のパスで安全側(既存挙動の "G=true 分岐" と一致)になります。

Playwright で再観測すると、12 秒間ずっと Juxtapose(左右)が active のままで、初期化との競合は完全に消えました。

t=    0ms: none              ← セレクタ未生成
t=  200ms: Juxtapose
t=  400ms: Juxtapose
...
t=11800ms: Juxtapose         ← 最後まで安定

JS 側の applyDefaultView 〜 リトライ 〜 ユーザーアクション検知のロジックは全削除しました。

なお vdiff には他に「強調」「赤青」「並列」のモードがあり、ツールバー左端のアイコン群から切り替えられます。サイト全体のレイアウト方針(上下端固定)は全モード共通で動きます。

強調モード(差分を青ハイライトで重ねる)

赤青モード(赤と青のチャンネル分離で重ね)

並列モード(2 枚を左右に並べる)

バンドル書き換えの注意点

  • vdiff.bundle.min.js は CODH 由来のサードパーティ成果物です。公開サイトでパッチ版を配信する場合、ライセンス(vdiff.js は MIT)に従い改変版である旨を明示しておくのが無難です。少なくともリポジトリの commit message には「1 バイトパッチ (Ot=G?0:1Ot=G?0:0) を当てた」と明記し、可能なら static/vdiff/ 配下に NOTICE 的なファイルを置いておくと後で自分が混乱せずに済みます
  • 本家更新を取り込み直すたびに同じパッチを当て直す必要があるので、shell スクリプト or Makefile タスクとして手順を残しておくと事故りにくいです
  • 圧縮ファイルなので、minifier のバージョンが変わると識別子(OtG)も変わります。ハードコードした b'Ot=G?0:1' のマッチ件数アサート(assert data.count(needle) == 1)は重要です

レスポンシブ対応

ここまでの実装をスマートフォンで開いてみると、本サイトでよく与える IIIF 切り出し(natural width が 600〜1200px くらい)に対して、

  • 画像が viewport 幅より広く、横方向にはみ出して右側が切れる
  • 凡例ラベル(書写本の所蔵先など)が長い日本語で 2〜3 行に折り返したとき、固定の --vm-legend-h: 40px 前提で max-height を計算していたために画像領域がそれを考慮せず、結果として凡例が下に押し出されたりレイアウトがガタつく

という課題が出ました。次の 3 段で順に直します。

1. レイアウトを完全に flex の伸縮に任せる

最初は「上端ツールバー高さ + 下端凡例高さ」を CSS 変数として持って max-height: calc(...) で計算していましたが、凡例の行数が動的に変わるので破綻します。各要素の固定値計算をやめて、

.vdiff-modern .vdiffjs-container {
  display: flex;
  flex-direction: column;
  height: calc(100vh - var(--vm-appbar-h));
  height: calc(100dvh - var(--vm-appbar-h));  /* iOS Safari 対応 */
  overflow: hidden;
}
.vdiff-modern .vdiffjs-container > .toolbar-container,
.vdiff-modern .vdiffjs-container > .legend-container.bottom {
  flex: 0 0 auto;            /* 自然サイズで上下端に貼り付く */
}
.vdiff-modern .vdiffjs-container > .images-compare-container {
  flex: 1 1 auto;            /* 残り全部を取る */
  min-height: 0;
  max-height: 100%;
  max-width: 100%;
}

の形に書き換えました。flex: 1 1 auto; min-height: 0; がペアで重要で、これがないと flex 子は内容物以下に縮まないため、natural サイズの大きい画像を与えると凡例が viewport 外に追い出されます。

100vh100dvh を二重に書いているのは、dvh(dynamic viewport height)非対応の古いブラウザは前者を、対応ブラウザは後者を使う段階フォールバックです。iOS Safari ではアドレスバーが伸縮するので 100vh だと「アドレスバー収納時はちょうど、表示時は下が切れる」状態になりがちで、100dvh がベターです。

2. アプリバーと凡例をモバイルでスリム化

メディアクエリで小さめに:

@media (max-width: 600px) {
  :root { --vm-appbar-h: 48px; }                     /* 56 → 48 */
  .vdiff-modern__appbar  { padding: 0 8px; }
  .vdiff-modern__icon-btn { width: 36px; height: 36px; }
  .vdiff-modern__icon-btn .material-icons { font-size: 20px; }
  .vdiff-modern .vdiffjs-container > .legend-container {
    padding: 6px 8px !important;
    font-size: 11px;
  }
}

凡例は 1 行で済むなら良いですが、本記事の例だと「引目:大阪公立大学中百舌鳥図書館所蔵 国文学研究資料館提供」のような長い帰属表記が入るので、フォントサイズを 11px まで落としても 2〜3 行は折り返します。それ自体は許容して、ただ折り返しに対応した flex で残り領域を画像に渡せれば OK です。

3. IIIF URL を viewport サイズで取得しなおす

横方向のはみ出しは、CSS でいくら頑張っても根本解決しません。jquery-images-compare はコンテナサイズを画像の natural サイズで決めるからです。max-width: 100% を強制しても、内側の natural 600px 幅の <img> は変わらず、コンテナの overflow: hidden で右半分が切れる、という結果になります。

サイト側で配信できる画像のオリジナルが IIIF Image API なら、これは最もきれいに解決できます。size セグメントを viewport にあわせて書き換えてからリクエストすれば、natural サイズが最初から最適になり、ライブラリも CSS も「闘う」必要がなくなります。

<!-- index.html — VDiffWrapper IIFE の直前 -->
<script>
  (function rewriteIiifSize() {
    var url = new URL(location.href);
    var dpr = Math.min(window.devicePixelRatio || 1, 2);
    var w = Math.round(window.innerWidth * dpr);
    var h = Math.round(window.innerHeight * dpr);
    var sizeSeg = '!' + w + ',' + h;             // !w,h = 縦横比保ってこの矩形に収める
    var qualityRe = /^(default|color|gray|bitonal)\.(jpg|jpeg|png|tif|tiff|gif|webp)$/i;
    var rotationRe = /^!?\d+(\.\d+)?$/;
    var changed = false;
    ['img1', 'img2'].forEach(function(key) {
      var v = url.searchParams.get(key);
      if (!v) return;
      var parts = v.split('/');
      if (parts.length < 5) return;
      // IIIF Image API URL かどうかを末尾 2 セグメントの形式で判定
      if (!qualityRe.test(parts[parts.length - 1])) return;
      if (!rotationRe.test(parts[parts.length - 2])) return;
      if (parts[parts.length - 3] === sizeSeg) return;
      parts[parts.length - 3] = sizeSeg;
      url.searchParams.set(key, parts.join('/'));
      changed = true;
    });
    if (changed) history.replaceState(null, document.title, url.toString());
  })();
</script>

ポイントは

  • 末尾 2 セグメント(<rotation>/<quality>.<format>)が IIIF パターンに合うときだけ書き換える。非 IIIF URL(生の .png など)は触らない
  • IIIF size の !w,h 構文を使うと、w×h の矩形に収まる範囲でアスペクト比を保って返してくれる。これが今回の用途にちょうど良い
  • DPR は 2 で頭打ち。3x のフラッグシップ機で 3000+ px の画像をリクエストすると、IIIF サーバ側のサイズ上限で 400 が返る場合がある
  • 比較は parts[parts.length - 3] === sizeSeg で、リロード時の二重書き換えを防ぐ
  • history.replaceState で URL を書き換えると、ユーザがアドレスバーをコピーしたとき「最適化済み」のサイズが入った URL を持ち帰れます。元の 600,/ の指定は失われますが、共有先の人もまたその端末の viewport で再書き換えされるので結果は同じ

書き換え後、Playwright で各 viewport を測ると、

viewport 1280×800 desktop  : compare=1126×652, legend 42px (1 行), 全要素フィット
viewport 390×844  iPhone 13: compare=390×664,  legend 82px (2 行), 全要素フィット
viewport 375×667  iPhone SE: compare=375×467,  legend 102px (3 行), 全要素フィット

のように、画像コンテナが viewport 幅にぴったり収まり、凡例が何行になっても押し出されなくなりました。

実際の表示はそれぞれ次の通りです。

iPhone 13 (390×844): 凡例 2 行、画像はフッタの直上まで使い切り

iPhone SE (375×667): 凡例 3 行に折り返しても、画像領域がそのぶん縮んでフッタは画面内

言語切替・テーマ・全画面

これは普通の DOM 操作なので簡単に。

<header class="vdiff-modern__appbar">
  <span class="vdiff-modern__subtitle"
        data-ja="画像比較ツール"
        data-en="Visual differencing">画像比較ツール</span>
  <button id="vdiff-modern-lang"></button>
  <button id="vdiff-modern-theme"></button>
  <button id="vdiff-modern-fullscreen"></button>
</header>
function applyLocale() {
  const lang = currentLang() // ?lang=ja|en or navigator.language
  document.documentElement.setAttribute('lang', lang)
  document.querySelectorAll('[data-ja][data-en]').forEach((el) => {
    el.textContent = el.getAttribute('data-' + lang)
  })
}

vdiff 本体の言語は ?lang=ja|en をクエリで渡せば従うので、langBtn クリック時にクエリを書き換えてリロードする方針にしました。完全 SPA 切替は不要なので、これで十分です。

ダークモードは prefers-color-scheme を起点に CSS 変数を切り替え、トグルボタンで data-theme="dark" を設定 + localStorage 保存。フルスクリーンは requestFullscreen() をそのまま叩くだけです。

まとめ

  • vdiff.js の UI を外側からカスタマイズするときは、まず本体に触らずに済む方法(CSS/JS のラッパ追加)を試す
  • ただし「デフォルト挙動の変更」のように、起動シーケンスに密に埋め込まれた挙動は、外側から DOM を叩いて再現すると初期化と競合してチラつき・リバートが発生しがち
  • そういうケースは、変更点がファイル中で一意に特定できる文字列であれば、ミニファイ済みバンドルへのピンポイントなバイト置換のほうがむしろクリーンになる
  • パッチを残すときは、適用箇所のマッチ件数 = 1 をアサートしてからハッシュチェック付きで書き換えるのが安全
  • 「無理にスケールして全部表示しようとせず、フッタを画面内に保ってクリップを許容」のように、ライブラリと闘う代わりに UI 仕様を譲る判断ができると、保守コストが大幅に下がります
  • スマホ対応では、固定ピクセルの calc に頼らず flex の伸縮に任せる + IIIF などサーバ側で画像サイズを指定できる仕組みがあるなら viewport 適応のサイズで取得しなおす のが、最終的に CSS も JS もシンプルに収まりやすい組み合わせでした

参考