Vue 3 自定义多行文本输入组件

在Vue 3项目中,我们经常需要创建自定义的UI组件,以满足特定的业务需求。本篇博客将介绍一个基于Vue 3的自定义多行文本输入组件,该组件具有灵活的配置选项和一些特定的功能,使其适用于各种场景。

组件结构

首先,让我们来看一下这个自定义组件的基本结构:

<template>
  <div
    class="input-count-container"
    :class="`${disabled ? 'is-disabled' : ''}`"
    :style="`height: ${height}; width: ${width}`">
    <div
      ref="displayContentRef"
      class="content-wrapper"
      v-html="displayContent"
      :contenteditable="!disabled"
      @blur="onDisplayContentBlur"
      @input="onDisplayContentInput"></div>
  </div>
</template>

<script setup lang="ts">
// 省略了引入部分

const emits = defineEmits(['update:modelValue', 'addressInputs']);
const props = defineProps({
  modelValue: {
    default: ''
  },
  disabled: {
    type: Boolean,
    default: false
  },
  height: {
    type: String,
    default: '100px'
  },
  upperCase: {
    type: Boolean,
    default: false
  },
  width: {
    type: String,
    default: '15vw'
  }
});
const displayContentRef = ref();
const displayContent = ref('');

// 省略了其他变量和方法的声明
</script>

<style scoped lang="scss">
// 省略了样式部分
</style>

主要特性

  1. 支持自动高度调整,可以设置固定的高度或百分比高度。
  2. 可以禁用输入,添加 .is-disabled 类来显示禁用状态。
  3. 可以转换输入为大写字母形式。
  4. 支持设置组件的宽度。

组件功能

失焦事件处理

const onDisplayContentBlur = (event) => {
  const $displayContent = displayContentRef.value;

  let updateContent = $displayContent.innerText.replace(/\n\n/gi, '\n');

  if (props.upperCase) {
    updateContent = updateContent.toUpperCase();
  }

  emits('update:modelValue', updateContent);
};

在失焦事件处理中,我们对文本进行了一些处理,去除了多余的换行符,并且根据配置将文本转换为大写形式。

输入事件处理

const onDisplayContentInput = (ev) => {
  const $displayContent = displayContentRef.value;

  $displayContent.childNodes.forEach((node) => {
    node.setAttribute('data-len', node.innerText.replace(/\n/gi, '').length);
  });
};

输入事件处理函数用于实时统计每行字符数,通过 data-len 属性保存在对应的 div 元素上。

复制和粘贴事件处理

onMounted(() => {
  const $displayContent = displayContentRef.value as HTMLDivElement;

  $displayContent.oncopy = (e) => {
    e.preventDefault();
    const copyText = document.getSelection()?.toString() || '';

    if (e.clipboardData) {
      return e.clipboardData.setData('text/plain', copyText);
    }
  };

  $displayContent.onpaste = (e) => {
    e.preventDefault();
    const $displayContent = displayContentRef.value as HTMLDivElement;
    const selection = document.getSelection()!;
    const contents: Array<string> = [];
    let pasteContent = e.clipboardData?.getData('text') || '';
    pasteContent = pasteContent.replace(/\r\n/gi, '\n');

    // 处理粘贴内容,将其插入到光标位置
    // ...(省略了处理逻辑)
  };
});

通过在 onMounted 钩子中设置复制和粘贴事件的处理逻辑,实现了对粘贴内容的自定义处理,将其插入到光标位置。

样式设计

<style scoped lang="scss">
.input-count-container::-webkit-scrollbar { width: 0 !important }

.input-count-container {
  // ...(省略了其他样式)

  &.is-disabled {
    // ...(省略了禁用状态样式)
  }
}
</style>

完整代码

<template>
  <div
    class="input-count-container"
    :class="`${disabled ? 'is-disabled' : ''}`"
    :style="`height: ${height};width:${width}`">
    <div
      ref="displayContentRef"
      class="content-wrapper"
      v-html="displayContent"
      :contenteditable="!disabled"
      @blur="onDisplayContentBlur"
      @input="onDisplayContentInput"></div>
  </div>
</template>

<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from 'vue';

const emits = defineEmits(['update:modelValue','addressInputs']);
const props = defineProps({
  modelValue: {
    default: ''
  },
  disabled: {
    type: Boolean,
    default: false
  },
  height: {
    type: String,
    default: '100px'
  },
  upperCase: {
    type: Boolean,
    default: false
  },
  width:{
    type: String,
    default: '15vw'
  }
});
const displayContentRef = ref();
const displayContent = ref('');

const onDisplayContentBlur = (event) => {
  const $displayContent = displayContentRef.value;
  let updateContent = $displayContent.innerText.replace(/\n\n/gi, '\n');

  if (props.upperCase) {
    updateContent = updateContent.toUpperCase();
  }

  emits('update:modelValue', updateContent);
};

