徹底的にキャレットを追跡する

iPadのソフトウェアキーボードに不足している矢印キーを補うため、前回までに以下のブックマークレットを追加して凌いできた。

  • キャレットを左右に移動する。(移動単位は1文字毎)
  • 選択範囲を左右に伸縮する。(移動単位は1文字毎)
  • キャレットを段落の先頭・末尾へジャンプさせる。

キャレット(文字カーソル)を1文字毎に動かしている時はほとんど気にならなかったが、段落の先頭・末尾にジャンプさせるようになって、どうにも気になる問題が出てきた。

それは、キャレットを次々とジャンプさせて上下に移動させると、スクロールが固定されているのですぐに見えなくなってしまうこと。特にiPadを横長のポジションで操作している時が困りもの。ソフトウェアキーボードが編集領域の半分以上を覆ってしまい、キーボードあるいはブックマークバーの下に、キャレットはすぐ隠れてしまう...。

普段何気なく行っているテキスト入力操作も、快適な入力環境を保つため、その裏では実に様々な補助的な処理が行われていたのだ。キャレットを移動したら、それが見える適切な位置までスクロールするというのは当然の挙動と思っていたが、それはOSX環境のテキストエディタが裏で一生懸命にスクロールさせた努力の結果なのであった。

矢印キーのないiPadで、矢印キーのように振る舞うブックマークレットを作ってみて気づかされた事実。キャレットだけ動かしていてはダメで、常に視界に入るようにスクロールさせて、追跡する必要があったのだ。

では、どうやってキャレット移動とスクロールをシンクロさせれば良いのだろう?幾多の試行錯誤が始まるのであった。

      • 半角¥は、半角\に置き換える必要あり。
//段落の先頭へジャンプ
javascript:
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
var str = el.value.substr(0, el.selectionStart-1);
var offset = str.split(/\n|\r/).pop().length+1;
el.setSelectionRange(el.selectionStart-offset, el.selectionStart-offset);

//段落の末尾へジャンプ
javascript:
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
var str = el.value.substr(el.selectionEnd+1);
var offset = str.split(/\n|\r/).shift().length+1;
el.setSelectionRange(el.selectionEnd+offset, el.selectionEnd+offset);

改行コードでスクロール

  • まずは単純に改行コードを目印にして、キャレットがそれを越えたら1行分の高さをスクロールするようにしてみた。
  • テキストエリアの高さは、textarea.offsetHeight で求められる。556pxだった。
  • その高さで何行入力できるか、実際にiPadで入力して確認した。25行だった。
  • ゆえに、556÷25=22.24。つまり、1行=22.24pxなのだ。
  • キャレットが改行コードを通過する場合に22.24px、上か下にスクロールするように修正してみた。
      • 半角¥は、半角\に置き換える必要あり。
//段落の先頭へジャンプ2
javascript:
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
var str1 = el.value.substr(0, el.selectionStart-1);
var offset = str1.split(/\n|\r/).pop().length+1;
var letter = el.value.substr(el.selectionStart-1, 1);
el.setSelectionRange(el.selectionStart-offset, el.selectionStart-offset);
if ("\n"==letter) window.scrollBy(0, -22.24);

//段落の末尾へジャンプ2
javascript:
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
var str2 = el.value.substr(el.selectionEnd+1);
var offset = str2.split(/\n|\r/).shift().length+1;
var letter = el.value.substr(el.selectionStart, 1);
el.setSelectionRange(el.selectionEnd+offset, el.selectionEnd+offset);
if ("\n"==letter) window.scrollBy(0, +22.24);
  • できた。が、当然ながら改行コードでしか、スクロールしない。
  • 一方、現実の文章は改行でなくとも、テキストエリアの右端に達すると、文章は折り返されて次の行に表示されている。
  • 常に1行ごとに改行した箇条書きのような文章ではある程度有効だが、そんな限定的な条件では実際、使い物にならない。

文字を挿入してスクロール

  • これまでの経験から、iPadでキャレットが見えない位置にあっても、何か文字を入力すると、キャレットの見える最適な位置までスクロールすることは分かっている。
  • ならば、この性質を利用して、キャレットが先頭あるいは末尾にジャンプした時に、何か1文字を入力して、またすぐ削除する処理を追加してみたら...どうだろうか?
キャレット位置に挿入する その1
  • まずは、キャレット位置に挿入する方法を模索する必要がある。
  • 検索してみると、一般的には以下の方法が使われているようだ。
