入力フォームにリアルタイムハイライト機能を自前実装した話
こんにちは!
株式会社ココナラ フロントエンド開発グループのよしみんです。
本記事では、Vue.jsを使用して入力フォームのテキストエリアにリアルタイムでハイライト機能を自前実装した話をしたいと思います。
なぜ自前実装?
テキストエリアにハイライトなんてcssでつければいいんじゃないの?
テキストエリアにcssで装飾することはもちろんできますが、入力した文字に対するハイライトのような装飾の仕方はできません。
ライブラリを使えば簡単に実装できるのでは?
Vue.jsでテキストエリアにハイライトをつける機能のライブラリを調べると、下記のようなライブラリがヒットします。
ただ、ライブラリではtextareaのタグが含まれているものでした。
今回実装したいのはBuefyの入力フォームコンポーネントを使用している箇所であり、このコンポーネントでは既にtextareaのタグがラップされている為、適用するのが難しいという問題があります。
そこで、textareaのタグをラップしている入力フォームコンポーネントを、さらにラップして使用できるようなハイライト機能コンポーネントを自前実装することとなりました。
(この記事に辿り着いた方でテキストエリアのハイライト機能実装を検討されている方は、まずはライブラリ使用で実装可能ではないのか?を検討することをおすすめします!)
どう実装するのか?
先ほど記載した通り入力した文字に対するハイライトのような装飾の仕方はできない為、入力フォームであるテキストエリアで文字入力は行うが、ハイライトの見た目には別のDOM(※以下ハイライトDOM
)を用意してあげることで実装します。
下記が実装例になります。
実装例
デプロイした画面
要素 | 役割 |
---|---|
ピンク色のブロック ( ハイライトDOM ) |
ハイライトの見た目 (文字の後ろにハイライトとなる色を装飾した要素を配置) |
青色のブロック | textareaのタグをラップしている入力フォーム (実際に文字を入力する箇所) |
使用言語・ライブラリ
- TypeScript
- Vue3
リアルタイムハイライト機能のコンポーネント
<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>
<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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
// ハイライトしたい文字列を判定する処理(今回は「ハイライト」の文字列としています)
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>
<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>
リアルタイムハイライト機能のコンポーネントを使用
<template>
<TextFieldHighlighter>
<!-- textareaを含む入力フォームコンポーネント -->
<InputFormTextArea />
</TextFieldHighlighter>
</template>
<script setup lang="ts">
import TestHighlighter from '~/components/TestHighlighter.vue'
import InputFormTextArea from '~/components/InputFormTextArea.vue'
</script>
実際の実装(処理)の流れ
ハイライトDOM
に適用するメソッドを用意する
1. テキストエリアの装飾をslot内に配置した入力フォームのtextareaの要素に適用されているstyleをハイライトDOM
用のstyleオブジェクトにコピーしてあげます。
こうすることで、実際に入力するテキストエリアと見た目のハイライトでズレることなく表示することができます。
※実装例内ではsynchronizeStyle
というメソッドとして置いているもので、$textField.value
(textareaの要素)からstyleを取得してbackdropStyle
(ハイライトDOM
用のstyleオブジェクト)にコピーしています。
2. ハイライトを適用したい文字を判定してハイライト処理するメソッドを用意する
以下の処理を行うメソッドを用意します。
- 入力された文字をそのまま扱うのはリスクがある為、エスケープしてあげる
-
&
,<
,>
,"
,'
の文字を文字コードに置き換える
-
- ハイライトしたい文字をmarkタグで囲い直してあげる
- 判定したい文字列や正規表現で該当箇所を置き換える
- 改行コードを<br>タグに置き換えてあげる
- textareaタグでは改行コードで改行されますが、今回見た目は
ハイライトDOM
でありdivタグとなる為<br>タグに置き換える
- textareaタグでは改行コードで改行されますが、今回見た目は
- 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を使用するなどパフォーマンスの改善を行いました。
パフォーマンス改善については、また別の機会にご紹介したいと思います。
ココナラではフロントエンド領域に限らず、各領域のエンジニアを募集しています。
よろしければぜひ以下のページもご覧ください。
フロントエンドの求人はこちらです。ご応募お待ちしております!
Discussion