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?

`<ruby>` 混じりの本文を contentEditable で編集する — 編集不可の島とゼロ幅スペース

0
Last updated at Posted at 2026-08-26

こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。

記事の本文は、漢字にふりがなが付いた 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 つです。

  1. ふりがなの付いていない漢字に印を付ける(markUnannotatedKanji)。付け忘れが目で分かります。
  2. 各ルビに contenteditable="false" を付けて、中身を編集できない島にする。
  3. ルビの前後にゼロ幅スペースを挿入する。

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 で兼ねると両方中途半端になる
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?