Selaa lähdekoodia

按钮跟随,点击单词开始播放

sequoia00 3 tuntia sitten
vanhempi
commit
69b41d7760
1 muutettua tiedostoa jossa 314 lisäystä ja 219 poistoa
  1. 314 219
      static/web/viewer.html

+ 314 - 219
static/web/viewer.html

@@ -42,19 +42,19 @@
             z-index: 10001;
         }
 
-        .button {
-            padding: 4px 6px;
-            background-color: #007bff;
-            color: white;
-            border: none;
-            border-radius: 4px;
-            cursor: pointer;
-            font-size: 11px;
-            margin: 0 2px;
-            flex: 1;
-            text-align: center;
-            white-space: nowrap;
-        }
+        .button {
+            padding: 4px 6px;
+            background-color: #007bff;
+            color: white;
+            border: none;
+            border-radius: 4px;
+            cursor: pointer;
+            font-size: 11px;
+            margin: 0 2px;
+            flex: 1;
+            text-align: center;
+            white-space: nowrap;
+        }
 
         .button-secondary {
             background-color: #6c757d;
@@ -73,30 +73,30 @@
             background-color: #dc3545;
         }
 
-        .button-pause {
-            background-color: #ffc107;
-            color: black;
-        }
-
-        .button-stop {
-            background-color: #dc3545;
-        }
+        .button-pause {
+            background-color: #ffc107;
+            color: black;
+        }
+
+        .button-stop {
+            background-color: #dc3545;
+        }
 
         /* 底部按钮栏 */
-        .bottom-bar {
-            position: fixed;
-            bottom: 0;
-            margin: auto;
-            width: 92%;
-            left: 50%;
-            transform: translateX(-50%);
-            background-color: rgba(255, 255, 255, 0.0);
-            display: flex;
-            justify-content: space-around;
-            padding: 6px 0;
-            box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
-            z-index: 1000;
-        }
+        .bottom-bar {
+            position: fixed;
+            bottom: 0;
+            margin: auto;
+            width: 75%;
+            left: 50%;
+            transform: translateX(-50%);
+            background-color: rgba(255, 255, 255, 0.0);
+            display: flex;
+            justify-content: space-around;
+            padding: 6px 0;
+            box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
+            z-index: 1000;
+        }
 
         /* 顶部状态条 */
         #loading-indicator {
@@ -159,17 +159,17 @@
         }
 
         /* 手机端优化 */
