利用contenteditable属性实现div可编辑,控制插入节点

316 阅读3分钟

富文本编辑器或者text是没有办法插入一个自定义的html节点,所以最后决定用div的contenteditable属性,使其变成可编辑的状态,控制光标的位置来插入。

<template>
  <div class="textarea-wrap">
    <div
      ref="textareaRef"
      class="textarea"
      :contenteditable="true"
      :placeholder="props.placeholder"
      style="height: 53px"
      @paste="optimizePasteEvent"
      @keypress="onKeypress"
      @compositionend="onCompositionend"
      @keydown="onKeydown"
      @input="onChange"
      id="editableDiv"
    />

    <a-popover
      v-model="visible"
      placement="bottom"
      :trigger="['click']"
      overlayClassName="selectVariable"
    >
      <a class="ant-dropdown-link" @click="e => e.preventDefault()"> 选择变量</a>
      <a-menu slot="content" class="selectVariableList">
        <a-menu-item v-for="item in props.list" :key="item.value" @click="selectVariableList(item)">
          {{ item.label }}
        </a-menu-item>
      </a-menu>
    </a-popover>
    <span class="showCount" v-if="showCount"> {{ textLength }} /{{ maxLength }}</span>
  </div>
</template>

<script setup>
import { nextTick } from 'vue';
// import { extractText } from '../utils/constants';
import { ref, onBeforeMount, onMounted, watch, watchEffect, defineProps } from 'vue';
// 定义 props
const props = defineProps({
  maxLength: {
    type: Number,
  },
  placeholder: {
    type: String,
    default: '请输入内容',
  },
  value: {
    type: String,
    default: '',
  },
  showCount: {
    type: Boolean,
    default: false,
  },
  list: {
    type: Array,
    default: () => {
      return [
        {
          value: '',
          label: '暂无无数据',
        },
      ];
    },
  },
  
});

const textareaRef = ref();
// 定义 emit
const emit = defineEmits(['update:innerHTML']);
// 定义 text 并初始化为 props.value
const text = ref(props.value);
const visible = ref(false);
const textLength = ref(0);
let hasBeenCalled = ref(false);
//输入非中文事件
const onKeypress = event => {
  const diff = event.target.innerText.length - props.maxLength;
  if (diff >= 0) {
    event.stopPropagation();
    event.preventDefault();
  }
  getTextLength();
};
const getTextLength = () => {
  nextTick(() => {
    textLength.value = textareaRef.value ? textareaRef.value.innerText.length : 0;
    console.log('[update:value]', textareaRef.value.innerHTML);
    emit('update:innerHTML', textareaRef.value.innerHTML);
  });
};
//插入变量
const selectVariableList = item => {
  const { value, label } = item;
  const cusTextareaRef = textareaRef.value;
  cusTextareaRef.focus();
  nextTick(() => {
    getTextLength();
    if (textLength.value >= props.maxLength) return; //超过最大值,不执行了
    // 创建新的 span 元素
    const strongElement = document.createElement('strong');
    let variable = `@{${label}}`;
    let len = variable.length - (textLength.value + variable.length - props.maxLength);
    if (len < variable.length) {
      //如果超出了最大长度,则截取超出的部分
      variable = variable.slice(0, len);
    }
    strongElement.textContent = variable;
    strongElement.classList.add('variable');
    strongElement.setAttribute('data-code', value);
    strongElement.setAttribute('contenteditable', false);

    if (cusTextareaRef) {
      // 将 span 元素插入到编辑区域的末尾
      cusTextareaRef.appendChild(strongElement);

      // 将光标移动到插入内容的后面
      const range = document.createRange();
      range.collapse(true);
      const selection = window.getSelection();
      selection.removeAllRanges();
      selection.addRange(range);
    } else {
      console.error('Editable div not found');
    }
    getTextLength(); //更新已输入字符的数量
    visible.value = false;
  });
};
const replaceAll = (str, search, replacement) => {
  return str.split(search).join(replacement);
};

//粘贴
const optimizePasteEvent = event => {
  // 监听粘贴内容到输入框事件,对内容进行处理 处理掉复制的样式标签,只拿取文本部分
  event && event.stopPropagation();
  event && event.preventDefault();
  let text = '';
  event = event.originalEvent || event;
  if (event.clipboardData && event.clipboardData.getData) {
    text = event.clipboardData.getData('text/plain');
  } else if (window.clipboardData && window.clipboardData.getData) {
    text = window.clipboardData.getData('text');
  }
  text = replaceAll(text, '\r\n', ' ');
  text = replaceAll(text, '\n', ' ');
  text = replaceAll(text, '\r', ' ');
  getTextLength(); //更新已输入字符的数量
  let len = text.length - (textLength.value + text.length - props.maxLength);
  if (len < text.length) {
    //如果超出了最大长度,则截取超出的部分
    text = text.slice(0, len);
  }

  window.document.execCommand('insertText', false, text);
  getTextLength(); //更新已输入字符的数量
};

