やってみる

アウトプットすべく己を導くためのブログ。その試行錯誤すらたれ流す。

RubyタグをIME入力に応じて入力するのが難しすぎた

 実装できなかった。途中までの断片コードをアップしておく。

成果物

できること

 IMEあいと入力しに変換し確定するとあいと入力される。

  1. エディタをクリックする
  2. IMEあいと入力する
  3. に変換する
  4. Enterキーで入力を確定する
  5. あいと入力される

概要

  1. HTMLのcontenteditable属性でWISYWIGできるようにする
    1. execCommand()は廃止された
  2. IME入力を受け付けるようにする: compositionstart,compositionupdate,compositionend
  3. Ruby要素をつくる: querySelector(), createElement(), createTextNode(), Node.textContent

 ここまでは簡単。

contenteditableにおけるルビ編集

 ここで挫折した。

デフォルト挙動

 ルビ作成・編集できない。<ruby>を新規追加できない。また、既存の<ruby>を削除できない。

 <rt>の削除はできた。既存<rt>のテキストをすべて削除すればいい。だが、その親の<ruby>が残ってしまう。ゴミタグが削除できない。

IME連動

 IME入力に応じて<ruby>を挿入したい。デフォルトでは存在しない機能である。自力で実装するしかない。

 実装できなかった。

  • IME入力文字のかわりに<ruby>タグを挿入したい
    • <ruby>タグが挿入されたあとにIME入力文字が挿入されてしまう
      • IME入力確定イベントを強制終了させて入力文字を入力しないようにしたい
        • event.preventDefault()でできるはず
          • Chromium 86ではできなかった
          • addEventListenrer('', (event)={event.preventDefault();}, {passive:false})でもダメだった
        • 代案:IME文字入力確定後にIME入力文字を削除する

IMEとcontenteditableの連携

 代案:IME文字入力確定後にIME入力文字を削除する。

 上記を実装する方法について。

  1. IME入力確定時に<ruby>を追加する
  2. 1のあとでIME入力確定文字を削除する

 selectionchangeイベントが必要。さらにIME確定後であるフラグで条件分岐する必要がある。これにてIME入力確定後に文字が挿入されたタイミングであると判断できる。そのときだけ文字を削除する処理をする。フラグなんて泥臭くて嫌だが。

let IS_IME_END = false;
document.addEventListener('selectionchange', ()=>{
    if (IS_IME_END) {
        // rangeの位置を変える。<rt>の直後にある次の要素に。
        IS_IME_END = false;
    }
});
document.addEventListener('compositionend', (event)=>{
    IS_IME_END = true;
    // ルビを追加する
    // IME文字を削除する
});

IME入力確定文字の削除

  1. 現在のキャレット位置を特定する: getSelection(), Selection.getRangeAt()
  2. IME確定文字の分だけ範囲選択する: Range.setStart(), Range.setEnd()
  3. 範囲選択したところを削除する: Range.deleteContents()

現在のキャレット位置を特定する

    let SELECTED = null; // contenteditable要素がselectionchangeしたときのRange
    document.addEventListener('selectionchange', ()=>{
        const sel = document.getSelection();
        const range = sel.getRangeAt(0);
        SELECTED = range;
    });

IME確定文字の分だけ範囲選択する

document.addEventListener('compositionend', (event)=>{
    const sel = document.getSelection();
    const range = sel.getRangeAt(0);
    range.setStart(sel.focusNode, sel.focusOffset - event.data.length);
    range.setEnd(sel.focusNode, sel.focusOffset);
    range.deleteContents();
});

 範囲を指定する。SelectionとRangeの両方を使う。このやり方を見つけるのに苦労した。

Range.setStart()で範囲選択する方法

 2段階で指定せねばならない。

  1. 範囲選択する対象ノードを指定する
  2. 文字位置を指定する
range.setStart(sel.focusNode, sel.focusOffset - event.data.length);
API 概要
Selection.focusNode 範囲選択しているノードを返す。
Selection.focusOffset 範囲選択しているノード内における文字位置を返す。
CompositionEvent IMEイベントオブジェクト。event.dataには現在の入力文字列が入る。

 選択範囲の始点を算出する。範囲選択しているノード内における文字位置から、入力文字数の長さを引く。これで始点が求まる。

sel.focusOffset - event.data.length

 Range.setStart()と同様にRange.setEnd()を指定する。

range.setEnd(sel.focusNode, sel.focusOffset);

 以上。始点と終点の間を範囲選択できた。

 位置の指定が難しい。文字数だけで指定したいができない。HTMLの仕様である。文字列は1次元配列だが、HTMLはツリー構造のためだ。位置を指定するには必ずツリー位置を指定せねばならない。Range.setStart()の第一引数にノードが必要なのはそのためである。