//textareaのキャレット位置にtextを挿入する
//範囲選択されている場合は、その部分をtextに置き換える
javascript:
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
insert_text(el, "__挿入する文字列__");
function insert_text(textarea, text){  
  textarea.value = textarea.value.substr(0, textarea.selectionStart) + text + textarea.value.substr(textarea.selectionEnd);
}
  • つまり、キャレット位置を境に「手前の文字列 + 挿入する文字列 + 後側の文字列」を求めて、それを丸ごとテキストエリアに代入しているのだ。
  • ところが、この方法で処理しても、挿入はされるが、スクロールはしないのである。
  • 期待したようなキャレットの見える最適な位置へは、スクロールしてくれないのだ。
    • スクロールは固定されたまま全く動かず。
    • キャレットは最後尾へジャンプしてしまう。
  • 問題は、キャレット位置に挿入するのではなく、テキストエリア全体の値を書き換えている所にあるようだ。
  • 代入する瞬間は、全体の値を再設定しているだけなので、キャレット位置とは無関係な処理となるようだ。
キャレット位置に挿入する その2
  • 文字を挿入して最適な位置にスクロールさせるには、GUIを実際に操作するような方法で処理する必要があるようだ。
  • AppleScriptで言うGUIスクリプティングのような仕様が、果たしてJavaScriptにもあるのだろうか? → 実はあった!
  • GUIの操作は、それに伴うイベントの発生によって処理される。
  • だから、目指す処理のイベントをコードの中で作成して、それをテキストエリアに投げてあげれば、GUIを操作したのと全く同じ結果が得られるのだ。
  • イベントを投げるには、3つの手順が必要だ。
    • イベントを生成して、
    • そのイベントの内容を設定して、
    • 操作対象のオブジェクトに渡す。
  • 早速、以下のように実装してみた。
//GUI操作と同等の処理で、textareaのキャレット位置にtextを挿入する
javascript:
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
insert_text(el, "__挿入する文字列__");
function insert_text(textarea, text){
  var textEvent = document.createEvent('TextEvent');
  textEvent.initTextEvent ('textInput', true, true, window, text);
  textarea.dispatchEvent(textEvent);
}
  • テストしてみた。キャレットを視界の外にスクロールさせてから、実行してみると...
  • 文字は挿入され、そして見事に最適な位置までスクロールして、視界の中に現れた!

上手くいった!

  • これらの機能を段落の先頭にジャンプと組み合わせて実装してみる。
  • 移動後、半角スペースを入力して、すぐに削除する処理を追加した。
      • 半角¥は、半角\に置き換える必要あり。
//段落の先頭へジャンプ3
javascript:
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
var str1 = el.value.substr(0, el.selectionStart-1);
var offset = str1.split(/\n|\r/).pop().length+1;
el.setSelectionRange(el.selectionStart-offset, el.selectionStart-offset);
insertText(el, " ");
backDelete(el);
function insertText(textarea, str){
  var textEvent = document.createEvent('TextEvent');
  textEvent.initTextEvent ('textInput', true, true, window, str);
  textarea.dispatchEvent(textEvent);
}
function backDelete(textarea){
  var caret = textarea.selectionStart;
  textarea.value = textarea.value.substr(0, caret-1) + textarea.value.substr(caret);
  textarea.setSelectionRange(caret-1, caret-1);
}

//段落の末尾へジャンプ3
javascript:
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
var str2 = el.value.substr(el.selectionEnd+1);
var offset = str2.split(/\n|\r/).shift().length+1;
el.setSelectionRange(el.selectionEnd+offset, el.selectionEnd+offset);
insertText(el, " ");
backDelete(el);
function insertText(textarea, str){
  var textEvent = document.createEvent('TextEvent');
  textEvent.initTextEvent ('textInput', true, true, window, str);
  textarea.dispatchEvent(textEvent);
}
function backDelete(textarea){
  var caret = textarea.selectionStart;
  textarea.value = textarea.value.substr(0, caret-1) + textarea.value.substr(caret);
  textarea.setSelectionRange(caret-1, caret-1);
}
  • できた、できた!
  • スペースの挿入・削除を繰り返しながら、キャレットは常に見える位置にスクロールされる。
  • 動きは遅いけど...。
削除について・KeyboardEventについて
  • 当初back deleteキーのエスケープコードである\bの入力で削除されることを期待したが、ダメだった。
  • 文字は削除されず、フォント幅0の見えない文字と文字コード8が入力される結果となった。
  • おそらく、'textInput'よりもさらに低レベルな'keydown'イベントを生成する必要があるのだと思う。
    • 'textInput'はその名のとおり、テキストに入力される段階のイベントのようだ。だからそのままテキストエリアに記録されるのかもしれない。
    • 'keydown'ならキーコードを生成する段階のイベントなので、キーコード8をback delete(1文字削除)として解釈してくれるかもしれない。
  • しかし、'keydown'イベントを生成して、テキストエリアに渡しても、何も起こらなかった。
  • KeyboardEvent系の処理については、Sfariではまだ処理できないのだろうか?あるいは、自分の実装の方法が悪いのか?謎である...。
