Dec 15, 2016

絵文字✨とTwitter Bot🤖と私👤とEmoji Editor📝

このエントリは絵文字Advent Calendar 2016とのクロスポストです。(→Qiitaの方の投稿

この記事では、自分が絵文字込みのテキストを楽に編集するために作ったEmoji Editorという簡単なツールを紹介します。

PCでパレットから絵文字を入力したいんです……😭

唐突ですが皆さん、どうやって絵文字を入力されてますか?

Android版のGoogle日本語のように絵文字のパレットが付いていたり、macOSの日本語入力のように標準辞書に入っていて普通に「すし」と入れて変換すれば「🍣」になったりする環境もあるようなのですが、自分が主に使っているWindows 7+ATOK2016の環境とUbuntu 16.04LTS+ATOK X3の環境では良い方法がなさげです。自分は絵文字はパレットから選択したい派、というか顔文字に対応する読みをいちいち覚えてられない派なので、やるなら一覧の中からぽちぽちクリックして選びたいです。しかしATOKに付属の文字パレットというユーティリティの分類には「顔」みたいなグループ分けが無いため、Unicode絵文字を使おうと思うとUnicodeの表のあっちこっちを行き来しながら探さないといけません。(最新のATOK2017ではこの辺どうなんでしょう?)

また、これはカラー絵文字のフォントが無いという古い環境だからのようですが、文字パレット上だけでなくテキストエディタ上でも絵文字は白黒表示です。自分が絵文字を使いたいのは主にTwitterでの投稿なので、見るならTwitter上でどう見えるかが分からないと安心して使えません。

元々絵文字を使う習慣が無かった自分が絵文字に触れる機会が増えたのは、「シス管系女子」の広報用Twitterアカウント(みんとちゃんbot)がきっかけです。いつもの自分のノリでやると堅すぎると思ったので、みんとちゃんbotの発言についてはあえて絵文字を入れて柔らかい印象を持ってもらえるようにしたい、という下心ドリブンです。

当初はAndroidのGoogle日本語入力から絵文字を入れていましたが、運用のためのbotスクリプトを作成して、発言データにするためのテキストを大量に用意する段階になってPC上で作業に入ろうとしたときに、前述のような状況に気が付きました。

そこでとりあえずTwitter絵文字が使えるパレットを探してみた所、テスト用にちょっと投稿するだけならTwitterの絵文字をデスクトップPCから使おう! Ver2というサービスで絵文字の入力自体はできる事が分かりました。が、このページは既に作成済みのデータの編集には使えません。やはりここは、Twitter絵文字を含んだプレーンテキストをWYSIWYGで編集できるツールが欲しくなるところです。

探し方が悪かったのかもしれませんが、自分はその時「まさにこれだ!」と思える物を見つけられなかったため、無いなら作るか……という事で作りました。その名もEmoji Editor(名前が安直すぎる)。HTMLファイル1つだけで完結する、フレームワークも何も使ってないSPAとも呼べないような代物です。 (Emoji Editorの画面)

このエントリの公開を機に、GitHubにリポジトリを作りましたので、forkしたりプルリクしたりして頂ければ幸いです。

Emoji Editorの使い方

説明の必要も無いと思いますが一応説明しておくと、上半分が入力エリア(右下の所をドラッグしてサイズを変えられます)で、下半分が絵文字のパレットになっています。

  • 入力エリアに普通にテキストを入力して、「ここに絵文字を入れたい!」と思ったらおもむろにパレットから好きな絵文字をクリックすると、カーソルの位置にその絵文字が挿入されます。
  • 真ん中の所には最近使った絵文字が25個まで保持されるようになっています。
  • 絵文字を1秒以上長押しすると、その絵文字単体をクリップボードにコピーします。
  • テキストの保存機能はありません。できたテキストはUTF-8等でのファイル編集に対応したテキストエディタにコピー&ペーストして自分で保存して下さい。
  • テキストのロード機能もありません。既にある絵文字を含んだテキストを編集したいときは、テキストファイルをエディタで開いて編集したい部分(あるいはファイル全体)を入力エリアにコピー&ペーストして下さい。

カスタマイズ🔧

パレット内の絵文字はTwitterの絵文字をデスクトップPCから使おう! Ver2やAndroidのGoogle日本語入力での並び順を参考にしつつ、「これはこっちの仲間じゃね?」と筆者が思った物を適当に並べ替えたりグループ化したりしています。

パレットの実体は、以下のような単純なリストになっています。

  <section id="all">
    <h3 title="People">😁</h3>
    <ul>
      <!-- Face, emoticon -->
      <li>😁</li>
      <li>😂</li>
      <li>🙂</li>
      <li>😃</li>
      <li>😄</li>
      <li>😅</li>
      ...

h3は切り替え式のタブになります。絵文字の並び順が気に入らない場合や収録絵文字が足りないという場合は、HTMLファイルを保存してソース上の絵文字リストを適当に編集して下さい。絵文字を普通に使える最近の環境や、最近のFirefoxのようにカラー絵文字に対応したブラウザであれば、ローカルに置いたHTMLファイルでもそこそこ使えます。

OSやブラウザの絵文字じゃアテにならないのでやっぱりTwitterの絵文字で見たいという場合には、Gistindex.htmlと名前を付けて保存してbl.ocks.orgあたりで表示させれば、twemojiが読み込まれて絵文字が画像に変換されるようになります。

なお、emoji-editor.html自体を編集するときに「普通のテキストエディタで開いても絵文字が見えないんだけど!?」という場合(まあそりゃそうだ)は、emoji-editor.htmlをエディタで開いた後全体をコピーしてEmoji Editorの入力欄にペーストすれば、絵文字がいい感じに表示された状態で編集できます。

また、ルート要素の属性値を編集すると、最近使った項目の保持数や長押しの判定の秒数も変更できます。

注意点⚠

  • Unicode絵文字を日本語入力ソフトから直接入力した場合、twiemojiで画像に置き換える操作が入る関係で、絵文字の入力や絵文字を含んだ文字列をペーストした直後のアンドゥができなくなります。
  • Firefoxでは問題ありませんが、Chromeだとカーソル位置の取得や設定がうまくいかなくて、Unicode絵文字の日本語入力ソフトからの挿入時にカーソル位置が吹っ飛ぶ事があります。仕様の範囲外の実装依存のChromeの挙動(contenteditable="true"の要素の中での改行操作をどう処理するかの違い)に起因する物です。

誰か直してくれると嬉しいな……

技術的な解説⌨

contenteditable="true"にしたdivにtwemojiを適用すればええんやろ、と思ってたんですが意外と細かい所でハマったのでちょっとだけ実装のポイントを解説しておきます。ほとんどはcontenteditable="true"を生で触る時の注意点集です。

パレットから絵文字を挿入する🎨

パレットをクリックした時の絵文字の挿入はdocument.execCommand()で行っています。

function insertEmoji(aEvent) {
  var item = getEmojiItemFromEvent(aEvent);
  if (!item)
    return;

  var emoji = item.getAttribute('data-emoji');

  var text = emoji;
  var command = 'insertText';
  if ('twemoji' in window) {
    text = twemoji.parse(text);
    command = 'insertHTML';
  }
  gEditor.focus();
  setTimeout(function() { // do after focused
    document.execCommand(command, false, text);
    addRecentEmoji(emoji);
  }, 0);
}

function getEmojiItemFromEvent(aEvent) {
  return document.evaluate(
    'ancestor-or-self::*[contains("li,LI", local-name())][contains(@class, "emoji")]',
    aEvent.target,
    null,
    XPathResult.FIRST_ORDERED_NODE_TYPE,
    null
  ).singleNodeValue;
}

twemojiを使えるときはUnicode絵文字をHTMLの<img>タグに変換してからinsertHTMLコマンドでHTMLのソースとして挿入し、そうでない時はUnicode絵文字のままinsertTextで挿入しています。これは操作をアンドゥできるようにするためで、これに限らず編集エリア内のDOMツリーを直接操作するとアンドゥできなくなるので、編集操作は基本的にdocument.execCommand()でやるようにしないといけません。

また、twemojiを使えるときとそうでないときでパレットの構造がちょっと変わるので、処理を楽にするために個々の<li>にはdata-emoji属性で絵文字のテキストを設定してあり、それを取得して使っています(DOM3 XPathでは属性値の文字列も一発で取れるのですが、<li>要素の方を操作したい場面があるので、ここでは要素ノードを取得しています)。

パレットの長押しで絵文字を1文字だけコピーする☝

この記事を書くにあたってパレットから1文字だけ絵文字をコピーしたいという場面がありました。ただ、「パレットをクリックすると編集領域に挿入する」という通常の動作を阻害したくなかったので、「長押しでコピー」という風に実装してしてみました。

「長押しでコピー」を普通に考えると「mousedownsetTimeout(..., 1000);してクリップボードにコピーすれば良いのでは?」という事になって、実際Firefoxの(従来の)アドオン開発ではそういう書き方で良かったのですが、WebのJSではそうはいきません。というのも、安全のためにdocument.execCommapd('copy')は何かしらのイベントが発生したときのイベントループ中以外では呼べないことになっている(呼ぶとエラーになる)のです。なので、mousedownclickを連携して実装しないといけません。要点だけ示すと以下のようになります。

var gLongPressSeconds = 1;
var gLongPressTimer = null;
var gLongPressProcessed = false;

palette.addEventListener('mousedown', function onMouseDown(aEvent) {
  ...
  gLongPressProcessed = false; // 長押しが成立したかどうかのフラグをリセット。
  gLongPressTimer = setTimeout(function() {
    gLongPressProcessed = true; // 1秒後にフラグを立てる。
    ...
  }, gLongPressSeconds * 1000);
});

// clickイベントはどうせmousedownの後にmouseupされたときに発火するので、
// マウスから手を放したときの事もここでまとめて行う。
palette.addEventListener('click', function onClick(aEvent) {
  ...
  // 長押し成立前にmouseupされた場合はタイマーをキャンセル。
  if (gLongPressTimer)
    clearTimeout(gLongPressTimer);
  gLongPressTimer = null;
  ...
  if (gLongPressProcessed) {
    // 長押しが成立していたらクリップボードにコピー。
    gLongPressProcessed = false;
    ...
    copyToClipboard(emoji);
    ...
  }
  else {
    // そうでなければ普通に入力欄に挿入。
    insertEmoji(aEvent)
  }
});

function copyToClipboard(aString) {
  // これは任意の文字列を`document.execCommand("copy")`で
  // コピーするための定番の方法。
  var textarea = document.createElement('textarea');
  textarea.setAttribute('style', 'position:fixed; bottom:0; left:0');
  document.body.appendChild(textarea);
  textarea.value = aString;
  textarea.select();
  document.execCommand('copy');
  textarea.parentNode.removeChild(textarea);
}

タブ文字を入力できるようにする✏

普通Webブラウザのウィンドウ内でTabキーを押すとリンク間のフォーカス移動になりますが、作りたいデータの形式がタブ区切りだったので、Tabでタブ文字を入れられるようにする必要がありました。

やり方は簡単で、keypressイベントが発生したときにモディファイアキー無しのTabキーの入力だったら\tを挿入してイベントをキャンセルするだけです。

gEditor.addEventListener('keypress', function onKeyPress(aEvent) {
  if (aEvent.keyCode == aEvent.DOM_VK_TAB &&
      !aEvent.altKey &&
      !aEvent.ctrlKey &&
      !aEvent.metaKey &&
      !aEvent.shiftKey &&
      document.execCommand('insertText', false, '\t'))
    aEvent.preventDefault();
});

これも、アンドゥできるようにするにはdocument.execCommand()insertTextコマンドで挿入しないといけません。

ただ、これだけだとタブ文字\u0009を入れたはずが何故か半角スペース1個だけ\u0020になってしまいます。これはcontenteditable="true"にしたHTML要素のレンダリングのされ方の影響で、white-space:normalな要素の中に上記の方法でタブ文字を入れても、通常のHTMLのソースに書かれたタブ文字と同様に、最終的に半角スペース1つに正規化されてしまうからです。なのでそれを防ぐために、編集エリアとして使うHTML要素にはwhite-space:preまたはwhite-space:pre-wrapを指定しておく必要があります

常にプレーンテキストとしてコピー&ペーストする📋

編集した内容を外部のテキストエディタにコピペして保存するときはいいのですが、エディタを経由せずそのままTwitterの入力欄などにコピペしようとすると問題が起こります。どっちの入力欄もcontenteditable="true"のリッチテキストエディタ扱いなので、貼り付け時には可能な限り多くの情報を引き継ごうとブラウザが気を利かせてtext/htmlのデータを貼り付けてくれるのですが、Twitterの入力欄では画像の直接の埋め込みは許可されていないため、twemojiが画像にした絵文字が消えてしまうのです。

なので、DOM Eventのcopycutをフックして、text/plain形式のデータだけをクリップボードに格納するようにしています。

gEditor.addEventListener('copy', function onCopy(aEvent){
  var text = rangeToPlainText(window.getSelection().getRangeAt(0));
  aEvent.clipboardData.setData('text/plain', text);
  aEvent.preventDefault();
});

gEditor.addEventListener('cut', function onCut(aEvent){
  var text = rangeToPlainText(window.getSelection().getRangeAt(0));
  aEvent.clipboardData.setData('text/plain', text);
  // `cut`の場合は切り取った後の部分を消す必要がある。
  if (document.execCommand('delete'))
    aEvent.preventDefault();
});

これにより、text/htmlのデータを伴わない、Unicode絵文字込みのtext/plainのデータだけを確実にコピーできます。

同じ理由で、テキストの貼り付け時にも対策が必要です。Twitterのサイト上で絵文字が画像になった状態のテキストなどをコピーしてEmoji Editorの入力欄に貼り付けようとすると、HTMLの要素がそのまま貼り付けられてしまって編集に難儀します。なので、DOM Eventのpasteをフックして、これまたtext/plain形式のデータだけを受け取り、Emoji Editorにとって都合の良い形式にしてから取り扱うようにしています。

gEditor.addEventListener('paste', function onPaste(aEvent){
  var text = aEvent.clipboardData.getData('text/plain') || '';
  var command = 'insertText';
  if ('twemoji' in window) {
    text = twemoji.parse(sanitizeForHTML(text));
    command = 'insertHTML';
  }
  gEditor.focus();
  if (document.execCommand(command, false, text))
    aEvent.preventDefault();
});

...

function sanitizeForHTML(aInput) {
  return aInput.replace(/&/g, '&amp;')
               .replace(/>/g, '&gt;')
               .replace(/</g, '&lt;');
}

テキストの挿入時にはパレットからの絵文字入力の時と同じようにinsertTextinsertHTMLを使うのですが、insertHTMLを使う時は文字列の中にある<>がそのままタグとして解釈されてしまうと困るので、sanitizeForHTML()という関数を用意してHTMLのソースとして安全な形に変換してから挿入します。

タブ文字を残してコピー&ペーストする✂

ここから話がややこしくなってきます。

先程のコード片でcopyイベントをフックしたとき、text/plainのデータとしてwindow.getSelection().toString()をそのまま設定しないでrangeToPlainText(rangeToPlainText(window.getSelection().getRangeAt(0)))としていたことに気付いたでしょうか?

Twitterのツイートあたりをそのままコピーしてテキストエディタにペーストすると分かるのですが、window.getSelection().toString()とすると、画像がalt属性の値で置き換えられたり改行がプラットフォームごとの一般的な改行コードに合わせてLFまたはCRLFになったりという具合に、通常はブラウザが良い感じに文字列を組み立ててくれます。

じゃあ何故それをそのまま使わないかというと、少なくともFirefoxでは、この方法を使ったときに選択範囲の中に画像が1個でもあるとタブ文字が半角スペース1個に変換されてしまうのです。実際に、テキストノードの部分だけを選択した場合と画像を含めて選択した場合でwindow.getSelection().toString()の結果を確認したら、テキストノードだけの時は確かに\u0009として取り出せていた部分が、画像も含めての選択時には\u0020になってしまっていました。

ということで仕方が無いので、DOM Rangeを走査して自分で必要なテキストを組み立てる処理を書きました。

function rangeToPlainText(aRange) {
  // 改行として使う文字はプラットフォーム標準の物を使う。
  var prettyText = window.getSelection().toString();
  var linesDelimiter = /(\r?\n)/.test(prettyText) ? RegExp.$1 : '\n';

  // 選択範囲のRangeの最初のテキストノード、画像、または
  // 改行のタグから探索を始める
  var text = '';
  var target = aRange.startContainer;
  if (target.nodeType == target.ELEMENT_NODE &&
      !/^(img|br)$/i.test(target.localName))
    target = target.childNodes[aRange.startOffset];

  // コピーしたときに内容を取り出す対象になり得るノードを収集する(後述)。
  var targets = editableNodes();
  var initialTarget = target;
  for (target of targets)
  {
    // Rangeの最初のノードまでスキップする。
    if (initialTarget) {
      if (target != initialTarget)
        continue;
      initialTarget = null;
    }

    if (target.nodeType == target.TEXT_NODE) {
      // テキストノードの場合、(部分)文字列を取り出す。
      let value = target.nodeValue;
      if (target == aRange.endContainer)
        value = value.slice(0, aRange.endOffset);
      if (target == aRange.startContainer)
        value = value.slice(aRange.startOffset);
      text += value;
    }
    else if (target.localName.toUpperCase() == 'BR') {
      // 改行のタグは改行文字に変換。
      text += linesDelimiter;
    }
    else if (target.alt) {
      // twemojiの画像は代替テキストに変換。
      text += target.alt;
    }

    // Rangeの終端に辿り着いたらループを打ち切る。
    if (target.nodeType == target.TEXT_NODE ?
          target == aRange.endContainer :
          target == aRange.endContainer.childNodes[aRange.endOffset - 1])
      break;
  }
  return text;
}

// 編集欄の中のテキストノード、画像、改行のタグだけを収集する。
function editableNodes() {
  // DOM3 XPathで一気に取ってくる。
  // これはC++の実装が使われるので高速。
  var nodes = document.evaluate(
    'descendant::text() | descendant::*[contains("img,IMG,br,BR", local-name())]',
    gEditor,
    null,
    XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
    null
  );
  // DOM3 XPathそのままのインターフェースは煩雑で使いにくいので、
  // イテレータにして返す。
  return (function* () {
    for (var i = 0, maxi = nodes.snapshotLength; i < maxi; i++)
    {
      yield nodes.snapshotItem(i);
    }
  })();
}

ペーストするときは前述の通りwhite-space:pre-wrapを指定しておけば大丈夫でした。

カーソル位置の取得🔍と復元💾

Emoji Editorのパレットとクリップボードからのペーストで入力される絵文字についてはtwemojiで画像のタグにしたものをinsertHTMLで挿入するようにしていますが、それ以外の方法、例えば日本語入力ソフトからの入力でUnicode絵文字が直接挿入された場合の対応も必要です。

入力欄に絵文字が入力されたこと自体は、MutationObserverで検知できます。

function startObserveEditor() {
  gEditingObserver = new MutationObserver(function(aMutations) {
    if (gEditing)
      return;
    aMutations.forEach(function(aMutation) {
      if (aMutation.type == 'attributes') {
        if (aMutation.attributeName == 'style')
          updateLayout();
      }
      else {
        onEdit(); // ここでtwemoji.parse()を実行する
      }
    });
  });
  gEditingObserver.observe(gEditor, {
    attributes    : true,
    childList     : true,
    characterData : true,
    subtree       : true
  });
}

この時、twemoji.parse()で編集エリア内のUnicode絵文字を画像に置き換えるとカーソルの位置がずれてしまうという問題が起こります。例えば、カーソル位置に新しくUnicode絵文字を挿入した場合はその文字の次の位置にカーソルが移動しているはずなのに、絵文字を入力する前の位置や、あるいはとんでもない明後日の位置にカーソルが飛んでしまったりします。

この問題を解消するためには、頑張ってカーソル位置を調整してやる必要があります。まず、twemoji.parse()の実行直前に元のカーソル位置(この時点ではきちんと絵文字の後に移動している)を取得します。

function getCursorPosition() {
  try {
    // カーソル位置から編集エリアの先頭までを選択し、
    var cursor = window.getSelection().getRangeAt(0).cloneRange();
    cursor.collapse(false);
    var firstEditable = editableNodes().next().value;
    cursor.setStart(firstEditable, 0);
    // その中に含まれる論理的な文字の数を数える。
    // 前述の方法で一回テキストに変換し、その文字数を数えている。
    return unicodeStringToChars(rangeToPlainText(cursor)).length;
  }
  catch(e) {
    return -1;
  }
}

// サロゲートペアが使われた絵文字を「1文字」として文字列を文字ごとに分割する。
// See: http://qiita.com/YusukeHirao/items/2f0fb8d5bbb981101be0
function unicodeStringToChars(aInput) {
  return aInput.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]|[^\uD800-\uDFFF]/g) || [];
}

