You've already forked SmartisanNote.Remake
1959 lines
74 KiB
Vue
1959 lines
74 KiB
Vue
<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>
|