javascript:
function log(e){
  for (i in e) console.log(i+" = "+e[i]);
}
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
el.addEventListener("keydown",log,false);

var keyboardEvent = document.createEvent("KeyboardEvent");
keyboardEvent.initKeyboardEvent("keydown",false,false,window,'U+0041',0,false,false,false,false);
el.dispatchEvent(keyboardEvent);
  • 上記を実行してみると、確かにkeydownイベントは生成され、テキストエリアで反応しているようだが...
    • 実際にキー入力した時と違って、文字が入力されない。
    • keyIdentifierをどうやって設定('a'、'A'、'\u0041'、'U+0041')しても、which と keyCode は、常に 0 になってしまう...。
  • イベントは生成できても、テキストエリアに文字が入力されない原因は、これだろうか?
keyLocation = 0
ctrlKey = false
shiftKey = false
keyIdentifier = U+0041
altKey = false
metaKey = false
altGraphKey = false
pageY = 0
layerY = 0
pageX = 0
charCode = 0
view = [object DOMWindow]
which = 0
keyCode = 0
detail = 0
layerX = 0
returnValue = true
timeStamp = 1307594994602
eventPhase = 2
target = [object HTMLTextAreaElement]
defaultPrevented = false
srcElement = [object HTMLTextAreaElement]
type = keydown
clipboardData = undefined
cancelable = false
currentTarget = [object HTMLTextAreaElement]
bubbles = false
cancelBubble = false
initKeyboardEvent = function initKeyboardEvent() {
    [native code]
}
initUIEvent = function initUIEvent() {
    [native code]
}
initEvent = function initEvent() {
    [native code]
}
MOUSEOUT = 8
preventDefault = function preventDefault() {
    [native code]
}
FOCUS = 4096
CHANGE = 32768
MOUSEMOVE = 16
AT_TARGET = 2
stopPropagation = function stopPropagation() {
    [native code]
}
SELECT = 16384
BLUR = 8192
KEYUP = 512
MOUSEDOWN = 1
MOUSEDRAG = 32
BUBBLING_PHASE = 3
MOUSEUP = 2
CAPTURING_PHASE = 1
MOUSEOVER = 4
CLICK = 64
DBLCLICK = 128
KEYDOWN = 256
KEYPRESS = 1024
DRAGDROP = 2048
stopImmediatePropagation = function stopImmediatePropagation() {
    [native code]
}
  • できないことに悩んで立ち止まるより、できる方法でどんどん前に進んだ方がいい。
  • そんな訳で「キャレット位置に挿入する その1」の方法で削除することにしている。
      • それにしても、なぜkeydownイベントで文字入力できないのか?謎である。ちゃんと入力できる方法を知りたい!

フォント幅を計算してスクロール

  • 文字の入力と削除を繰り返しながら、キャレットが上下に移動しても、それを視界に追跡できるようになった。
  • しかし、無駄に文字の入力と削除を繰り返すこのやり方は、やはり邪道な気がする。
  • ここまで試行錯誤する中で、究極的にはフォント幅を緻密に計算してスクロールさせたいと考え始めた。
  • でも、JavaScriptからフォント幅を求める関数って、あるのだろうか?多分ない。
  • プロポーショナルなフォント幅は様々である。
  • フォントの種類やサイズも固定とは限らない。

考え出すと否定的になってしまうが、もっと前向きに考えてみると...

  • 自分のiPad環境では、はてなダイアリーの編集ページのフォントは、おそらくヒラギノ角ゴシックW3、12ポイントだと思う。
  • スタイルシートで、テキストエリアのフォントだって指定できる。
  • プロポーショナルと言っても、日本語フォントの幅は常に固定である。
  • それほど多くないASCII文字についてのフォント幅の情報があれば、1段落の行数をかなり正確に計算できるかもしれない。
  • で、ASCII文字のフォント幅情報って、どこにある?フォントデータから取り出す方法なんて知らない...。
  • ならば、たかが100文字弱のASCIIフォント、スクリーンショットで撮影してピクセル数を数えてしまえば良いのだ。

勢いのあるうちに、早速やってみた。

  • フォントを10文字ずつ書き出し、そのピクセル数を数えた。
