0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

見開きにしたら、3ページ目が消えた――素のJavaScriptでしおりの位置を保つ

0
Posted at

最後のページを開いて本を閉じたら、店員が「読了おめでとうございます」と言う。そんな書店を考えてみます。

「いま開いたところです」

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. 最初は1ページ表示の3ページ目。黄色い「さしすせ」にしおりがあり、後ろにページが残るので B です。
  2. 見開きに切り替えると、同じ段落が右の2ページ目に入ります。これが最後のページなので A です。
  3. 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>

読了証の代わりに

店員は、最後のページを含む画面で本を閉じた人に、こう声をかけることがあります。

最後まで開いたんだ。読んだかどうかは聞かない。

気の利いたセリフに聞こえますが、後半はずいぶん正確な仕様説明です。店員に渡した引数には、そもそもそこまで書いてありません。


参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?