✏️

入力フォームにリアルタイムハイライト機能を自前実装した話

2023/10/30に公開

こんにちは!
株式会社ココナラ フロントエンド開発グループのよしみんです。

本記事では、Vue.jsを使用して入力フォームのテキストエリアにリアルタイムでハイライト機能を自前実装した話をしたいと思います。

なぜ自前実装?

テキストエリアにハイライトなんてcssでつければいいんじゃないの?

テキストエリアにcssで装飾することはもちろんできますが、入力した文字に対するハイライトのような装飾の仕方はできません。

ライブラリを使えば簡単に実装できるのでは?

Vue.jsでテキストエリアにハイライトをつける機能のライブラリを調べると、下記のようなライブラリがヒットします。
https://github.com/deerchao/vue-hilight-textarea

ただ、ライブラリではtextareaのタグが含まれているものでした。
今回実装したいのはBuefyの入力フォームコンポーネントを使用している箇所であり、このコンポーネントでは既にtextareaのタグがラップされている為、適用するのが難しいという問題があります。
そこで、textareaのタグをラップしている入力フォームコンポーネントを、さらにラップして使用できるようなハイライト機能コンポーネントを自前実装することとなりました。

(この記事に辿り着いた方でテキストエリアのハイライト機能実装を検討されている方は、まずはライブラリ使用で実装可能ではないのか?を検討することをおすすめします!)

どう実装するのか?

先ほど記載した通り入力した文字に対するハイライトのような装飾の仕方はできない為、入力フォームであるテキストエリアで文字入力は行うが、ハイライトの見た目には別のDOM(※以下ハイライトDOM)を用意してあげることで実装します。
下記が実装例になります。

実装例

デプロイした画面

実装例

要素 役割
ピンク色のブロック
ハイライトDOM
ハイライトの見た目
(文字の後ろにハイライトとなる色を装飾した要素を配置)
青色のブロック textareaのタグをラップしている入力フォーム
(実際に文字を入力する箇所)

使用言語・ライブラリ

  • TypeScript
  • Vue3

リアルタイムハイライト機能のコンポーネント

TextFieldHighlighter.vue内のtemplate記述
<template>
  <div
    ref="textFieldHighlighter"
    class="textFieldHighlighter"
  >
    <div
      class="textFieldHighlighter_backdrop"
      :style="backdropStyle"
    >
      <div
        v-html="textValue"
        class="textFieldHighlighter_highlights"
        :style="highlightsStyle"
      />
    </div>
    <!-- textareaを含んでいるコンポーネントを配置する -->
    <slot />
  </div>
</template>
TextFieldHighlighter.vue内のscript記述
<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'

const $textField = ref<HTMLTextAreaElement | HTMLInputElement>()
const textValue = ref('')
const backdropStyle = reactive({})
const highlightsStyle = reactive({})
const textFieldHighlighter = ref<HTMLElement>()

const getStyle = (element: Element, property: keyof CSSStyleDeclaration, numericalize: boolean = true): string | number => {
  const style = document.defaultView?.getComputedStyle(element, '')
  let value = style?.[property]
  if (numericalize && value.match(/px$/)) {
    value = Number(value.replace('px', ''))
  }
  return value || (numericalize ? 0 : 'initial')
}

const onInput = e => {
  replaceHighlightsText(e.target.value)
}

const onScroll = e => {
  if (!$textField.value) return
  Object.assign(highlightsStyle, {
    height: `${getStyle($textField.value, 'height') + e.target.scrollTop}px`,
    top: `-${e.target.scrollTop}px`
  })
}

const listenEvents = () => {
  if (!$textField.value) return
  $textField.value.addEventListener('input', onInput)
  $textField.value.addEventListener('scroll', onScroll)
}

const removeEvents = () => {
  if (!$textField.value) return
  $textField.value.removeEventListener('input', onInput)
  $textField.value.removeEventListener('scroll', onScroll)
}

const replaceHighlightsText = (val: string) => {
  const escapeTextValue = val.replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;')

  // ハイライトしたい文字列を判定する処理(今回は「ハイライト」の文字列としています)
  let newTextValue = escapeTextValue.replace(/ハイライト/g, '<mark>$&</mark>')

  if (!newTextValue.match(/<br>$/)) newTextValue = `${newTextValue}<br>`
  textValue.value = newTextValue.replace(/\r?\n/g, '<br>')
}

const synchronizeStyle = () => {
  if (!$textField.value) return
  Object.assign(backdropStyle, {
    width: getStyle($textField.value, 'width', false),
    height: getStyle($textField.value, 'height', false),
    letterSpacing: getStyle($textField.value, 'letterSpacing', false),
    font: getStyle($textField.value, 'font', false),
    borderWidth: getStyle($textField.value, 'borderWidth', false),
    borderStyle: getStyle($textField.value, 'borderStyle', false),
    padding: getStyle($textField.value, 'padding', false),
    caretColor: getStyle($textField.value, 'caretColor', false),
    backgroundColor: getStyle($textField.value, 'backgroundColor', false)
  })
}

