Files
SmartisanNote.Remake/src/components/RichTextEditor.vue

1849 lines
61 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.

This file contains Unicode characters that might be confused with other characters. 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" :class="{ visible: isToolbarVisible, 'keyboard-visible': isKeyboardVisible, 'dynamic-position': isKeyboardVisible }" @mousedown.prevent @focusin="keepToolbarVisible" @focusout="handleToolbarFocusOut">
<button v-for="tool in tools" :key="tool.name" :class="{ active: tool.active }" @click.stop="handleToolClick(tool.action, $event)" @mousedown.prevent @focusout.prevent 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" @focus="showToolbar" @blur="handleBlur"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:modelValue'])
const editorRef = ref(null)
const content = ref(props.modelValue || '')
const isToolbarVisible = ref(false)
const isKeyboardVisible = ref(false)
const initialViewportHeight = ref(0)
// 初始化编辑器内容
onMounted(() => {
console.log('Editor mounted')
if (editorRef.value) {
console.log('Editor ref available')
if (props.modelValue) {
console.log('Setting initial content')
try {
editorRef.value.innerHTML = props.modelValue
content.value = props.modelValue
console.log('Initial content set successfully')
// 调整已有图片的高度
adjustExistingImages()
} catch (error) {
console.error('Failed to set initial content:', error)
}
} else {
// 即使没有初始内容,也要确保编辑器是可编辑的
editorRef.value.contentEditable = true
console.log('Editor initialized without initial content')
}
}
// 记录初始视口高度
initialViewportHeight.value = window.visualViewport?.height || window.innerHeight
console.log('Initial viewport height:', initialViewportHeight.value)
// 初始化CSS变量
document.documentElement.style.setProperty('--viewport-height', `${initialViewportHeight.value}px`)
console.log('Set viewport height CSS variable')
// 添加虚拟键盘检测事件监听器
if (window.visualViewport) {
console.log('Adding viewport resize listener')
window.visualViewport.addEventListener('resize', handleViewportResize)
} else {
console.log('Adding window resize listener')
window.addEventListener('resize', handleWindowResize)
}
// 为已有图片添加拖拽事件监听器
setTimeout(() => {
console.log('Adding drag event listeners to existing images')
const imageElements = editorRef.value.querySelectorAll('img.editor-image')
console.log('Found existing images:', imageElements.length)
imageElements.forEach(img => {
console.log('Adding drag listeners to image:', img)
img.addEventListener('dragstart', handleImageDragStart)
img.addEventListener('dragover', handleImageDragOver)
img.addEventListener('drop', handleImageDrop)
img.addEventListener('dragend', handleImageDragEnd)
})
}, 0)
})
// 组件卸载时移除事件监听器
onUnmounted(() => {
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', handleViewportResize)
} else {
window.removeEventListener('resize', handleWindowResize)
}
})
// 工具栏配置
// 定义富文本编辑器的所有工具按钮及其功能
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: () => formatText('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 handleInput = () => {
if (editorRef.value) {
// 获取编辑器内容
let innerHTML = editorRef.value.innerHTML
// 处理换行符,确保在段落之间有明确的分隔
innerHTML = innerHTML.replace(/<\/p><p>/g, '</p>\n<p>')
// 处理div标签换行
innerHTML = innerHTML.replace(/<\/div><div>/g, '</div>\n<div>')
// 处理br标签换行
innerHTML = innerHTML.replace(/<br>/g, '\n')
innerHTML = innerHTML.replace(/<br\/>/g, '\n')
// 处理div标签内的换行
innerHTML = innerHTML.replace(/<div>/g, '\n<div>')
content.value = innerHTML
emit('update:modelValue', content.value)
}
}
// 检查当前选区是否已经在某种格式中
// 用于防止重复应用相同的格式,例如重复加粗
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')) {
document.execCommand('bold', false, null)
updateToolbarState()
handleInput()
return
}
// 处理居中对齐切换:如果已居中则取消居中
if (command === 'justifyCenter' && isAlreadyInFormat('center')) {
document.execCommand('justifyLeft', false, null)
updateToolbarState()
handleInput()
return
}
// 处理标题格式切换:如果已是标题则转为普通段落
if (command === 'formatBlock' && value === 'h2' && isAlreadyInFormat('header')) {
document.execCommand('formatBlock', false, '<p>')
updateToolbarState()
handleInput()
return
}
// 处理列表格式切换:如果已是列表则取消列表
if (command === 'insertUnorderedList' && isAlreadyInFormat('list')) {
document.execCommand('insertUnorderedList', false, null)
updateToolbarState()
handleInput()
return
}
// 处理自定义待办事项
if (command === 'insertTodoList') {
insertTodoList()
updateToolbarState()
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('div')
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)
// 聚焦到内容区域延迟执行确保在handleToolClick处理完后再聚焦
setTimeout(() => {
if (editorRef.value) {
const newRange = document.createRange()
newRange.selectNodeContents(contentSpan)
newRange.collapse(false)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(newRange)
// 确保编辑器保持焦点状态
editorRef.value.focus()
}
}, 0)
// 添加事件监听器到内容区域,监听内容变化
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('div')
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)
// 聚焦到内容区域延迟执行确保在handleToolClick处理完后再聚焦
setTimeout(() => {
if (editorRef.value) {
const newRange = document.createRange()
newRange.selectNodeContents(contentSpan)
newRange.collapse(false)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(newRange)
// 重要:不要重新聚焦到编辑器,因为待办事项有自己的可编辑区域
// 但我们仍需要确保当前可编辑区域有焦点
contentSpan.focus()
// 确保工具栏保持可见
isToolbarVisible.value = true
}
}, 0)
// 添加事件监听器到图标,实现待办事项完成状态切换
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('focus', () => {
isToolbarVisible.value = true
})
// 监听回车键,创建同级待办事项
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('div')
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('focus', () => {
isToolbarVisible.value = true
})
// 监听新内容区域的回车键
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('div')
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)
// 添加焦点事件监听器,确保工具栏在待办事项获得焦点时保持可见
nextContentSpan.addEventListener('focus', () => {
isToolbarVisible.value = true
})
handleInput()
}
})
handleInput()
}
})
handleInput()
}
}
// 图片拖拽相关状态
const dragState = ref({
isDragging: false,
draggedImage: null,
startX: 0,
startY: 0,
currentY: 0,
longPressTimer: null,
isLongPress: false,
indicator: null,
lastCheckTime: 0,
lastMoveTime: 0
})
// 重置拖拽状态
const resetDragState = () => {
// 清除长按定时器
if (dragState.value.longPressTimer) {
clearTimeout(dragState.value.longPressTimer)
dragState.value.longPressTimer = null
}
// 重置所有拖拽状态
dragState.value.isLongPress = false
dragState.value.draggedImage = null
dragState.value.startX = 0
dragState.value.startY = 0
dragState.value.currentY = 0
dragState.value.lastMoveTime = 0
// 移除拖拽指示器
if (dragState.value.indicator) {
const indicator = dragState.value.indicator
indicator.style.opacity = '0'
setTimeout(() => {
if (indicator.parentNode) {
indicator.parentNode.removeChild(indicator)
}
}, 150)
dragState.value.indicator = null
}
// 重置所有图片的拖拽样式
if (editorRef.value) {
const draggedImages = editorRef.value.querySelectorAll('.editor-image.dragging')
draggedImages.forEach(img => {
img.classList.remove('dragging')
img.style.zIndex = ''
img.style.transition = ''
img.style.transform = ''
img.style.opacity = ''
})
}
}
// 插入图片
const insertImage = () => {
console.log('Inserting image')
// 创建文件输入元素
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'image/*'
fileInput.style.display = 'none'
// 添加到文档中
document.body.appendChild(fileInput)
// 监听文件选择事件
fileInput.addEventListener('change', function (event) {
console.log('File selected:', event.target.files)
const file = event.target.files[0]
if (file && file.type.startsWith('image/')) {
console.log('Image file selected')
// 创建FileReader读取文件
const reader = new FileReader()
reader.onload = function (e) {
console.log('File read successfully')
// 获取图片数据URL
const imageDataUrl = e.target.result
console.log('Image data URL:', imageDataUrl)
// 获取当前选区
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
console.log('Current range:', range)
// 创建图片容器
const imgContainer = document.createElement('div')
imgContainer.className = 'image-container'
imgContainer.style.position = 'relative'
imgContainer.style.display = 'inline-block'
// 创建图片元素
const img = document.createElement('img')
img.src = imageDataUrl
img.className = 'editor-image'
img.setAttribute('data-draggable', 'true')
img.style.maxWidth = '100%'
img.style.height = 'auto'
img.style.display = 'block'
img.style.objectFit = 'cover'
img.style.boxSizing = 'border-box'
img.style.border = '0.625rem solid white'
img.style.borderRadius = '0.2rem'
img.style.boxShadow = '0 1px 5px rgba(0, 0, 0, 0.18)'
img.style.background = 'var(--background-secondary)'
img.style.position = 'relative'
img.style.outline = 'none' // 移除默认焦点轮廓
img.draggable = true
// 创建删除按钮
const deleteBtn = document.createElement('img')
deleteBtn.src = '/assets/icons/drawable-xxhdpi/item_image_btn_unbrella_delete.png'
deleteBtn.className = 'image-delete-btn'
deleteBtn.style.position = 'absolute'
deleteBtn.style.top = '8px'
deleteBtn.style.right = '8px'
deleteBtn.style.width = '24px'
deleteBtn.style.height = '24px'
deleteBtn.style.cursor = 'pointer'
deleteBtn.style.zIndex = '10'
deleteBtn.style.display = 'none' // 默认隐藏
deleteBtn.style.transition = 'opacity 0.2s ease'
// 将图片和删除按钮添加到容器中
imgContainer.appendChild(img)
imgContainer.appendChild(deleteBtn)
console.log('Created image element:', img)
// 创建一个临时图片来获取原始尺寸
const tempImg = new Image()
tempImg.onload = function () {
console.log('Temp image loaded')
// 获取CSS变量
const editorFontSize = getComputedStyle(document.documentElement).getPropertyValue('--editor-font-size').trim() || '16px'
const editorLineHeight = getComputedStyle(document.documentElement).getPropertyValue('--editor-line-height').trim() || '1.6'
const fontSize = parseInt(editorFontSize)
const lineHeight = parseFloat(editorLineHeight)
// 计算行高
const computedLineHeight = fontSize * lineHeight
// 获取编辑器的宽度(减去一些内边距)
const editorWidth = editorRef.value.offsetWidth - 20 // 20px为左右内边距
// 按宽度撑满计算调整后的尺寸
const originalHeight = tempImg.height
const originalWidth = tempImg.width
const scaleRatio = editorWidth / originalWidth
const scaledHeight = originalHeight * scaleRatio
// 计算调整后的高度,使其为行高的整数倍
const scaleFactor = Math.max(1, Math.round(scaledHeight / computedLineHeight))
const adjustedHeight = scaleFactor * computedLineHeight
// 按比例调整宽度
const adjustedWidth = (originalWidth * adjustedHeight) / originalHeight
img.style.height = `${adjustedHeight}px`
img.style.width = `${adjustedWidth}px`
// 确保图片与基准线对齐
img.style.verticalAlign = 'top'
}
tempImg.src = imageDataUrl
// 添加触摸事件监听器实现拖拽功能
img.addEventListener('touchstart', handleTouchStart)
img.addEventListener('touchmove', handleTouchMove)
img.addEventListener('touchend', handleTouchEnd)
img.addEventListener('touchcancel', handleTouchCancel)
// 为图片容器添加事件监听器
imgContainer.addEventListener('touchstart', handleTouchStart)
imgContainer.addEventListener('touchmove', handleTouchMove)
imgContainer.addEventListener('touchend', handleTouchEnd)
imgContainer.addEventListener('touchcancel', handleTouchCancel)
// 为删除按钮添加点击事件
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
imgContainer.remove();
handleInput();
});
// 为图片容器添加鼠标悬停事件以显示/隐藏删除按钮
imgContainer.addEventListener('mouseenter', function() {
deleteBtn.style.display = 'block';
});
imgContainer.addEventListener('mouseleave', function() {
deleteBtn.style.display = 'none';
});
console.log('Added touch event listeners')
// 插入图片容器到当前光标位置
range.insertNode(imgContainer)
console.log('Inserted image container into editor')
// 调试信息
console.log('Image container inserted:', imgContainer)
console.log('Next sibling (should be drag handle):', imgContainer.nextSibling)
// 添加换行
const br = document.createElement('br')
imgContainer.parentNode.insertBefore(br, imgContainer.nextSibling)
console.log('Added line break after image container')
// 触发输入事件更新内容
handleInput()
console.log('Handled input event')
// 重新聚焦到编辑器
if (editorRef.value) {
editorRef.value.focus()
console.log('Focused editor')
}
}
}
reader.readAsDataURL(file)
}
// 清理文件输入元素
document.body.removeChild(fileInput)
console.log('Removed file input')
})
// 触发文件选择对话框
fileInput.click()
console.log('Clicked file input')
}
// 处理键盘事件
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 handleTouchStart = (e) => {
const img = e.target
if (!img.classList.contains('editor-image')) return
// 防止图片被选中
img.style.userSelect = 'none'
img.style.webkitUserSelect = 'none'
img.style.mozUserSelect = 'none'
img.style.msUserSelect = 'none'
// 清除之前的定时器
if (dragState.value.longPressTimer) {
clearTimeout(dragState.value.longPressTimer)
}
// 记录触摸开始位置
dragState.value.startX = e.touches[0].clientX
dragState.value.startY = e.touches[0].clientY
// 设置长按检测定时器300毫秒
dragState.value.longPressTimer = setTimeout(() => {
dragState.value.isLongPress = true
dragState.value.draggedImage = img
dragState.value.startY = e.touches[0].clientY
dragState.value.currentY = e.touches[0].clientY
// 添加拖拽样式
img.classList.add('dragging')
img.style.opacity = '0.9'
img.style.transform = 'scale(0.98)'
img.style.zIndex = '999'
img.style.transition = 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
// 添加拖拽指示器
const indicator = document.createElement('div')
indicator.className = 'drag-indicator'
indicator.style.position = 'fixed'
indicator.style.top = '50%'
indicator.style.left = '50%'
indicator.style.transform = 'translate(-50%, -50%)'
indicator.style.padding = '8px 16px'
indicator.style.background = 'rgba(0, 0, 0, 0.8)'
indicator.style.color = 'white'
indicator.style.borderRadius = '16px'
indicator.style.fontSize = '14px'
indicator.style.fontWeight = '500'
indicator.style.zIndex = '1000'
indicator.style.opacity = '0'
indicator.style.transition = 'opacity 0.15s ease-out'
indicator.textContent = '拖拽排序'
document.body.appendChild(indicator)
// 渐显指示器
setTimeout(() => {
indicator.style.opacity = '1'
}, 5)
// 保存指示器引用以便后续移除
dragState.value.indicator = indicator
// 添加震动反馈(如果设备支持)
if (navigator.vibrate) {
navigator.vibrate(10)
}
// 阻止页面滚动
e.preventDefault()
}, 300) // 300毫秒长按触发拖拽
}
// 处理触摸移动事件
const handleTouchMove = (e) => {
if (!dragState.value.longPressTimer && !dragState.value.isLongPress) return
const img = dragState.value.draggedImage
const currentX = e.touches[0].clientX
const currentY = e.touches[0].clientY
// 防止图片被选中
e.preventDefault()
// 如果还没有触发长按检查是否移动过多超过8px则取消长按
if (!dragState.value.isLongPress) {
const deltaX = Math.abs(currentX - dragState.value.startX)
const deltaY = Math.abs(currentY - dragState.value.startY)
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (distance > 8) {
// 移动过多,取消长按
if (dragState.value.longPressTimer) {
clearTimeout(dragState.value.longPressTimer)
dragState.value.longPressTimer = null
}
return
}
}
if (!dragState.value.isLongPress || !img) return
e.preventDefault() // 阻止页面滚动
dragState.value.currentY = currentY
// 计算位移
const deltaY = dragState.value.currentY - dragState.value.startY
// 更新图片位置,添加缓动效果
const easeFactor = 0.9 // 调整缓动因子使拖拽更跟手
img.style.transform = `translateY(${deltaY * easeFactor}px) scale(0.98)`
// 使用节流优化,避免过于频繁的检查
if (!dragState.value.lastMoveTime) {
dragState.value.lastMoveTime = 0
}
const now = Date.now()
// 限制检查频率为每25ms一次提高响应速度
if (now - dragState.value.lastMoveTime >= 25) {
dragState.value.lastMoveTime = now
checkAndSwapImages(img, deltaY)
}
}
// 处理触摸结束事件
const handleTouchEnd = (e) => {
// 清除长按定时器
if (dragState.value.longPressTimer) {
clearTimeout(dragState.value.longPressTimer)
dragState.value.longPressTimer = null
}
if (!dragState.value.isLongPress || !dragState.value.draggedImage) {
dragState.value.isLongPress = false
return
}
// 重置拖拽状态
const img = dragState.value.draggedImage
// 添加释放动画
img.style.transition = 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
img.style.transform = 'translateY(0) scale(1)'
img.style.opacity = '1'
// 移除拖拽指示器
if (dragState.value.indicator) {
const indicator = dragState.value.indicator
indicator.style.transition = 'opacity 0.15s ease-out'
indicator.style.opacity = '0'
setTimeout(() => {
if (indicator.parentNode) {
indicator.parentNode.removeChild(indicator)
}
}, 150)
}
// 添加震动反馈(如果设备支持)
if (navigator.vibrate) {
navigator.vibrate(5)
}
// 延迟重置样式以显示动画
setTimeout(() => {
if (img) {
img.classList.remove('dragging')
img.style.zIndex = ''
img.style.transition = ''
}
}, 200)
// 重置状态
dragState.value.isLongPress = false
dragState.value.draggedImage = null
dragState.value.startY = 0
dragState.value.currentY = 0
dragState.value.indicator = null
// 触发内容更新
handleInput()
}
// 处理触摸取消事件
const handleTouchCancel = (e) => {
// 清除长按定时器
if (dragState.value.longPressTimer) {
clearTimeout(dragState.value.longPressTimer)
dragState.value.longPressTimer = null
}
if (!dragState.value.isLongPress || !dragState.value.draggedImage) {
dragState.value.isLongPress = false
return
}
// 重置拖拽状态
const img = dragState.value.draggedImage
// 添加取消动画
img.style.transition = 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
img.style.transform = 'translateY(0) scale(1)'
img.style.opacity = '1'
// 移除拖拽指示器
if (dragState.value.indicator) {
const indicator = dragState.value.indicator
indicator.style.transition = 'opacity 0.15s ease-out'
indicator.style.opacity = '0'
setTimeout(() => {
if (indicator.parentNode) {
indicator.parentNode.removeChild(indicator)
}
}, 150)
}
// 延迟重置样式以显示动画
setTimeout(() => {
if (img) {
img.classList.remove('dragging')
img.style.zIndex = ''
img.style.transition = ''
}
}, 200)
// 重置状态
dragState.value.isLongPress = false
dragState.value.draggedImage = null
dragState.value.startY = 0
dragState.value.currentY = 0
dragState.value.indicator = null
}
// 检查并交换图片位置
const checkAndSwapImages = (draggedImg, deltaY) => {
const allImages = Array.from(editorRef.value.querySelectorAll('.editor-image'))
const draggedIndex = allImages.indexOf(draggedImg)
if (draggedIndex === -1) return
// 计算拖拽图片的中心位置
const draggedRect = draggedImg.getBoundingClientRect()
const draggedCenterY = draggedRect.top + draggedRect.height / 2 + deltaY * 0.9 // 调整缓动因子以匹配触摸移动
// 查找最近的图片进行交换
for (let i = 0; i < allImages.length; i++) {
if (i === draggedIndex) continue
const targetImg = allImages[i]
const targetRect = targetImg.getBoundingClientRect()
const targetCenterY = targetRect.top + targetRect.height / 2
// 检查是否与目标图片重叠,使用更精确的碰撞检测
// 当拖拽图片覆盖目标图片高度的三分之二时触发排序
const overlapThreshold = targetRect.height * 0.27
const distance = Math.abs(draggedCenterY - targetCenterY)
if (distance < overlapThreshold) {
// 直接交换位置,移除视觉反馈以避免闪烁
swapImages(draggedImg, targetImg)
break
}
}
}
// 交换两张图片的位置
const swapImages = (img1, img2) => {
const parent1 = img1.parentNode
const parent2 = img2.parentNode
// 如果两张图片在同一父元素中
if (parent1 === parent2) {
// 直接交换DOM位置避免复杂的动画导致的闪烁
const tempMarker = document.createElement('div')
parent1.insertBefore(tempMarker, img1)
parent1.insertBefore(img1, img2)
parent1.insertBefore(img2, tempMarker)
tempMarker.remove()
} else {
// 不同父元素的情况(更复杂,需要特殊处理)
// 这里简化处理,实际项目中可能需要更复杂的逻辑
const temp = document.createElement('div')
parent1.insertBefore(temp, img1)
parent2.insertBefore(img1, img2)
parent1.insertBefore(img2, temp)
temp.remove()
}
// 触发内容更新
handleInput()
// 自动退出排序模式
resetDragState()
}
// 更新工具栏状态
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')
}
})
})
}
// 处理视口大小变化(用于检测虚拟键盘)
const handleViewportResize = () => {
if (!window.visualViewport) return
const currentHeight = window.visualViewport.height
const heightDifference = initialViewportHeight.value - currentHeight
// 如果高度差超过150px认为虚拟键盘已弹出
isKeyboardVisible.value = heightDifference > 150
// 动态设置CSS变量以调整工具栏位置
document.documentElement.style.setProperty('--viewport-height', `${currentHeight}px`)
// 根据虚拟键盘状态更新工具栏可见性
updateToolbarVisibility()
}
// 处理窗口大小变化(备用方案)
const handleWindowResize = () => {
const currentHeight = window.innerHeight
const heightDifference = initialViewportHeight.value - currentHeight
// 如果高度差超过150px认为虚拟键盘已弹出
isKeyboardVisible.value = heightDifference > 150
// 根据虚拟键盘状态更新工具栏可见性
updateToolbarVisibility()
}
// 更新工具栏可见性
const updateToolbarVisibility = () => {
// 只有当编辑器有焦点且虚拟键盘可见时才显示工具栏
if (isKeyboardVisible.value && (document.activeElement === editorRef.value || isTodoContentActive())) {
isToolbarVisible.value = true
} else {
// 延迟隐藏工具栏,确保用户有时间操作
setTimeout(() => {
// 只有在没有待办事项获得焦点且虚拟键盘不可见时才隐藏工具栏
if (!isTodoContentActive() && !isKeyboardVisible.value) {
isToolbarVisible.value = false
}
}, 200)
}
}
// 检查是否有待办事项内容区域获得焦点
const isTodoContentActive = () => {
const todoContentElements = document.querySelectorAll('.todo-content')
for (let i = 0; i < todoContentElements.length; i++) {
if (todoContentElements[i] === document.activeElement || todoContentElements[i].contains(document.activeElement)) {
return true
}
}
return false
}
// 监听虚拟键盘状态变化
watch(isKeyboardVisible, (newVal) => {
updateToolbarVisibility()
})
// 初始化编辑器内容
onMounted(() => {
if (editorRef.value) {
editorRef.value.innerHTML = content.value
}
})
// 显示工具栏
const showToolbar = () => {
// 只有当虚拟键盘可见时才显示工具栏
if (isKeyboardVisible.value) {
isToolbarVisible.value = true
}
}
// 处理编辑器失焦
const handleBlur = () => {
// 不立即隐藏工具栏而是通过handleToolbarFocusOut处理
// 添加延迟以确保点击工具栏按钮时不会立即隐藏
setTimeout(() => {
handleToolbarFocusOut()
}, 200)
}
// 处理工具栏按钮点击事件
const handleToolClick = (action, event) => {
// 阻止事件冒泡,防止触发编辑器失焦
if (event) {
event.preventDefault()
event.stopPropagation()
}
// 执行工具操作
action()
// 对于待办事项,不需要重新聚焦到编辑器,因为它有自己的可编辑区域
// 其他工具需要重新聚焦到编辑器
const isTodoAction = action === insertTodoList
if (!isTodoAction) {
// 重新聚焦到编辑器
setTimeout(() => {
if (editorRef.value) {
editorRef.value.focus()
}
}, 0)
} else {
// 对于待办事项,确保工具栏保持可见
isToolbarVisible.value = true
}
// 确保工具栏在虚拟键盘可见时保持显示
if (isKeyboardVisible.value) {
isToolbarVisible.value = true
}
}
// 保持工具栏可见
const keepToolbarVisible = () => {
isToolbarVisible.value = true
}
// 处理工具栏失焦
const handleToolbarFocusOut = () => {
// 添加一个小延迟,以便处理工具栏按钮的点击事件
setTimeout(() => {
// 检查焦点是否在工具栏上
const activeElement = document.activeElement
const toolbarElement = document.querySelector('.toolbar')
// 如果焦点不在工具栏上才隐藏
if (!toolbarElement || !toolbarElement.contains(activeElement)) {
// 额外检查是否有待办事项的内容区域有焦点
const todoContentElements = document.querySelectorAll('.todo-content')
let todoHasFocus = false
for (let i = 0; i < todoContentElements.length; i++) {
if (todoContentElements[i] === activeElement || todoContentElements[i].contains(activeElement)) {
todoHasFocus = true
break
}
}
// 只有在没有待办事项获得焦点且虚拟键盘不可见时才隐藏工具栏
if (!todoHasFocus && !isKeyboardVisible.value) {
isToolbarVisible.value = false
}
}
}, 200) // 增加延迟时间,确保有足够时间处理点击事件
}
// 调整已有图片的高度
const adjustExistingImages = () => {
console.log('Adjusting existing images')
// 等待DOM更新完成
setTimeout(() => {
if (editorRef.value) {
const imageContainers = editorRef.value.querySelectorAll('.image-container')
console.log('Found image containers:', imageContainers.length)
imageContainers.forEach(container => {
const img = container.querySelector('img.editor-image')
if (!img) return
console.log('Processing image:', img)
// 只处理还没有调整过高度的图片
if (!img.dataset.heightAdjusted) {
console.log('Adjusting height for image')
// 创建一个临时图片来获取原始尺寸
const tempImg = new Image()
tempImg.onload = function () {
// 获取CSS变量
const editorFontSize = getComputedStyle(document.documentElement).getPropertyValue('--editor-font-size').trim() || '16px'
const editorLineHeight = getComputedStyle(document.documentElement).getPropertyValue('--editor-line-height').trim() || '1.6'
const fontSize = parseInt(editorFontSize)
const lineHeight = parseFloat(editorLineHeight)
// 计算行高
const computedLineHeight = fontSize * lineHeight
// 获取编辑器的宽度(减去一些内边距)
const editorWidth = editorRef.value.offsetWidth - 20 // 20px为左右内边距
// 按宽度撑满计算调整后的尺寸
const originalHeight = tempImg.height
const originalWidth = tempImg.width
const scaleRatio = editorWidth / originalWidth
const scaledHeight = originalHeight * scaleRatio
// 计算调整后的高度,使其为行高的整数倍
const scaleFactor = Math.max(1, Math.round(scaledHeight / computedLineHeight))
const adjustedHeight = scaleFactor * computedLineHeight
// 按比例调整宽度
const adjustedWidth = (originalWidth * adjustedHeight) / originalHeight
img.style.height = `${adjustedHeight}px`
img.style.width = `${adjustedWidth}px`
// 确保图片与基准线对齐
img.style.verticalAlign = 'top'
// 标记图片已调整过高度
img.dataset.heightAdjusted = 'true'
console.log('Adjusted image dimensions:', adjustedWidth, adjustedHeight)
}
tempImg.src = img.src
}
})
// 为现有图片添加拖拽功能
imageContainers.forEach(container => {
const img = container.querySelector('img.editor-image')
if (!img) return
console.log('Adding drag functionality to image:', img)
// 添加触摸事件监听器
if (!img.hasAttribute('data-touch-listeners')) {
console.log('Adding touch event listeners')
// 为图片容器添加事件监听器
container.addEventListener('touchstart', handleTouchStart)
container.addEventListener('touchmove', handleTouchMove)
container.addEventListener('touchend', handleTouchEnd)
container.addEventListener('touchcancel', handleTouchCancel)
// 为删除按钮添加点击事件
const deleteBtn = container.querySelector('.image-delete-btn')
if (deleteBtn) {
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
container.remove();
handleInput();
});
}
// 为图片容器添加鼠标悬停事件以显示/隐藏删除按钮
container.addEventListener('mouseenter', function() {
if (deleteBtn) deleteBtn.style.display = 'block';
});
container.addEventListener('mouseleave', function() {
if (deleteBtn) deleteBtn.style.display = 'none';
});
img.setAttribute('data-touch-listeners', 'true')
console.log('Added touch event listeners')
}
})
}
}, 0)
}
// 暴露方法给父组件
defineExpose({
getContent: () => content.value,
setContent: newContent => {
console.log('Setting content:', newContent)
content.value = newContent || ''
if (editorRef.value) {
try {
editorRef.value.innerHTML = content.value
console.log('Content set successfully in editorRef')
// 调整已有图片的高度并添加拖拽功能
adjustExistingImages()
// 为图片添加拖拽事件监听器
setTimeout(() => {
console.log('Adding drag event listeners to images in setContent')
const imageContainers = editorRef.value.querySelectorAll('.image-container')
console.log('Found image containers in setContent:', imageContainers.length)
imageContainers.forEach(container => {
const img = container.querySelector('img.editor-image')
if (!img) return
console.log('Adding touch listeners to image in setContent:', img)
// 先移除可能已有的事件监听器,避免重复
container.removeEventListener('touchstart', handleTouchStart)
container.removeEventListener('touchmove', handleTouchMove)
container.removeEventListener('touchend', handleTouchEnd)
container.removeEventListener('touchcancel', handleTouchCancel)
// 重新添加事件监听器
container.addEventListener('touchstart', handleTouchStart)
container.addEventListener('touchmove', handleTouchMove)
container.addEventListener('touchend', handleTouchEnd)
container.addEventListener('touchcancel', handleTouchCancel)
// 为删除按钮添加点击事件
const deleteBtn = container.querySelector('.image-delete-btn')
if (deleteBtn) {
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
container.remove();
handleInput();
});
}
})
}, 0)
} catch (error) {
console.error('Failed to set innerHTML:', error)
// 备选方案使用textContent
try {
editorRef.value.textContent = content.value
console.log('Content set using textContent')
} catch (textContentError) {
console.error('Failed to set textContent:', textContentError)
}
}
} else {
// 如果editorRef还不可用延迟设置
console.log('Editor ref is not available, will retry when mounted')
setTimeout(() => {
if (editorRef.value) {
try {
editorRef.value.innerHTML = content.value
console.log('Content set successfully after delay')
// 调整已有图片的高度并添加拖拽功能
adjustExistingImages()
// 为图片添加拖拽事件监听器
setTimeout(() => {
console.log('Adding drag event listeners to images in delayed setContent')
const imageContainers = editorRef.value.querySelectorAll('.image-container')
console.log('Found image containers in delayed setContent:', imageContainers.length)
imageContainers.forEach(container => {
const img = container.querySelector('img.editor-image')
if (!img) return
console.log('Adding touch listeners to image in delayed setContent:', img)
// 先移除可能已有的事件监听器,避免重复
container.removeEventListener('touchstart', handleTouchStart)
container.removeEventListener('touchmove', handleTouchMove)
container.removeEventListener('touchend', handleTouchEnd)
container.removeEventListener('touchcancel', handleTouchCancel)
// 重新添加事件监听器
container.addEventListener('touchstart', handleTouchStart)
container.addEventListener('touchmove', handleTouchMove)
container.addEventListener('touchend', handleTouchEnd)
container.addEventListener('touchcancel', handleTouchCancel)
// 为删除按钮添加点击事件
const deleteBtn = container.querySelector('.image-delete-btn')
if (deleteBtn) {
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
container.remove();
handleInput();
});
}
})
}, 0)
} catch (error) {
console.error('Failed to set innerHTML after delay:', error)
}
}
}, 100)
}
},
insertImage,
})
</script>
<style scoped>
.editor-container {
display: flex;
flex-direction: column;
min-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 {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border);
background-color: var(--background-card);
flex-shrink: 0;
transform: translateY(100%);
transition: transform 0.3s ease;
z-index: 1000;
/* 确保工具栏能正确获取焦点 */
tabindex: 0;
}
.toolbar.visible {
transform: translateY(0);
}
/* 当虚拟键盘可见时,调整工具栏位置 */
.toolbar.keyboard-visible {
bottom: 0;
}
/* 使用visualViewport API动态调整工具栏位置 */
.toolbar.dynamic-position {
bottom: calc(100vh - var(--viewport-height, 100vh));
}
.toolbar.visible {
transform: translateY(0);
}
.toolbar-btn {
padding: 0.375rem;
margin-right: 0.375rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 0.25rem;
transition: background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
/* 确保按钮能正确获取焦点 */
tabindex: 0;
}
.toolbar-btn:hover {
background-color: var(--background-secondary);
}
.toolbar-btn.active {
background-color: var(--primary-light);
border: 1px solid var(--border);
}
.toolbar-icon {
width: 1.25rem;
height: 1.25rem;
opacity: 0.8;
}
.toolbar-btn.active .toolbar-icon {
opacity: 1;
}
.editor-content {
flex: 1;
padding: 0 0.625rem 3.75rem 0.625rem; /* 添加底部内边距,防止内容被工具栏遮挡 */
outline: none;
overflow-y: auto;
font-size: var(--editor-font-size, 1rem);
line-height: var(--editor-line-height, 1.6);
color: var(--note-content);
min-height: 12.5rem;
background-color: var(--background-card);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
position: relative;
/* 基准线样式 */
background-image: linear-gradient(to bottom, var(--border) 1px, transparent 1px);
background-size: 100% calc(var(--editor-font-size, 1rem) * var(--editor-line-height, 1.6)); /* var(--editor-font-size) * var(--editor-line-height) */
background-repeat: repeat-y;
background-position: 0 calc((var(--editor-font-size, 1rem) * var(--editor-line-height, 1.6) - var(--editor-font-size, 1rem)) / 2);
}
.editor-content::before {
display: none;
}
/* 统一处理所有直接子元素的间距 */
.editor-content > * {
margin: 0;
padding: 0;
}
.editor-content:focus,
.editor-content * {
outline: none;
}
/* 优化段落样式,确保与基准线对齐 */
:deep(.editor-content p) {
margin: 0 0 0.75rem 0;
line-height: var(--editor-line-height, 1.6);
letter-spacing: 0.3px;
}
/* 自定义内容样式 - 统一行高和间距 */
:deep(.editor-content h2) {
font-size: var(--editor-font-size, 1rem);
font-weight: 600;
margin: 0 0 0.75rem 0;
color: var(--note-title);
line-height: var(--editor-line-height, 1.6);
letter-spacing: 0.3px;
text-align: center;
position: relative;
}
:deep(.editor-content blockquote) {
border-left: 3px solid var(--primary);
padding: 0 1rem 0 1rem;
margin: 0 0 0.75rem 0;
color: var(--text-secondary);
background-color: var(--background-secondary);
font-style: italic;
line-height: var(--editor-line-height, 1.6);
}
:deep(.quote-container) {
position: relative;
margin: 0 0 0.75rem 0;
line-height: var(--editor-line-height, 1.6);
}
:deep(.quote-icon) {
position: absolute;
left: 0;
top: 0;
width: var(--editor-font-size, 1rem);
height: var(--editor-font-size, 1rem);
margin-top: 0.1875rem;
}
:deep(.quote-content) {
border-left: 3px solid var(--primary);
padding: 0 var(--editor-font-size, 1rem) 0 2rem;
margin-left: var(--editor-font-size, 1rem);
color: var(--text-secondary);
background-color: var(--background-secondary);
font-style: italic;
line-height: var(--editor-line-height, 1.6);
}
:deep(.editor-content ul),
:deep(.editor-content ol) {
margin: 0 0 0.75rem 0;
padding-left: 2rem;
position: relative;
line-height: var(--editor-line-height, 1.6);
}
:deep(.editor-content li) {
margin: 0;
line-height: var(--editor-line-height, 1.6);
padding: 0;
}
: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: 0.75rem 0;
}
.editor-content div[style*='text-align: center'] {
text-align: center;
}
:deep(.editor-content .image-container) {
display: inline-block;
position: relative;
margin: calc((var(--editor-line-height, 1.6) * 10) * 1px) auto;
}
:deep(.editor-content .editor-image) {
max-width: 100%;
height: auto;
display: block;
object-fit: cover;
box-sizing: border-box;
border: 0.625rem solid white;
border-radius: 0.2rem;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.18);
background: var(--background-secondary);
position: relative;
outline: none; /* 移除默认焦点轮廓 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
:deep(.editor-content .image-delete-btn) {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
cursor: pointer;
z-index: 10;
display: none;
transition: opacity 0.2s ease;
}
:deep(.editor-content .editor-image.draggable) {
cursor: move;
}
:deep(.editor-content .editor-image.dragging) {
opacity: 0.9;
transform: scale(0.98);
z-index: 999;
transition: transform 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.15s ease; /* 优化过渡效果 */
box-shadow: 0 12px 25px rgba(0, 0, 0, 0.22);
will-change: transform, opacity; /* 提示浏览器优化这些属性 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* 待办事项样式 */
:deep(.todo-container) {
display: flex;
align-items: flex-start;
margin: 0;
line-height: var(--editor-line-height, 1.6);
position: relative;
padding-left: calc(var(--editor-font-size, 1rem) * 1.5);
}
:deep(.todo-icon) {
width: calc(var(--editor-font-size, 1rem) * 1.5);
height: calc(var(--editor-font-size, 1rem) * 1.5);
cursor: pointer;
flex-shrink: 0;
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
}
:deep(.todo-content) {
flex: 1;
outline: none;
line-height: var(--editor-line-height, 1.6);
padding: 0;
margin: 0;
}
</style>