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

1959 lines
74 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'
import { pxToRemStr } from '@/utils/sizeUtils'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
// 全局常量定义
const DELETE_BUTTON_DELAY = 1000 // 删除按钮延时时间(毫秒),用于防止误触
const editorRef = ref(null)
const content = ref(props.modelValue || '')
const isToolbarVisible = ref(false)
const isKeyboardVisible = ref(false)
const initialViewportHeight = ref(0)
const dragState = ref({
isDragging: false,
draggedImage: null,
startX: 0,
startY: 0,
currentY: 0,
longPressTimer: null,
isLongPress: false,
indicator: null,
lastCheckTime: 0,
lastMoveTime: 0,
})
// 统一的事件监听器管理器
const eventManager = {
// 为图片容器添加事件监听器
addImageContainerListeners(container, deleteBtn, downloadBtn, previewBtn, cropBtn) {
// 先移除可能已有的事件监听器,避免重复
this.removeImageContainerListeners(container)
// 添加触摸事件监听器
container.addEventListener('touchstart', handleTouchStart)
container.addEventListener('touchmove', handleTouchMove)
container.addEventListener('touchend', handleTouchEnd)
container.addEventListener('touchcancel', handleTouchCancel)
// 为删除按钮添加点击事件
if (deleteBtn) {
// 保存事件处理函数的引用,以便后续移除
const deleteHandler = function (e) {
e.stopPropagation()
e.preventDefault()
// 检查删除按钮是否可见,只有在可见状态下才能触发删除
if (deleteBtn.classList.contains('visible')) {
// 检查是否是刚显示的按钮点击(通过时间戳判断)
const lastVisibleTime = deleteBtn._lastVisibleTime || 0
const currentTime = Date.now()
// 如果距离上次显示时间超过300ms才执行删除操作
if (currentTime - lastVisibleTime > 300) {
container.remove()
handleInput()
}
}
}
deleteBtn.addEventListener('click', deleteHandler)
deleteBtn._deleteHandler = deleteHandler
}
// 为下载按钮添加点击事件
if (downloadBtn) {
const downloadHandler = function (e) {
e.stopPropagation()
e.preventDefault()
// 检查下载按钮是否可见,只有在可见状态下才能触发下载
if (downloadBtn.classList.contains('visible')) {
// 检查是否是刚显示的按钮点击(通过时间戳判断)
const lastVisibleTime = downloadBtn._lastVisibleTime || 0
const currentTime = Date.now()
// 如果距离上次显示时间超过300ms才执行下载操作
if (currentTime - lastVisibleTime > 300) {
const img = container.querySelector('img.editor-image')
if (img) {
// 创建临时的a标签用于下载
const link = document.createElement('a')
link.href = img.src
link.download = 'image.png' // 默认文件名
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
}
}
downloadBtn.addEventListener('click', downloadHandler)
downloadBtn._downloadHandler = downloadHandler
}
// 为预览按钮添加点击事件
if (previewBtn) {
const previewHandler = function (e) {
e.stopPropagation()
e.preventDefault()
// 检查预览按钮是否可见,只有在可见状态下才能触发预览
if (previewBtn.classList.contains('visible')) {
// 检查是否是刚显示的按钮点击(通过时间戳判断)
const lastVisibleTime = previewBtn._lastVisibleTime || 0
const currentTime = Date.now()
// 如果距离上次显示时间超过300ms才执行预览操作
if (currentTime - lastVisibleTime > 300) {
const img = container.querySelector('img.editor-image')
if (img) {
// 创建模态框用于全屏预览
const modal = document.createElement('div')
modal.className = 'image-preview-modal'
modal.style.position = 'fixed'
modal.style.top = '0'
modal.style.left = '0'
modal.style.width = '100%'
modal.style.height = '100%'
modal.style.backgroundColor = 'rgba(0, 0, 0, 0.9)'
modal.style.display = 'flex'
modal.style.justifyContent = 'center'
modal.style.alignItems = 'center'
modal.style.zIndex = '9999'
const modalImg = document.createElement('img')
modalImg.src = img.src
modalImg.style.maxWidth = '90%'
modalImg.style.maxHeight = '90%'
modalImg.style.objectFit = 'contain'
// 添加关闭按钮
const closeBtn = document.createElement('div')
closeBtn.textContent = '×'
closeBtn.style.position = 'absolute'
closeBtn.style.top = pxToRemStr(20)
closeBtn.style.right = pxToRemStr(20)
closeBtn.style.fontSize = pxToRemStr(30)
closeBtn.style.color = 'white'
closeBtn.style.cursor = 'pointer'
closeBtn.addEventListener('click', function () {
document.body.removeChild(modal)
})
modal.addEventListener('click', function (e) {
if (e.target === modal) {
document.body.removeChild(modal)
}
})
modal.appendChild(modalImg)
modal.appendChild(closeBtn)
document.body.appendChild(modal)
}
}
}
}
previewBtn.addEventListener('click', previewHandler)
previewBtn._previewHandler = previewHandler
}
// 为裁切按钮添加点击事件
if (cropBtn) {
const cropHandler = function (e) {
e.stopPropagation()
e.preventDefault()
// 检查裁切按钮是否可见,只有在可见状态下才能触发裁切
if (cropBtn.classList.contains('visible')) {
// 检查是否是刚显示的按钮点击(通过时间戳判断)
const lastVisibleTime = cropBtn._lastVisibleTime || 0
const currentTime = Date.now()
// 如果距离上次显示时间超过300ms才执行裁切操作
if (currentTime - lastVisibleTime > 300) {
const img = container.querySelector('img.editor-image')
if (img) {
// 创建裁切模态框
const modal = document.createElement('div')
modal.className = 'image-crop-modal'
modal.style.position = 'fixed'
modal.style.top = '0'
modal.style.left = '0'
modal.style.width = '100%'
modal.style.height = '100%'
modal.style.backgroundColor = 'rgba(0, 0, 0, 0.9)'
modal.style.display = 'flex'
modal.style.flexDirection = 'column'
modal.style.justifyContent = 'center'
modal.style.alignItems = 'center'
modal.style.zIndex = '9999'
const modalImg = document.createElement('img')
modalImg.src = img.src
modalImg.style.maxWidth = '90%'
modalImg.style.maxHeight = '70%'
modalImg.style.objectFit = 'contain'
// 创建裁切区域容器
const cropContainer = document.createElement('div')
cropContainer.style.position = 'relative'
cropContainer.style.display = 'inline-block'
cropContainer.appendChild(modalImg)
// 添加确认和取消按钮
const buttonContainer = document.createElement('div')
buttonContainer.style.marginTop = pxToRemStr(20)
const confirmBtn = document.createElement('button')
confirmBtn.textContent = '确认'
confirmBtn.style.margin = `0 ${pxToRemStr(10)}`
confirmBtn.style.padding = `${pxToRemStr(10)} ${pxToRemStr(20)}`
confirmBtn.style.backgroundColor = '#007AFF'
confirmBtn.style.color = 'white'
confirmBtn.style.border = 'none'
confirmBtn.style.borderRadius = pxToRemStr(5)
confirmBtn.style.cursor = 'pointer'
const cancelBtn = document.createElement('button')
cancelBtn.textContent = '取消'
cancelBtn.style.margin = `0 ${pxToRemStr(10)}`
cancelBtn.style.padding = `${pxToRemStr(10)} ${pxToRemStr(20)}`
cancelBtn.style.backgroundColor = '#FF3B30'
cancelBtn.style.color = 'white'
cancelBtn.style.border = 'none'
cancelBtn.style.borderRadius = pxToRemStr(5)
cancelBtn.style.cursor = 'pointer'
confirmBtn.addEventListener('click', function () {
// 这里应该实现实际的裁切逻辑
// 简化实现:直接更新原图片
img.src = modalImg.src
document.body.removeChild(modal)
handleInput()
})
cancelBtn.addEventListener('click', function () {
document.body.removeChild(modal)
})
buttonContainer.appendChild(confirmBtn)
buttonContainer.appendChild(cancelBtn)
modal.appendChild(cropContainer)
modal.appendChild(buttonContainer)
document.body.appendChild(modal)
}
}
}
}
cropBtn.addEventListener('click', cropHandler)
cropBtn._cropHandler = cropHandler
}
// 为图片容器添加短按事件以显示/隐藏所有按钮
let touchStartTime = 0
let isAnyButtonClicked = false
// 标记按钮被点击
const markButtonClicked = function () {
isAnyButtonClicked = true
// 重置标记
setTimeout(() => {
isAnyButtonClicked = false
}, 300)
}
// 为所有按钮添加标记事件
const allButtons = [deleteBtn, downloadBtn, previewBtn, cropBtn].filter(btn => btn)
allButtons.forEach(btn => {
btn._markClickHandler = markButtonClicked
btn.addEventListener('touchstart', markButtonClicked)
})
const touchStartHandler = function (e) {
touchStartTime = Date.now()
}
const touchEndHandler = function (e) {
const touchDuration = Date.now() - touchStartTime
// 短按小于200ms且非长按拖拽状态且不是按钮点击时切换所有按钮显示
if (touchDuration < 200 && !dragState.value.isLongPress && !isAnyButtonClicked) {
e.stopPropagation()
// 切换所有按钮的显示状态
const allButtons = [deleteBtn, downloadBtn, previewBtn, cropBtn].filter(btn => btn)
if (allButtons.length > 0) {
const isCurrentlyVisible = allButtons[0].classList.contains('visible')
allButtons.forEach(btn => {
if (isCurrentlyVisible) {
btn.classList.remove('visible')
} else {
btn.classList.add('visible')
// 记录显示时间
btn._lastVisibleTime = Date.now()
}
})
}
}
// 重置按钮点击标记
setTimeout(() => {
isAnyButtonClicked = false
}, 50)
}
container.addEventListener('touchstart', touchStartHandler)
container.addEventListener('touchend', touchEndHandler)
// 保存事件处理函数的引用,以便后续移除
container._touchStartHandler = touchStartHandler
container._touchEndHandler = touchEndHandler
container._markButtonClicked = markButtonClicked
},
// 移除图片容器的事件监听器
removeImageContainerListeners(container) {
// 移除拖拽事件监听器
container.removeEventListener('touchstart', handleTouchStart)
container.removeEventListener('touchmove', handleTouchMove)
container.removeEventListener('touchend', handleTouchEnd)
container.removeEventListener('touchcancel', handleTouchCancel)
// 移除短按事件监听器
const touchStartHandler = container._touchStartHandler
const touchEndHandler = container._touchEndHandler
const markButtonClicked = container._markButtonClicked
if (touchStartHandler) {
container.removeEventListener('touchstart', touchStartHandler)
delete container._touchStartHandler
}
if (touchEndHandler) {
container.removeEventListener('touchend', touchEndHandler)
delete container._touchEndHandler
}
if (markButtonClicked) {
delete container._markButtonClicked
}
// 移除所有按钮事件监听器
const deleteBtn = container.querySelector('.image-delete-btn')
const downloadBtn = container.querySelector('.image-download-btn')
const previewBtn = container.querySelector('.image-preview-btn')
const cropBtn = container.querySelector('.image-crop-btn')
const removeButtonListeners = (btn, handlerName, markHandlerName) => {
if (btn) {
if (btn[handlerName]) {
btn.removeEventListener('click', btn[handlerName])
delete btn[handlerName]
}
if (btn[markHandlerName]) {
btn.removeEventListener('touchstart', btn[markHandlerName])
delete btn[markHandlerName]
}
}
}
removeButtonListeners(deleteBtn, '_deleteHandler', '_markClickHandler')
removeButtonListeners(downloadBtn, '_downloadHandler', '_markClickHandler')
removeButtonListeners(previewBtn, '_previewHandler', '_markClickHandler')
removeButtonListeners(cropBtn, '_cropHandler', '_markClickHandler')
},
}
// 初始化编辑器内容
onMounted(() => {
if (editorRef.value) {
if (props.modelValue) {
try {
editorRef.value.innerHTML = props.modelValue
content.value = props.modelValue
// 调整已有图片的高度
adjustExistingImages()
} catch (error) {
console.error('Failed to set initial content:', error)
}
} else {
// 即使没有初始内容,也要确保编辑器是可编辑的
editorRef.value.contentEditable = true
}
}
// 记录初始视口高度
initialViewportHeight.value = window.visualViewport?.height || window.innerHeight
// 初始化CSS变量
document.documentElement.style.setProperty('--viewport-height', `${initialViewportHeight.value}px`)
// 添加虚拟键盘检测事件监听器
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handleViewportResize)
} else {
window.addEventListener('resize', handleWindowResize)
}
// 为已有图片添加拖拽事件监听器
setTimeout(() => {
const imageContainers = editorRef.value.querySelectorAll('.image-container')
imageContainers.forEach(container => {
const img = container.querySelector('img.editor-image')
if (!img) return
// 为删除按钮添加点击事件
let deleteBtn = container.querySelector('.image-delete-btn')
if (!deleteBtn) {
// 如果删除按钮不存在,创建它
deleteBtn = document.createElement('div')
deleteBtn.className = 'image-delete-btn'
container.appendChild(deleteBtn)
}
// 为下载按钮添加点击事件
let downloadBtn = container.querySelector('.image-download-btn')
if (!downloadBtn) {
// 如果下载按钮不存在,创建它
downloadBtn = document.createElement('div')
downloadBtn.className = 'image-download-btn'
container.appendChild(downloadBtn)
}
// 为预览按钮添加点击事件
let previewBtn = container.querySelector('.image-preview-btn')
if (!previewBtn) {
// 如果预览按钮不存在,创建它
previewBtn = document.createElement('div')
previewBtn.className = 'image-preview-btn'
container.appendChild(previewBtn)
}
// 为裁切按钮添加点击事件
let cropBtn = container.querySelector('.image-crop-btn')
if (!cropBtn) {
// 如果裁切按钮不存在,创建它
cropBtn = document.createElement('div')
cropBtn.className = 'image-crop-btn'
container.appendChild(cropBtn)
}
// 使用事件管理器添加事件监听器
eventManager.addImageContainerListeners(container, deleteBtn, downloadBtn, previewBtn, cropBtn)
})
}, 0)
})
// 组件卸载时移除事件监听器
onUnmounted(() => {
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', handleViewportResize)
} else {
window.removeEventListener('resize', handleWindowResize)
}
// 清理所有图片容器的事件监听器
if (editorRef.value) {
const imageContainers = editorRef.value.querySelectorAll('.image-container')
imageContainers.forEach(container => {
eventManager.removeImageContainerListeners(container)
})
}
})
// 工具栏配置
// 定义富文本编辑器的所有工具按钮及其功能
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'
// 创建图标元素使用CSS背景而非img标签
const icon = document.createElement('div')
icon.className = 'quote-icon'
// 创建内容容器
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 createTodoItem = (text = '') => {
// 创建待办事项容器
const todoContainer = document.createElement('div')
todoContainer.contentEditable = false // 容器本身不可编辑
todoContainer.className = 'todo-container'
// 创建图标元素复选框使用CSS背景而非img标签
const icon = document.createElement('div')
icon.className = 'todo-icon'
// 创建内容容器(可编辑区域)
const contentSpan = document.createElement('div')
contentSpan.contentEditable = true // 内容区域可编辑
contentSpan.className = 'todo-content'
contentSpan.textContent = text || '待办事项' // 默认文本
// 组装元素:将图标和内容区域添加到容器中
todoContainer.appendChild(icon)
todoContainer.appendChild(contentSpan)
return { todoContainer, icon, contentSpan }
}
// 为待办事项添加事件监听器
const addTodoEventListeners = (icon, contentSpan, todoContainer) => {
// 添加事件监听器到图标,实现待办事项完成状态切换
icon.addEventListener('click', function () {
// 根据当前状态切换图标和样式
if (this.classList.contains('completed')) {
// 切换到未完成状态
this.classList.remove('completed')
contentSpan.style.color = 'var(--note-content)' // 正常文字颜色
contentSpan.style.textDecoration = 'none' // 移除删除线
} else {
// 切换到完成状态
this.classList.add('completed')
contentSpan.style.color = 'var(--text-tertiary)' // 灰色文字
contentSpan.style.textDecoration = 'line-through' // 添加删除线
}
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
})
}
// 插入待办事项列表
// 创建一个可交互的待办事项元素,包含复选框图标和可编辑内容区域
const insertTodoList = () => {
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
// 检查嵌套限制,防止在列表或引用中插入待办事项
if (isInListOrQuote()) return
// 创建待办事项
const { todoContainer, icon, contentSpan } = createTodoItem()
// 插入到当前光标位置
range.insertNode(todoContainer)
// 添加换行,确保待办事项下方有空白行
const br = document.createElement('br')
todoContainer.parentNode.insertBefore(br, todoContainer.nextSibling)
// 为待办事项添加事件监听器
addTodoEventListeners(icon, contentSpan, todoContainer)
// 聚焦到内容区域延迟执行确保在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)
// 监听回车键,创建同级待办事项
contentSpan.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault()
// 创建新的待办事项
const { todoContainer: newTodoContainer, icon: newIcon, contentSpan: newContentSpan } = createTodoItem()
// 插入到当前待办事项后面
todoContainer.parentNode.insertBefore(newTodoContainer, todoContainer.nextSibling)
// 添加换行
const newBr = document.createElement('br')
newTodoContainer.parentNode.insertBefore(newBr, newTodoContainer.nextSibling)
// 为新待办事项添加事件监听器
addTodoEventListeners(newIcon, newContentSpan, newTodoContainer)
// 聚焦到新内容区域
const newRange = document.createRange()
newRange.selectNodeContents(newContentSpan)
newRange.collapse(false)
selection.removeAllRanges()
selection.addRange(newRange)
handleInput()
}
})
handleInput()
}
}
// 重置拖拽状态
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 createImageContainer = imageDataUrl => {
// 创建图片容器
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 ${pxToRemStr(1)} ${pxToRemStr(5)} rgba(0, 0, 0, 0.18)`
img.style.background = 'var(--background-secondary)'
img.style.position = 'relative'
img.style.outline = 'none' // 移除默认焦点轮廓
img.style.userSelect = 'none' // 防止选中
img.style.webkitUserSelect = 'none' // 防止选中
img.style.mozUserSelect = 'none' // 防止选中
img.style.msUserSelect = 'none' // 防止选中
img.style.webkitTouchCallout = 'none' // 防止长按弹出菜单
img.style.webkitTapHighlightColor = 'transparent' // 防止点击高亮
img.draggable = true
// 创建删除按钮
const deleteBtn = document.createElement('div')
deleteBtn.className = 'image-delete-btn'
// 创建下载按钮
const downloadBtn = document.createElement('div')
downloadBtn.className = 'image-download-btn'
// 创建预览按钮
const previewBtn = document.createElement('div')
previewBtn.className = 'image-preview-btn'
// 创建裁切按钮
const cropBtn = document.createElement('div')
cropBtn.className = 'image-crop-btn'
// 将图片和所有按钮添加到容器中
imgContainer.appendChild(img)
imgContainer.appendChild(deleteBtn)
imgContainer.appendChild(downloadBtn)
imgContainer.appendChild(previewBtn)
imgContainer.appendChild(cropBtn)
// 使用事件管理器添加事件监听器
eventManager.addImageContainerListeners(imgContainer, deleteBtn, downloadBtn, previewBtn, cropBtn)
return { imgContainer, img, deleteBtn, downloadBtn, previewBtn, cropBtn }
}
// 调整图片尺寸的函数
const adjustImageSize = img => {
// 创建一个临时图片来获取原始尺寸
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'
}
tempImg.src = img.src
}
// 插入图片
const insertImage = () => {
// 创建文件输入元素
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'image/*'
fileInput.style.display = 'none'
// 添加到文档中
document.body.appendChild(fileInput)
// 监听文件选择事件
fileInput.addEventListener('change', function (event) {
const file = event.target.files[0]
if (file && file.type.startsWith('image/')) {
// 创建FileReader读取文件
const reader = new FileReader()
reader.onload = function (e) {
// 获取图片数据URL
const imageDataUrl = e.target.result
// 获取当前选区
const selection = window.getSelection()
if (selection.rangeCount > 0) {
let range = selection.getRangeAt(0)
// 检查选区是否在图片容器内部,如果是则调整到容器后面
const startContainer = range.startContainer
let imageContainer = null
// 如果startContainer是图片容器本身
if (startContainer.classList && startContainer.classList.contains('image-container')) {
imageContainer = startContainer
}
// 如果startContainer是图片容器的子元素
else if (startContainer.parentNode && startContainer.parentNode.classList && startContainer.parentNode.classList.contains('image-container')) {
imageContainer = startContainer.parentNode
}
// 向上查找父元素
else {
let parent = startContainer.parentNode
while (parent && parent !== editorRef.value) {
if (parent.classList && parent.classList.contains('image-container')) {
imageContainer = parent
break
}
parent = parent.parentNode
}
}
// 如果选区在图片容器内部,调整到容器后面
if (imageContainer) {
range = document.createRange()
range.setStartAfter(imageContainer)
range.collapse(true)
}
// 创建图片容器
const { imgContainer, img, deleteBtn, downloadBtn, previewBtn, cropBtn } = createImageContainer(imageDataUrl)
// 调整图片尺寸
adjustImageSize(img)
// 插入图片容器到当前光标位置
range.insertNode(imgContainer)
// 添加换行
const br = document.createElement('br')
imgContainer.parentNode.insertBefore(br, imgContainer.nextSibling)
// 修正选区位置,避免嵌套插入
// 使用setTimeout确保DOM更新完成后再设置选区
setTimeout(() => {
const newRange = document.createRange()
newRange.setStartAfter(br)
newRange.collapse(true)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(newRange)
// 重新聚焦到编辑器
if (editorRef.value) {
editorRef.value.focus()
}
}, 0)
// 触发输入事件更新内容
handleInput()
}
}
reader.readAsDataURL(file)
}
// 清理文件输入元素
document.body.removeChild(fileInput)
})
// 触发文件选择对话框
fileInput.click()
}
// 处理键盘事件
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'
img.style.webkitTouchCallout = 'none'
img.style.webkitTapHighlightColor = 'transparent'
// 清除之前的定时器
if (dragState.value.longPressTimer) {
clearTimeout(dragState.value.longPressTimer)
}
// 记录触摸开始位置
dragState.value.startX = e.touches[0].clientX
dragState.value.startY = e.touches[0].clientY
// 设置长按检测定时器500毫秒
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.85'
img.style.transform = 'scale(0.96)'
img.style.zIndex = '999'
img.style.transition = 'all 0.15s 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 = `${pxToRemStr(8)} ${pxToRemStr(16)}`
indicator.style.background = 'rgba(0, 0, 0, 0.85)'
indicator.style.color = 'white'
indicator.style.borderRadius = pxToRemStr(16)
indicator.style.fontSize = pxToRemStr(14)
indicator.style.fontWeight = '500'
indicator.style.zIndex = '1000'
indicator.style.opacity = '0'
indicator.style.transition = 'opacity 0.1s ease-out'
indicator.style.boxShadow = `0 ${pxToRemStr(4)} ${pxToRemStr(12)} rgba(0, 0, 0, 0.3)`
indicator.textContent = '拖拽排序'
document.body.appendChild(indicator)
// 渐显指示器
setTimeout(() => {
indicator.style.opacity = '1'
}, 1)
// 保存指示器引用以便后续移除
dragState.value.indicator = indicator
// 添加震动反馈(如果设备支持)
if (navigator.vibrate) {
navigator.vibrate(15)
}
// 阻止页面滚动
e.preventDefault()
}, 500) // 500毫秒长按触发拖拽
}
// 处理触摸移动事件
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()
// 如果还没有触发长按检查是否移动过多超过6px则取消长按
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 > 6) {
// 移动过多,取消长按
if (dragState.value.longPressTimer) {
clearTimeout(dragState.value.longPressTimer)
dragState.value.longPressTimer = null
}
return
}
}
if (!dragState.value.isLongPress || !img) return
dragState.value.currentY = currentY
// 计算位移
const deltaY = dragState.value.currentY - dragState.value.startY
// 使用requestAnimationFrame确保流畅的动画
requestAnimationFrame(() => {
// 更新图片位置,添加缓动效果
const easeFactor = 0.95 // 调整缓动因子使拖拽更跟手
img.style.transform = `translateY(${deltaY * easeFactor}px) scale(0.96)`
})
// 使用节流优化,避免过于频繁的检查
const now = Date.now()
// 限制检查频率为每16ms一次约60fps提高响应速度
if (now - dragState.value.lastMoveTime >= 16) {
dragState.value.lastMoveTime = now
checkAndSwapImages(img, deltaY)
}
}
// 重置拖拽动画
const resetDragAnimation = img => {
// 添加释放动画
img.style.transition = 'all 0.15s 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.1s ease-out'
indicator.style.opacity = '0'
setTimeout(() => {
if (indicator.parentNode) {
indicator.parentNode.removeChild(indicator)
}
}, 100)
}
// 延迟重置样式以显示动画
setTimeout(() => {
if (img) {
img.classList.remove('dragging')
img.style.zIndex = ''
img.style.transition = ''
}
}, 150)
}
// 处理触摸结束事件
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
// 重置拖拽动画
resetDragAnimation(img)
// 添加震动反馈(如果设备支持)
if (navigator.vibrate) {
navigator.vibrate(8)
}
// 重置状态
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
// 重置拖拽动画
resetDragAnimation(img)
// 重置状态
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.95 // 调整缓动因子以匹配触摸移动
// 查找最近的图片进行交换
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
// 检查是否与目标图片重叠,使用更精确的碰撞检测
// 当拖拽图片覆盖目标图片高度的60%时触发排序
const overlapThreshold = targetRect.height * 0.6
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
// 添加交换动画类
img1.classList.add('swap-animation')
img2.classList.add('swap-animation')
// 如果两张图片在同一父元素中
if (parent1 === parent2) {
// 计算两个图片的位置差
const rect1 = img1.getBoundingClientRect()
const rect2 = img2.getBoundingClientRect()
const deltaY = rect2.top - rect1.top
// 添加临时的变换动画
img1.style.transform = `translateY(${deltaY}px)`
img2.style.transform = `translateY(${-deltaY}px)`
// 在动画完成后交换DOM位置
setTimeout(() => {
// 移除临时变换
img1.style.transform = ''
img2.style.transform = ''
// 移除交换动画类
img1.classList.remove('swap-animation')
img2.classList.remove('swap-animation')
// 交换DOM位置
const tempMarker = document.createElement('div')
parent1.insertBefore(tempMarker, img1)
parent1.insertBefore(img1, img2)
parent1.insertBefore(img2, tempMarker)
tempMarker.remove()
// 触发内容更新
handleInput()
// 自动退出排序模式,提高响应速度
setTimeout(() => {
resetDragState()
}, 10)
}, 200)
} else {
// 不同父元素的情况(更复杂,需要特殊处理)
// 这里简化处理,实际项目中可能需要更复杂的逻辑
// 移除交换动画类
img1.classList.remove('swap-animation')
img2.classList.remove('swap-animation')
const temp = document.createElement('div')
parent1.insertBefore(temp, img1)
parent2.insertBefore(img1, img2)
parent1.insertBefore(img2, temp)
temp.remove()
// 触发内容更新
handleInput()
// 自动退出排序模式,提高响应速度
setTimeout(() => {
resetDragState()
}, 10)
}
}
// 更新工具栏状态
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
}
// 通知父组件编辑器获得焦点
emit('focus')
}
// 处理编辑器失焦
const handleBlur = () => {
// 不立即隐藏工具栏而是通过handleToolbarFocusOut处理
// 添加延迟以确保点击工具栏按钮时不会立即隐藏
setTimeout(() => {
handleToolbarFocusOut()
// 通知父组件编辑器失去焦点
emit('blur')
}, 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)
}
// 确保工具栏保持可见
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) // 增加延迟时间,确保有足够时间处理点击事件
}
// 包装孤立的图片(没有被.image-container包装的图片
const wrapOrphanedImages = () => {
if (!editorRef.value) return
// 查找所有没有被.image-container包装的图片
const images = editorRef.value.querySelectorAll('img:not(.editor-image)')
images.forEach(img => {
// 检查图片是否已经在.image-container中
if (img.closest('.image-container')) return
// 检查图片的父元素是否是.image-container避免嵌套
if (img.parentNode && img.parentNode.classList && img.parentNode.classList.contains('image-container')) {
// 确保图片有正确的类名
img.className = 'editor-image'
img.setAttribute('data-draggable', 'true')
// 为已存在的图片容器添加删除按钮事件监听器
const imgContainer = img.parentNode
let deleteBtn = imgContainer.querySelector('.image-delete-btn')
if (!deleteBtn) {
// 如果删除按钮不存在,创建它
deleteBtn = document.createElement('div')
deleteBtn.className = 'image-delete-btn'
imgContainer.appendChild(deleteBtn)
}
// 为下载按钮添加点击事件
let downloadBtn = imgContainer.querySelector('.image-download-btn')
if (!downloadBtn) {
// 如果下载按钮不存在,创建它
downloadBtn = document.createElement('div')
downloadBtn.className = 'image-download-btn'
imgContainer.appendChild(downloadBtn)
}
// 为预览按钮添加点击事件
let previewBtn = imgContainer.querySelector('.image-preview-btn')
if (!previewBtn) {
// 如果预览按钮不存在,创建它
previewBtn = document.createElement('div')
previewBtn.className = 'image-preview-btn'
imgContainer.appendChild(previewBtn)
}
// 为裁切按钮添加点击事件
let cropBtn = imgContainer.querySelector('.image-crop-btn')
if (!cropBtn) {
// 如果裁切按钮不存在,创建它
cropBtn = document.createElement('div')
cropBtn.className = 'image-crop-btn'
imgContainer.appendChild(cropBtn)
}
// 使用事件管理器添加事件监听器
eventManager.addImageContainerListeners(imgContainer, deleteBtn, downloadBtn, previewBtn, cropBtn)
return
}
// 创建图片容器
const { imgContainer, img: editorImg, deleteBtn } = createImageContainer(img.src)
// 替换原来的图片
img.parentNode.replaceChild(imgContainer, img)
// 调整图片尺寸
adjustImageSize(editorImg)
})
}
// 调整已有图片的高度
const adjustExistingImages = () => {
// 等待DOM更新完成
setTimeout(() => {
if (editorRef.value) {
const imageContainers = editorRef.value.querySelectorAll('.image-container')
imageContainers.forEach(container => {
const img = container.querySelector('img.editor-image')
if (!img) return
// 只处理还没有调整过高度的图片
if (!img.dataset.heightAdjusted) {
// 调整图片尺寸
adjustImageSize(img)
// 标记图片已调整过高度
img.dataset.heightAdjusted = 'true'
}
})
// 为现有图片添加拖拽功能和删除按钮功能
imageContainers.forEach(container => {
const img = container.querySelector('img.editor-image')
if (!img) return
// 为删除按钮添加点击事件
let deleteBtn = container.querySelector('.image-delete-btn')
if (!deleteBtn) {
// 如果删除按钮不存在,创建它
deleteBtn = document.createElement('div')
deleteBtn.className = 'image-delete-btn'
container.appendChild(deleteBtn)
}
// 为下载按钮添加点击事件
let downloadBtn = container.querySelector('.image-download-btn')
if (!downloadBtn) {
// 如果下载按钮不存在,创建它
downloadBtn = document.createElement('div')
downloadBtn.className = 'image-download-btn'
container.appendChild(downloadBtn)
}
// 为预览按钮添加点击事件
let previewBtn = container.querySelector('.image-preview-btn')
if (!previewBtn) {
// 如果预览按钮不存在,创建它
previewBtn = document.createElement('div')
previewBtn.className = 'image-preview-btn'
container.appendChild(previewBtn)
}
// 为裁切按钮添加点击事件
let cropBtn = container.querySelector('.image-crop-btn')
if (!cropBtn) {
// 如果裁切按钮不存在,创建它
cropBtn = document.createElement('div')
cropBtn.className = 'image-crop-btn'
container.appendChild(cropBtn)
}
// 使用事件管理器添加事件监听器
eventManager.addImageContainerListeners(container, deleteBtn, downloadBtn, previewBtn, cropBtn)
img.setAttribute('data-touch-listeners', 'true')
})
}
}, 0)
}
// 清理动态添加的属性(仅在保存时移除临时属性,保留必要属性)
const cleanContentForSave = () => {
if (!editorRef.value) return content.value
// 创建一个临时的div来操作内容
const tempDiv = document.createElement('div')
tempDiv.innerHTML = editorRef.value.innerHTML
// 移除图片上的临时动态属性
const images = tempDiv.querySelectorAll('img.editor-image')
images.forEach(img => {
// 移除拖拽时的临时样式属性
img.style.removeProperty('z-index')
img.style.removeProperty('transition')
img.style.removeProperty('transform')
img.style.removeProperty('opacity')
// 移除拖拽时的临时类名
img.classList.remove('dragging')
img.classList.remove('swap-animation')
// 移除临时的数据属性保留必要的属性如data-draggable
img.removeAttribute('data-height-adjusted')
img.removeAttribute('data-touch-listeners')
})
// 移除删除按钮的显示状态类
const deleteButtons = tempDiv.querySelectorAll('.image-delete-btn')
deleteButtons.forEach(btn => {
btn.classList.remove('visible')
})
// 移除下载按钮的显示状态类
const downloadButtons = tempDiv.querySelectorAll('.image-download-btn')
downloadButtons.forEach(btn => {
btn.classList.remove('visible')
})
// 移除预览按钮的显示状态类
const previewButtons = tempDiv.querySelectorAll('.image-preview-btn')
previewButtons.forEach(btn => {
btn.classList.remove('visible')
})
// 移除裁切按钮的显示状态类
const cropButtons = tempDiv.querySelectorAll('.image-crop-btn')
cropButtons.forEach(btn => {
btn.classList.remove('visible')
})
// 移除拖拽指示器(如果存在)
const indicators = tempDiv.querySelectorAll('.drag-indicator')
indicators.forEach(indicator => {
indicator.remove()
})
return tempDiv.innerHTML
}
// 暴露方法给父组件
defineExpose({
getContent: () => cleanContentForSave(),
setContent: newContent => {
content.value = newContent || ''
if (editorRef.value) {
try {
editorRef.value.innerHTML = content.value
// 重置拖拽状态确保isLongPress为false
dragState.value.isLongPress = false
dragState.value.draggedImage = null
dragState.value.startX = 0
dragState.value.startY = 0
dragState.value.currentY = 0
// 确保所有图片都被正确包装在.image-container中
wrapOrphanedImages()
// 调整已有图片的高度并添加拖拽功能
adjustExistingImages()
} catch (error) {
console.error('Failed to set innerHTML:', error)
// 备选方案使用textContent
try {
editorRef.value.textContent = content.value
} catch (textContentError) {
console.error('Failed to set textContent:', textContentError)
}
}
} else {
// 如果editorRef还不可用延迟设置
setTimeout(() => {
if (editorRef.value) {
try {
editorRef.value.innerHTML = content.value
// 重置拖拽状态确保isLongPress为false
dragState.value.isLongPress = false
dragState.value.draggedImage = null
dragState.value.startX = 0
dragState.value.startY = 0
dragState.value.currentY = 0
// 确保所有图片都被正确包装在.image-container中
wrapOrphanedImages()
// 调整已有图片的高度并添加拖拽功能
adjustExistingImages()
} 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: 0.0625rem 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: 0.0625rem 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: 100vh;
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) 0.0625rem, transparent 0.0625rem);
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);
touch-action: pan-y;
-webkit-touch-callout: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-overflow-scrolling: touch;
}
.editor-content::before {
display: none;
}
/* 统一处理所有直接子元素的间距 */
.editor-content > * {
margin: 0;
padding: 0;
}
.editor-content:focus,
.editor-content * {
outline: none;
}
/* 优化段落样式,确保与基准线对齐 */
:deep(.editor-content p) {
margin: 0;
line-height: var(--editor-line-height, 1.6);
letter-spacing: 0.01875rem;
}
/* 自定义内容样式 - 统一行高和间距 */
:deep(.editor-content h2) {
font-size: var(--editor-font-size, 1rem);
font-weight: 600;
margin: 0;
color: var(--note-title);
line-height: var(--editor-line-height, 1.6);
letter-spacing: 0.01875rem;
text-align: center;
position: relative;
}
:deep(.editor-content blockquote) {
border-left: 0.1875rem solid var(--primary);
padding: 0 1rem 0 1rem;
margin: 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;
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;
background-image: url('/assets/icons/drawable-xxhdpi/rag_quote.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
:deep(.quote-content) {
border-left: 0.1875rem solid var(--primary);
padding: 0 var(--editor-font-size, 1rem) 0 1rem;
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) {
margin: 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;
}
.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) * 0.0625rem) 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 0.0625rem 0.3125rem 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: 0.25rem;
right: 0.25rem;
width: 2.5rem;
height: 2.5rem;
cursor: pointer;
z-index: 1000;
transition: opacity calc(v-bind(DELETE_BUTTON_DELAY) / 2 * 1ms) ease;
/* 使用背景图片而不是背景色和边框,确保图标正确显示 */
background-image: url('/assets/icons/drawable-xxhdpi/item_image_btn_unbrella_delete.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-color: transparent; /* 确保背景透明 */
pointer-events: none;
opacity: 0;
}
:deep(.editor-content .image-download-btn) {
position: absolute;
top: 0.25rem;
right: 3.125rem; /* 在删除按钮左侧 */
width: 2.5rem;
height: 2.5rem;
cursor: pointer;
z-index: 1000;
transition: opacity calc(v-bind(DELETE_BUTTON_DELAY) / 2 * 1ms) ease;
/* 使用背景图片 */
background-image: url('/assets/icons/drawable-xxhdpi/item_image_btn_unbrella_download_image.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-color: transparent;
pointer-events: none;
opacity: 0;
}
:deep(.editor-content .image-preview-btn) {
position: absolute;
top: 0.25rem;
right: 6rem; /* 在下载按钮左侧 */
width: 2.5rem;
height: 2.5rem;
cursor: pointer;
z-index: 1000;
transition: opacity calc(v-bind(DELETE_BUTTON_DELAY) / 2 * 1ms) ease;
/* 使用背景图片 */
background-image: url('/assets/icons/drawable-xxhdpi/item_image_btn_unbrella_preview_image.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-color: transparent;
pointer-events: none;
opacity: 0;
}
:deep(.editor-content .image-crop-btn) {
position: absolute;
top: 0.25rem;
right: 8.875rem; /* 在预览按钮左侧 */
width: 2.5rem;
height: 2.5rem;
cursor: pointer;
z-index: 1000;
transition: opacity calc(v-bind(DELETE_BUTTON_DELAY) / 2 * 1ms) ease;
/* 使用背景图片 */
background-image: url('/assets/icons/drawable-xxhdpi/item_image_btn_unbrella_edit_image.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-color: transparent;
pointer-events: none;
opacity: 0;
}
:deep(.editor-content .image-delete-btn.visible),
:deep(.editor-content .image-download-btn.visible),
:deep(.editor-content .image-preview-btn.visible),
:deep(.editor-content .image-crop-btn.visible) {
pointer-events: auto;
opacity: 1;
}
:deep(.editor-content .editor-image.draggable) {
cursor: move;
}
:deep(.editor-content .editor-image.dragging) {
opacity: 0.85;
transform: scale(0.96);
z-index: 999;
transition: transform 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.15s ease; /* 优化过渡效果 */
box-shadow: 0 0.75rem 1.5625rem 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(.editor-content .editor-image.swap-animation) {
transition: transform 0.2s ease-out;
}
/* 待办事项样式 */
: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%);
background-image: url('/assets/icons/drawable-xxhdpi/rtf_icon_gtasks.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
:deep(.todo-icon.completed) {
background-image: url('/assets/icons/drawable-xxhdpi/rtf_icon_gtasks_light.png');
}
:deep(.todo-content) {
flex: 1;
outline: none;
line-height: var(--editor-line-height, 1.6);
padding: 0;
margin: 0;
}
</style>