この文庫は、既定で縦書き表示です。作品ページを開くと、右から左へ本文が流れます。
CSSの writing-mode: vertical-rl を指定すれば縦書きにはなります。しかし、それだけでは読書体験としては成立しませんでした。実際に作って直したものを、順に書きます。
縦書きにするだけなら一行
.honbun {
writing-mode: vertical-rl;
text-orientation: mixed;
height: min(74vh, 760px);
overflow-x: auto;
overflow-y: hidden;
}
縦書きでは、本文は横方向に伸びていきます。だから高さを固定して、横スクロールさせます。ここまでは素直です。
問題はこの先でした。
落とし穴1:マウスホイールが効かない
縦書きなのに、マウスホイールは縦にしか回りません。読者は縦に回して、横に進みたい。
そこで、ホイールの縦移動を横スクロールに変換します。
// これは動かない
function onWheel(e) {
e.preventDefault(); // ← 効かない
stage.scrollLeft -= e.deltaY;
}
return <div onWheel={onWheel}>…</div>
書いた当初、横には動くのにページ全体も一緒に縦スクロールしてしまうという症状が出ました。preventDefault() を呼んでいるのに止まらない。
原因は、React が wheel を passive リスナーとして登録していることでした。passive なリスナーでは preventDefault() が無視されます。
ネイティブのリスナーを、明示的に非passiveで張れば解決します。
useEffect(() => {
const stage = stageRef.current;
function onWheel(e) {
const max = stage.scrollWidth - stage.clientWidth;
if (max <= 0) return;
const delta = e.deltaY || e.deltaX;
if (delta === 0) return;
const before = stage.scrollLeft;
stage.scrollLeft -= delta; // ブラウザが端でクランプする
// 実際に動いたときだけ既定動作を止める
if (stage.scrollLeft !== before) e.preventDefault();
}
stage.addEventListener('wheel', onWheel, { passive: false });
return () => stage.removeEventListener('wheel', onWheel);
}, [orientation]);
ポイントは最後の三行です。**「実際にスクロール位置が変わったときだけ preventDefault() する」**という判定を入れています。
こうすると、本文の途中ではページが縦に動かず、本文の端まで来たらホイールがページに素通しされるという自然な挙動になります。常に止めてしまうと、本文の上でホイールを回してもページを離れられなくなります。
なおこの書き方には副産物があって、scrollLeft の符号を気にしなくて済みます。縦書き(vertical-rl)では scrollLeft が負の値を取りますが、「動いたかどうか」で判定するので符号に依存しません。
落とし穴2:半角の疑問符が横倒しになる
これは実装ではなく、本文のほうの問題でした。
縦書きでは、半角文字は横に倒れて表示されます。 ? や ! を半角で書いていると、そこだけ寝転がります。日本語の縦組みでは全角の ? ! を使わなければいけません。
公開後に気づいて調べたところ、自作の五箇所が半角のままでした。書いた本人には見えない種類の不具合です。
同じ理由で、本文中の半角英数字も避けます。数字は漢数字にすると、縦中横(数字だけ横に組む処理)を考えなくて済みます。「二十二年」と書けば、それ以上何もしなくてよい。
ダッシュも二種類が混在していました。—(U+2014)と ―(U+2015)です。日本語組版では U+2015 を二つ重ねた ―― が標準なので、そちらに統一しました。
こういうものは感覚では直せないので、公開前に機械で数えることにしました。
const RULES = [
{ name: '半角の疑問符・感嘆符', detect: s => (s.match(/[?!]/g) || []).length },
{ name: 'ダッシュ U+2014', detect: s => (s.match(/—/g) || []).length },
{ name: '本文中の半角英数字', detect: s => [...new Set(body.match(/[0-9A-Za-z]+/g) || [])].length },
];
違反があれば終了コード1で落ちるようにして、公開前に必ず走らせています。
落とし穴3:スマホでボタンが押せない
読書中の主要な操作は「縦書き/横書きの切り替え」と「文字サイズ」です。
実測したところ、これらのボタンの高さが 28〜31ピクセルしかありませんでした。指で押すには小さすぎます(推奨は44ピクセル)。
見た目のバランスを取ろうとすると、こうなりがちです。デスクトップでは問題なく押せてしまうので、気づきません。
@media (max-width: 560px) {
.seg button { min-height: 44px; padding-inline: 1.15em; }
.stepper .step { width: 44px; height: 44px; }
}
モバイルでだけ拡大しました。読書中にいちばん触る場所なので、ここは最優先で確保する価値があります。
落とし穴4:キーボードで送れない
縦書きでは、進む方向が「左」です。直感と逆になるので、明示的に実装します。
if (e.key === 'ArrowLeft' || e.key === ' ' || e.key === 'PageDown') {
stage.scrollLeft -= page; // 読み進める
} else if (e.key === 'ArrowRight' || e.key === 'PageUp') {
stage.scrollLeft += page; // 戻る
}
あわせて、フォーカスリングを必ず出すこと。キーボードで送れるようにしたのに、いまどこにフォーカスがあるか見えないのでは意味がありません。
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid var(--shu);
outline-offset: 3px;
}
:focus-visible に限定すると、マウス操作では出ず、キーボード操作のときだけ出ます。
横書きも用意する。ただし手を抜かない
縦書きが読みにくい環境もあるので、切り替えを用意しています。
横書きのときに効くのは、一行の字数です。日本語では三十五〜四十五字が可読の範囲で、それを超えると視線の戻りがつらくなります。
.honbun.yokogaki {
max-width: 40em; /* ≒ 一行40字 */
line-height: 2.15;
letter-spacing: 0.03em;
}
em で指定すると、文字サイズを変えても一行の字数が保たれます。実測で一行40字・行間2.15になっていることを確認しました。
ルビと傍点は、記法から作る
青空文庫の記法をそのまま使えるようにしています。
- ルビ:
<ruby>漢字<rp>(</rp><rt>かんじ</rt><rp>)</rp></ruby>→ 直前の漢字連続にルビ - ルビ(明示):
<ruby>任意の語<rp>(</rp><rt>よみ</rt><rp>)</rp></ruby> - 傍点:
<em class="bouten">強調</em>
Markdown を HTML に変換する前に、正規表現で <ruby> タグに置き換えています。傍点は text-emphasis で、縦書きでは自動的に右側に打たれます。
運用上のルールも決めました。ルビは初出のみ。同じ語に何度も振ると、画面がうるさくなります。傍点は一篇に一〜二箇所。多いと安っぽくなります。
まとめ:縦書きは「見た目」ではなく「操作」の問題だった
writing-mode を書いた時点では、仕事の一割も終わっていませんでした。
残りの九割は、縦書きになったことで壊れる操作を、ひとつずつ直す作業です。ホイールの向き、キーの向き、約物の向き、タップ標的の大きさ、フォーカスの見え方。
どれも派手ではありませんが、これをやらないと「縦書きっぽく表示されるが、読めないページ」になります。
実際に読める状態になったものは、作品ページで試せます。右上のボタンで横書きにも切り替えられます。