textarea 实现部分文字更改颜色

379 阅读2分钟

如下图所示,需求是完成一个填写周报的功能,功能点有:

  1. @ 选人和 # 选择相关的项目,并且变色
  2. 换行点击回车的时候 自动生成 序号,同时输入框变大一行
  3. 删除的时候 遇到 @/# 整体删除
  4. 删除的时候遇到 换行符\n 输入框变小一行
  5. 检测到输入的最后一个值是@/# 弹框选择
  6. 等等其他功能点

image.png

采用的是 textarea 绝对定位方案,大概原理 参考自 juejin.cn/post/724105… 同时还多了很多其他的细节点

一、template

<div style="position: relative" :style="cssVar">
    <!--隐藏在下面,用于展示颜色-->
    <p class="copyp" ref="copytext" v-html="copyVal" />
    <!--绝对定位在上面-->
    <p style="" class="textarea-wrap">
      <textarea
        ref="textarea"
        class="textarea"
        v-model="inputText"
        :rows="rows"
        @keydown="handleTextarea"
        @input="handleInput"
        @change="handleInput"
        placeholder="请输入"
        @scroll="onScroll"
      ></textarea>
    </p>
</div>

二、js

 data() {
    return {
      rows: 2,
      copyVal: "",
      inputText: "",
      copypHeight: "100px",
    }
 },
 computed: {
    cssVar() {
      return { "--copyp-height": this.copypHeight };
    },
  },
  methods: {
      
  }

handleInput 检测当前输入的最后一个字符是否是关键字

    handleInput(event) {
      const textarea = event.target;
      const clcRows = ~~(textarea.scrollHeight / textarea.clientHeight);

      const rowArr = (textarea.value || "").match(/\n/g) || [];
      const rows = rowArr.length + 3;
      this.rows = clcRows > rows ? clcRows : rows;

      let val = textarea.value;
      let len = val.length;
      this.copypHeight = this.rows * 28 + "px";
      if (val.substring(len - 1) === "\n") {
        this.copypHeight = (this.rows - 1) * 28 + "px";
      }
      this.setCopyColor(val);

      const cursorPosition = textarea.selectionStart;
      const beforeCursor = val.substring(0, cursorPosition);
      const afterCursor = val.substring(cursorPosition);

      this.beforeCursor = beforeCursor;
      this.afterCursor = afterCursor;
      let lastWord = beforeCursor.substring(beforeCursor.length - 1);
      if (lastWord === "@") {
        this.selUserVisible = true;
      }

      if (lastWord === "#") {
        this.selProjectVisible = true;
      }
    },
    

