contenteditable 属性

HTML5 の Content Editable 属性を使用したノートを作成してみました。

See the Pen note (contenteditable) by senmyou (@senmyou) on CodePen.

現在 Android 用の軽量ノート、というかメモの切れ端の代わりになるようなアプリを作っているところです。
最初 textarea 要素で作り始めたのですが罫線がうまく引けませんでした。テキストエリアの背景の高さ(background-size)と行の高さ(line-height)を合わせ linear-gradient で背景に罫線を引くといい感じになるのですが、縦スクロールが発生したとき罫線は固定されたままでスクロールについてきません。試行錯誤を繰り返しているうち、背景(background-size)の高さがテキストエリアより小さいのでスクロールが発生しないのではと思い至りこの方法をあきらめました。
ほかに方法がないか探していたところ contenteditable 属性というものを発見。聞いたこともなかったのですが使ってみました。そのさいに気づいたことをまとめてみます。

contenteditable 属性の挙動について

HTML 要素の contenteditable 属性に true を設定すると、それだけで編集可能状態になります。入力した文字はその要素のコンテンツとなり、要素の幅に達すると overflow プロパティに従い自動的にスクロールバーを表示したり折り返したりします。改行すると新しい子要素が作成され、行の先頭でバックスペースを入力するとその行にあたる子要素が削除されカーソルが上の行へ移動します。
実際の html とその表示は以下のようになります。CSS で装飾していますが html は一行のみです。

  <div id="notearea" contenteditable="true"></div>

See the Pen RgPEry by senmyou (@senmyou) on CodePen.

white-space プロパティの設定

HTML5.jp で以下の解説を見つけました。

ウェブ制作者は、編集ホスト、および、これら編集メカニズムを通して生成されたマークアップ上で、’white-space’ プロパティを値 ‘pre-wrap’ にセットすることが推奨されます。デフォルトの HTML ホワイトスペースのハンドリングは、WYSIWYG 編集に良く適しているわけではありません。’white-space’ がデフォルト値のままだと、行の折り返しが正しく機能しないでしょう。

行の折り返しに連続した半角スペースがあたる場合について警告しています。また、デフォルト(normal)のままだと半角スペースを何個入力しても一個分のスペースになってしまいますが、これはエディタとしてはありがたくないですね。そこで white-space を設定しました。

white-space: pre-wrap;

ちなみに pre-wrap と pre の挙動の違いはわかりませんでした。

罫線を引く

ノートを作るにあたり、最初から罫線を引きたいと思っていました。そこで、あらかじめ子要素として div 要素を10個配置し、CSS で各要素に線を引きました。textarea 要素に線を引くと前述のとおり縦スクロールについてこないのですが、div 要素に線を引くことで div 要素のコンテンツと罫線が一緒にスクロールするようになります。

#notearea > div {
  margin: 0;
  padding: 0 4px;
  min-height: 2rem;
  background-image:
    linear-gradient(to bottom, transparent, transparent calc(2rem - 1px), rgba(0,150,136,0.4) calc(2rem - 1px));
  background-size: auto 2rem;
}

pre-wrap の影響

Monaca デバッガを使用し android 上で確認しながら作っていたのですが、試しに APK を構築して動かしてみることにしました。すると以下のように、一見すると line-height が3倍になったかのような表示になってしまいました。

See the Pen NgqerV by senmyou (@senmyou) on CodePen.

この現象は PC 上のシミュレータ(プレビュー機能)でも発生していました。よくみると空白文字がいくつか挿入されています。また、罫線が引かれておらずインスペクタで DOM をみても一つの div 要素になっています。つまり通常の改行文字の入力や折り返しによるものではなさそうです。このときの html のコードは以下のようになっていました。

<div id="notearea" contenteditable="true">
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
</div>

ここで white-space プロパティの pre-wrap をデフォルト(normal)にすると空白文字が詰められるせいか問題がなくなります。ではなぜ上記コードで空白文字が挿入されるのか。これは私見ですが、contenteditable を指定した要素のなかでは html の記述がそのままコンテンツの対象になっていると思われます。つまり、上記 html では「div 開始タグの前にあるタブ文字」と「div 終了タグの後ろにある改行」が空白文字に変換され、それぞれ div 要素の外に挿入される感じです。
html の記述から以下のようにタブ文字と改行を削除してみるとうまくいきました。

<div id="notearea" contenteditable="true"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>

ふと「inline 要素を横に並べると要素間に不要な隙間が生じるけれど html の記述から改行を削除して詰めると隙間がなくなる」という現象を思い出しました。また、なぜ Monaca デバッガでは大丈夫だったのかは謎です。

backspace 入力

行の先頭でバックスペースを入力すると、その行にあたる div 要素が削除され、カーソルが上の行へ移動します。このとき、上の行でコンテンツが空(””)の div 要素はまとめて削除され、文字のある行までカーソルが一気に移動しました。この現象は各 div 要素のコンテンツに <br> を入力しておくことで防ぐことができました。これは子要素を持たない contenteditable 属性の要素で改行を行うと div 要素のコンテンツに <br> が入力されるというデフォルト動作になります。<br> なんて必要なさそうに思えますが、実は必要だったわけです。
以下の html が最終形態となります。うーん、見づらいですねw