const onDisplayContentInput = (ev) => {
  const $displayContent = displayContentRef.value;

  $displayContent.childNodes.forEach((node) => {
    node.setAttribute('data-len', node.innerText.replace(/\n/gi, '').length);
  });
};


const setDisplayContent = () => {
  displayContent.value = (props.modelValue || '')
    .split('\n')
    .map((lineContent) => {
      return `<div class="line-content" data-len="${lineContent.length}">${lineContent}</div>`;
    })
    .join('');
};

onMounted(() => {
  const $displayContent = displayContentRef.value as HTMLDivElement;

  $displayContent.oncopy = (e) => {
    e.preventDefault();
    const copyText = document.getSelection()?.toString() || '';

    if (e.clipboardData) {
      return e.clipboardData.setData('text/plain', copyText);
    }
    
  };
  $displayContent.onpaste = (e) => {
    e.preventDefault();
    const $displayContent = displayContentRef.value as HTMLDivElement;
    const selection = document.getSelection()!;
    const contents: Array<string> = [];
    let pasteContent = e.clipboardData?.getData('text') || '';
    pasteContent = pasteContent.replace(/\r\n/gi, '\n');

    if (selection.isCollapsed) {
      $displayContent.childNodes.forEach((node: HTMLDivElement) => {
        if (node == selection.anchorNode || node == selection.anchorNode?.parentNode) {
          contents.push(
            node.innerText.slice(0, Math.min(selection.anchorOffset, selection.focusOffset)) +
              pasteContent +
              node.innerText.slice(Math.max(selection.anchorOffset, selection.focusOffset))
          );
        } else {
          contents.push(node.innerText);
        }
      });
    } else {
      let direction: boolean | undefined = undefined;
      let begin = false;
      let startNode: any = undefined;
      let endNode: any = undefined;
      $displayContent.childNodes.forEach((node: HTMLDivElement) => {
        if (direction == undefined) {
          if (node == (selection.anchorNode?.parentElement as HTMLDivElement)) {
            direction = true;
            begin = true;
            startNode = { node: selection.anchorNode, offset: selection.anchorOffset };
            endNode = { node: selection.focusNode, offset: selection.focusOffset };
          }
          if (node == (selection.focusNode?.parentElement as HTMLDivElement)) {
            direction = false;
            begin = true;
            startNode = { node: selection.focusNode, offset: selection.focusOffset };
            endNode = { node: selection.anchorNode, offset: selection.anchorOffset };
          }
          if (begin) {
            contents.push(node.innerText.slice(0, startNode.offset) + pasteContent);
          }
        }
        if (begin) {
          if (node == (endNode.node.parentElement as HTMLDivElement)) {
            begin = false;
            if (endNode.node.length != endNode.offset) {
              contents.push(node.innerText.slice(endNode.offset) + '\n');
            }
          }
        } else {
          contents.push(node.innerText + '\n');
        }
      });
    }

    emits('update:modelValue', contents.join(''));
    emits('addressInputs', contents.join(''));
  };
});

watch(
  [() => props.modelValue],
  () => {
    setDisplayContent();
  },
  { immediate: true }
);
</script>

<style scoped lang="scss">
 .input-count-container::-webkit-scrollbar { width: 0 !important }
.input-count-container {
  position: relative;
  width: 100%;
  height: 100px;
  overflow-y: auto;
  box-sizing: border-box;
  color: var(--el-input-text-color, var(--el-text-color-regular));
  background-color: var(--el-input-bg-color, var(--el-fill-color-blank));
  background-image: none;
  box-shadow: 0 0 0 1px var(--el-input-border-color, var(--el-border-color)) inset;
  border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
  transition: var(--el-transition-box-shadow);
  margin-bottom: 2px;

  :deep(.content-wrapper) {
    resize: vertical;
    padding: 5px 11px 0 11px;
    line-height: 1.5;
    width: 100%;
    min-height: 100%;
    .line-content {
      position: relative;
      min-height: 18px;
      margin: 0 -10px;
      padding: 0 18px 0 10px;

      &::after {
        content: attr(data-len);
        text-align: center;
        width: 18px;
        height: 100%;
        position: absolute;
        top: 0;
        right: 0;
        margin-top: -5px;
        padding-top: 5px;
        background-color: rgba($color: #000000, $alpha: 0.2);
        color: #333333;
      }

      &:nth-of-type(even) {
        background-color: #eaeaea;
      }
    }

    .line-content + .line-content {
      &::after {
        margin-top: 0;
        padding-top: 0;
      }
    }
  }

  &.is-disabled {
    background-color: var(--el-disabled-bg-color);
    border-color: var(--el-disabled-border-color);
    color: var(--el-disabled-text-color);
    cursor: not-allowed;
  }
}
</style>

使用组件

<InputCount
            :disabled="disabled"
            v-model="form.notifyLine"
            :upper-case="true"
            :width="width"></InputCount>

  • 5
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值