で、絵文字が画像になった後で改めてカーソル位置を再設定します。

function setCursorAt(aPosition) {
  try {
    // さっき使った編集対象のノードを収集する処理をここでも使う。
    var targets = editableNodes();
    var target = targets.next().value;

    var selection = window.getSelection();
    var cursor = document.createRange();
    cursor.setStart(target, 0);
    // あと何文字分カーソルを移動すれば良いか、のカウンタ。
    var restCount = aPosition;
    while (target && restCount > 0)
    {
      if (target.nodeType == target.TEXT_NODE) {
        // テキストノードで、そのノード内にカーソルが来る場合、
        // そこで走査を打ち切る。
        let value = target.nodeValue;
        if (value.length >= restCount) {
          cursor.setEnd(target, restCount);
          break;
        }
        // そのノードよりも後にカーソルがある場合、
        // ノードの文字数分だけカウンタを減らす。
        restCount -= unicodeStringToChars(value).length;
      }
      else {
        // 画像と改行要素の場合は1文字分進むことにして
        // カウンタを減らす。
        restCount--;
      }
      cursor.setEndAfter(target);

      target = targets.next().value;
    }
    // カウンタが0になる、または走査対象のノードが尽きたら
    // その位置をカーソルとして確定する。
    cursor.collapse(false);
    selection.removeAllRanges();
    selection.addRange(cursor);
  }
  catch(e) {
    console.log('failed to move cursor: ', e);
  }
}

