Преглед изворни кода

'朗读功能实现——基于原生api'

admincjy пре 2 недеља
родитељ
комит
753b2c4c90
3 измењених фајлова са 501 додато и 0 уклоњено
  1. 121 0
      public/css/style.css
  2. 359 0
      public/js/reader.js
  3. 21 0
      public/reader.html

+ 121 - 0
public/css/style.css

@@ -1458,3 +1458,124 @@ pre code {
     line-height: 100px;
     line-height: 100px;
 }
 }
 
 
+/* ==================== 语音朗读功能样式 ==================== */
+/* 语音朗读按钮位置(仅移动端显示) */
+.speech-btn {
+    left: 20px !important;
+    right: auto !important;
+}
+
+/* 移动端显示控制 */
+.mobile-only {
+    display: none !important;
+}
+
+/* 移动端显示语音按钮 */
+@media (max-width: 768px) {
+    .speech-btn.mobile-only {
+        display: flex !important;
+        align-items: center;
+        justify-content: center;
+        bottom: 100px !important; /* 在左侧目录按钮上方 */
+    }
+}
+
+/* 平板设备也显示语音按钮 */
+@media (max-width: 1024px) {
+    .speech-btn.mobile-only {
+        display: flex !important;
+        align-items: center;
+        justify-content: center;
+        bottom: 100px !important;
+    }
+}
+
+/* 播放/暂停状态强制保持原始样式 */
+.speech-btn.playing,
+.speech-btn.paused,
+.speech-btn.playing:hover,
+.speech-btn.paused:hover,
+.speech-btn.playing:focus,
+.speech-btn.paused:focus,
+.speech-btn.playing:active,
+.speech-btn.paused:active {
+    /* 强制保持与floating-action-btn相同的样式 */
+    background: var(--primary-color) !important;
+    opacity: 0.9 !important;
+    transform: none !important;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
+}
+
+/* 移动端下播放/暂停状态也要保持原始透明度 */
+@media (max-width: 1024px) {
+    .speech-btn.playing,
+    .speech-btn.paused,
+    .speech-btn.playing:hover,
+    .speech-btn.paused:hover,
+    .speech-btn.playing:focus,
+    .speech-btn.paused:focus,
+    .speech-btn.playing:active,
+    .speech-btn.paused:active {
+        opacity: 0.45 !important; /* 与移动端floating-action-btn保持一致 */
+        background: var(--primary-color) !important;
+        transform: none !important;
+        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
+    }
+
+    /* 悬停时只改变透明度,其他保持不变 */
+    .speech-btn:hover {
+        opacity: 1 !important;
+    }
+}
+
+/* 朗读高亮样式 */
+.speech-highlight {
+    background-color: rgba(9, 105, 218, 0.2) !important; /* 浅蓝色高亮 */
+    padding: 2px 4px;
+    border-radius: 3px;
+    transition: background-color 0.3s ease;
+    position: relative;
+    animation: speechPulse 1.5s ease-in-out infinite;
+}
+
+@keyframes speechPulse {
+    0%, 100% {
+        background-color: rgba(9, 105, 218, 0.2);
+    }
+    50% {
+        background-color: rgba(9, 105, 218, 0.35);
+    }
+}
+
+/* 根据状态显示不同图标 */
+.speech-btn .speech-icon,
+.speech-btn .play-icon,
+.speech-btn .pause-icon {
+    display: none !important; /* 默认隐藏所有图标 */
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+}
+
+.speech-btn svg {
+    width: 24px;
+    height: 24px;
+    display: block;
+}
+
+/* 默认状态:显示耳机图标 */
+.speech-btn:not(.playing):not(.paused) .speech-icon {
+    display: flex !important;
+}
+
+/* 播放状态:显示暂停图标 */
+.speech-btn.playing .pause-icon {
+    display: flex !important;
+}
+
+/* 暂停状态:显示播放图标 */
+.speech-btn.paused .play-icon {
+    display: flex !important;
+}
+

+ 359 - 0
public/js/reader.js

@@ -1385,6 +1385,361 @@ function showCopyError(button) {
     }, 2000);
     }, 2000);
 }
 }
 
 
