最後のページを開いて本を閉じたら、店員が「読了おめでとうございます」と言う。そんな書店を考えてみます。
「いま開いたところです」
2ページの本を見開きで開けば、この返事はそのまま通ります。最初の画面に、もう最後のページが載っています。見開きの右側は、読んでいなくても画面にあります。
個人開発のAfter 6 Streetでは、画面に合わせて店番日記をページに分けています。画面を広げると、さっきの3ページ目が右ページに移ることもあります。
しおりには本文中の位置を持ち、ページを組み直したら、その位置を含むページを探し直します。 本を閉じるときに店員へ渡すのは、その時点でどのページまで表示していたかです。
ページの切れ目は、ブラウザーに聞く
紙面に何行入るかは、幅や文字の大きさで変わります。1ページ目には見出しもあります。文字数だけで区切ると、同じ文字数でも収まるページと収まらないページができます。
Streetの fits() は候補の段落を紙面に入れ、最後の段落の下端と紙面の下端を比べます。textInto() は段落を <p> にして差し替える関数です。
function fits(chunks, index) {
setPageHeader(index);
textInto(left, chunks);
const last = left.lastElementChild;
return !last ||
last.getBoundingClientRect().bottom <= left.getBoundingClientRect().bottom + .3;
}
比較式の .3 を足しているので、段落が紙面の下端を 0.3 CSS px まで越えても「収まった」と判定します。
次の段落まで入れば同じページへ、はみ出せば次のページへ送ります。段落一つでも長ければ、収まる長さを二分探索して分けます。
紙面を組み直すきっかけには ResizeObserver を使っています。観測するのは本屋の画面を包む要素です。layout() で幅を読み、1ページ表示か見開きかを決めてから paginate() を呼びます。初回のフォント読み込みが済んだときにも測り直します。
new ResizeObserver(layout).observe(root);
document.fonts.ready.then(layout);
本が開いていて本文を表示している間は、リサイズの通知ごとにページを組み直します。
こうしてページを作り直すと、同じ本文でもページ数が変わります。しおりに「3ページ目」とだけ書いてあると、見開きで全部が2ページに収まった時点で、探す先がありません。
左ページの先頭で、しおりを書き換えない
本文に「あいうえ」「かきくけ」「さしすせ」「たちつて」の4段落があるとします。しおりは「さしすせ」の先頭。本文の先頭から数えると、位置は8です。
しおりの位置を state.offset に持ちます。state.mobile は表示形式を表す真偽値で、1ページ表示なら true、見開きなら false です。
| 表示を切り替える順番 | 新しいページの切れ目 | 画面に出すページ | しおり |
|---|---|---|---|
| 1ページ表示 | 4段落を1段落ずつ、全4ページ | 3ページ目 | 8 |
| 見開き | 2段落ずつ、全2ページ | 1・2ページ目 | 8 |
| 1ページ表示へ戻す | 1段落ずつ、全4ページ | 3ページ目 | 8 |
見開きに切り替えると、しおりは右側の2ページ目に入ります。一方、見開きの描画は左の1ページ目から始まります。ここで表示の先頭位置である0をしおりに書き戻すと、1ページ表示へ戻したときに「あいうえ」へ飛びます。
Streetの paginate() では、ページの長さを足し上げて、しおりを含むページを探します。見開きでは、そのページを含む組の先頭に表示をそろえます。更新するのは画面の添字 state.index です。
state.pages = pages;
let end = 0;
state.index = pages.findIndex(page => {
end += page.reduce((n, text) => n + text.length, 0);
return state.offset < end;
});
if (state.index < 0) state.index = Math.max(0, pages.length - 1);
if (!state.mobile) state.index = Math.floor(state.index / 2) * 2;
paintPages();
判定は offset < end です。たとえば最初のページの終端が8なら、位置8はその次のページの先頭です。ページの切れ目で一つ手前に戻らずに済みます。
ページ送りでは、開いたページの先頭までの長さを state.offset に記録します。長さの単位は JavaScript の String.length に合わせた UTF-16 コード単位です。同じ本文を組み直す間は、この位置を使い回せます。
閉じる直前に、表示範囲を見る
ページの組み方が決まったら、最後のページを含むかは index + 表示幅 >= pageCount で判定できます。index は表示の先頭を0から数えた添字で、mobile ? 1 : 2 が一度に表示するページ数です。
Streetの分類関数では、A が最後のページを含む本文、B が後ろにページが残る本文、C が紹介本の画面です。これらのラベルをセリフ選びに渡します。
function classify({ open, hasColumn, mode, index, pageCount, mobile }) {
if (!open || !hasColumn) return null;
if (mode === 'related') return 'C';
if (mode !== 'read' || pageCount < 1) return null;
return index + (mobile ? 1 : 2) >= pageCount ? 'A' : 'B';
}
4ページの3ページ目を1ページ表示で開けば 2 + 1 < 4 で B。同じ3ページ目から見開きなら 2 + 2 >= 4 で A です。5ページの末尾が片ページでも 4 + 2 >= 5 で A になります。
この本の画面は role="dialog" を付けた <section> で、表示の切り替えには hidden を使っています。closeBook() では、分類に必要な値を先に渡します。ここでの react は、閉じたときに店員の反応を出すかどうかを指定する真偽値です。該当箇所を抜き出すとこうです。
const closeKind = react ? window.BookshopClose.classify({
open: !$('.bk-overlay').hidden,
hasColumn: columns.includes(current),
mode: state.mode,
index: state.index,
pageCount: state.pages.length,
mobile: state.mobile
}) : null;
$('.bk-overlay').hidden = true;
hidden = true を先に書くと、その直後に読んだ open は偽です。「どの画面を閉じたか」を聞いたつもりが、答えは毎回「閉じています」になります。
添字は数値が前提です。たとえば dataset から得た "2" をそのまま使うと、"2" + 1 は "21" になり、4ページの途中でも末尾扱いになります。外から値を受け取るなら、空欄を除いて数値に変換し、整数かつページの範囲内かを確認してから渡します。
見開きにして、戻して、閉じる
下のHTMLでは短い4段落を使い、紙面に入る段落数を切り替えます。段落が収まるかは実際の要素の位置で比べ、長い段落の分割は省いています。
- 最初は1ページ表示の3ページ目。黄色い「さしすせ」にしおりがあり、後ろにページが残るので
Bです。 - 見開きに切り替えると、同じ段落が右の2ページ目に入ります。これが最後のページなので
Aです。 - 1ページ表示へ戻すと、しおりは同じ段落のまま3ページ目に戻ります。
B → A → B と変わったのは、画面に入る範囲です。しおりの位置は3回とも8のままです。
保存して開けるHTML一式
<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>見開きとしおり</title>
<style>
body { margin: 1rem; color: #263b34; background: #f5f1e7; font: 1rem/1.7 system-ui, sans-serif; }
h1 { margin: 0; font-size: 1.2rem; }
button { margin: .15rem; padding: .35rem .65rem; font: inherit; }
button[aria-pressed="true"] { color: white; background: #345d4d; }
#spread { --row: 2.25rem; display: grid; gap: .5rem; margin: .7rem 0; }
#spread[data-layout="wide"] { grid-template-columns: 1fr 1fr; }
.paper { block-size: var(--row); overflow: hidden; background: #fffdf8; outline: 1px solid #a4a590; }
#spread[data-layout="wide"] .paper { block-size: calc(2 * var(--row)); }
.paper p { margin: 0; padding-inline: .6rem; block-size: var(--row); line-height: var(--row); white-space: nowrap; }
.bookmark { background: #f4d77d; color: #312d1d; }
#status, #message { margin: .45rem 0; }
</style>
<h1>3ページ目にしおりを挟んであります</h1>
<p>見開きに切り替えてから1ページ表示に戻すと、同じ黄色い段落に戻ります。</p>
<button id="single" type="button">1ページ表示</button>
<button id="wide" type="button">見開き</button>
<section id="book" aria-label="本の紙面">
<div id="spread" data-layout="single">
<div id="left" class="paper" aria-label="左の紙面"></div>
<div id="right" class="paper" aria-label="右の紙面" hidden></div>
</div>
<button id="prev" type="button">前へ</button>
<button id="next" type="button">次へ</button>
<button id="close" type="button">閉じる</button>
</section>
<button id="reopen" type="button" hidden>開く</button>
<p id="status" role="status"></p>
<p id="message" role="status"></p>
<script>
function classify({ open, hasColumn, mode, index, pageCount, mobile }) {
if (!open || !hasColumn) return null;
if (mode === 'related') return 'C';
if (mode !== 'read' || pageCount < 1) return null;
return index + (mobile ? 1 : 2) >= pageCount ? 'A' : 'B';
}
const paragraphs = ['あいうえ', 'かきくけ', 'さしすせ', 'たちつて'];
const state = { pages: [], index: 0, offset: 8, mobile: true };
const $ = id => document.getElementById(id);
const left = $('left'), right = $('right'), book = $('book');
const pageLength = page => page.reduce((sum, text) => sum + text.length, 0);
const pageStart = index => state.pages.slice(0, index).reduce((sum, page) => sum + pageLength(page), 0);
const visibleCount = () => state.mobile ? 1 : 2;
function draw(element, chunks, start = -1) {
element.replaceChildren(...chunks.map(text => {
const p = document.createElement('p');
p.textContent = text;
if (start >= 0 && start <= state.offset && state.offset < start + text.length) {
p.className = 'bookmark';
}
if (start >= 0) start += text.length;
return p;
}));
}
function fits(chunks) {
draw(left, chunks);
const last = left.lastElementChild;
return !last || last.getBoundingClientRect().bottom <= left.getBoundingClientRect().bottom + 0.3;
}
function paginate() {
const pages = [];
let page = [];
for (const paragraph of paragraphs) {
if (page.length && !fits([...page, paragraph])) {
pages.push(page);
page = [];
}
page.push(paragraph);
}
pages.push(page);
state.pages = pages;
let end = 0;
const containing = pages.findIndex(page => {
end += pageLength(page);
return state.offset < end;
});
state.index = state.mobile ? containing : Math.floor(containing / 2) * 2;
}
function closeKind() {
return classify({
open: !book.hidden,
hasColumn: true,
mode: 'read',
index: state.index,
pageCount: state.pages.length,
mobile: state.mobile,
});
}
function render() {
const last = Math.min(state.index + visibleCount(), state.pages.length);
draw(left, state.pages[state.index], pageStart(state.index));
right.hidden = state.mobile || last === state.index + 1;
draw(right, right.hidden ? [] : state.pages[state.index + 1], pageStart(state.index + 1));
$('prev').disabled = state.index === 0;
$('next').disabled = last === state.pages.length;
const range = last === state.index + 1 ? String(last) : (state.index + 1) + '〜' + last;
$('status').textContent = range + ' / ' + state.pages.length + 'ページ、しおり ' + state.offset + '、分類 ' + closeKind();
}
function layout(mobile) {
state.mobile = mobile;
$('spread').dataset.layout = mobile ? 'single' : 'wide';
$('single').setAttribute('aria-pressed', String(mobile));
$('wide').setAttribute('aria-pressed', String(!mobile));
if (book.hidden) return;
paginate();
render();
}
function turn(direction) {
const next = state.index + direction * visibleCount();
if (next < 0 || next >= state.pages.length) return;
state.index = next;
state.offset = pageStart(next);
render();
}
$('single').addEventListener('click', () => layout(true));
$('wide').addEventListener('click', () => layout(false));
$('prev').addEventListener('click', () => turn(-1));
$('next').addEventListener('click', () => turn(1));
$('close').addEventListener('click', () => {
const kind = closeKind();
book.hidden = true;
$('reopen').hidden = false;
$('status').textContent = '';
$('message').textContent = kind === 'A'
? 'A — 最後まで開いたんだ。読んだかどうかは聞かない。'
: 'B — 後ろにまだページがある画面でした。';
});
$('reopen').addEventListener('click', () => {
book.hidden = false;
$('reopen').hidden = true;
$('message').textContent = '';
layout(state.mobile);
});
layout(true);
</script>
</html>
読了証の代わりに
店員は、最後のページを含む画面で本を閉じた人に、こう声をかけることがあります。
最後まで開いたんだ。読んだかどうかは聞かない。
気の利いたセリフに聞こえますが、後半はずいぶん正確な仕様説明です。店員に渡した引数には、そもそもそこまで書いてありません。