<div id="notearea" contenteditable="true"><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div><div><br></div></div>

Android のキーボード出現時のスクロール

実機上で contenteditable 属性を指定した要素をタップすると編集可能状態になりキーボードが表示されます。このとき、要素のスクロールが行われないためカーソルがキーボードに隠れてしまうことがあります。これは contenteditable の影響か onsen ui の影響か、はたまた android の影響か私の作り方が原因か定かではありませんが、以下の対応でカーソルを見える位置にスクロールできました。
なお、以下のコードでは cordova のプラグイン ionic-plugin-keyboard を使用してキーボード表示イベントの監視を行っています。ちなみにこのリスナ内での window.innerHeight は画面の高さからキーボードの高さを引いた値になります。

var clickedDiv = {
  elem: null,
  y: 0
};
document.getElementById("notearea").addEventListener('click', function(e) {
  clickedDiv.elem = e.target;
  clickedDiv.y = e.y;
});
window.addEventListener('native.keyboardshow', function(e) {
  if (clickedDiv.y + clickedDiv.elem.clientHeight > window.innerHeight) {
    clickedDiv.elem.scrollIntoView(false);
  }
});

読み出しと書き込み

最後に上記 html(div の子要素10個配置)で使用している読み書き関数を記載して本記事を終了します。

function read() {
  var divs = document.getElementById("notearea").getElementsByTagName("div");
  var num = divs.length;
  var i, note = [];

  for (i=0; i<num; i++ ) {
    note.push(divs[i].textContent);
  }
  return note;
}
function write(note) {
  var num = note.length;
  var i, text = "";

  for (i=0; i<num; i++) {
    text = note[i] ? note[i] : "<br>";
    text += ("<div>" + text + "</div>");
  }
  document.getElementById("notearea").innerHTML = text;
}

クラス操作関数

以前に正規表現を使用したことを思い出し調べていたときにでてきたソースです。実際、正規表現は一か所でしか使用していないのですが、こうゆう使い方もあるということで、クラス操作関数とあわせて備忘録として残しておこうと思います。
クラス操作は classList のメソッドで容易に行うことができますが、Can I use で調べてみると Android 4.4 からの対応になっています。それ以前のバージョンに対応する場合は classList の代用が必要になりますが、そのようなときに使えるかと思います。(indexOf()を使用しているのでクラス名の重複部分には注意が必要になります)

/***
 * クラス追加
 */
function add_class(elem, cls) {
    if (!elem || !cls) return;
    elem.className = add_str(elem.className, cls);
}
/***
 * クラス削除
 */
function remove_class(elem, cls) {
    if (!elem || !cls) return;
    elem.className = remove_str(elem.className, cls);
}
/***
 * クラス確認
 */
function has_class(elem, cls) {
    if (!elem || !cls) return;
    return has_str(elem.className, cls);
}
/***
 * クラス追加・削除
 */
function toggle_class(elem, cls) {
    if (!elem || !cls) return;
    elem.className = toggle_str(elem.className, cls);
}

// 文字列 strings に文字列 str を追加する
function add_str(strings, str) {
    var i = strings.indexOf(str);
    if (i === -1) {
        var len = strings.length;
        if (len !== 0 && strings.charAt(len -1) !== " ") {
            strings += " ";
        }
        strings += str;
    }
    return strings;
}

// 文字列 strings から文字列 str を削除する
function remove_str(strings, str) {
    var i = strings.indexOf(str);
    if (i !== -1) {
        strings = strings.substr(0, i)
                + strings.substr(i + str.length + 1);
    }
    return strings;
}

// 文字列 strings に文字列 str が含まれているか検査する
function has_str(strings, str) {
    var regex = new RegExp("(^|\\s)" + str + "(\\s|$)");
    if (regex.test(strings)) return true;
    else return false;
}

// 文字列 strings に文字列 str があれば str を削除し、
// str がなければ追加する
function toggle_str(strings, str) {
    if (has_str(strings, str)) {
        return remove_str(strings, str);
    } else {
        return add_str(strings, str);
    }
}

カレンダーのソースコード

Daily Noteのプロジェクトを整理しているときに、カレンダー作成のソースコードが流用できそうな形になっていたので抜き出してみました。リリース版ではもっと複雑怪奇になっているのですが、評価段階で作成したソースコードはわりとシンプルなのでベースにしやすいかと思います。

See the Pen wzQOKW by NORIAKI MIFUNE (@senmyou) on CodePen.

ところで、今日このソースコードをブログ上で動作させたくて、何かないかなとネットで探していたら「codepen」を紹介している記事を見つけました。それで登録して使ってみたのですが、なんかよくわからないけど、たのしいですね、これ。