//iPadプロポーショナル文字幅チェック用
javascript:
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
for (var n=32, str = ""; n<128; n++){
  for( var i = 0, buf = ""; i < 10; i++ ) buf += String.fromCharCode(n);
  str += "W" + buf + "W\n"
}
el.value = str;
  • 両端の W は、左右の端を計測するときの目安。



  • このように地道にピクセル数を数えて、フォント幅を求める関数fontWidth(str)を得た。
      • 半角¥は、半角\に置き換える必要あり。
function fontWidth(str){
return {
' ' : 4.3,
'!' : 4.7,
'"' : 6.0,
'#' : 9.3,
'$' : 9.3,
'%' : 14.9,
'&' : 11.2,
"'" : 3.2,
'(' : 5.6,
')' : 5.6,
'*' : 6.5,
'+' : 9.8,
',' : 4.7,
'-' : 6.4,
'.' : 4.7,
'/' : 4.7,
'0' : 9.3,
'1' : 9.3,
'2' : 9.3,
'3' : 9.3,
'4' : 9.3,
'5' : 9.3,
'6' : 9.3,
'7' : 9.3,
'8' : 9.3,
'9' : 9.3,
':' : 4.7,
';' : 4.7,
'<' : 9.8,
'=' : 9.8,
'>' : 9.8,
'?' : 9.5,
'@' : 17.0,
'A' : 11.2,
'B' : 11.2,
'C' : 12.1,
'D' : 12.1,
'E' : 11.2,
'F' : 10.2,
'G' : 13.0,
'H' : 12.1,
'I' : 4.7,
'J' : 8.4,
'K' : 11.2,
'L' : 9.3,
'M' : 14.0,
'N' : 12.1,
'O' : 13.0,
'P' : 11.2,
'Q' : 13.0,
'R' : 12.1,
'S' : 11.2,
'T' : 10.3,
'U' : 12.1,
'V' : 11.2,
'W' : 15.8,
'X' : 11.2,
'Y' : 11.2,
'Z' : 10.2,
'[' : 4.7,
'\\' : 9.3,
'\u00a5' : 9.3,
']' : 4.7,
'^' : 7.9,
'_' : 9.3,
'`' : 5.6,
'a' : 9.3,
'b' : 9.3,
'c' : 8.4,
'd' : 9.3,
'e' : 9.3,
'f' : 4.7,
'g' : 9.3,
'h' : 9.3,
'i' : 3.7,
'j' : 3.7,
'k' : 8.4,
'l' : 3.7,
'm' : 14.0,
'n' : 9.3,
'o' : 9.3,
'p' : 9.3,
'q' : 9.3,
'r' : 5.6,
's' : 8.4,
't' : 4.7,
'u' : 9.3,
'v' : 8.4,
'w' : 12.1,
'x' : 8.4,
'y' : 8.4,
'z' : 8.4,
'{' : 5.6,
'|' : 4.4,
'}' : 5.6,
'~' : 9.8}[str] || 16.7;
}
  • 上記の関数fontWidth(str)と、キャレットが段落の先頭・末尾へジャンプする処理とを組み合わせてみた。
      • 半角¥は、半角\に置き換える必要あり。
//段落の先頭へジャンプ4
javascript:
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
var str1 = el.value.substr(0, el.selectionStart-1);
var jumpstr = str1.split(/\n|\r/).pop();
var offset = jumpstr.length+1;
var letter = el.value.substr(el.selectionStart-1, 1);
el.setSelectionRange(el.selectionStart-offset, el.selectionStart-offset);
var line = Math.floor(textWidth(jumpstr) / el.offsetWidth);
if ("\n"==letter) line++;
window.scrollBy(0, -22.24*line);
function textWidth(text){
  for(var i=0, w=0; i < text.length; i++) w += fontWidth(text[i]);
  return w;
}
function fontWidth(str){return{" ":4.3,"!":4.7,'"':6,"#":9.3,$:9.3,"%":14.9,"&":11.2,"'":3.2,"(":5.6,")":5.6,"*":6.5,"+":9.8,",":4.7,"-":6.4,".":4.7,"/":4.7,0:9.3,1:9.3,2:9.3,3:9.3,4:9.3,5:9.3,6:9.3,7:9.3,8:9.3,9:9.3,":":4.7,";":4.7,"<":9.8,"=":9.8,">":9.8,"?":9.5,"@":17,A:11.2,B:11.2,C:12.1,D:12.1,E:11.2,F:10.2,G:13,H:12.1,I:4.7,J:8.4,K:11.2,L:9.3,M:14,N:12.1,O:13,P:11.2,Q:13,R:12.1,S:11.2,T:10.3,U:12.1,V:11.2,W:15.8,X:11.2,Y:11.2,Z:10.2,"[":4.7,"\\":9.3,"\u00a5":9.3,"]":4.7,"^":7.9,_:9.3,"`":5.6,
a:9.3,b:9.3,c:8.4,d:9.3,e:9.3,f:4.7,g:9.3,h:9.3,i:3.7,j:3.7,k:8.4,l:3.7,m:14,n:9.3,o:9.3,p:9.3,q:9.3,r:5.6,s:8.4,t:4.7,u:9.3,v:8.4,w:12.1,x:8.4,y:8.4,z:8.4,"{":5.6,"|":4.4,"}":5.6,"~":9.8}[str]||16.7};

