こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。
記事の本文は、漢字にふりがなが付いた HTML として保存しています。
<ruby>今年<rt>ことし</rt></ruby>の<ruby>夏<rt>なつ</rt></ruby>は…
これを管理画面で直せるようにしたいところです。本文の文章そのものを直すことも、ふりがなを直すこともあります。素の <textarea> にこの HTML を出すと、タグに埋もれて文章が読めません。かといって普通の contentEditable にすると、ルビの構造が編集中に簡単に壊れます。
落とし所として、ルビを編集できない島として置き、その間のテキストだけを編集させる形にしました。
ルビを編集不可の島にする
保存されている HTML を、編集用の HTML に変換してから contentEditable へ入れます。
export function toEditableHtml(stored: string, singleLine: boolean): string {
const h = markUnannotatedKanji(stored)
.replace(/<ruby>([\s\S]*?)<\/ruby>/g, `${ZWSP}<ruby contenteditable="false">$1</ruby>${ZWSP}`);
return singleLine ? h.replace(/\n/g, '') : h.replace(/\n/g, '<br>');
}
やっていることは 3 つです。
- ふりがなの付いていない漢字に印を付ける(
markUnannotatedKanji)。付け忘れが目で分かります。 - 各ルビに
contenteditable="false"を付けて、中身を編集できない島にする。 - ルビの前後にゼロ幅スペースを挿入する。
3 番目が、この記事でいちばん書きたかったところです。
島の隣にキャレットを置けない問題
contentEditable の中に contenteditable="false" の要素があると、その要素の直前・直後にキャレット(文字入力の位置)を置けないことがあります。とくに行頭がルビで始まっているときが問題でした。
<ruby contenteditable="false">今年<rt>ことし</rt></ruby>の夏は…
この行の先頭にカーソルを置きたいところですが、置けません。編集できないブロックが先頭にあると、その手前にテキストノードが存在しないので、キャレットの置き場所そのものが無いためです。
結果、「この行を前の段落につなげる」「行頭に一文字足す」といった普通の編集ができません。
対処は、置き場所を作ってやることです。
// ruby 島(contenteditable=false)の前後に zero-width space を挿入する。これが無いと、
// 行頭/行末や島の直前にキャレットを置けず、その行を前段落に繋げる等の編集ができない。
// ZWSP は不可視で、serializeEditable 側で除去する。
const ZWSP = '\u200B';
ゼロ幅スペース(U+200B)は幅を持たない文字ですが、テキストノードとしては実在します。島の前後に 1 文字ずつ置いておけば、そこにキャレットが立ちます。見た目には何も増えません。
代償として、編集結果の中にゼロ幅スペースが混ざります。これは保存時に取り除きます。
保存は DOM を歩いて組み直す
contentEditable の innerHTML をそのまま保存してはいけません。ブラウザが挿入した <div> や <span style="...">、貼り付けで入ってきたタグが、そのまま記事の HTML になります。
なので、DOM を歩いて、保存したい形だけを組み立て直します。
export function serializeEditable(root: HTMLElement): string {
let out = '';
const walk = (node: Node) => {
node.childNodes.forEach(n => {
if (n.nodeType === Node.TEXT_NODE) {
out += escapeHtml((n.textContent ?? '').replace(/\u200B/g, ''));
} else if (n.nodeName === 'BR') {
out += '\n';
} else if (n.nodeName === 'RUBY') {
const el = n as HTMLElement;
const kanji = el.firstChild?.textContent ?? '';
const reading = el.querySelector('rt')?.textContent ?? '';
out += `<ruby>${escapeHtml(kanji)}<rt>${escapeHtml(reading)}</rt></ruby>`;
} else if (n.nodeName === 'DIV' || n.nodeName === 'P') {
if (out && !out.endsWith('\n')) out += '\n';
walk(n);
if (!out.endsWith('\n')) out += '\n';
} else {
walk(n);
}
});
};
walk(root);
return out;
}
方針は「知っているものだけを出力し、知らないものは中身だけ拾う」です。
- テキストノード: ゼロ幅スペースを除いてエスケープ
-
<br>: 改行 -
<ruby>: 属性を落とした保存形に組み直す(編集用に付けたcontenteditable="false"はここで消える) -
<div>/<p>: 前後に改行を入れて中へ潜る(ブラウザが勝手に作る行の入れ物) - それ以外: 中身だけ拾って、タグは捨てる
最後の一行が効きます。貼り付けで <span style="color:red"> が入っても、<b> が入っても、テキストだけが残ります。ホワイトリストを維持するのではなく、知らないものは展開する、という向きです。
<div> の改行処理で endsWith('\n') を見ているのは、入れ子になったときに改行が二重に入るのを防ぐためです。contentEditable が作る DOM の形はブラウザによって違うので、出力側で正規化しています。
派生データも一緒に更新する
保存時は、ルビ付き HTML だけでなく、そこから機械的に作れる形も一緒に更新します。
const ok = await post({
title_html: newTitleHtml, title_kana: stripRubyToKana(newTitleHtml), title: stripRubyToText(newTitleHtml),
body_html: newBodyHtml, body_kana: stripRubyToKana(newBodyHtml), body: stripRubyToText(newBodyHtml),
});
<ruby>今年<rt>ことし</rt></ruby> から、ふりがなを採れば「ことし」、親文字を採れば「今年」。全文のひらがな版とプレーンテキストが同時に手に入ります。
同じ内容を 3 つの列に持つのは冗長に見えますが、ルビ付き HTML から毎回導出するのはコストが高く、検索やエクスポートで使う形が決まっているなら保存しておくほうが速いです。大事なのは、更新の入口を 1 つにして、そこで必ず 3 つとも書き換えることでした。片方だけ更新できる経路があると、すぐに食い違います。
ふりがなの編集は別モードにした
ここまでが「テキストを編集するモード」です。ふりがなの追加・修正は、同じ画面の読みモードで漢字をクリックして行います。編集モードのボタンにもそう書いてあります。
テキスト・改行を編集できます。ふりがなの追加/編集は「キャンセル」後に漢字をクリック。
1 つの contentEditable で両方やろうとすると、ルビの中に入れるかどうかで挙動が変わり、どちらの操作も中途半端になります。ルビを島にする設計を選んだ時点で、島の中身は別の UI で編集する、と決めたほうが素直でした。
まとめ
- ルビ混じりの本文を編集させるなら、ルビを
contenteditable="false"の島にして構造を守る - 島の前後にはゼロ幅スペースを入れる。無いと行頭や島の直前にキャレットを置けず、普通の編集ができない
- 保存は
innerHTMLを使わず、DOM を歩いて知っているノードだけを組み直す。知らないタグは中身だけ拾う - ゼロ幅スペースは保存時に除去する(挿入と除去を必ず対にする)
- ルビから導出できる派生データは、保存の入口を 1 つにして同時に更新する
- テキスト編集とふりがな編集はモードを分ける。1 つの contentEditable で兼ねると両方中途半端になる