実装できなかった。途中までの断片コードをアップしておく。
成果物
できること
IMEであい
と入力し愛
に変換し確定すると愛と入力される。
- エディタをクリックする
- IMEで
あい
と入力する 愛
に変換する- Enterキーで入力を確定する
- 愛と入力される
概要
- HTMLのcontenteditable属性でWISYWIGできるようにする
- execCommand()は廃止された
- IME入力を受け付けるようにする: compositionstart,compositionupdate,compositionend
- Ruby要素をつくる: querySelector(), createElement(), createTextNode(), Node.textContent
ここまでは簡単。
contenteditableにおけるルビ編集
ここで挫折した。
デフォルト挙動
ルビ作成・編集できない。<ruby>
を新規追加できない。また、既存の<ruby>
を削除できない。
<rt>
の削除はできた。既存<rt>
のテキストをすべて削除すればいい。だが、その親の<ruby>
が残ってしまう。ゴミタグが削除できない。
IME連動
IME入力に応じて<ruby>
を挿入したい。デフォルトでは存在しない機能である。自力で実装するしかない。
実装できなかった。
- IME入力文字のかわりに
<ruby>
タグを挿入したい
IMEとcontenteditableの連携
上記を実装する方法について。
selectionchangeイベントが必要。さらにIME確定後であるフラグで条件分岐する必要がある。これにてIME入力確定後に文字が挿入されたタイミングであると判断できる。そのときだけ文字を削除する処理をする。フラグなんて泥臭くて嫌だが。
- IME
- contenteditable
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入力確定文字の削除
- 現在のキャレット位置を特定する: getSelection(), Selection.getRangeAt()
- IME確定文字の分だけ範囲選択する: Range.setStart(), Range.setEnd()
- 範囲選択したところを削除する: 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段階で指定せねばならない。
- 範囲選択する対象ノードを指定する
- 文字位置を指定する
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:
- TextNode:
- TextNode:
続き。
- TextNode:
キャレット|
の位置は<rt>
の中にあるTextNodeかんじ
の末尾にあった。
<rt>かんじ|</rt>
このキャレット位置を変えたい。<rt>
要素の直後にあるノードの先頭にしたい。つまりdiv
要素内の2つ目のTextNode続き。
の先頭にしたい。
|続き。
キャレット位置をそのように変えるのが以下コードである。
range.selectNode(sel.focusNode.parentNode.parentNode.nextSibling);
やってみると別の問題が発生した。
上記コードだけではTextNode続き。
が全範囲選択されてしまう。
範囲選択を解除したい。そのためにはcollapse
系APIを使う。ここでは先頭にしたいため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>
の複数同時作成
できること以外のすべてができないと思ったほうがいい。
所感
このくらいデフォルトでできて欲しかった。
対象環境
- Raspbierry pi 4 Model B
- Raspberry Pi OS buster 10.0 2020-08-20 ※
- bash 5.0.3(1)-release
$ uname -a Linux raspberrypi 5.4.83-v7l+ #1379 SMP Mon Dec 14 13:11:54 GMT 2020 armv7l GNU/Linux