const onChange = event => {
  const editableDiv = event.target;
  const innerHTML = editableDiv.innerHTML;

  // 检查是否只包含一个 <br> 标签
  if (innerHTML === '<br>' || innerHTML === '<br/>') {
    editableDiv.innerHTML = '';
  }

  getTextLength();
};
const onKeydown = event => {
  console.log('[键盘按下onKeydown]', event);
  if (event.keyCode === 13) {
    event.preventDefault(); // 阻止浏览器默认换行操作
  }
};
//输入中文时,触发的事件
const onCompositionend = event => {
  const diff = event.target.innerText.length - props.maxLength;
  getTextLength();
  console.log('[diff]', diff);
  if (diff > 0) {
    const range = document.createRange();
    const sel = window.getSelection();
    const offset = sel.anchorOffset;
    const node = sel.anchorNode;
    const text = node.textContent;
    range.selectNodeContents(node);
    sel.removeAllRanges();
    setTimeout(() => {
      sel.addRange(range);
      sel.extend(node, offset);
      document.execCommand('delete', false);
      document.execCommand('insertText', false, text.substring(0, offset - diff));
      getTextLength();
    }, 0);
  }
};
const getHtml = () => {
  return textareaRef.value.innerHTML;
};
const onEditorReady = newValue => {
  nextTick(() => {
    textareaRef.value.innerHTML = newValue;
    getTextLength(); //更新已输入字符的数量
  });
};
// 监听 props.value 的变化并更新 text
watch(
  () => props.value,
  newValue => {
    if (newValue && !hasBeenCalled.value) {
      hasBeenCalled.value = true;
      onEditorReady(newValue);
    }
  },
  { immediate: true }, // 立即执行一次
);

defineExpose({
  getHtml,
});
</script>
<style scoped lang="scss">
.textarea-wrap {
  width: 100%;
  position: relative;
  height: 80px;
  border-radius: 4px;
  border: 1px solid #d9d9d9;
  overflow-y: scroll;
  //   margin-bottom: 10px;
  .textarea {
    overflow: auto;
    padding-left: 12px;
    padding-top: 5px;
    width: 100%;
    height: 53px;
    border: none;
    border-radius: 2px;
    color: #323233;
    font-size: 14px;
    font-weight: 400;
    line-height: 18px;

    &:empty::before {
      font-size: 14px;
      font-family: PingFangSC, PingFangSC-Regular;
      font-weight: 400;
      color: #bfbfbf;
      content: attr(placeholder);
    }
    &:focus-visible {
      outline: none;
    }
  }
  ::v-deep .ant-input {
    resize: none;
  }
  .showCount {
    position: absolute;
    bottom: 0;
    right: 10px;
    display: inline-block;
    color: #999;
    height: 36px;
  }
  .ant-dropdown-link {
    cursor: pointer;
    position: absolute;
    left: 10px;
    bottom: 0;
    height: 36px;
    color: #3058ee;
  }
  ::v-deep .ql-container {
    // border-radius: 4px;
    // border: 1px solid #d9d9d9;
    border: none;
  }
  ::v-deep .ql-blank {
    &::before {
      font-style: normal;
      left: 11px;
      color: #bfbfbf;
      line-height: 20px;
      font-size: 14px;
    }
  }
  ::v-deep.ql-editor {
    padding: 11px;
    box-sizing: border-box;
  }
}
::v-deep .ql-container.ql-snow {
  p,
  span {
    font-size: 14px;
    font-weight: 400;
  }
}
::v-deep .variable {
  color: #3058ee;
  cursor: pointer;
  margin: 0 1px;
  font-weight: 400;
}
</style>
<style>
.selectVariable {
  .ant-popover-inner-content {
    padding: 0 !important;
  }
  .selectVariableList {
    width: 160px;
    max-height: 240px;
    background: #ffffff;
    box-shadow: 0 2px 12px 0 #3337461a;
    border-radius: 4px;
    box-sizing: border-box;
    overflow-y: scroll;
    color: #333746;
  }
  .ant-menu-item-active {
    background: #f7f8fa;
  }
  .ant-menu-item {
    width: 100%;
  }
  ::-webkit-scrollbar {
    /*隐藏滚轮*/
    display: none;
  }
}
</style>