onMounted(() => {
  if (!textFieldHighlighter.value) return
  const el = textFieldHighlighter.value.querySelector('textarea')
  if (!el) return
  $textField.value = el
  replaceHighlightsText($textField.value.value)
  synchronizeStyle()
  listenEvents()
})

onBeforeUnmount(() => {
  removeEvents()
})
</script>
TextFieldHighlighter.vue内のstyle記述
<style lang="scss">
.textFieldHighlighter {
  position: relative;
  &_backdrop {
    position: absolute;
    z-index: 10;
    overflow: auto;
    pointer-events: none;
    border-color: transparent;
  }
  &_highlights {
    height: 100%;
    white-space: pre-wrap;
    word-wrap: break-word;
    color: transparent;
    position: relative;
    mark {
      color: transparent;
      background-color: #fc6674; // ハイライトの色指定
      opacity: 0.2;
    }
  }
  textarea {
    display: block;
    z-index: 11 !important;
    margin: 0;
    background-color: transparent;
    overflow: auto;
    resize: none;
  }
}
</style>

リアルタイムハイライト機能のコンポーネントを使用

使用箇所のvueファイル
<template>
  <TextFieldHighlighter>
    <!-- textareaを含む入力フォームコンポーネント -->
    <InputFormTextArea />
  </TextFieldHighlighter>
</template>

<script setup lang="ts">
import TestHighlighter from '~/components/TestHighlighter.vue'
import InputFormTextArea from '~/components/InputFormTextArea.vue'
</script>

実際の実装(処理)の流れ

1. テキストエリアの装飾をハイライトDOMに適用するメソッドを用意する

slot内に配置した入力フォームのtextareaの要素に適用されているstyleをハイライトDOM用のstyleオブジェクトにコピーしてあげます。
こうすることで、実際に入力するテキストエリアと見た目のハイライトでズレることなく表示することができます。

※実装例内ではsynchronizeStyleというメソッドとして置いているもので、$textField.value(textareaの要素)からstyleを取得してbackdropStyleハイライトDOM用のstyleオブジェクト)にコピーしています。

2. ハイライトを適用したい文字を判定してハイライト処理するメソッドを用意する

以下の処理を行うメソッドを用意します。

  1. 入力された文字をそのまま扱うのはリスクがある為、エスケープしてあげる
    • &,<,>,",'の文字を文字コードに置き換える
  2. ハイライトしたい文字をmarkタグで囲い直してあげる
    • 判定したい文字列や正規表現で該当箇所を置き換える
  3. 改行コードを<br>タグに置き換えてあげる
    • textareaタグでは改行コードで改行されますが、今回見た目はハイライトDOMでありdivタグとなる為<br>タグに置き換える
  4. 1~3の処理を行ったものをv-htmlハイライトDOMへ適用する

※実装例内ではreplaceHighlightsTextというメソッドとして置いているもので、今回は「ハイライト」という文字列をすべてハイライトするような処理としています。

3. 1, 2のメソッドを画面表示時に呼び出す

onMounted時にslot内に配置した入力フォームのtextareaの要素を取得し、テキストエリアの装飾を適用(上記1)・ハイライト処理(上記2)を実行します。
ハイライト処理もこの時点で実行するのは、画面表示時に入力フォームに初期値があった場合にもハイライトされるようにする為です。

4. 文字が入力される度にリアルタイムでハイライトされるように、textareaのinputイベントへハイライト処理をイベントリスナー登録する

※実装例内ではlistenEventsというメソッドでonMounted時にメソッド実行をイベントリスナー登録しています。
また、登録したイベントリスナーは残り続けてしまうので、onBeforeUnmount時にremoveEventsというメソッドでイベントリスナーの解除を行なっています。

詰まりポイント

テキストエリア内をスクロールするとハイライトがずれてしまう

入力フォームがスクロールされても見えているハイライトは入力フォームと別のハイライトDOMに存在している為、そのままではハイライトDOMはスクロールされずにそのまま留まってしまいます。
なので、入力フォームがスクロールされたことを検知してハイライトDOMにもテキストエリアのスクロール位置を同期してあげます。

※実装例内ではonScrollというメソッドを用意し、リアルタイムのハイライト処理同様にonMounted時にメソッド実行をイベントリスナー登録・onBeforeUnmount時にイベントリスナー解除しています。

最後に

実際の実装時にはハイライトの判定に少し時間の処理を入れていた為、上記の実装に加えてWebWorkerを使用するなどパフォーマンスの改善を行いました。
パフォーマンス改善については、また別の機会にご紹介したいと思います。


ココナラではフロントエンド領域に限らず、各領域のエンジニアを募集しています。
よろしければぜひ以下のページもご覧ください。
https://coconala.co.jp/recruit/engineer/

フロントエンドの求人はこちらです。ご応募お待ちしております!
https://open.talentio.com/r/1/c/coconala/pages/49717

Discussion