contenteditable实现简易文本编辑

227 阅读1分钟

2024-12-06 11.48.32.gif

利用contenteditable属性,实现一个简易的文本编辑功能

<template>
  <div class="text-editor-container">
    <div
      ref="editor"
      class="text-editor"
      contenteditable="true"
      :placeholder="placeholder"
      v-bind="$attrs"
      @input="updateContent"
      @blur="onBlur"
      @focus="onFocus"
      @contextmenu.prevent="showContextMenu"
      @keydown="handleKeydown"
      @mouseup="onMouseup"
      @paste="handlePaste"
    ></div>
    <div class="text-editor-toolbar">
      <span @click="toggleBold">B</span>
      <span @click="toggleItalic">I</span>
      <span @click="toggleUnderline">U</span>
    </div>
    <!-- 自定义右键菜单 -->
    <div v-if="contextMenuVisible" :style="menuStyle" ref="contextMenuRef" class="context-menu">
      <ul>
        <li @click="toggleBold">加粗 (Ctrl+B)</li>
        <li @click="toggleItalic">斜体 (Ctrl+I)</li>
        <li @click="toggleUnderline">下划线 (Ctrl+U)</li>
      </ul>
    </div>
  </div>
</template>

<script>
export default {
  name: 'TextEditor',
  props: {
    value: String,
    placeholder: {
      type: String,
      default: '请输入内容...'
    }
  },
  data() {
    return {
      currentSelectedRange: null, // 当前选择的文本范围对象
      selectedText: '', // 当前选择的文本内容
      contextMenuVisible: false, // 右键菜单是否可见
      contextMenuPosition: { x: 0, y: 0 }, // 右键菜单位置
      editorContent: this.value // 编辑器内容
    }
  },
  computed: {
    menuStyle() {
      return {
        top: `${this.contextMenuPosition.y}px`,
        left: `${this.contextMenuPosition.x}px`
      }
    }
  },
  watch: {
    value(newValue) {
      this.editorContent = newValue
    },
    editorContent(newContent) {
      this.$emit('input', newContent) // 触发父组件的 v-model
    }
  },
  methods: {
    handlePaste(event) {
      // 阻止默认的粘贴行为
      event.preventDefault();

      // 获取粘贴板数据作为纯文本
      const clipboardData = event.clipboardData || window.clipboardData;
      const text = clipboardData ? clipboardData.getData('text/plain') : '';

      // 获取编辑器div元素
      const editor = this.$refs.editor;

      try {
        document.execCommand('insertText', false, text);
      } catch (error) {
        
      }
    },
    onMouseup(evt) {
      evt.stopPropagation() // 阻止 事件冒泡,禁用第三方插件
      // 做个延时,防止误判
      setTimeout(() => {
        const selection = window.getSelection()
        const selectedText = selection.toString()
        // 如果没有选择文本,则不处理
        if (!selectedText || !selection.getRangeAt(0)) return
        // 保存选择的范围,用于后面恢复
        this.currentSelectedRange = selection.getRangeAt(0)
        this.selectedText = selectedText
      }, 100)
    },
    showContextMenu(event) {
      if (!this.currentSelectedRange) return
      this.contextMenuPosition = {
        x: event.layerX,
        y: event.layerY
      }
      this.contextMenuVisible = true
    },
    hideContextMenu() {
      this.contextMenuVisible = false
    },
    handleClickOutside(event) {
      if (this.$refs.contextMenuRef && !this.$refs.contextMenuRef.contains(event.target)) {
        this.hideContextMenu()
      }
    },
    changeBackgroundColor() {
      document.execCommand('backColor', false, 'yellow')
      this.hideContextMenu()
    },
    handleShortcuts(event) {
      // 如果选中的文本为空,则不执行任何操作
      if (!this.currentSelectedRange) return
      // 根据按下的键执行相应的操作
      if (event.ctrlKey) {
        if (event.key === 'b') {
          this.toggleBold()
        } else if (event.key === 'i') {
          this.toggleItalic()
        } else if (event.key === 'u') {
          this.toggleUnderline()
        }
      }
    },
    handleKeydown(event) {
      if (event.key === 'Enter') {
        event.preventDefault()
        document.execCommand('insertLineBreak') // 插入换行符
      }
    },
    updateContent(event) {
      const editor = event.target
      this.editorContent = editor.innerHTML // 使用 innerHTML 以支持 HTML 样式
    },
    updateSelection(selection) {
      if (!selection) return
      try {
        // 更新选中的文本和范围
        this.currentSelectedRange = selection?.getRangeAt(0)
        this.selectedText = selection?.toString()
      } catch (error) {
        
      }
    },
    // 加粗
    toggleBold() {
      try {
        if (!this.currentSelectedRange) return
        // 恢复之前的选择范围
        const selection = window.getSelection()
        selection.removeAllRanges()
        selection.addRange(this.currentSelectedRange)
        document.execCommand('bold') // 使用 execCommand 兼容旧浏览器

        // 更新选中的文本和范围
        this.updateSelection(selection)
      } catch (e) {
        this.toggleStyle('fontWeight', 'bold') // 现代浏览器直接通过样式操作
      }
      this.hideContextMenu()
    },
    toggleItalic() {
      try {
        if (!this.currentSelectedRange) return
        // 恢复之前的选择范围
        const selection = window.getSelection()
        selection.removeAllRanges()
        selection.addRange(this.currentSelectedRange)
        document.execCommand('italic')

        // 更新选中的文本和范围
        this.updateSelection(selection)
      } catch (e) {
        this.toggleStyle('fontStyle', 'italic')
      }
      this.hideContextMenu()
    },
    toggleUnderline() {
      try {
        if (!this.currentSelectedRange) return
        // 恢复之前的选择范围
        const selection = window.getSelection()
        selection.removeAllRanges()
        selection.addRange(this.currentSelectedRange)
        document.execCommand('underline')
        
        // 更新选中的文本和范围
        this.updateSelection(selection)
      } catch (e) {
        this.toggleStyle('textDecoration', 'underline')
      }
      this.hideContextMenu()
    },
    toggleStyle(styleName, value) {
      if (!this.currentSelectedRange || !value) return
      const selection = window.getSelection()
      const range = selection.getRangeAt(0)
      const span = document.createElement('span')
      span.style[styleName] = value
      range.surroundContents(span)
    },
    onFocus() {
      this.$refs.editor.classList.add('focused')
    },
    onBlur() {
      this.$refs.editor.classList.remove('focused')
    }
  },
  mounted() {
    document.addEventListener('keydown', this.handleShortcuts)
    document.addEventListener('click', this.handleClickOutside) // 监听全局点击事件
  },
  beforeDestroy() {
    document.removeEventListener('keydown', this.handleShortcuts)
    document.removeEventListener('click', this.handleClickOutside) // 移除事件监听
  }
}
</script>

<style lang="scss" scoped>
.text-editor-toolbar {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 10px;

  span {
    cursor: pointer;
    padding: 2px 5px;
  }
}

.text-editor-container {
  position: relative;
}

.context-menu {
  position: absolute;
  z-index: 10;
  background: white;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
  padding: 10px;
}

.context-menu ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.context-menu ul li {
  padding: 8px;
  cursor: pointer;
}

.context-menu ul li:hover {
  background-color: #f0f0f0;
}
  .text-editor {
    min-height: 100px;
  }
.text-editor:empty:before {
  content: attr(placeholder);
  color: #888;
}

.text-editor.focused {
  border: 1px solid #ccc !important;
}
</style>

当前document.execCommand是过时了的API (MDN) 可以考虑使用比较成熟的三方富文本库,或者自己通过window.getSelection() API实现。