-        @media (max-width: 768px) {
+        @media (max-width: 768px) {
             .modal {
                 width: 95%;
                 padding: 10px;
             }
 
-            .button {
-                font-size: 10px;
-                padding: 4px 4px;
-                margin: 0 2px;
-            }
+            .button {
+                font-size: 10px;
+                padding: 4px 4px;
+                margin: 0 2px;
+            }
 
             .bottom-bar {
                 padding: 6px 0;
@@ -188,14 +188,14 @@
 
     <audio id="audio-player" controls></audio>
 
-    <div class="bottom-bar">
-        <button id="uploadButton" class="button">上传 PDF</button>
-        <button id="openDirectoryButton" class="button">打开目录</button>
-        <button id="select-all-text" class="button button-light">阅读整页</button>
-        <button id="toggle-text-select" class="button button-success">文本选择</button>
-        <button id="pause-play-button" class="button button-pause">暂停播放</button>
-        <button id="stop-play-button" class="button button-stop">停止</button>
-    </div>
+    <div class="bottom-bar">
+        <button id="uploadButton" class="button">上传 PDF</button>
+        <button id="openDirectoryButton" class="button">打开目录</button>
+        <button id="select-all-text" class="button button-light">阅读整页</button>
+        <button id="pause-play-button" class="button button-pause">暂停播放</button>
+        <button id="stop-play-button" class="button button-stop">停止</button>
+        <button id="toggle-text-select" class="button button-success">文本选择</button>
+    </div>
 
     <input type="file" id="pdfInput" accept="application/pdf" style="display: none;" />
 
@@ -1045,18 +1045,72 @@
                 const directoryModal = document.getElementById('directoryModal');
                 const closeDirectoryButton = document.getElementById('closeDirectoryButton');
                 const pdfList = document.getElementById('pdfList');
-                const prevPageButton = document.getElementById('prevPage');
-                const nextPageButton = document.getElementById('nextPage');
+                const prevPageButton = document.getElementById('prevPage');
+                const nextPageButton = document.getElementById('nextPage');
                 const pageInfo = document.getElementById('pageInfo');
                 const readPageButton = document.getElementById('select-all-text');
                 const pausePlayButton = document.getElementById('pause-play-button');
                 const stopPlayButton = document.getElementById('stop-play-button');
-
-                let selectedFile = null;
-                let currentPage = 1;
+                const bottomBar = document.querySelector('.bottom-bar');
+
+                let selectedFile = null;
+                let currentPage = 1;
                 const itemsPerPage = 10;
                 let allFiles = [];
-                let selectedStartWord = '';
+                let selectedStartContext = null;
+
+                function syncBottomBarToPage() {
+                    if (!bottomBar) return;
+
+                    const app = window.PDFViewerApplication;
+                    const currentPageNumber = app && app.pdfViewer ? app.pdfViewer.currentPageNumber : null;
+                    const pageEl = currentPageNumber
+                        ? document.querySelector(`.page[data-page-number="${currentPageNumber}"]`)
+                        : document.querySelector('.page');
+
+                    if (!pageEl) {
+                        bottomBar.style.width = '75%';
+                        bottomBar.style.left = '50%';
+                        return;
+                    }
+
+                    const rect = pageEl.getBoundingClientRect();
+                    if (!rect.width) return;
+
+                    const viewportWidth = window.innerWidth;
+                    const maxWidth = Math.max(240, viewportWidth - 16);
+                    const width = Math.min(rect.width, maxWidth);
+                    bottomBar.style.width = `${Math.round(width)}px`;
+
+                    let centerX = rect.left + rect.width / 2;
+                    const minCenter = width / 2 + 8;
+                    const maxCenter = viewportWidth - width / 2 - 8;
+                    centerX = Math.min(maxCenter, Math.max(minCenter, centerX));
+                    bottomBar.style.left = `${Math.round(centerX)}px`;
+                }
+
+                function setupBottomBarSync() {
+                    const viewerContainer = document.getElementById('viewerContainer');
+                    if (viewerContainer) {
+                        viewerContainer.addEventListener('scroll', () => requestAnimationFrame(syncBottomBarToPage), { passive: true });
+                    }
+                    window.addEventListener('resize', () => requestAnimationFrame(syncBottomBarToPage), { passive: true });
+
+                    const tryBindPdfEvents = () => {
+                        const app = window.PDFViewerApplication;
+                        const eventBus = app ? app.eventBus : null;
+                        if (!eventBus) {
+                            setTimeout(tryBindPdfEvents, 200);
+                            return;
+                        }
+                        ['pagechanging', 'scalechanging', 'updateviewarea', 'pagerendered'].forEach(evt => {
+                            eventBus.on(evt, () => requestAnimationFrame(syncBottomBarToPage));
+                        });
+                        requestAnimationFrame(syncBottomBarToPage);
+                    };
+                    tryBindPdfEvents();
+                }
+                setupBottomBarSync();
 
                 // 上传按钮点击事件
                 uploadButton.addEventListener('click', function () {
@@ -1110,16 +1164,16 @@
                     formData.append('file', selectedFile);
                     formData.append('custom_name', customFileName);
 
-                    fetch('/upload-pdf', {
-                        method: 'POST',
-                        body: formData
-                    })
-                        .then(response => {
-                            if (!response.ok) {
-                                throw new Error(`HTTP ${response.status}`);
-                            }
-                            return response.json();
-                        })
+                    fetch('/upload-pdf', {
+                        method: 'POST',
+                        body: formData
+                    })
+                        .then(response => {
+                            if (!response.ok) {
+                                throw new Error(`HTTP ${response.status}`);
+                            }
+                            return response.json();
+                        })
                         .then(data => {
                             if (data.success) {
                                 const uploadedFilePath = data.file_path;
@@ -1178,13 +1232,13 @@
                     directoryModal.style.display = 'block';
                     currentPage = 1;
 
-                    fetch('/list-pdfs')
-                        .then(response => {
-                            if (!response.ok) {
-                                throw new Error(`HTTP ${response.status}`);
-                            }
-                            return response.json();
-                        })
+                    fetch('/list-pdfs')
+                        .then(response => {
+                            if (!response.ok) {
+                                throw new Error(`HTTP ${response.status}`);
+                            }
+                            return response.json();
+                        })
                         .then(data => {
                             if (data.success) {
                                 allFiles = data.files;
@@ -1234,12 +1288,12 @@
                 });
 
                 // 语音控制
-                let isListening = false;
-                let audioContext = null;
-                let isPaused = false;
-                let currentAudio = null;
-                let activePlayController = null;
-                let activeStreamReader = null;
+                let isListening = false;
+                let audioContext = null;
+                let isPaused = false;
+                let currentAudio = null;
+                let activePlayController = null;
+                let activeStreamReader = null;
 
                 // 初始化 AudioContext
                 function initAudioContext() {
@@ -1273,84 +1327,127 @@
                     button.classList.toggle('button-danger', isListening);
                 });
 
-                // 暂停/继续播放
-                pausePlayButton.addEventListener('click', () => {
-                    isPaused = !isPaused;
-                    pausePlayButton.textContent = isPaused ? "继续播放" : "暂停播放";
-                    if (isPaused && currentAudio) {
+                // 暂停/继续播放
+                pausePlayButton.addEventListener('click', () => {
+                    isPaused = !isPaused;
+                    pausePlayButton.textContent = isPaused ? "继续播放" : "暂停播放";
+                    if (isPaused && currentAudio) {
                         currentAudio.pause();
                         if (audioContext) audioContext.suspend();
                     } else if (!isPaused && currentAudio) {
-                        currentAudio.play();
-                        if (audioContext) audioContext.resume();
-                    }
-                });
-
-                // 停止播放
-                function stopCurrentPlayback(showTip = true) {
-                    if (activePlayController) {
-                        activePlayController.abort();
-                        activePlayController = null;
-                    }
-                    if (activeStreamReader) {
-                        try {
-                            activeStreamReader.cancel();
-                        } catch (e) {
-                            console.warn('取消流读取失败:', e);
-                        }
-                        activeStreamReader = null;
-                    }
-                    if (currentAudio) {
-                        currentAudio.pause();
-                        currentAudio.src = '';
-                        currentAudio = null;
-                    }
-                    isPaused = false;
-                    pausePlayButton.textContent = "暂停播放";
-                    if (showTip) {
-                        const loadingIndicator = document.getElementById('loading-indicator');
-                        loadingIndicator.textContent = '已停止';
-                        loadingIndicator.style.display = 'block';
-                        setTimeout(() => {
-                            loadingIndicator.style.display = 'none';
-                        }, 800);
-                    }
-                }
-
-                stopPlayButton.addEventListener('click', () => {
-                    stopCurrentPlayback();
-                });
-
-                // 仅识别 PDF 页面文字层里的选择
-                function getSelectedWordOnPage() {
-                    const selection = window.getSelection();
-                    if (!selection || selection.rangeCount === 0) {
-                        return '';
-                    }
-
-                    const selectedText = selection.toString().trim();
-                    if (!selectedText) {
-                        return '';
-                    }
-
-                    const range = selection.getRangeAt(0);
-                    const container = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
-                        ? range.commonAncestorContainer
-                        : range.commonAncestorContainer.parentElement;
-                    if (!container || !container.closest('.textLayer')) {
-                        return '';
-                    }
-
-                    const firstWord = selectedText.split(/\s+/)[0].trim();
-                    return firstWord.replace(/(^[^A-Za-z0-9\u4e00-\u9fa5]+|[^A-Za-z0-9\u4e00-\u9fa5]+$)/g, '') || firstWord;
-                }
-
-                function updateReadPageButtonText() {
-                    selectedStartWord = getSelectedWordOnPage();
-                    readPageButton.textContent = selectedStartWord ? '从此阅读' : '阅读整页';
-                }
-                document.addEventListener('selectionchange', updateReadPageButtonText);
-                updateReadPageButtonText();
+                        currentAudio.play();
+                        if (audioContext) audioContext.resume();
+                    }
+                });
+
+                // 停止播放
+                function stopCurrentPlayback(showTip = true) {
+                    if (activePlayController) {
+                        activePlayController.abort();
+                        activePlayController = null;
+                    }
+                    if (activeStreamReader) {
+                        try {
+                            activeStreamReader.cancel();
+                        } catch (e) {
+                            console.warn('取消流读取失败:', e);
+                        }
+                        activeStreamReader = null;
+                    }
+                    if (currentAudio) {
+                        currentAudio.pause();
+                        currentAudio.src = '';
+                        currentAudio = null;
+                    }
+                    isPaused = false;
+                    pausePlayButton.textContent = "暂停播放";
+                    if (showTip) {
+                        const loadingIndicator = document.getElementById('loading-indicator');
+                        loadingIndicator.textContent = '已停止';
+                        loadingIndicator.style.display = 'block';
+                        setTimeout(() => {
+                            loadingIndicator.style.display = 'none';
+                        }, 800);
+                    }
+                }
+
+                stopPlayButton.addEventListener('click', () => {
+                    stopCurrentPlayback();
+                });
+
+                function countOccurrences(haystack, needle) {
+                    if (!haystack || !needle) return 0;
+                    let count = 0;
+                    let pos = 0;
+                    const source = haystack.toLowerCase();
+                    const target = needle.toLowerCase();
+                    while (true) {
+                        const idx = source.indexOf(target, pos);
+                        if (idx === -1) break;
+                        count++;
+                        pos = idx + target.length;
+                    }
+                    return count;
+                }
+
+                function findNthOccurrence(haystack, needle, n) {
+                    if (!haystack || !needle || !n || n < 1) return -1;
+                    let pos = 0;
+                    let seen = 0;
+                    const source = haystack.toLowerCase();
+                    const target = needle.toLowerCase();
+                    while (true) {
+                        const idx = source.indexOf(target, pos);
+                        if (idx === -1) return -1;
+                        seen++;
+                        if (seen === n) return idx;
+                        pos = idx + target.length;
+                    }
+                }
+
+                // 仅识别 PDF 页面文字层里的选择,并记录是该页第几次出现
+                function getSelectedContextOnPage() {
+                    const selection = window.getSelection();
+                    if (!selection || selection.rangeCount === 0) {
+                        return null;
+                    }
+
+                    const selectedText = selection.toString().trim();
+                    if (!selectedText) {
+                        return null;
+                    }
+
+                    const range = selection.getRangeAt(0);
+                    const container = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
+                        ? range.commonAncestorContainer
+                        : range.commonAncestorContainer.parentElement;
+                    if (!container || !container.closest('.textLayer')) {
+                        return null;
+                    }
+
+                    const textLayer = container.closest('.textLayer');
+                    const firstWord = selectedText.split(/\s+/)[0].trim();
+                    const word = firstWord.replace(/(^[^A-Za-z0-9\u4e00-\u9fa5]+|[^A-Za-z0-9\u4e00-\u9fa5]+$)/g, '') || firstWord;
+                    if (!word) {
+                        return null;
+                    }
+
+                    const prefixRange = document.createRange();
+                    prefixRange.selectNodeContents(textLayer);
+                    prefixRange.setEnd(range.startContainer, range.startOffset);
+                    const prefixText = prefixRange.toString();
+
+                    // 当前词是“前缀中已出现次数 + 1”
+                    const occurrence = countOccurrences(prefixText, word) + 1;
+                    return { word, occurrence };
+                }
+
+                function updateReadPageButtonText() {
+                    selectedStartContext = getSelectedContextOnPage();
+                    readPageButton.textContent = selectedStartContext ? '从此阅读' : '阅读整页';
+                }
+                document.addEventListener('selectionchange', updateReadPageButtonText);
+                updateReadPageButtonText();
 
                 // 播放音频队列(添加句子间停顿以模拟段落停顿)
                 async function playAudioSequentially(queue, signal) {
@@ -1458,29 +1555,29 @@
                     queue.splice(index, 0, chunk);
                 }
 
-                // 开始转换(发送完整文本,让TTS API处理分割)
-                async function startConversion(fullText) {
-                    const loadingIndicator = document.getElementById('loading-indicator');
-                    try {
-                        stopCurrentPlayback(false);
-                        const response = await fetch('http://141.140.15.30:8028/generate', {
-                            method: 'POST',
-                            headers: { 'Content-Type': 'application/json' },
-                            body: JSON.stringify({ text: fullText, voice: "af_heart", speed: 1.0 })
-                        });
+                // 开始转换(发送完整文本,让TTS API处理分割)
+                async function startConversion(fullText) {
+                    const loadingIndicator = document.getElementById('loading-indicator');
+                    try {
+                        stopCurrentPlayback(false);
+                        const response = await fetch('http://141.140.15.30:8028/generate', {
+                            method: 'POST',
+                            headers: { 'Content-Type': 'application/json' },
+                            body: JSON.stringify({ text: fullText, voice: "af_heart", speed: 1.0 })
+                        });
 
                         if (!response.ok) throw new Error(`服务器错误: ${response.status}`);
 
-                        const reader = response.body.getReader();
-                        activeStreamReader = reader;
-                        const decoder = new TextDecoder();
-                        let buffer = '';
-                        const audioQueue = [];
-                        let expectedIndex = 0;
-
-                        const playController = new AbortController();
-                        activePlayController = playController;
-                        const playPromise = playAudioSequentially(audioQueue, playController.signal);
+                        const reader = response.body.getReader();
+                        activeStreamReader = reader;
+                        const decoder = new TextDecoder();
+                        let buffer = '';
+                        const audioQueue = [];
+                        let expectedIndex = 0;
+
+                        const playController = new AbortController();
+                        activePlayController = playController;
+                        const playPromise = playAudioSequentially(audioQueue, playController.signal);
 
                         while (true) {
                             const { done, value } = await reader.read();
@@ -1511,21 +1608,21 @@
                         }
 
                         audioQueue.push(null); // 结束信号
-                        await playPromise;
-                        activePlayController = null;
-                        activeStreamReader = null;
-
-                    } catch (error) {
-                        activePlayController = null;
-                        activeStreamReader = null;
-                        if (error.name === 'AbortError') {
-                            return;
-                        }
-                        console.error(error);
-                        loadingIndicator.textContent = `错误: ${error.message}`;
-                        loadingIndicator.style.display = 'block';
-                    }
-                }
+                        await playPromise;
+                        activePlayController = null;
+                        activeStreamReader = null;
+
+                    } catch (error) {
+                        activePlayController = null;
+                        activeStreamReader = null;
+                        if (error.name === 'AbortError') {
+                            return;
+                        }
+                        console.error(error);
+                        loadingIndicator.textContent = `错误: ${error.message}`;
+                        loadingIndicator.style.display = 'block';
+                    }
+                }
 
                 // 监听文本选择
                 document.addEventListener('DOMContentLoaded', () => {
@@ -1556,41 +1653,39 @@
                 });
 
                 // 阅读整页
-                readPageButton.addEventListener('click', async function () {
-                    const { pdfViewer } = PDFViewerApplication;
-                    const currentPageNumber = pdfViewer.currentPageNumber;
-                    const loadingIndicator = document.getElementById('loading-indicator');
-
-                    try {
+                readPageButton.addEventListener('click', async function () {
+                    const { pdfViewer } = PDFViewerApplication;
+                    const currentPageNumber = pdfViewer.currentPageNumber;
+                    const loadingIndicator = document.getElementById('loading-indicator');
+
+                    try {
                         initAudioContext();
                         await getPlayPermission();
                         loadingIndicator.textContent = '正在提取页面文本...';
                         loadingIndicator.style.display = 'block';
 
-                        const pdfDocument = PDFViewerApplication.pdfDocument;
-                        const page = await pdfDocument.getPage(currentPageNumber);
-                        const textContent = await page.getTextContent();
-
-                        // 提取完整文本,保留标点符号(不预分割)
-                        let fullText = textContent.items.map(item => item.str).join(' ').trim();
-
-                        const startWord = selectedStartWord || getSelectedWordOnPage();
-                        if (startWord) {
-                            const normalizedText = fullText.toLowerCase();
-                            const normalizedStart = startWord.toLowerCase();
-                            const startIndex = normalizedText.indexOf(normalizedStart);
-                            if (startIndex >= 0) {
-                                fullText = fullText.slice(startIndex).trim();
-                            } else {
-                                loadingIndicator.textContent = `未在当前页找到起始词:${startWord},将从页首阅读`;
-                                loadingIndicator.style.display = 'block';
-                            }
-                        }
-
-                        if (fullText.length === 0) {
-                            loadingIndicator.textContent = '页面无文本内容';
-                            return;
-                        }
+                        const pdfDocument = PDFViewerApplication.pdfDocument;
+                        const page = await pdfDocument.getPage(currentPageNumber);
+                        const textContent = await page.getTextContent();
+
+                        // 提取完整文本,保留标点符号(不预分割)
+                        let fullText = textContent.items.map(item => item.str).join(' ').trim();
+
+                        const startContext = selectedStartContext || getSelectedContextOnPage();
+                        if (startContext?.word) {
+                            const startIndex = findNthOccurrence(fullText, startContext.word, startContext.occurrence);
+                            if (startIndex >= 0) {
+                                fullText = fullText.slice(startIndex).trim();
+                            } else {
+                                loadingIndicator.textContent = `未在当前页找到对应位置词:${startContext.word},将从页首阅读`;
+                                loadingIndicator.style.display = 'block';
+                            }
+                        }
+
+                        if (fullText.length === 0) {
+                            loadingIndicator.textContent = '页面无文本内容';
+                            return;
+                        }
 
                         await startConversion(fullText);
                     } catch (error) {
@@ -1601,4 +1696,4 @@
                 });
             </script>
 </body>
-</html>
+</html>