iPhone の WebKit がブログを 100% 未満まで縮小していた — overflow-wrap と表のスクロールボックスで解決

iPhone でピンチインすると、このブログが 100% 未満まで縮小しました。WebKit は最小スケールをコンテンツ幅から決めるため、overflow-wrap と表のスクロールボックスで解決しました。

白いカードに、青・黄・オレンジの角丸四角形を3枚重ね、一番上の青い面に白い輪郭のコンパスの針を描いたWebKitのロゴと、黒のWebKitという文字
目次

はじめに

スマートフォンで自分の記事をピンチインしたところ、本来のサイズより小さく縮んでしまいました。違和感があったので調べることにしました。

トップページではそうなりません。ピンチインしてもページはその場に留まります。どちらも同じ <meta name="viewport"> で配信しているので、原因はタグではないだろうと考えました。

WebKit は縮小方向の最小スケールをドキュメントの実際のコンテンツ幅から決めますし、iOS 10 以降は minimum-scale を無視します。つまり下限を指定する手段はありません。本当の問題は ページがスマートフォンの画面からはみ出していること でした。

解決したのは CSS のルール 2 つです。インラインコードへの overflow-wrap: anywhere と、本文の幅より広い表を囲むスクロールボックスです。あわせて、全記事をスマートフォン幅で測定するチェックも追加しました。画面からはみ出したページは公開前に落ちます。

Chrome on iPhone で最小ズームまで縮小した記事ページ。アドレスバーには oharu121.com と表示され、本文カラムは画面幅の約 4 分の 3 を占め、右側には暗い余白の帯が残っている

画面は 430pt、ドキュメントは 591px なので、ブラウザが許す最小スケールは 430/591 になり、残りの 27% は空のページです。

私の直感は間違っていた:viewport メタタグはズームアウトを止められない

ページのズームアウトを止められる viewport の値は存在しません。 スクリーンショットを見れば、メタタグに下限が足りないのだろうと考えたくなります。このサイトが全ページで配信しているのも、次の最小限の 1 行です。

src/layouts/BaseLayout.astro
<meta name="viewport" content="width=device-width, initial-scale=1" />

この行に minimum-scale=1 を足すのが、私が最初に探した修正でした。しかし何も起きません。iOS 10 以降、WebKit は user-scalable、minimum-scale、maximum-scale をいずれも無視しており。リリース記事 にはこう書かれています。

そもそも直感の向き先も間違っていました。読めないほど広いコンテンツに読者が対処する手段がズームアウトです。 それを禁じるページは、別の問題を持ち込みかねません。

手がかりはトップページ — 同じメタタグ、違うコンテンツ

トップページはまったくズームアウトしませんし、記事と同じレイアウトから配信されています。タグは 1 つなのに挙動は 2 つなので、違いを生んでいるのはタグではありません。違うのは、それぞれのページが中に入れているものです。

これはページごとに 1 つの式で確かめられます。数値はすぐに食い違いました。

トップページ、390px のビューポートで
入力
document.documentElement.scrollWidth
出力
390
記事ページ、同じビューポートで
入力
document.documentElement.scrollWidth
出力
591

clientWidth はどちらも 390 でした。 390 のビューポートの中に 591 幅のドキュメントがあるということは、201px 分のコンテンツが画面の外にぶら下がっているということです。

同じ記事は、ウィンドウが 390 でも 500 でも 591 を返します。原因がビューポート側にないと分かったのはこの点でした。幅を決めていたのは記事の中にある何か であり、ビューポートを無視する数値はビューポートでは直せません。

ズームの下限はドキュメント自身の幅

WebKit はページを device-width でレイアウトし、コンテンツがそこからはみ出していることを検知して、すべてが画面に収まるスケールを選びます。WebKit 自身の説明によれば、これは読者がピンチインで全体が見えるところまで縮小するのと概念的には同じです。

つまり 最小スケールはページが自分自身について計算する商 です。画面幅をコンテンツ幅で割った値になります。

