Files
SmartisanNote.Remake/src/components/RichTextEditor.vue
2025-10-10 22:54:14 +08:00

736 lines
21 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 isAlreadyInFormat = formatType => {
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const container = range.commonAncestorContainer
// 向上查找父元素,检查是否已经在指定格式中
let current = container.nodeType === Node.TEXT_NODE ? container.parentElement : container
while (current && current !== editorRef.value) {
if (formatType === 'bold' && current.tagName === 'B') return true
if (formatType === 'center' && current.style.textAlign === 'center') return true
if (formatType === 'header' && current.tagName === 'H2') return true
if (formatType === 'quote' && (current.tagName === 'BLOCKQUOTE' || current.classList.contains('quote-content'))) return true
if (formatType === 'list' && (current.tagName === 'UL' || current.tagName === 'OL' || current.tagName === 'LI')) return true
current = current.parentElement
}
}
return false
}
// 检查是否在列表、引用或待办事项中(用于嵌套限制)
const isInListOrQuote = () => {
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const container = range.commonAncestorContainer
// 向上查找父元素,检查是否在列表、引用或待办事项中
let current = container.nodeType === Node.TEXT_NODE ? container.parentElement : container
while (current && current !== editorRef.value) {
if (current.tagName === 'UL' || current.tagName === 'OL' || current.tagName === 'LI' || current.tagName === 'BLOCKQUOTE' || current.classList.contains('quote-content') || current.classList.contains('todo-container')) {
return true
}
current = current.parentElement
}
}
return false
}
// 格式化文本
const formatText = (command, value = null) => {
// 检查是否已经应用了相同的格式
if (command === 'bold' && isAlreadyInFormat('bold')) return
if (command === 'justifyCenter' && isAlreadyInFormat('center')) return
if (command === 'formatBlock' && value === 'h2' && isAlreadyInFormat('header')) return
if (command === 'formatBlock' && value === 'blockquote' && isAlreadyInFormat('quote')) return
if (command === 'insertUnorderedList' && isAlreadyInFormat('list')) return
// 检查嵌套限制
if ((command === 'insertUnorderedList' || (command === 'formatBlock' && value === 'blockquote')) && isInListOrQuote()) {
return
}
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 container = range.commonAncestorContainer
let current = container.nodeType === Node.TEXT_NODE ? container.parentElement : container
while (current && current !== editorRef.value) {
if (current.tagName === 'BLOCKQUOTE' || (current.classList && current.classList.contains('quote-content'))) return
current = current.parentElement
}
// 检查嵌套限制
if (isInListOrQuote()) return
// 创建引用容器
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)
// 添加事件监听器到内容区域,监听内容变化
const checkContent = () => {
// 延迟检查,确保内容已更新
setTimeout(() => {
if (contentSpan.textContent.trim() === '') {
// 如果内容为空,移除整个引用容器
quoteContainer.remove()
handleInput()
}
}, 0)
}
contentSpan.addEventListener('input', checkContent)
contentSpan.addEventListener('blur', checkContent)
handleInput()
}
}
// 插入待办事项列表
const insertTodoList = () => {
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
// 检查嵌套限制
if (isInListOrQuote()) return
// 创建待办事项容器
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)
// 监听回车键,创建同级待办事项
contentSpan.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault()
// 创建新的待办事项
const newTodoContainer = document.createElement('div')
newTodoContainer.contentEditable = false
newTodoContainer.className = 'todo-container'
// 创建图标元素
const newIcon = document.createElement('img')
newIcon.className = 'todo-icon'
newIcon.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks.png'
newIcon.alt = '待办事项'
// 创建内容容器
const newContentSpan = document.createElement('span')
newContentSpan.contentEditable = true
newContentSpan.className = 'todo-content'
newContentSpan.textContent = ''
// 组装元素
newTodoContainer.appendChild(newIcon)
newTodoContainer.appendChild(newContentSpan)
// 插入到当前待办事项后面
todoContainer.parentNode.insertBefore(newTodoContainer, todoContainer.nextSibling)
// 添加换行
const newBr = document.createElement('br')
newTodoContainer.parentNode.insertBefore(newBr, newTodoContainer.nextSibling)
// 聚焦到新内容区域
const newRange = document.createRange()
newRange.selectNodeContents(newContentSpan)
newRange.collapse(false)
selection.removeAllRanges()
selection.addRange(newRange)
// 添加事件监听器到新图标
newIcon.addEventListener('click', function () {
// 根据当前状态切换图标
if (this.src.includes('rtf_icon_gtasks.png')) {
this.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks_light.png'
newContentSpan.style.color = 'var(--text-tertiary)'
newContentSpan.style.textDecoration = 'line-through'
} else {
this.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks.png'
newContentSpan.style.color = 'var(--note-content)'
newContentSpan.style.textDecoration = 'none'
}
handleInput()
})
// 添加事件监听器到新内容区域
const newCheckContent = () => {
setTimeout(() => {
if (newContentSpan.textContent.trim() === '') {
newTodoContainer.remove()
handleInput()
}
}, 0)
}
newContentSpan.addEventListener('input', newCheckContent)
newContentSpan.addEventListener('blur', newCheckContent)
// 监听新内容区域的回车键
newContentSpan.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault()
// 创建新的待办事项
const nextTodoContainer = document.createElement('div')
nextTodoContainer.contentEditable = false
nextTodoContainer.className = 'todo-container'
// 创建图标元素
const nextIcon = document.createElement('img')
nextIcon.className = 'todo-icon'
nextIcon.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks.png'
nextIcon.alt = '待办事项'
// 创建内容容器
const nextContentSpan = document.createElement('span')
nextContentSpan.contentEditable = true
nextContentSpan.className = 'todo-content'
nextContentSpan.textContent = ''
// 组装元素
nextTodoContainer.appendChild(nextIcon)
nextTodoContainer.appendChild(nextContentSpan)
// 插入到当前待办事项后面
newTodoContainer.parentNode.insertBefore(nextTodoContainer, newTodoContainer.nextSibling)
// 添加换行
const nextBr = document.createElement('br')
nextTodoContainer.parentNode.insertBefore(nextBr, nextTodoContainer.nextSibling)
// 聚焦到新内容区域
const nextRange = document.createRange()
nextRange.selectNodeContents(nextContentSpan)
nextRange.collapse(false)
selection.removeAllRanges()
selection.addRange(nextRange)
// 添加事件监听器到新图标
nextIcon.addEventListener('click', function () {
// 根据当前状态切换图标
if (this.src.includes('rtf_icon_gtasks.png')) {
this.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks_light.png'
nextContentSpan.style.color = 'var(--text-tertiary)'
nextContentSpan.style.textDecoration = 'line-through'
} else {
this.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks.png'
nextContentSpan.style.color = 'var(--note-content)'
nextContentSpan.style.textDecoration = 'none'
}
handleInput()
})
// 添加事件监听器到新内容区域
const nextCheckContent = () => {
setTimeout(() => {
if (nextContentSpan.textContent.trim() === '') {
nextTodoContainer.remove()
handleInput()
}
}, 0)
}
nextContentSpan.addEventListener('input', nextCheckContent)
nextContentSpan.addEventListener('blur', nextCheckContent)
handleInput()
}
})
handleInput()
}
})
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;
}
/* 自定义内容样式 */
:deep(.editor-content h2) {
font-size: 20px;
font-weight: 600;
margin: 8px 0 8px 0;
color: var(--note-title);
line-height: 1.4;
letter-spacing: 0.5px;
text-align: center;
position: relative;
}
:deep(.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;
}
:deep(.quote-container) {
position: relative;
margin: 16px 0;
}
:deep(.quote-icon) {
position: absolute;
left: 0;
top: 10px;
width: 16px;
height: 16px;
}
:deep(.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;
}
:deep(.editor-content ul),
:deep(.editor-content ol) {
margin: 16px 0;
padding-left: 32px;
position: relative;
}
:deep(.editor-content li) {
margin: 6px 0;
line-height: 1.5;
}
:deep(.editor-content p) {
margin: 0 0 12px 0;
line-height: 1.6;
letter-spacing: 0.3px;
}
:deep(.editor-content strong) {
color: var(--text-primary);
font-weight: 600;
}
:deep(.editor-content em) {
color: var(--text-secondary);
font-style: italic;
}
:deep(.editor-content u) {
text-decoration: none;
border-bottom: 1px solid var(--text-primary);
}
:deep(.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.1rem 0;
padding: 0.02rem 0;
position: relative;
padding-left: 1.2rem;
}
:deep(.todo-icon) {
position: absolute;
left: 0;
top: 0;
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;
}
</style>