読書アシストの実装方法について考えてみる
はじめに
まず、読書アシストについては、こちらを参照してください。
この読書アシストの「4. 冒頭文字を階段状に字下げする表示方式」をどのように実装するのかについて考えてみました。
画像は読書アシストより
レギュレーション
こちらのサンプルを確認するとわかるように、
- 段落 (HTML でいうと
p
タグ) ごとに字下げはクリアされ - 1行ずつ字下げが増えていき
- 7行ぐらい (作品によっては9行のものもある) で字下げが2文字ぐらいに戻り
- 以降5行ごとぐらいに字下げが2文字ぐらいに戻る。
図にすると以下のような感じ。
実装
現時点で以下の値が設定できると良さそうな気がします。
- 最大字下げ
- 最初の字下げ行数
- 続きの字下げ行数
これらを CSS カスタムプロパティとして以下のように設定しておきます。
:root {
--assist-indent-max: 7em; /* 最大字下げ */
--assist-lines-first: 7; /* 最初の字下げ行数 */
--assist-lines-after: 5; /* 続きの字下げ行数 */
}
CSS シェイプについて知る
では、どのように実装したらよいか。結論から言いますと、CSS シェイプ (shape-outside
プロパティ) を使うと実現できそう。shape-outside
は <basic-shape>
データ型で図形を作ることで、テキストを図形に従って回り込ませることができます。
例:
<p>Lorem ipsum dolor ...</p>
p::before {
content: "";
float: left;
height: 15em;
shape-outside: circle();
width: 15em;
}
実装中に今どのような形になっているのかわかりにくいので、色を付けたくなるでしょう。残念ながら background
を入れるだけでは、要素全体の四角形に色が付きます。
p::before {
+ background: #0080ff33;
content: "";
float: left;
height: 15em;
shape-outside: circle();
width: 15em;
}
下記のように clip-path
プロパティに shape-outside
と同じ図形を設定すれば確認することができます。
p::before {
background: #0080ff33;
+ clip-path: circle();
content: "";
float: left;
height: 15em;
shape-outside: circle();
width: 15em;
}
図形をカスタムプロパティにしておけば、実装中の確認が楽になります。
p::before {
+ --shape: circle();
background: #ed5565;
- clip-path: circle();
+ clip-path: var(--shape);
content: "";
float: left;
height: 15em;
- shape-outside: circle();
+ shape-outside: var(--shape);
width: 15em;
}
polygon()
で多角形を作る
今回は polygon()
を使って多角形を作ることになりそうです。形は下図のようになるでしょう。それぞれの頂点座標がどのようになるのかについて考えてみます。
- 頂点
の座標はA 0 0
であることは自明です。 - 頂点
の x 座標は、図形のB, D, F width
を--assist-indent-max
にすれば、100%
を使うことができます。
-
三角形
と△ABZ は相似しているので、△CDE が成り立ちます。 頂点w' = w \times \frac{h'}{h} の x 座標はC, E なので、w - w' も代入して計算すると以下になります。w =100\% (1 - \frac{続きの字下げ行数}{最初の字下げ行数}) \times 100\% -
頂点
の y 座標は、B, C 、行の高さ×最初の字下げ行数 -
頂点
の y 座標は、D, E 、行の高さ×(最初の字下げ行数+続きの字下げ行数) -
頂点
の y 座標は、F, G 、行の高さ×(最初の字下げ行数+続きの字下げ行数×2) -
以上のことから y 座標は以下のように表すことができます。
行の高さ \times (最初の字下げ行数 + 続きの字下げ行数 \times i) \\ -
ここで、行の高さを知る必要があることがわかりました。
lh
という、行の高さを表す新しい単位がありますが、残念ながら現時点ではまだどのブラウザもサポートしていません。ということで行の高さもカスタムプロパティとして設定しておきます。css:root { + --line-height: 2em; --assist-indent-max: 7em; --assist-lines-first: 7; --assist-lines-after: 5; }
ここまでをまとめると、以下のような感じです。
p {
line-height: var(--line-height);
}
p::before {
content: "";
float: left;
shape-outside: polygon(
/* 頂点 A */
0 0,
/* 頂点 B */
100%
calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 0)),
/* 頂点 C */
calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%)
calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 0)),
/* 頂点 D */
100%
calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 1)),
/* 頂点 E */
calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%)
calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 1)),
/* 頂点 F */
100%
calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 2)),
/* 頂点 G */
0
calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 2))
);
width: var(--assist-indent-max);
}
汎用化について考える
各段落がこんな都合のいい行数になるわけではないし、レスポンシブのことも考えると、段落ごとに頂点の数や位置を計算するのは現実的ではありません。
幸いなことに、CSS シェイプに絶対値を使うと、要素のサイズを超えた場合は、図形が縮んだりするのではなく、単純にクロップされます。例えば height: 320px;
とすると以下の3つ目の図のようになります。
ということで、十分な高さがある図形を用意して、段落ごとの高さに合わせて要素に height
を設定すれば大丈夫そう。
十分な高さがある図形を作る
十分な高さがある図形は、これまでの計算で @for
の力を借りて、
$polygon: null;
$end: 100
@for $i from 0 through $end {
$polygon:
$polygon,
100% calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})),
#{if( $i == $end, '', calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%) calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})) )};
}
--polygon: 0 0, #{$polygon}, 0 calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$end}));
$polygon
変数を予め宣言しておき、$i
が 0
から 100
になるまで、0
なので、最初は分岐して 0
にしていましたが、後述右側の図形のため、ここはあえて最後の頂点を出力しないようにします。
こう見ると簡単にたどり着いたように見えますが、実は Sass は最後の ,
を出力しないので、,
の位置を変えたり、','
で強制的に出力したり、試行錯誤した末、これが一番きれいな書き方だと思います。
height
を設定する
段落に合わせて要素に 最終的に JS で高さを算出することになるでしょう。通常のタグであれば何の問題もありませんが、今回は疑似要素を使っていますので、JS から直接スタイルを設定することができません。実は CSS カスタムプロパティを利用すれば、スマートに実現することができます。
下記のように、CSS 内でカスタムプロパティを設定し、HTML 側の style
属性でそれぞれに値をセットすれば大丈夫です。
p::before {
height: var(--p-height, 0);
}
<p style="--p-height: 2em;">吾輩は猫である。名前はまだ無い。</p>
<p style="--p-height: 26em;">どこで生れたかとんと見当がつかぬ。...</p>
右側の図形について考える
左側と同様に考えれば右側も同じように実装できますが、実は上図の赤い線はまったく同じ頂点を通ることに気づきます。最後にそれぞれ左下と右上の頂点を追加すれば図形は完成します。先ほどの Sass で最後の頂点を出力しなかったのはこれのためです。また、この共通部分をカスタムプロパティに登録しておけば、最終的な CSS の量は約半分で済みます。
$polygon: null;
$end: 100
@for $i from 0 through $end {
$polygon:
$polygon,
100% calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})),
#{if( $i == $end, '', calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%) calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})) )};
}
- --polygon: 0 0, #{$polygon}, 0 calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$end}));
+ --polygon: 0 0, #{$polygon};
+ --polygon-left: polygon(var(--polygon), 0 calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$end})));
+ --polygon-right: polygon(var(--polygon), 100% 0);
右側の図形を出力する
これまでは、p
タグの before
疑似要素で左側を出力していました。右側は after
を使いたいところですが、残念ながら float
で回り込ませるため、HTML 上では右側もテキストの前にある必要があります。
<p>
<!-- 左側 float: left; -->
<!-- 右側 float: right; -->
テキスト
</p>
ということで仕方なく、テキストの前に <span>
を挿入し、その ::before
::after
に左右の図形を設定することにします。<span>
にクラスをつけてもいいんですが、衝突のリスクを減らすためここはあえてデータ属性 data-assist
をつけてあげます。ここまでをまとめるとコードは以下のような感じです。
<p style="--p-height: 2em;"><span data-assist></span>吾輩は猫である。名前はまだ無い。</p>
<p style="--p-height: 28em;"><span data-assist></span>どこで生れたかとんと見当がつかぬ。...</p>
:root {
--line-height: 2em;
--assist-indent-max: 7em;
--assist-lines-first: 7;
--assist-lines-after: 5;
}
p {
line-height: var(--line-height);
[data-assist] {
$polygon: null;
$end: 100;
@for $i from 0 through $end {
$polygon:
$polygon,
100% calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})),
#{if( $i == $end, '', calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%) calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})) )};
}
--polygon: 0 0, #{$polygon};
--polygon-left: polygon(var(--polygon), 0 calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$end})));
--polygon-right: polygon(var(--polygon), 100% 0);
&::before,
&::after {
content: "";
height: var(--p-height, 0);
width: var(--assist-indent-max);
}
&::before {
float: left;
shape-outside: var(--polygon-left);
}
&::after {
float: right;
shape-outside: var(--polygon-right);
}
}
}
ちゃんと読書アシストの「冒頭文字を階段状に字下げする表示方式」で表示されました 🎉
CSS を軽量化する
--assist-lines-first
などのカスタムプロパティはループでそのまま CSS として出力されるので、カスタムプロパティ名が長いと、ファイルサイズが一気に大きくなってしまいます。しかし、カスタムプロパティは設定の上書きで使いたいので、わかりやすい名前にしておく必要があります。そこで、内部的に短い名前のカスタムプロパティに再代入することで、この問題を解決できます。さらに、共通部分を予め計算したものをカスタムプロパティとして設定しておくことで、さらにサイズを軽量化できます。
:root {
--line-height: 2em;
--assist-indent-max: 7em;
--assist-lines-first: 7;
--assist-lines-after: 5;
}
p {
line-height: var(--line-height);
[data-assist] {
+ --l1: calc(var(--line-height) * var(--assist-lines-first));
+ --l2: calc(var(--line-height) * var(--assist-lines-after));
+ --in: calc((1 - var(--assist-lines-after) / var(--assist-lines-first) ) * 100%);
+
$polygon: null;
$end: 100;
@for $i from 0 through $end {
$polygon:
$polygon,
- 100% calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})),
- #{if( $i == $end, '', calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%) calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})) )};
+ 100% calc(var(--l1) + var(--l2) * #{$i}),
+ #{if( $i == $end, '', var(--in) calc(var(--l1) + var(--l2) * #{$i}) )};
}
--polygon: 0 0, #{$polygon};
- --polygon-left: polygon(var(--polygon), 0 calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$end})));
+ --polygon-left: polygon(var(--polygon), 0 calc(var(--l1) + var(--l2) * #{$end}));
--polygon-right: polygon(var(--polygon), 100% 0);
&::before,
&::after {
content: "";
height: var(--p-height, 0);
width: var(--assist-indent-max);
}
&::before {
float: left;
shape-outside: var(--polygon-left);
}
&::after {
float: right;
shape-outside: var(--polygon-right);
}
}
}
軽量化前と後を比較すると、ファイルサイズを約1/3に減らすことができます。さらに、以下は1段落500行ぐらいまで対応していますが、100行ぐらいで十分であれば、$end: 20;
にすることで最終的な CSS のサイズは約2.4KBになります。十分実用的なレベルですね。
JavaScript で各段落の高さを計算する
CSS の部分はほぼ完成しました。あとは JS を使ってそれぞれの段落の高さを計算すればよい。
HTML では、手動で記述した高さや data-assist
要素を削除し、data-reading-assist
要素にまとめておきます。
-<p style="--p-height: 2em;"><span data-assist></span>吾輩は猫である。名前はまだ無い。</p>
-<p style="--p-height: 28em;"><span data-assist></span>どこで生れたかとんと見当がつかぬ。...</p>
+<div data-reading-asssit>
+ <p>吾輩は猫である。名前はまだ無い。</p>
+ <p>どこで生れたかとんと見当がつかぬ。...</p>
+</div>
CSS もそれに合わせて [data-reading-assit]
の中に入れます。
+[data-reading-assist] {
p {
...
}
+}
JS ではまず各段落を取得して data-assist
要素を挿入します。段落内のイベントなどを壊さないために insertAdjacentHTML()
メソッドを利用します。次に各段落の高さを取得して、インラインスタイルでカスタムプロパティ --p-height
にセットします。
const paragraphs = document.querySelectorAll('[data-reading-assist] p');
paragraphs.forEach(p => {
p.insertAdjacentHTML('afterbegin', '<span data-assist />');
const height = getComputedStyle(p).height;
p.style.cssText = `
${p.style.cssText};
--p-height: ${height};
`;
});
しかし、これはまだ完成ではありません。なぜなら図形を挿入したことで、段落の高さが変わるからです。
段落の高さを再度取得して --p-height
に設定し直す必要があります。それによって段落の高さがさらに変わる可能性があるので、--p-height
が段落の高さより低い間これを繰り返す必要があります。初回は必ず実行するので do...while
でループしていきます。
const paragraphs = document.querySelectorAll('[data-reading-assist] p');
paragraphs.forEach(p => {
p.insertAdjacentHTML('afterbegin', '<span data-assist />');
+ let height;
+ do {
- const height = getComputedStyle(p).height;
+ height = getComputedStyle(p).height;
p.style.cssText = `
${p.style.cssText};
--p-height: ${height};
`;
+ } while (parseFloat(height) < parseFloat(getComputedStyle(p).height));
});
レスポンシブ対応する
レスポンシブ対応のために、ウィンドウサイズが変更されたとき、段落の高さを再取得します。段落の高さを取得する処理を関数にして、初期化のときとウィンドウがリサイズされたときのどちらでも実行されるようにすれば完成です。
const paragraphs = document.querySelectorAll('[data-reading-assist] p');
-paragraphs.forEach(p => {
- p.insertAdjacentHTML('afterbegin', '<span data-assist />');
-
+const getPHeight = p => {
let height;
do {
height = getComputedStyle(p).height;
p.style.cssText = `
${p.style.cssText};
--p-height: ${height};
`;
} while (parseFloat(height) < parseFloat(getComputedStyle(p).height));
+};
+
+paragraphs.forEach(p => {
+ p.insertAdjacentHTML('afterbegin', '<span data-assist />');
+ getPHeight(p);
+});
+
+let timeout;
+window.addEventListener('resize', () => {
+ if (timeout) {
+ window.cancelAnimationFrame(timeout);
+ }
+
+ timeout = window.requestAnimationFrame(() => {
+ paragraphs.forEach(p => {
+ getPHeight(p);
+ });
+ });
});
カスタマイズする
以下のようにカスタムプロパティを上書きすることで、行間・字下げの字数や行数をカスタマイズすることができます。また、メディアクエリを使って --assist-indent-max
を書き換えれば、狭い画面では字下げなし、広い画面では字下げありにすることもできます。
body {
--line-height: 1.75em;
--assist-indent-max: 0;
--assist-lines-first: 9;
--assist-lines-after: 7;
}
@media (min-width: 640px) {
body {
--assist-indent-max: 7em;
}
}
完成版デモ
おわりに
こんな超マイナーな内容、果たして誰かの何かに役に立つのだろうか。記事を書きながら、心の中のヤナギブソンが「誰が興味あんねん!」ってずっとツッコんでいました。でもほかの誰もやらないだろうし書かないことなので、逆にオンリーワンなわけです。
文章を読むときに、次の行を見失ってしまう方は一定数いると思います。ウェブアクセシビリティの観点でこれが役に立つ可能性はあるかもしれません。
また、ここで利用している技術や手法そして僕の思考を楽しんでいただければうれしく思います。
追記
実は形態素解析して、単語の途中で改行しないようにしたものもあります。ただ、JS によるフロントでの形態素解析は、処理が重すぎてあまり使いものになりませんでした。
Discussion