添加了富文本编辑器雏形

This commit is contained in:
2025-10-10 22:28:27 +08:00
parent 5bb3839b1b
commit 61a058eab3
3 changed files with 772 additions and 479 deletions

View File

@@ -0,0 +1,563 @@
<template>
<div class="editor-container">
<!-- 工具栏 -->
<div class="toolbar">
<button v-for="tool in tools" :key="tool.name" :class="{ active: tool.active }" @click="tool.action" class="toolbar-btn">
<img :src="tool.icon" :alt="tool.name" class="toolbar-icon" />
</button>
</div>
<!-- 编辑区域 -->
<div ref="editorRef" contenteditable="true" class="editor-content" @input="handleInput" @keydown="handleKeydown" @click="updateToolbarState" @keyup="updateToolbarState"></div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:modelValue'])
const editorRef = ref(null)
const content = ref(props.modelValue || '')
// 工具配置
const tools = ref([
{
name: 'bold',
icon: '/assets/icons/drawable-xxhdpi/rtf_bold_normal.9.png',
action: () => formatText('bold'),
active: false,
},
{
name: 'center',
icon: '/assets/icons/drawable-xxhdpi/rtf_center_normal.9.png',
action: () => formatText('justifyCenter'),
active: false,
},
{
name: 'todo',
icon: '/assets/icons/drawable-xxhdpi/rtf_gtasks_normal.9.png',
action: () => insertTodoList(),
active: false,
},
{
name: 'list',
icon: '/assets/icons/drawable-xxhdpi/rtf_list_normal.9.png',
action: () => formatText('insertUnorderedList'),
active: false,
},
{
name: 'header',
icon: '/assets/icons/drawable-xxhdpi/rtf_header_normal.9.png',
action: () => formatText('formatBlock', 'h2'),
active: false,
},
{
name: 'quote',
icon: '/assets/icons/drawable-xxhdpi/rtf_quot_normal.9.png',
action: () => insertQuote(),
active: false,
},
])
// 格式化文本
const formatText = (command, value = null) => {
document.execCommand(command, false, value)
updateToolbarState()
handleInput()
}
// 插入水平线
const insertHorizontalRule = () => {
document.execCommand('insertHorizontalRule', false, null)
handleInput()
}
// 插入引用格式
const insertQuote = () => {
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
// 创建引用容器
const quoteContainer = document.createElement('div')
quoteContainer.className = 'quote-container'
// 创建图标元素
const icon = document.createElement('img')
icon.className = 'quote-icon'
icon.src = '/assets/icons/drawable-xxhdpi/rag_quote.png'
icon.alt = '引用'
// 创建内容容器
const contentSpan = document.createElement('blockquote')
contentSpan.className = 'quote-content'
contentSpan.textContent = '引用内容'
// 组装元素
quoteContainer.appendChild(icon)
quoteContainer.appendChild(contentSpan)
// 插入到当前光标位置
range.insertNode(quoteContainer)
// 添加换行
const br = document.createElement('br')
quoteContainer.parentNode.insertBefore(br, quoteContainer.nextSibling)
// 聚焦到内容区域
const newRange = document.createRange()
newRange.selectNodeContents(contentSpan)
newRange.collapse(false)
selection.removeAllRanges()
selection.addRange(newRange)
handleInput()
}
}
// 插入待办事项列表
const insertTodoList = () => {
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
// 创建待办事项容器
const todoContainer = document.createElement('div')
todoContainer.contentEditable = false
todoContainer.className = 'todo-container'
// 创建图标元素
const icon = document.createElement('img')
icon.className = 'todo-icon'
icon.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks.png'
icon.alt = '待办事项'
// 创建内容容器
const contentSpan = document.createElement('span')
contentSpan.contentEditable = true
contentSpan.className = 'todo-content'
contentSpan.textContent = '待办事项'
// 组装元素
todoContainer.appendChild(icon)
todoContainer.appendChild(contentSpan)
// 插入到当前光标位置
range.insertNode(todoContainer)
// 添加换行
const br = document.createElement('br')
todoContainer.parentNode.insertBefore(br, todoContainer.nextSibling)
// 聚焦到内容区域
const newRange = document.createRange()
newRange.selectNodeContents(contentSpan)
newRange.collapse(false)
selection.removeAllRanges()
selection.addRange(newRange)
// 添加事件监听器到图标
icon.addEventListener('click', function () {
// 根据当前状态切换图标
if (this.src.includes('rtf_icon_gtasks.png')) {
this.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks_light.png'
contentSpan.style.color = 'var(--text-tertiary)'
contentSpan.style.textDecoration = 'line-through'
} else {
this.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks.png'
contentSpan.style.color = 'var(--note-content)'
contentSpan.style.textDecoration = 'none'
}
handleInput()
})
// 添加事件监听器到内容区域,监听内容变化
const checkContent = () => {
// 延迟检查,确保内容已更新
setTimeout(() => {
if (contentSpan.textContent.trim() === '') {
// 如果内容为空,移除整个待办事项容器
todoContainer.remove()
handleInput()
}
}, 0)
}
contentSpan.addEventListener('input', checkContent)
contentSpan.addEventListener('blur', checkContent)
handleInput()
}
}
// 处理输入事件
const handleInput = () => {
if (editorRef.value) {
content.value = editorRef.value.innerHTML
emit('update:modelValue', content.value)
}
}
// 处理键盘事件
const handleKeydown = e => {
// 处理Shift+Enter键换行
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault()
document.execCommand('insertHTML', false, '<br><br>')
handleInput()
return
}
// 处理Tab键缩进
if (e.key === 'Tab') {
e.preventDefault()
document.execCommand('insertHTML', false, '    ')
handleInput()
return
}
// 处理回车键创建新列表项
if (e.key === 'Enter' && !e.shiftKey) {
// 检查是否在列表中
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const container = range.startContainer
// 检查是否在ul或ol内
let listElement = null
let current = container.nodeType === Node.TEXT_NODE ? container.parentElement : container
while (current && current !== editorRef.value) {
if (current.tagName === 'LI') {
listElement = current
break
}
current = current.parentElement
}
// 如果在列表项中且为空,退出列表
if (listElement) {
const liContent = listElement.textContent.trim()
if (liContent === '') {
e.preventDefault()
// 退出列表
document.execCommand('outdent')
handleInput()
}
}
}
}
}
// 更新工具栏状态
const updateToolbarState = () => {
nextTick(() => {
tools.value.forEach(tool => {
if (tool.name === 'bold') {
tool.active = document.queryCommandState('bold')
} else if (tool.name === 'center') {
tool.active = document.queryCommandState('justifyCenter')
} else if (tool.name === 'list') {
tool.active = document.queryCommandState('insertUnorderedList')
} else if (tool.name === 'quote') {
tool.active = document.queryCommandState('formatBlock')
} else if (tool.name === 'header') {
tool.active = document.queryCommandState('formatBlock')
}
})
})
}
// 初始化编辑器内容
onMounted(() => {
if (editorRef.value) {
editorRef.value.innerHTML = content.value
}
})
// 监听外部值变化
defineExpose({
getContent: () => content.value,
setContent: newContent => {
content.value = newContent
if (editorRef.value) {
editorRef.value.innerHTML = newContent
}
},
})
</script>
<style scoped>
.editor-container {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--background-card);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.toolbar {
display: flex;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background-color: var(--background-card);
flex-shrink: 0;
}
.toolbar-btn {
padding: 6px;
margin-right: 6px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.toolbar-btn:hover {
background-color: var(--background-secondary);
}
.toolbar-btn.active {
background-color: var(--primary-light);
border: 1px solid var(--border);
}
.toolbar-icon {
width: 20px;
height: 20px;
opacity: 0.8;
}
.toolbar-btn.active .toolbar-icon {
opacity: 1;
}
.editor-content {
flex: 1;
padding: 20px 16px;
outline: none;
overflow-y: auto;
font-size: 16px;
line-height: 1.6;
color: var(--note-content);
min-height: 200px;
background-color: var(--background-card);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.editor-content:focus,
.editor-content * {
outline: none;
}
/* 自定义内容样式 */
.editor-content h2 {
font-size: 20px;
font-weight: 600;
margin: 16px 0 8px 0;
color: var(--note-title);
line-height: 1.4;
letter-spacing: 0.5px;
padding-left: 24px;
position: relative;
}
.editor-content blockquote {
border-left: 3px solid var(--primary);
padding-left: 16px;
margin: 16px 0;
color: var(--text-secondary);
background-color: var(--background-secondary);
padding: 8px 16px 8px 16px;
border-radius: 0 4px 4px 0;
font-style: italic;
}
.quote-container {
position: relative;
margin: 16px 0;
}
.quote-icon {
position: absolute;
left: 0;
top: 10px;
width: 16px;
height: 16px;
}
.quote-content {
border-left: 3px solid var(--primary);
padding-left: 32px;
margin-left: 16px;
color: var(--text-secondary);
background-color: var(--background-secondary);
padding: 8px 16px 8px 16px;
border-radius: 0 4px 4px 0;
font-style: italic;
}
.editor-content ul,
.editor-content ol {
margin: 16px 0;
padding-left: 32px;
position: relative;
}
.editor-content li {
margin: 6px 0;
line-height: 1.5;
}
.editor-content p {
margin: 0 0 12px 0;
line-height: 1.6;
letter-spacing: 0.3px;
}
.editor-content strong {
color: var(--text-primary);
font-weight: 600;
}
.editor-content em {
color: var(--text-secondary);
font-style: italic;
}
.editor-content u {
text-decoration: none;
border-bottom: 1px solid var(--text-primary);
}
.editor-content hr {
border: none;
height: 1px;
background-color: var(--border);
margin: 20px 0;
}
.editor-content div[style*='text-align: center'] {
text-align: center;
}
/* 待办事项样式 */
:deep(.todo-container) {
display: flex;
align-items: flex-start;
margin: 0.5rem 0;
padding: 0.25rem 0;
position: relative;
padding-left: 1.5rem;
}
:deep(.todo-icon) {
position: absolute;
left: 0;
top: 0.2rem;
width: 2rem;
height: 2rem;
cursor: pointer;
}
:deep(.todo-content) {
flex: 1;
outline: none;
padding: 0.125rem 0.25rem;
min-height: 1.25rem;
border-radius: 0.125rem;
color: var(--note-content);
margin-left: 0.5rem;
}
/* 引用格式样式 */
:deep(.quote-container) {
position: relative;
margin: 1rem 0;
}
:deep(.quote-icon) {
position: absolute;
left: 0;
top: 0.625rem;
width: 1rem;
height: 1rem;
}
:deep(.quote-content) {
border-left: 0.1875rem solid var(--primary);
padding-left: 2rem;
margin-left: 1.5rem;
color: var(--text-secondary);
background-color: var(--background-secondary);
padding: 0.5rem 1rem;
border-radius: 0 0.25rem 0.25rem 0;
font-style: italic;
}
/* 格式标识图标 */
.editor-content h2::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
background-image: url('/assets/icons/drawable-xxhdpi/rtf_header_normal.9.png');
background-size: contain;
background-repeat: no-repeat;
position: absolute;
left: 0;
top: 2px;
}
.editor-content blockquote::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
background-image: url('/assets/icons/drawable-xxhdpi/rtf_quot_normal.9.png');
background-size: contain;
background-repeat: no-repeat;
position: absolute;
left: 8px;
top: 10px;
}
.editor-content ul::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
background-image: url('/assets/icons/drawable-xxhdpi/rtf_list_normal.9.png');
background-size: contain;
background-repeat: no-repeat;
position: absolute;
left: 0;
top: 2px;
}
.editor-content ol::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
background-image: url('/assets/icons/drawable-xxhdpi/rtf_list_normal.9.png');
background-size: contain;
background-repeat: no-repeat;
position: absolute;
left: 0;
top: 2px;
}
</style>