高亮显示文本内容的Textarea(用可编辑的div来实现textarea文本高亮的效果)

1,159 阅读3分钟

背景

最近在做一个项目,有一个功能是为了发送短信。要求是,用户可以在文本输入框内自定义短信模板,其中短信模板的 变量以【】包括起来。

产品:为了提升可读性,要求高亮展示【】变量的内容,以提示用户自定义了变量。 像这样: image.png

当时觉得没什么大不了,不就是高亮展示输入文本么,用正则表达式过一遍模板就可以。可实践起来发现textarea标签内的文字是不能自定义样式的。此外,一些富文本好像是不能自定义高亮的规则的,魔改起来还要花时间,于是开动脑筋。

可编辑的div

当时想到的一个例子就是飞书文档,或者说网页端markdown在线编辑工具,可以在输入的一行内容里高亮一部分的文字,比如: image.png 可以发现contenteditable属性可以让div内的内容变得可以编辑。

如果我对其中高亮的文字用行内块元素 包裹起来 并 设置样式,那就可以高亮出来了。

然而,实践起来发现存在问题:

  1. 可编辑的div内部文字改变,可能会影响内部的html元素组成形式。
  2. 需要考虑光标行为和元素展示方式(短信模板粘贴到文本框就能高亮?还是光标编辑到的那一刻高亮元素?)如果改变了内容,还得考虑光标放在哪(这可能要接触 Range 和 Selection 类了,开发可没有这么多时间)
  3. 换行了以后,第一行文字又是纯文本,而<div><br></div>表示换行的内容,还要合理考虑高亮文本的话,这个坑就特别大了。

于是放弃

透明的textarea和文本展示区div

这个想法是,外层用隐藏的textarea编辑文本,内层用div展示内容。
这让编辑和展示分离。不用考虑编辑区域所有光标选择的情况,并且能更好地排布展示区内容。

需要用到子绝父相,同时需要对其textarea和div。

但是又失败了:textarea和div就是不能对齐,哪怕调整letter-spacing还是不对。

于是放弃

透明且可编辑的div和文本展示区div

div和div总是可以对齐吧?确实可以
直接展示代码(Vue3):

<template>
  <div class="my-textarea" :class="{ editale: editable }" :style="{ fontSize, lineHeight }">
    <!-- 文本展示区 -->
    <div class="my-textarea-result">
      <p v-for="(pItem, pIndex) in templateTextList" :key="pIndex" :style="{minHeight: lineHeight}">
        <span
          style="display: inline-block;"
          v-for="(textItem, tIndex) in pItem"
          :key="tIndex"
          :class="{ highlight: textItem.highlight }"
        >
          {{ textItem.value }}
        </span>
      </p>
    </div>
    <!-- 文本编辑区 -->
    <div
      v-show="editable"
      class="my-textarea-input"
      contenteditable="true"
      @input="handleTextAreaInput"
    >
    </div>
  </div>
</template>

<script setup>
import { ref, defineProps, computed } from "vue";

const props = defineProps({
  editable: {
    type: Boolean,
    default: true,
  },
  fontSize: {
    type: Number,
    default: 16,
  },
  lineHeight: {
    type: Number,
    default: 1.5,
  }
});
const editable = computed(() => props.editable);
const fontSize = computed(() => props.fontSize);
const lineHeight = computed(() => `${props.lineHeight}em`);

// 文本列表
const templateTextList = ref([]);

// 划分高亮区域
function _highlightparagaph(p) {
  const regex = /【(.*?)】/g;
  let match;
  const extractedStrings = [];
  let currentIndex = 0;

  while ((match = regex.exec(p)) !== null) {
    const matchIndex = match.index;
    const matchedString = match[0];

    // 将【】之前的字符串加入数组
    if (matchIndex > currentIndex) {
      extractedStrings.push({value: p.substring(currentIndex, matchIndex), highlight: false});
    }

    // 将带有【】的字符串加入数组
    extractedStrings.push({ value: matchedString, highlight: true});

    currentIndex = matchIndex + matchedString.length;
  }

  // 加入最后一个【】之后的字符串
  if (currentIndex < p.length) {
    extractedStrings.push({value: p.substring(currentIndex), highlight: false});
  }

  return extractedStrings;
}

const handleTextAreaInput = (e) => {
  const innerText = e.target.innerText;
  const paragaphList = innerText.replaceAll("\n\n","\n").split("\n");
  console.log(paragaphList)
  templateTextList.value = paragaphList.map(_highlightparagaph);
};
</script>

<style scoped>
.my-textarea {
  width: 80%;
  height: 350px;
  position: relative;
  overflow: auto;
  border-radius: 4px;
  background-color: #fff;
}
.editale {
  transition: all 0.3s ease-in-out;
}
.editale:hover {
  outline: 2px solid #68c8ff;
}
.editale:focus {
  outline: 2px solid #2faff9;
}
.my-textarea > div {
  width: 100%;
  height: 100%;
  padding: 4px;
  box-sizing: border-box;
}
.my-textarea-result span {
  word-break: break-all;
  white-space: normal;
}
.my-textarea-result span.highlight {
  border-radius: 4px;
  background-color: #68c8ff;
}
.my-textarea-input {
  position: absolute;
  left: 0;
  top: 0;
  background-color: transparent;
  color: transparent;
  caret-color: #000;
  outline: none;
}
</style>

其中template标签内就处理显示样式,可编辑div处理input事件即可

(其实还是有点问题,变量前输入空格会造成内容不对齐,以后再看看)

父组件简单调用一下:

<template>
  <div class="wrapper">
    <div class="section">
      <h1 style="width: fit-content">高亮文本框测试</h1>
      <MyTextArea :editable="true"></MyTextArea>
    </div>
  </div>
</template>

<script setup>
import MyTextArea from "./components/MyTextArea.vue";
</script>

<style>
* {
  margin: 0;
  padding: 0;
}
html,
body,
#app,
.wrapper {
  width: 100%;
  height: 100%;
}
.wrapper {
  display: flex;
  flex-flow: row;
  justify-content: center;
  align-items: center;
}
.section {
  width: 600px;
  height: 400px;
  background-color: #999;
  display: flex;
  flex-flow: column nowrap;
  align-items: center;
}
</style>