範囲選択したところを削除する

 Range.deleteContents()を使う。上記までで範囲選択はできた。ここでは範囲選択したところを削除する。

range.deleteContents();

範囲選択したところに要素を挿入する

 Range.insertNode()を使う。追加するDOM要素を引数に渡す。

SELECTED.insertNode(Ruby.toDom(event.data, IME_YOMI));

キャレット位置を指定する

 キャレット位置を指定したい。IME確定後のキャレット位置がルビの<rt>における末尾にあった。しかしもうひとつ先にしたい。ルビ直後の本文にキャレットを配置したい。なにせルビはもう入力を確定させたのだ。本文に加筆するのがユースケースとして正しい。

 Range.selectNode()を使う。指定したノードを選択したことにするAPIだ。引数は後述する。Selection.collapseToStart()で全範囲選択になってしまうのを抑制する。

const sel = document.getSelection();
const range = sel.getRangeAt(0);

range.selectNode(sel.focusNode.parentNode.parentNode.nextSibling);
sel.collapseToStart(sel.focusNode, 0);
API 概要
Range.selectNode() 引数で指定したノードを選択したことにする
Node.parentNode 親ノードを返す
Node.nextSibling 次ノードを返す

 <ruby>ノードの次にあるノードを選択する。以下のコードで。

range.selectNode(sel.focusNode.parentNode.parentNode.nextSibling);

 ひとつずつ見てみる。

コード 概要
sel.focusNode 選択中ノード。<rt>のTextNodeである
sel.focusNode.parentNode 選択中ノード。<rt>である
sel.focusNode.parentNode.parentNode <ruby>である
sel.focusNode.parentNode.parentNode.nextSibling <div id="editor">内の上記<ruby>要素にある次のノードである(それはTextNodeかもしれないし、他のElementNodeかもしれない)

 TextNode、ElementNodeについて。たとえば以下のようなHTMLがあったら<p>がElementNodeである。子要素にTextNodeを持っている。TextNodeの内容はテキストである。

<p>テキスト<p>

 今回の例で考えてみる。以下HTMLがあったとする。IME連携で<ruby>タグを挿入した状態。

<div id="editor">本文1。<ruby>漢字<rt>かんじ</rt>続き。</div>

 HTML(DOM)の階層構造は以下のようになっている。

  • ElementNode: div
    • TextNode: 本文1。
    • ElementNode: ruby
      • TextNode: 漢字
      • ElementNode: rt
        • TextNode: かんじ
    • TextNode: 続き。

 キャレットの位置は<rt>の中にあるTextNodeかんじの末尾にあった。

<rt>かんじ|</rt>

 このキャレット位置を変えたい。<rt>要素の直後にあるノードの先頭にしたい。つまりdiv要素内の2つ目のTextNode続き。の先頭にしたい。

|続き。

 キャレット位置をそのように変えるのが以下コードである。

range.selectNode(sel.focusNode.parentNode.parentNode.nextSibling);

 やってみると別の問題が発生した。

 上記コードだけではTextNode続き。が全範囲選択されてしまう。

 範囲選択を解除したい。そのためにはcollapseAPIを使う。ここでは先頭にしたいためSelection.collapseToStart()を使う。

sel.collapseToStart(sel.focusNode, 0);

 sel.focusNodeはTextNode続きである。Range.selectNode()sel.focusNode.parentNode.parentNode.nextSiblingと指定したのと同じノードだ。

 collapse系APIを見てみよう。3種類ある。

API 概要
Selection.collapseToStart() 選択範囲の始点を指定した値にする
Selection.collapseToEnd() 選択範囲の終点を指定した値にする
Selection.collapse() 現在の選択を1つのポイントに折りたたみます

 つまり以下コードはTextNode続きの文字位置0を始点にするという意味。

sel.collapseToStart(sel.focusNode, 0);

 「折りたたむ」って何? APIサイトを翻訳して読むとcollapseは「折りたたむ」とある。意味がわからなかった。collapse()を使うとうまく位置を指定できなかった。

未解決課題

 あまりにも多すぎて列挙する気が失せる。

  • キャレット位置がおかしくなる
  • ひらがな以外のIME連動ルビ入力
  • <ruby>の複数同時作成

 できること以外のすべてができないと思ったほうがいい。

所感

 このくらいデフォルトでできて欲しかった。

対象環境

$ uname -a
Linux raspberrypi 5.4.83-v7l+ #1379 SMP Mon Dec 14 13:11:54 GMT 2020 armv7l GNU/Linux