🦔

LLMによる添削機能付きエディタをWebブラウザで動かしたいなら、textarea overlayを使え!

2024/03/06に公開

やりたいこと

  • Grammalyみたいな添削機能付きのエディタを、ブラウザ上で動かしたい!
  • 具体的には、<textarea>の中で、添削を入れた箇所に赤線を表示したい。赤線箇所をクリックしたら、textarea右側に修正後の文章の提案が表示されてほしい!

背景

  • Web画面上の投稿フォームに添削機能を組み込んでみたかった
  • 添削用のプロンプト自体はすぐに書けたが、UIが想像の何倍も難しく、実現のための技術検討にめちゃくちゃ時間を取られた(1ヶ月かかった)
  • この手の情報が検索してもなかなか見つけられなかったので、同じように苦しんでいる人のために自分が調べた内容をまとめた

サンプルコード

  • textarea内の赤線部分をクリックすると指摘事項が選択されます
  • 修正事項の「修正」をクリックすると提案された修正例に置換されます
  • いちおう、OPENAI API KEY のところにAPI KEYを入れて「添削実行」を押すと添削開始しますが、本記事の主題はUIなので、今回は取り上げません
  • 本文は青空文庫の「ごん狐」を取ってきました

伝えたいことまとめ

  • 文字装飾が不要(太字などは使わず、プレーンなテキストさえ入力できければOK)なら、textarea overlayが圧倒的におすすめ。というかこれ一択。
  • contenteditable は地獄を見るからやめよう
  • editor.jsなどのwysywigエディタを使う手もあるけど、機能が多いので手なづけるのは大変かも。

今回の手法

textarea overlayとは?

  • LINEのエンジニア公式ブログ「Web フォントを使って contenteditable から脱出する」に教えてもらった概念
  • 通常の<textarea>と完全に位置が重なるように、<div>などの要素を配置しすることで、textarea内に、通常ではtextarea内には表示できない画像などの要素を擬似的に表示する手法


(図は「Web フォントを使って contenteditable から脱出する」より引用)

どうやって<textarea>内に赤線を表示しているのか

  • position: absolute; を使い、<textarea><pre> を完全に重ね合わせる
    • (上の図とは逆で、私はtextareaを上層に、pre要素を下層に配置しています)
  • vue.js の computed を使い、<textarea><pre> の中のテキストを同期した上で、LLMが指摘した要修正箇所を <span class="highlight">...</span> に置き換える

以下のコードは重要な箇所以外は削除しています

html

<pre class="c-editor__overlay" v-html="targetTextWithNote">
<textarea class=".c-editor__textarea" v-model="targetText">
%editor__common { // textarea と pre の形状をできるだけ揃える
  position: absolute;
  width: 100%;
  height: 600px;
  font-size: 14px;
  font-family: Roboto, sans-serif;
  white-space: pre-wrap;
  overflow-y: scroll;
  padding: 0;
  margin: 0;
  border: none;
}

.c-editor {
  position: relative;

  &__textarea {
    @extend %editor__common;
    background: transparent; // 上に被せるtextareaは背景を透明に
  }
  &__overlay {
    @extend %editor__common;
    color: transparent; // 下に置くpreは文字を透明に
  }
  & .highlight {
    border-bottom: 2px solid #ea1537; // 指摘事項に赤線を引く
  }
}
    const targetTextWithNote = computed(() => {
      let text = targetText.value;
      notes.forEach((note) => { // notes は指摘箇所が入っている配列
        text = text.replace( // 指摘箇所を<span>で置き換える
          note.before,
          `<span class='highlight' data-note-id=${note.id}>${
            note.before
          }</span>`
        );
      });
      return text;
    });

なお、文章が長くてテキストエリア内にスクロールが発生する場合は、textareaとpreのスクロールを同期してやる必要がある