ここまでの測定はすべて iPhone の Chrome で行い、スクリーンショットもそこから取ったものです。この挙動はブラウザ側ではなくエンジン側のもので、iOS のブラウザはいずれも WKWebView で描画するため、Safari もほかのブラウザも同じ挙動を受け継ぎます。

最小スケールは画面幅をコンテンツ幅で割った値2 つのパネルが並ぶ。左はスケール 1 でのレイアウトで、画面幅 390px に対してドキュメントは 1028px あり、638px が右端の外にはみ出している。右はブラウザがドキュメント全体を画面に収めるスケールを選んだ状態で、本文は幅の約 3 分の 1 を占め、残りは余白になる。中央には 390 / 1028 = 0.38 の計算が置かれている。スケール 1 でのレイアウト最小スケール時画面 390pxドキュメント 1028px638px画面外画面 390pxドキュメント 1028px本文余白390 / 1028 = 0.38最小スケール = 画面幅 / コンテンツ幅
レイアウトビューポートはデバイス幅のままです。ドキュメントはそれより広いので、それが収まるスケールが読者の到達できる下限になります。

スクリーンショットの数値はすべてこの商から導けます。430pt の画面に対して 591px のドキュメントなら下限は 0.73、このサイトで最悪のページはドキュメントが 1028px あり、下限は 0.38 でした。調整が必要だったのはズームではなく、商の分母のほうでした。

全記事を調べて分かったこと

1 ページ分のはみ出しはそのページだけの修正で終わります。次の問いは、何ページで起きているのかでした。本番のサイトマップにある 356 ページすべてを 390 × 844 で読み込み、scrollWidth を clientWidth と比較しました。

ターミナルウィンドウ
入力
node --input-type=module -e "…" # playwright over every route in the sitemap
出力
routes measured: 356 overflowing: 63

原因は最初のページから想像したものとは違いました。ビューポートを越えた要素をタグごとに集計するとこうなります。

要素その要素が現れたはみ出しページ数
code63
table とそのセル52
a8
strong6

インラインコードは 63 件すべてに現れ、表は 52 件でした。 私が見ていた記事はたまたま表のケースで、サイトで最悪のページには表が 1 つもありません。段落の中でインライン code として組まれた Windows のレジストリパスが、それだけで 1009px に達していました。

入力
node scripts/check-overflow.ts
出力
right=1009 div.page > main > article > div.prose > p > code
text: "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnloc"

Expressive Code の行 span はほぼすべての記事でビューポートを越えますが、上の表には一度も出てきません。なぜ出てこないのかが、2 つの修正の背後にある仕組みそのものです。

ボックスに収まらないものは、隠れてくれません。 既定の overflow: visible は、ボックスより広い内容もそのまま描き、その分を「ページを横にどれだけスクロールできるか」に数える、という意味です。すぐ外のボックスもその幅を受け取り、さらに外側も同じで、最後は文書全体まで届きます。だから段落の中の 1009px の文字列が、文書そのものを 1009px にしてしまいます。

その幅を受け取らないのが overflow-x: auto です。 これを付けたボックスはスクロールコンテナになり、はみ出した分はボックスの縁で切り取られて、内側でスクロールします。外側のボックスに見えるのはボックス自身の幅だけで、余分な幅は渡りません。文字の幅は変わらず、文書がその幅を数えなくなるだけです。

だからこそ この行 span はページを広げられません。それらが入っている <pre> はすでにスクロールするので、はみ出しはその縁で止まります。ビューポートを越えた要素をすべて報告するチェックなら、どの記事でも永久にこれを挙げ続けることになります。

スクロールコンテナ内のオーバーフローはドキュメントに届かない入れ子のボックスが 2 列並ぶ。左は span が pre からはみ出しているが、pre には overflow-x: auto があるためそこでオーバーフローが止まり、ドキュメント幅は変わらない。これは欠陥ではない。右は div.prose の中の段落からインラインコードがはみ出していて、スクロールもクリップもする要素がないため、はみ出しがページの外まで届いてドキュメントを広げる。これは欠陥である。スクロールボックスでクリップdiv.expressive-codepre (overflow-x: auto)spanドキュメントまで届くdiv.prosepcode欠陥ではない欠陥スクロールもクリップもしない祖先だけのとき、葉はドキュメント幅を変える
スクロールコンテナの内側のはみ出しは、そのコンテナで切り取られます。葉の要素がドキュメント幅を変えるのは、その上にスクロールも切り取りもする要素がないときだけです。

