|
|
@@ -1385,6 +1385,361 @@ function showCopyError(button) {
|
|
|
}, 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', () => {
|
|
|
loadDocList();
|
|
|
@@ -1392,6 +1747,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
initSearch();
|
|
|
setupBackToTop();
|
|
|
setupEditFeature();
|
|
|
+ // 延迟初始化语音朗读,确保DOM完全加载
|
|
|
+ setTimeout(() => {
|
|
|
+ initSpeech();
|
|
|
+ }, 500);
|
|
|
});
|
|
|
|
|
|
// 处理浏览器后退/前进
|