setCopyColor 这边是输入内容后获取所有内容进行一个正则匹配,然后再把对应的关键字加上样式

    setCopyColor(val) {
      this.$nextTick(() => {
        this.copyVal = val;
        let arr = val.match(/((@|#)\s*[^\s]+)/g) || [];
        arr = arr.filter((v, i, arr) => arr.indexOf(v, 0) === i);
        arr = arr.filter((v) => v.indexOf("\n") == -1);
        arr = arr.sort((a, b) => b.length - a.length);
        for (let i = 0; i < arr.length; i++) {
          this.copyVal = this.copyVal.replaceAll(
            arr[i],
            `<span style='color:#409eff;position: relative;z-index: 9;font-size: 14px;'>${arr[i]}</span>`
          );
        }
        this.$nextTick(() => {
          this.$refs.copytext.scrollTop = this.$refs.textarea.scrollTop;
        });
      });
    },

handleTextarea 监听 Enter Backspace 行为进行换行自动添加序号和删除一整块关键字的功能

handleTextarea(event) {
      const textarea = event.target;
      const cursorPosition = textarea.selectionStart;
      const currentText = textarea.value;
      const beforeCursor = currentText.substring(0, cursorPosition);
      const afterCursor = currentText.substring(cursorPosition);

      // Check if the key pressed is 'Enter'
      if (event.key === "Enter") {
        // Prevent default 'Enter' behavior which is inserting a newline
        event.preventDefault();

        // Find the last number before the cursor
        const matches = beforeCursor.match(/(\d+)(\.|\n|$)/g);
        let lastNumber = 0;
        if (matches) {
          const lastMatch = matches[matches.length - 1];
          lastNumber = parseInt(lastMatch);
        }

        // Insert the next line number at the cursor position
        // if (lastNumber == 0) {
        //   textarea.value =
        //     "1." + beforeCursor + "\n" + (lastNumber + 2) + "." + afterCursor;
        // } else {

        let str = "";
        if (lastNumber) {
          str = beforeCursor + "\n" + (lastNumber + 1) + "." + afterCursor;
        } else {
          str = beforeCursor + "\n" + afterCursor;
        }
        this.setVal(str);
        // }

        // Move the cursor to the next line after the line number
        const nextCursorPosition =
          cursorPosition + (lastNumber + 1).toString().length + 4;
        textarea.setSelectionRange(nextCursorPosition, nextCursorPosition);
      } else if (
        event.key === "Backspace" &&
        beforeCursor.match(/(^|\n)\d+\.$/)
      ) {
        // Prevent default 'Backspace' behavior
        event.preventDefault();

        // Remove the current line number
        const newBeforeCursor = beforeCursor.replace(/(\n|^)\d+\.$/, "$1");
        let str = newBeforeCursor + afterCursor;
        this.setVal(str);

        if (this.rows > 2) this.rows--;
        // Move the cursor to the end of the previous line
        this.$nextTick(() => {
          textarea.setSelectionRange(
            newBeforeCursor.length,
            newBeforeCursor.length
          );
        });
      } else if (event.key === "Backspace") {
        let arr = beforeCursor.match(/((@|#)\s*[^\s]+)/g) || [];
        if (arr.length) {
          let str = arr[arr.length - 1];
          let start = beforeCursor.length - str.length;
          if (beforeCursor.substring(start) == str) {
            let val = beforeCursor.substring(0, start) + afterCursor;
            this.inputText = val;
            this.$nextTick(() => {
              event.target.setSelectionRange(start, start);
            });
          }
        }
      }

      // this.textVal = textarea.value;
    },

监听 onScroll 可视内容滚动的时候,隐藏的那部分内容也需要一起滚动

image.png

    // 解决滚动时 @/# 不随着滚动的问题
    onScroll(event) {
      const scrollTop = event.target.scrollTop;
      this.$refs.copytext.scrollTop = scrollTop;
    }, 

selUserConfirm 选完人员后的拼接

selUserConfirm(list) {
      let allList = [...this.selUserList, ...list];
      this.selUserList = JSON.parse(JSON.stringify(allList));
      this.selUserClose();
      this.beforeCursor = this.beforeCursor.substring(
        0,
        this.beforeCursor.length - 1
      );
      let str = "";
      list.forEach((e, i) => {
        str += ` @${e.name}`;
      });
      str += " ";
      let val = this.beforeCursor + str + this.afterCursor;
      this.setVal(val);
      this.$nextTick().then(() => {
        this.setCopyColor(val);
      });
    },
    setVal(val) {
      this.$nextTick(() => {
        // let text = val.replace(/<[^>]*>/g, '') // 去除样式
        let rowArr = (val || "").match(/\n/g) || [];
        this.rows = Math.max(rowArr.length + 3, 4);
        this.inputText = val;
        this.copypHeight = this.rows * 28 + "px";
        let len = val.length;
        // 这里是因为 换行的时候textarea 的rows增加1,但是后面隐藏的那部分没有增加一行空行,导致可能带颜色的文字会错乱
        if (val.substring(len - 1) === "\n") {
          this.copypHeight = (this.rows - 1) * 28 + "px";
        }
        this.setCopyColor(val);
      });
    },

三、css

<style lang="scss" scoped>
.textarea-wrap {
  position: absolute;
  z-index: 1;
  top: 0;
  left: 0;
  width: 100%;

  .textarea {
    color: #333;
    caret-color: black;
    background: transparent;
    font-size: 14px;
    width: 100%;
    padding: 0;
    border: none;
    line-height: 28px;
    resize: none;
  }
}

.copyp {
  white-space: pre-wrap;
  line-height: 28px;
  font-size: 14px;
  height: var(--copyp-height);
  color: #fff;
  overflow: auto;
}

.textarea {
  // width: 100%;
  // padding: 0;
  // border: none;
  // line-height: 28px;
  // resize: none;
}

/*修改textarea中placeholder颜色*/
textarea::-webkit-input-placeholder {
  /* WebKit browsers */
  color: #bbb;
}

textarea:-moz-placeholder {
  /* Mozilla Firefox 4 to 18 */
  color: #bbb;
}

textarea::-moz-placeholder {
  /* Mozilla Firefox 19+ */
  color: #bbb;
}

textarea:-ms-input-placeholder {
  /* Internet Explorer 10+ */
  color: #bbb;
}
</style>