コードを折り返す:break-word ではなく overflow-wrap

折り返せる位置のない単語は、ブラウザも折り返せません。だからインラインコードの中の長いトークンは、文書そのものをそのトークンと同じ幅まで広げます。直すのは、このサイトにもとからあったセレクタに overflow-wrap: anywhere を 1 行足すだけです。

src/styles/global.css
:not(pre) > code {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.1em 0.35em;
overflow-wrap: anywhere;
}

break-word ではなく anywhere です。min-content 幅を縮めるのは anywhere だけです。

そのため、効くのは段落のケースだけではありません。インラインコードを含む表のセルはここではほとんどがそれに当たり、それらが軒並み狭くなるので、表の問題の一部もこれで一緒に片付きます。

表を箱に入れる:2 つの選択肢と DevelopersIO の実装

はみ出すほど広い表に対する修正は 2 つあります。私は、表を毎日のように載せているサイトがどうしているかを知りたいと思いました。DevelopersIO は表の多い日本語の技術記事を出しているので、その 1 ページを同じ方法で測りました。

dev.classmethod.jpこのブログ(修正前)
viewport メタタグwidth=device-width, initial-scale=1同一
390 での documentElement.scrollWidth390591 から 1028
表の計算後の displayblocktable
表の計算後の overflow-xautovisible
表を囲むラッパー要素なしなし
インラインコードの計算後の overflow-wrapbreak-word未指定

採用されているのは CSS だけの修正 で、ラッパー要素はどこにもありません。表をブロックボックスにしてスクロールさせています。これは機能しますし、ビルド手順も要らず、2 行で済みます。

display: block と、幅の狭い表が払う代償

代償を払うのは、もともと広すぎなかった表のほうです。 内容が本文の幅より狭い表は、そこいっぱいに広がるのをやめて自分の文字の幅まで縮み、残りは空白になります。このサイトのレイアウトで測ると、712px のカラムに対して 711px だったものが 139px になりました。

display: block が狭い表に強いる代償同じ本文の幅の中に同じ表を置いた 2 行の図。上の行は display: table で、712px の幅に対して 711px と幅いっぱいに広がる。下の行は display: block で、左端の 139px だけを占め、残りは空白になる。本文の幅、712pxdisplay: table状態件数幅いっぱいに広がる · 711pxdisplay: block空白状態件数自分の文字の幅まで縮む · 139px
同じ本文の幅に置いた同じ表です。display: block では自分の文字の幅しか残らず、もともと本文より広い表は見え方が変わりません。

DevelopersIO はこの代償をそのまま受け入れています。 記事 8 本を測ったところ、表 7 つのうち 4 つは本文の幅より狭く、左に寄って縮んだまま表示されます。内容がカラムより広い表なら両者の差は見えません。そうした行は 656px のカラムの中で 655px を返します。

ラッパー要素と、こちらを選んだ理由

もう 1 つの選択肢は、表そのものには手を付けず、スクロールする要素で囲むものです。すべての表が display: table のままなので、描画は何も変わりません。 スクロールバーが付くのは広すぎる表だけです。私が選んだのはこちらで、決め手になったのは、このリポジトリにすでに書かれていて使われていなかったルールを見つけたことでした。

src/styles/global.css
/* Wide content must scroll in its own box, never the page body. */
.table-scroll {
overflow-x: auto;
}

このクラスを適用している箇所はリポジトリのどこにもありませんでした。意図だけが書き残されて、それを動かす実装がなかったわけです。そこで、ツリーの構築時に各 Markdown の表を囲む rehype プラグインを書きました。