onMounted(() => {
  const textareaElement = document.getElementById("textarea");
  const overlayElement = document.getElementById("overlay");

  const onEditorScroll = function (event) {
    overlayElement.scrollTop = event.target.scrollTop;
  };
  textareaElement.addEventListener("scroll", onEditorScroll);
}

どうやってクリックイベントを取っているのか

教えて!goo 重なった要素上でのイベントで下層要素を特定したい のChaireさんの回答を参考にしました

  • 指摘事項の箇所をクリックしたときにイベントを取りたい
  • が、普通に<span class="highlight">にonClickイベントを設定しただけでは機能しない
    • なぜなら、textareaの方が上層にあるが、textareaはonClickイベントの存在する要素のため、ここでイベントの伝播が終わってしまうから
  • textareaのonClickイベント発生時に、一時的に上層側のtextareaを visibility = "hidden"; にし、document.elementFromPoint(x, y);を使うと一つ下の要素が取得できる
    • このとき、DOMの再描画が走る前に visibility = "visible";に戻せば画面に影響を与えずに済むっぽい

preの中の<span>に、以下のように data-note-id 要素を付与しておく

 <span class="highlight" data-note-id="0">かくれて</span>
onMounted(() => {
  const textareaElement = document.getElementById("textarea");
  const overlayElement = document.getElementById("overlay");

  const onEditorClick = function (event) {
    const x = event.clientX;
    const y = event.clientY;
    textareaElement.style.visibility = "hidden";
    const element = document.elementFromPoint(x, y);
    textareaElement.style.visibility = "visible";
    if (element == null) return;
    if (element.dataset.noteId == null) return;
    selectNoteId(Number(element.dataset.noteId));
  };

  textareaElement.addEventListener("click", onEditorClick);
});

その前に検討していた手法

contenteditable

  • <div contenteditable="true"> のように、div要素などに付与することで、ユーザがその要素の中身を編集可能になるグローバル属性。mozillaのドキュメント上にデモがある。
  • これにより、divの中に画像や装飾付きの文字など、通常のtextarea内には置けない要素を配置することができる。実際、Twitterやnote、このzennの投稿フォームなどでも使われている。
  • が、ブラウザごとに挙動が違ったり、改行や削除、コピペなどの挙動にかなり癖があり、使いこなすには大量のイベントハンドラを設定して挙動を調整してやる必要がある。

contenteditableと悪戦苦闘している人たちの記録(私が見つけた範囲で):

ContentEditableの手懐け方
LINE BLOGアプリ開発で contenteditable と戦った話
noteと"contenteditable"
Why ContentEditable is Terrible

noteやLINE、有名なWYSYWIGエディタの開発者など、熟練したフロントエンドエンジニアでも苦しむ魔境であり、フロントエンドに詳しくない人間が手を出すべきではない。

個人的には、「Ctrl + Z」による「元に戻す」が安定しなくなるところが却下要因だった。
添削結果をエディタ内に表示しようとすると、要素を置き換える必要があるが、これが「元に戻す」動作と衝突する。
執筆作業の効率化のために機能を導入したいので、「元に戻す」が使えなくなるのは辛い。
(どちらかといえば添削よりも「元に戻す」の方がよく使うし)

ちなみに、下で示す editor.js の イベントハンドラの設定をGithubのこのへんで読めるが、EnterやBackspaceなどの入力に対し、一つ一つイベントハンドラを設定して挙動を調整しており、個人でやるものではないなと思った。

editor.js

  • 最近利用率が増えているWYSYWIGエディタ。
  • Notionのようなブロックライクな見た目で編集ができ、かつインライン要素もプラグインとして比較的容易に自作できる。
  • 既成のWYSYWIGエディタは、contenteditableをそのまま使うのに比べ、改行や削除のキー入力に対する挙動が調整されているところがメリット。

最初はeditor.jsを採用する方向で検討していた(2週間くらい試しにコードを書いていた)のだが、今回やりたかったことに比べ、editor.jsの機能が多すぎた。
変に機能が増えてデータの取りうる形式が増えると、本来の目的と関係ないが、検討しないといけないパターンが増えすぎるという事情もあり、却下。

謝辞

ここからは敬語で。

上で紹介した、LINEエンジニアのAkihiro Tamadaさんの記事がなければ、私は今でもcontenteditableと格闘していたと思います。貴重な知見を公開してくださったAkihiro Tamadaさんに感謝します。
また、教えて!gooの回答者Chaireさんの回答に出会わなければ、textarea overlayでのクリックイベント制御の仕方がわからず、よく検討する前にtextarea overlayの採用を選択肢から外していたかもしれません。Chaireさんにも非常に感謝しています。

今回はWebブラウザ上で添削機能のUIを実装する方法について、私が1ヶ月ほど調査・検討した結果を公開しました。私は普段はバックエンドの仕事をメインとしていることもあり、フロント専門の方から見ると拙いところもあったかと思いますが、同じ課題で苦しんでいる方の助けになれば幸いです。

私は引き続き、LLMによる補完機能のついたエディタの開発など、LLMをWebエディタに組み込む場合のUI検討をしている予定なので、もし私と同じように、LLMをWebエディタに組み込む場合のUI開発している方がいれば、私のTwitter(@piyoketa)などにご連絡をいただけると嬉しいです。この分野はWeb上に転がっている知見が少ないのもあり、知見を共有できる友達を求めています。苦労を分かち合いましょう。

Discussion