diff --git a/src/components/RichTextEditor.vue b/src/components/RichTextEditor.vue index 2e75817..7a667a6 100644 --- a/src/components/RichTextEditor.vue +++ b/src/components/RichTextEditor.vue @@ -67,8 +67,60 @@ const tools = ref([ }, ]) +// 检查当前选区是否已经在某种格式中 +const isAlreadyInFormat = formatType => { + const selection = window.getSelection() + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0) + const container = range.commonAncestorContainer + + // 向上查找父元素,检查是否已经在指定格式中 + let current = container.nodeType === Node.TEXT_NODE ? container.parentElement : container + while (current && current !== editorRef.value) { + if (formatType === 'bold' && current.tagName === 'B') return true + if (formatType === 'center' && current.style.textAlign === 'center') return true + if (formatType === 'header' && current.tagName === 'H2') return true + if (formatType === 'quote' && (current.tagName === 'BLOCKQUOTE' || current.classList.contains('quote-content'))) return true + if (formatType === 'list' && (current.tagName === 'UL' || current.tagName === 'OL' || current.tagName === 'LI')) return true + current = current.parentElement + } + } + return false +} + +// 检查是否在列表、引用或待办事项中(用于嵌套限制) +const isInListOrQuote = () => { + const selection = window.getSelection() + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0) + const container = range.commonAncestorContainer + + // 向上查找父元素,检查是否在列表、引用或待办事项中 + let current = container.nodeType === Node.TEXT_NODE ? container.parentElement : container + while (current && current !== editorRef.value) { + if (current.tagName === 'UL' || current.tagName === 'OL' || current.tagName === 'LI' || current.tagName === 'BLOCKQUOTE' || current.classList.contains('quote-content') || current.classList.contains('todo-container')) { + return true + } + current = current.parentElement + } + } + return false +} + // 格式化文本 const formatText = (command, value = null) => { + // 检查是否已经应用了相同的格式 + if (command === 'bold' && isAlreadyInFormat('bold')) return + if (command === 'justifyCenter' && isAlreadyInFormat('center')) return + if (command === 'formatBlock' && value === 'h2' && isAlreadyInFormat('header')) return + if (command === 'formatBlock' && value === 'blockquote' && isAlreadyInFormat('quote')) return + if (command === 'insertUnorderedList' && isAlreadyInFormat('list')) return + + // 检查嵌套限制 + if ((command === 'insertUnorderedList' || (command === 'formatBlock' && value === 'blockquote')) && isInListOrQuote()) { + return + } + document.execCommand(command, false, value) updateToolbarState() handleInput() @@ -85,40 +137,66 @@ const insertQuote = () => { const selection = window.getSelection() if (selection.rangeCount > 0) { const range = selection.getRangeAt(0) - + + // 检查是否已经在引用中 + const container = range.commonAncestorContainer + let current = container.nodeType === Node.TEXT_NODE ? container.parentElement : container + while (current && current !== editorRef.value) { + if (current.tagName === 'BLOCKQUOTE' || (current.classList && current.classList.contains('quote-content'))) return + current = current.parentElement + } + + // 检查嵌套限制 + if (isInListOrQuote()) return + // 创建引用容器 const quoteContainer = document.createElement('div') quoteContainer.className = 'quote-container' - + // 创建图标元素 const icon = document.createElement('img') icon.className = 'quote-icon' icon.src = '/assets/icons/drawable-xxhdpi/rag_quote.png' icon.alt = '引用' - + // 创建内容容器 const contentSpan = document.createElement('blockquote') contentSpan.className = 'quote-content' contentSpan.textContent = '引用内容' - + // 组装元素 quoteContainer.appendChild(icon) quoteContainer.appendChild(contentSpan) - + // 插入到当前光标位置 range.insertNode(quoteContainer) - + // 添加换行 const br = document.createElement('br') quoteContainer.parentNode.insertBefore(br, quoteContainer.nextSibling) - + // 聚焦到内容区域 const newRange = document.createRange() newRange.selectNodeContents(contentSpan) newRange.collapse(false) selection.removeAllRanges() selection.addRange(newRange) - + + // 添加事件监听器到内容区域,监听内容变化 + const checkContent = () => { + // 延迟检查,确保内容已更新 + setTimeout(() => { + if (contentSpan.textContent.trim() === '') { + // 如果内容为空,移除整个引用容器 + quoteContainer.remove() + handleInput() + } + }, 0) + } + + contentSpan.addEventListener('input', checkContent) + contentSpan.addEventListener('blur', checkContent) + handleInput() } } @@ -129,6 +207,9 @@ const insertTodoList = () => { if (selection.rangeCount > 0) { const range = selection.getRangeAt(0) + // 检查嵌套限制 + if (isInListOrQuote()) return + // 创建待办事项容器 const todoContainer = document.createElement('div') todoContainer.contentEditable = false @@ -179,7 +260,7 @@ const insertTodoList = () => { handleInput() }) - // 添加事件监听器到内容区域,监听内容变化 + // 添加事件监听器到内容区域,监听内容变化和按键事件 const checkContent = () => { // 延迟检查,确保内容已更新 setTimeout(() => { @@ -194,6 +275,150 @@ const insertTodoList = () => { contentSpan.addEventListener('input', checkContent) contentSpan.addEventListener('blur', checkContent) + // 监听回车键,创建同级待办事项 + contentSpan.addEventListener('keydown', e => { + if (e.key === 'Enter') { + e.preventDefault() + + // 创建新的待办事项 + const newTodoContainer = document.createElement('div') + newTodoContainer.contentEditable = false + newTodoContainer.className = 'todo-container' + + // 创建图标元素 + const newIcon = document.createElement('img') + newIcon.className = 'todo-icon' + newIcon.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks.png' + newIcon.alt = '待办事项' + + // 创建内容容器 + const newContentSpan = document.createElement('span') + newContentSpan.contentEditable = true + newContentSpan.className = 'todo-content' + newContentSpan.textContent = '' + + // 组装元素 + newTodoContainer.appendChild(newIcon) + newTodoContainer.appendChild(newContentSpan) + + // 插入到当前待办事项后面 + todoContainer.parentNode.insertBefore(newTodoContainer, todoContainer.nextSibling) + + // 添加换行 + const newBr = document.createElement('br') + newTodoContainer.parentNode.insertBefore(newBr, newTodoContainer.nextSibling) + + // 聚焦到新内容区域 + const newRange = document.createRange() + newRange.selectNodeContents(newContentSpan) + newRange.collapse(false) + selection.removeAllRanges() + selection.addRange(newRange) + + // 添加事件监听器到新图标 + newIcon.addEventListener('click', function () { + // 根据当前状态切换图标 + if (this.src.includes('rtf_icon_gtasks.png')) { + this.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks_light.png' + newContentSpan.style.color = 'var(--text-tertiary)' + newContentSpan.style.textDecoration = 'line-through' + } else { + this.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks.png' + newContentSpan.style.color = 'var(--note-content)' + newContentSpan.style.textDecoration = 'none' + } + handleInput() + }) + + // 添加事件监听器到新内容区域 + const newCheckContent = () => { + setTimeout(() => { + if (newContentSpan.textContent.trim() === '') { + newTodoContainer.remove() + handleInput() + } + }, 0) + } + + newContentSpan.addEventListener('input', newCheckContent) + newContentSpan.addEventListener('blur', newCheckContent) + + // 监听新内容区域的回车键 + newContentSpan.addEventListener('keydown', e => { + if (e.key === 'Enter') { + e.preventDefault() + + // 创建新的待办事项 + const nextTodoContainer = document.createElement('div') + nextTodoContainer.contentEditable = false + nextTodoContainer.className = 'todo-container' + + // 创建图标元素 + const nextIcon = document.createElement('img') + nextIcon.className = 'todo-icon' + nextIcon.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks.png' + nextIcon.alt = '待办事项' + + // 创建内容容器 + const nextContentSpan = document.createElement('span') + nextContentSpan.contentEditable = true + nextContentSpan.className = 'todo-content' + nextContentSpan.textContent = '' + + // 组装元素 + nextTodoContainer.appendChild(nextIcon) + nextTodoContainer.appendChild(nextContentSpan) + + // 插入到当前待办事项后面 + newTodoContainer.parentNode.insertBefore(nextTodoContainer, newTodoContainer.nextSibling) + + // 添加换行 + const nextBr = document.createElement('br') + nextTodoContainer.parentNode.insertBefore(nextBr, nextTodoContainer.nextSibling) + + // 聚焦到新内容区域 + const nextRange = document.createRange() + nextRange.selectNodeContents(nextContentSpan) + nextRange.collapse(false) + selection.removeAllRanges() + selection.addRange(nextRange) + + // 添加事件监听器到新图标 + nextIcon.addEventListener('click', function () { + // 根据当前状态切换图标 + if (this.src.includes('rtf_icon_gtasks.png')) { + this.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks_light.png' + nextContentSpan.style.color = 'var(--text-tertiary)' + nextContentSpan.style.textDecoration = 'line-through' + } else { + this.src = '/assets/icons/drawable-xxhdpi/rtf_icon_gtasks.png' + nextContentSpan.style.color = 'var(--note-content)' + nextContentSpan.style.textDecoration = 'none' + } + handleInput() + }) + + // 添加事件监听器到新内容区域 + const nextCheckContent = () => { + setTimeout(() => { + if (nextContentSpan.textContent.trim() === '') { + nextTodoContainer.remove() + handleInput() + } + }, 0) + } + + nextContentSpan.addEventListener('input', nextCheckContent) + nextContentSpan.addEventListener('blur', nextCheckContent) + + handleInput() + } + }) + + handleInput() + } + }) + handleInput() } } @@ -364,18 +589,18 @@ defineExpose({ } /* 自定义内容样式 */ -.editor-content h2 { +:deep(.editor-content h2) { font-size: 20px; font-weight: 600; - margin: 16px 0 8px 0; + margin: 8px 0 8px 0; color: var(--note-title); line-height: 1.4; letter-spacing: 0.5px; - padding-left: 24px; + text-align: center; position: relative; } -.editor-content blockquote { +:deep(.editor-content blockquote) { border-left: 3px solid var(--primary); padding-left: 16px; margin: 16px 0; @@ -386,12 +611,12 @@ defineExpose({ font-style: italic; } -.quote-container { +:deep(.quote-container) { position: relative; margin: 16px 0; } -.quote-icon { +:deep(.quote-icon) { position: absolute; left: 0; top: 10px; @@ -399,7 +624,7 @@ defineExpose({ height: 16px; } -.quote-content { +:deep(.quote-content) { border-left: 3px solid var(--primary); padding-left: 32px; margin-left: 16px; @@ -410,40 +635,40 @@ defineExpose({ font-style: italic; } -.editor-content ul, -.editor-content ol { +:deep(.editor-content ul), +:deep(.editor-content ol) { margin: 16px 0; padding-left: 32px; position: relative; } -.editor-content li { +:deep(.editor-content li) { margin: 6px 0; line-height: 1.5; } -.editor-content p { +:deep(.editor-content p) { margin: 0 0 12px 0; line-height: 1.6; letter-spacing: 0.3px; } -.editor-content strong { +:deep(.editor-content strong) { color: var(--text-primary); font-weight: 600; } -.editor-content em { +:deep(.editor-content em) { color: var(--text-secondary); font-style: italic; } -.editor-content u { +:deep(.editor-content u) { text-decoration: none; border-bottom: 1px solid var(--text-primary); } -.editor-content hr { +:deep(.editor-content hr) { border: none; height: 1px; background-color: var(--border); @@ -458,16 +683,16 @@ defineExpose({ :deep(.todo-container) { display: flex; align-items: flex-start; - margin: 0.5rem 0; - padding: 0.25rem 0; + margin: 0.1rem 0; + padding: 0.02rem 0; position: relative; - padding-left: 1.5rem; + padding-left: 1.2rem; } :deep(.todo-icon) { position: absolute; left: 0; - top: 0.2rem; + top: 0; width: 2rem; height: 2rem; cursor: pointer; @@ -507,57 +732,4 @@ defineExpose({ border-radius: 0 0.25rem 0.25rem 0; font-style: italic; } - -/* 格式标识图标 */ -.editor-content h2::before { - content: ''; - display: inline-block; - width: 16px; - height: 16px; - background-image: url('/assets/icons/drawable-xxhdpi/rtf_header_normal.9.png'); - background-size: contain; - background-repeat: no-repeat; - position: absolute; - left: 0; - top: 2px; -} - -.editor-content blockquote::before { - content: ''; - display: inline-block; - width: 16px; - height: 16px; - background-image: url('/assets/icons/drawable-xxhdpi/rtf_quot_normal.9.png'); - background-size: contain; - background-repeat: no-repeat; - position: absolute; - left: 8px; - top: 10px; -} - -.editor-content ul::before { - content: ''; - display: inline-block; - width: 16px; - height: 16px; - background-image: url('/assets/icons/drawable-xxhdpi/rtf_list_normal.9.png'); - background-size: contain; - background-repeat: no-repeat; - position: absolute; - left: 0; - top: 2px; -} - -.editor-content ol::before { - content: ''; - display: inline-block; - width: 16px; - height: 16px; - background-image: url('/assets/icons/drawable-xxhdpi/rtf_list_normal.9.png'); - background-size: contain; - background-repeat: no-repeat; - position: absolute; - left: 0; - top: 2px; -}