Firefoxではこれで良かったのですが、Google Chromeでは入力欄でEnterすると改行要素ではなくdivが追加される場合があるみたいで、どうもこの処理が期待通りに動いてくれませんでした。誰か直せる人は直してもらえるとありがたいです。

ということで、自分でcontenteditable="true"の要素をコントロールしようと思うと結構色々大変なのでした。妙な事は考えずに、素直にどっかのフレームワークとかFacebookの投稿欄とかを使った方がよかったですかね……?

絵文字を含むツイートを簡単にできる幸せ💃

みんとちゃんbotの運用に使っているbotやそのベースになっているTwitterクライアントはどちらもシェルスクリプト製で、データの取り扱いは一事が万事プレーンテキストなのですが、Twitterが「単にUnicode絵文字を含んだデータを投稿すればOK」という仕様なおかげで、botのデータファイルは以下のように容易にプレビュー可能な形で作成できています。

おはようございまーす!🙌
おはようございまーす!🙌 今日も一日がんばりましょう🎵

これが仮に:smile:みたいな専用のマークアップを必要とする仕様だったら、自分でEmoji Editorを作ってみようなんて思わなかったと思いますし、みんとちゃんbotはかわいげの無い発言をするbotになっていたことでしょう。電子テキストの世界を彩り、楽しくかわいくしてくれるUnicode絵文字。色々賛否はあるようですが、筆者は素晴らしいものだと思っております。

そういうわけで、みんとちゃんbotは今日も元気に絵文字を織り交ぜつつLinuxのコマンド操作やシェルスクリプトの豆知識を披露したり本や連載の宣伝をしたりしています。イラストもたまに流していますので、もし良かったらフォローしてあげて下さい。また、「シス管系女子」をテーマにしたAdvent Calendarなんてのもありますので、そっちも見て頂けると嬉しいです。

以上、絵文字 Advent Calendar の15日目でした。次の方の記事もお楽しみに!

エントリを編集します。

wikieditish message: Ready to edit this entry.











拡張機能