利用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实现。