+// ==================== 语音朗读功能 ====================
+let speechSynthesis = window.speechSynthesis;
+let currentUtterance = null;
+let isPaused = false;
+let isPlaying = false;
+let currentHighlightElement = null;
+let speechTexts = []; // 存储所有要朗读的文本段落
+let currentSpeechIndex = 0; // 当前朗读的段落索引
+let lastPausedElement = null; // 记录上次暂停的元素
+
+// 初始化语音朗读
+function initSpeech() {
+    const speechBtn = document.getElementById('speech-btn');
+    if (!speechBtn) return;
+
+    // 检查浏览器是否支持语音合成
+    if (!('speechSynthesis' in window)) {
+        speechBtn.style.display = 'none';
+        return;
+    }
+
+    // 某些浏览器需要先加载语音列表
+    if (speechSynthesis.getVoices().length === 0) {
+        speechSynthesis.addEventListener('voiceschanged', () => {}, { once: true });
+    }
+
+    // 添加点击事件监听
+    speechBtn.addEventListener('click', (e) => {
+        e.preventDefault();
+        e.stopPropagation();
+        handleSpeechButtonClick();
+    });
+}
+
+// 获取当前视口中第一个可见的文本元素
+function getFirstVisibleTextElement() {
+    const content = document.getElementById('markdown-content');
+    if (!content) return null;
+
+    // 获取所有可朗读的文本元素
+    const elements = content.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th');
+    const viewportHeight = window.innerHeight;
+
+    for (const element of elements) {
+        // 跳过代码块内的元素
+        if (element.closest('pre') || element.closest('code')) {
+            continue;
+        }
+
+        const rect = element.getBoundingClientRect();
+        // 检查元素是否在视口内(顶部在视口内或元素跨越视口)
+        if ((rect.top >= 0 && rect.top < viewportHeight) ||
+            (rect.top < 0 && rect.bottom > 0)) {
+            return element;
+        }
+    }
+
+    return null;
+}
+
+// 判断元素是否在视口内可见
+function isElementVisible(element) {
+    if (!element) return false;
+
+    const rect = element.getBoundingClientRect();
+    const windowHeight = window.innerHeight;
+
+    // 元素至少有一部分在视口内
+    return rect.bottom > 0 && rect.top < windowHeight;
+}
+
+// 处理语音按钮点击
+function handleSpeechButtonClick() {
+    const speechBtn = document.getElementById('speech-btn');
+
+    if (isPlaying && !isPaused) {
+        // 正在播放 -> 暂停
+        pauseSpeech();
+        speechBtn.classList.remove('playing');
+        speechBtn.classList.add('paused');
+    } else if (isPaused) {
+        // 已暂停 -> 判断暂停位置是否可见
+        if (isElementVisible(lastPausedElement)) {
+            // 暂停位置可见,继续播放
+            resumeSpeech();
+            speechBtn.classList.remove('paused');
+            speechBtn.classList.add('playing');
+        } else {
+            // 暂停位置不可见,从当前可见的第一行开始
+            const currentVisibleElement = getFirstVisibleTextElement();
+            stopSpeech();
+            isPaused = false;
+            isPlaying = false;
+            startSpeechFromElement(currentVisibleElement);
+            speechBtn.classList.remove('paused');
+            speechBtn.classList.add('playing');
+        }
+    } else {
+        // 未开始 -> 从当前滚动位置开始朗读
+        const currentVisibleElement = getFirstVisibleTextElement();
+        startSpeechFromElement(currentVisibleElement);
+        speechBtn.classList.add('playing');
+    }
+}
+
+// 从指定元素开始朗读
+function startSpeechFromElement(startElement) {
+    if (!DOM.content) DOM.content = document.getElementById('markdown-content');
+
+    if (!DOM.content || !DOM.content.textContent.trim()) {
+        alert('请先加载文档内容');
+        return;
+    }
+
+    // 提取要朗读的文本
+    speechTexts = extractSpeechTexts();
+
+    if (speechTexts.length === 0) {
+        alert('没有找到可朗读的文本内容');
+        return;
+    }
+
+    // 如果指定了起始元素,找到它在列表中的位置
+    if (startElement) {
+        const startIndex = speechTexts.findIndex(item => item.element === startElement);
+        currentSpeechIndex = startIndex !== -1 ? startIndex :
+            // 如果没找到,找最接近的元素
+            speechTexts.reduce((closestIdx, item, idx) => {
+                const rect = item.element.getBoundingClientRect();
+                const distance = Math.abs(rect.top);
+                const prevRect = speechTexts[closestIdx].element.getBoundingClientRect();
+                return distance < Math.abs(prevRect.top) ? idx : closestIdx;
+            }, 0);
+    } else {
+        currentSpeechIndex = 0;
+    }
+
+    isPlaying = true;
+    isPaused = false;
+    lastPausedElement = null; // 清除之前的暂停记录
+
+    // 开始朗读
+    speakNextSegment();
+}
+
+// 提取要朗读的文本(跳过图片和代码块)
+function extractSpeechTexts() {
+    const texts = [];
+    const content = document.getElementById('markdown-content');
+
+    if (!content) return texts;
+
+    // 获取所有文本元素,排除代码块和图片
+    const elements = content.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th');
+
+    elements.forEach(element => {
+        // 跳过代码块内的元素
+        if (element.closest('pre') || element.closest('code')) {
+            return;
+        }
+
+        // 获取纯文本内容
+        const text = element.textContent.trim();
+
+        if (text) {
+            texts.push({
+                text: text,
+                element: element
+            });
+        }
+    });
+
+    return texts;
+}
+
+// 朗读下一段
+function speakNextSegment() {
+    if (currentSpeechIndex >= speechTexts.length) {
+        // 朗读完成
+        console.log('朗读完成');
+        stopSpeech();
+        return;
+    }
+
+    const segment = speechTexts[currentSpeechIndex];
+    console.log('朗读第', currentSpeechIndex + 1, '段,内容:', segment.text.substring(0, 50) + '...');
+
+    // 清除之前的高亮
+    clearHighlight();
+
+    // 高亮当前段落
+    highlightElement(segment.element);
+
+    // 取消之前的朗读(如果有)
+    if (speechSynthesis.speaking) {
+        speechSynthesis.cancel();
+    }
+
+    // 创建语音实例
+    currentUtterance = new SpeechSynthesisUtterance(segment.text);
+
+    // 设置语音参数 - 优先尝试中文,如果不支持则使用默认语言
+    const voices = speechSynthesis.getVoices();
+    const chineseVoice = voices.find(voice =>
+        voice.lang === 'zh-CN' ||
+        voice.lang.startsWith('zh') ||
+        voice.lang.includes('Chinese')
+    );
+
+    if (chineseVoice) {
+        currentUtterance.voice = chineseVoice;
+        currentUtterance.lang = chineseVoice.lang;
+    } else {
+        currentUtterance.lang = 'zh-CN'; // 仍然设置为中文,让系统选择最佳声音
+    }
+
+    currentUtterance.rate = 1.0; // 语速
+    currentUtterance.pitch = 1.0; // 音调
+    currentUtterance.volume = 1.0; // 音量
+
+    // 语音错误处理
+    currentUtterance.onerror = (event) => {
+        alert('语音朗读出现错误,请检查浏览器设置');
+        stopSpeech();
+    };
+
+    // 语音结束事件
+    currentUtterance.onend = () => {
+        currentSpeechIndex++;
+        if (isPlaying && !isPaused) {
+            // 延迟一点再朗读下一段,让用户有停顿感
+            setTimeout(() => {
+                if (isPlaying && !isPaused) {
+                    speakNextSegment();
+                }
+            }, 300);
+        }
+    };
+
+    // 语音边界事件(用于更精确的高亮,但Web Speech API支持有限)
+    currentUtterance.onboundary = (event) => {
+        // 检查是否需要滚动
+        checkAndScroll(segment.element);
+    };
+
+    // 开始朗读
+    try {
+        speechSynthesis.speak(currentUtterance);
+    } catch (error) {
+        alert('无法启动语音朗读,请检查浏览器权限');
+        stopSpeech();
+    }
+}
+
+// 高亮元素
+function highlightElement(element) {
+    if (!element) return;
+
+    currentHighlightElement = element;
+    element.classList.add('speech-highlight');
+
+    // 确保元素在视口内
+    checkAndScroll(element);
+}
+
+// 清除高亮
+function clearHighlight() {
+    if (currentHighlightElement) {
+        currentHighlightElement.classList.remove('speech-highlight');
+        currentHighlightElement = null;
+    }
+}
+
+// 检查并滚动
+function checkAndScroll(element) {
+    if (!element) return;
+
+    const rect = element.getBoundingClientRect();
+    const viewportHeight = window.innerHeight;
+
+    // 如果元素在视口底部附近(最后20%),向下滚动
+    if (rect.bottom > viewportHeight * 0.8) {
+        element.scrollIntoView({
+            behavior: 'smooth',
+            block: 'center'
+        });
+    }
+    // 如果元素在视口顶部之上,也滚动到中心
+    else if (rect.top < 0) {
+        element.scrollIntoView({
+            behavior: 'smooth',
+            block: 'center'
+        });
+    }
+}
+
+// 暂停朗读
+function pauseSpeech() {
+    if (speechSynthesis && isPlaying) {
+        // 记录当前朗读的元素(优先使用当前高亮的元素)
+        if (currentHighlightElement) {
+            lastPausedElement = currentHighlightElement;
+        } else if (currentSpeechIndex < speechTexts.length) {
+            lastPausedElement = speechTexts[currentSpeechIndex].element;
+        }
+        speechSynthesis.pause();
+        isPaused = true;
+    }
+}
+
+// 继续朗读
+function resumeSpeech() {
+    if (speechSynthesis && isPaused) {
+        speechSynthesis.resume();
+        isPaused = false;
+    }
+}
+
+// 停止朗读
+function stopSpeech() {
+    if (speechSynthesis) {
+        speechSynthesis.cancel();
+    }
+    cleanupSpeechState();
+}
+
+// 清理语音状态
+function cleanupSpeechState() {
+    isPlaying = false;
+    isPaused = false;
+    currentSpeechIndex = 0;
+    currentUtterance = null;
+    speechTexts = [];
+    lastPausedElement = null; // 清除暂停记录
+
+    // 清除高亮
+    clearHighlight();
+
+    // 重置按钮状态
+    const speechBtn = document.getElementById('speech-btn');
+    if (speechBtn) {
+        speechBtn.classList.remove('playing', 'paused');
+    }
+}
+
+// 当加载新文档时停止朗读
+const originalLoadDocument = loadDocument;
+loadDocument = function(docName, scrollToText = null) {
+    // 停止当前朗读
+    stopSpeech();
+
+    // 调用原始的loadDocument
+    return originalLoadDocument.call(this, docName, scrollToText);
+};
+
 // 初始化
 // 初始化
 document.addEventListener('DOMContentLoaded', () => {
 document.addEventListener('DOMContentLoaded', () => {
     loadDocList();
     loadDocList();
@@ -1392,6 +1747,10 @@ document.addEventListener('DOMContentLoaded', () => {
     initSearch();
     initSearch();
     setupBackToTop();
     setupBackToTop();
     setupEditFeature();
     setupEditFeature();
+    // 延迟初始化语音朗读,确保DOM完全加载
+    setTimeout(() => {
+        initSpeech();
+    }, 500);
 });
 });
 
 
 // 处理浏览器后退/前进
 // 处理浏览器后退/前进

+ 21 - 0
public/reader.html

@@ -121,6 +121,27 @@
         <button class="toggle-sidebar-btn" id="toggle-right" title="切换标题目录">
         <button class="toggle-sidebar-btn" id="toggle-right" title="切换标题目录">
             <span>≡</span>
             <span>≡</span>
         </button>
         </button>
+
+        <!-- 语音朗读按钮(仅移动端显示) -->
+        <button class="floating-action-btn speech-btn mobile-only" id="speech-btn" title="语音朗读">
+            <span class="speech-icon">
+                <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+                    <path d="M3 18v-6a9 9 0 0118 0v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+                    <path d="M21 19a2 2 0 01-2 2h-1a2 2 0 01-2-2v-3a2 2 0 012-2h3zM3 19a2 2 0 002 2h1a2 2 0 002-2v-3a2 2 0 00-2-2H3z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+                </svg>
+            </span>
+            <span class="play-icon">
+                <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+                    <path d="M8 5v14l11-7z"/>
+                </svg>
+            </span>
+            <span class="pause-icon">
+                <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+                    <rect x="6" y="4" width="4" height="16" rx="1"/>
+                    <rect x="14" y="4" width="4" height="16" rx="1"/>
+                </svg>
+            </span>
+        </button>
     </div>
     </div>
 
 
     <script src="/js/reader.js"></script>
     <script src="/js/reader.js"></script>