//段落の末尾へジャンプ4
javascript:
var fn = document.getSelection().focusNode;
var el = fn.getElementsByTagName('textarea')[0] || fn.getElementsByTagName('input')[0];
var str1 = el.value.substr(0, el.selectionEnd+1);
var str2 = el.value.substr(el.selectionEnd+1);
var jumpstr = str2.split(/\n|\r/).shift();
var linestr = str1.split(/\n|\r/).pop() + jumpstr;
var offset = jumpstr.length+1;
var letter = el.value.substr(el.selectionStart, 1);
el.setSelectionRange(el.selectionEnd+offset, el.selectionEnd+offset);
var line = Math.floor(textWidth(linestr) / el.offsetWidth);
if ("\n"==letter) line++;
window.scrollBy(0, 22.24*line);
console.log(line);
function textWidth(text){
  for(var i=0, w=0; i < text.length; i++) w += fontWidth(text[i]);
  return w;
}
function fontWidth(str){return{" ":4.3,"!":4.7,'"':6,"#":9.3,$:9.3,"%":14.9,"&":11.2,"'":3.2,"(":5.6,")":5.6,"*":6.5,"+":9.8,",":4.7,"-":6.4,".":4.7,"/":4.7,0:9.3,1:9.3,2:9.3,3:9.3,4:9.3,5:9.3,6:9.3,7:9.3,8:9.3,9:9.3,":":4.7,";":4.7,"<":9.8,"=":9.8,">":9.8,"?":9.5,"@":17,A:11.2,B:11.2,C:12.1,D:12.1,E:11.2,F:10.2,G:13,H:12.1,I:4.7,J:8.4,K:11.2,L:9.3,M:14,N:12.1,O:13,P:11.2,Q:13,R:12.1,S:11.2,T:10.3,U:12.1,V:11.2,W:15.8,X:11.2,Y:11.2,Z:10.2,"[":4.7,"\\":9.3,"\u00a5":9.3,"]":4.7,"^":7.9,_:9.3,"`":5.6,
a:9.3,b:9.3,c:8.4,d:9.3,e:9.3,f:4.7,g:9.3,h:9.3,i:3.7,j:3.7,k:8.4,l:3.7,m:14,n:9.3,o:9.3,p:9.3,q:9.3,r:5.6,s:8.4,t:4.7,u:9.3,v:8.4,w:12.1,x:8.4,y:8.4,z:8.4,"{":5.6,"|":4.4,"}":5.6,"~":9.8}[str]||16.7};
  • できた、できた!
  • 繰り返し実行していると若干ズレるけど、許せる範囲の誤差だと思う。
  • そもそも何十行も上下に移動したいなら、フリックした方が早いのだ。
  • 2、3段落を上下に移動したい時に、キャレットがすぐに視界から消えてしまうのを防止する効果は十分ある。
  • 但し、iPadヒラギノ角ゴシックW3、12ピクセルのフォントサイズに限定されたテキストエリア専用(はてなダイアリーの編集・下書きページ専用)になってしまう。

所感

一生懸命、ピクセル数を数えたけど...

  • イベントで1文字追加と削除の操作をしてスクロール位置を調整する方が良いかもしれない。
    • どんな時でも確実にキャレットが見える最適なポジションまでスクロールしてくれる。
    • スクロールも滑らかで、動きが目に優しい。
  • 先日のWWDCiOS5の概要が公開された。200を超える新機能があるのだと言う。
  • 果たしてその中に、ソフトウェアキーボードの矢印キーは含まれるだろうか?
  • あるいは、ソフトウェアキーボードにショートカット操作は追加されるのだろうか?
  • それらの機能が追加されて初めて、ソフトウェアキーボードは現実のキーボードと対等になれるのだと思う。
  • あるいは、矢印キーやショートカット操作無しでも快適と感じられる、自分の中の意識改革が必要なのだろうか?

今後のソフトウェアキーボードの方向性が気になる。