src/lib/rehype-table-scroll.ts
if (child.type === "element" && child.tagName === "table") {
children[i] = {
type: "element",
tagName: "div",
properties: { className: ["table-scroll"], tabIndex: 0 },
children: [child],
};
}

markdown.rehypePlugins は非推奨で、実行のたびにそう表示されます。

[astro] `markdown.remarkPlugins`, `markdown.rehypePlugins`, and
`markdown.remarkRehype` are deprecated. Pass them to `unified({...})` from
`@astrojs/markdown-remark` directly instead.
astro.config.mjs
import { unified } from "@astrojs/markdown-remark";
markdown: {
processor: unified({ rehypePlugins: [rehypeTableScroll] }),
},

unified() は Markdown 処理系そのもの、Markdown を解析して HTML を出すパイプラインです。Astro のエクスポートは本来使われるはずのものをそのまま組み立て、プラグインのリストを受け取ります。remark プラグインは Markdown のツリーに、rehype プラグインは変換後の HTML のツリーに作用します。rehypeTableScroll が包む <table> 要素は、HTML のツリーができて初めて存在します。

渡したオプションはそのまま使われ、渡さなかったものは既定値で埋まるので、rehype プラグインを 1 つ登録しても他が切れることはありません。GFM も有効なままです。 GFM は Markdown に表の記法を足す拡張で、素の Markdown に表はありません。これがなければ、包む表そのものが存在しません。

このスニペットの tabIndex: 0 があると、読者は Tab キーでスクロールボックスに移動し、矢印キーで広い表の右側の列まで送れます。

390px で全記事を測るゲート

この調査はそのまま pnpm check:overflow というスクリプトになりました。全ロケールの全記事とユーティリティページを 390 × 844 で読み込み、scrollWidth が clientWidth を超えたページを失敗させます。

ターミナルウィンドウ
入力
pnpm check:overflow
出力
Nothing overflows. 211 route(s) measured at 390px.

まだ修正していないサイトに対して、同じチェックを実行しました。

ターミナルウィンドウ
入力
FIT_BASE_URL=https://oharu121.com pnpm check:overflow
出力
63 route(s) wider than the viewport, of 191 measured.

その要素より外側のボックスがすでにスクロールか切り取りをしている場合、つまり overflow-x の計算値が auto、scroll、hidden のいずれかである場合、その要素は数えません。これにより Expressive Code の span は報告から外れ、表と段落は残ります。

このチェックは フィードの後続ページを計算ではなく実際に叩いて探します。これにより POSTS_PER_PAGE から算出したのでは見落としていた 13 ページが見つかり、対象は 198 から 211 になりました。

このチェックは pnpm check には含めていません。サーバーの起動が必要だからで、このリポジトリの図のチェックが除外されているのと同じ理由です。代わりに公開ステップが、すでに起動している開発サーバーに対して実行します。

まとめ

iPhone で 100% 未満までピンチインできるページは、viewport の値が足りないのではなく、自分のコンテンツ幅をそのままブラウザに伝えているだけです。WebKit はスケールの下限を画面幅とコンテンツ幅の比で決め、iOS 10 以降は minimum-scale を無視するので、動かせるのはレイアウトだけです。

ここでのすべてのケースはルール 2 つで片付きました。1 つはインラインコードへの overflow-wrap: anywhere です。break-word ではなく anywhere なら min-content も縮み、コードを含む表のセルも狭くなります。

もう 1 つは広い表を囲むスクロールボックスで、表そのものへの display: block ではなくラッパーとして入れます。そうすればもともと収まっていた表は本文の幅を埋めたままです。ラッパーには tabindex="0" を付けます。読者は Tab キーでそこに移動し、矢印キーで表の右側の列まで送れます。

残りは測定です。スマートフォン幅で scrollWidth を clientWidth と比べます。きっかけになった 1 ページではなく全記事に対して行い、スクロールコンテナの内側の要素は、そのはみ出しがドキュメントまで届かないので除外します。

参考リンク

この記事をシェア