// ==UserScript== // @name 思源在线视频时间戳和截图 // @namespace https://github.com/KuiyueRO/siyuan-media-timestamp // @version 1.2 // @description 捕获视频时间戳和当前帧截图和点击跳转 // @author A_Cai // @match *://*/* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect 127.0.0.1 // @grant GM_getValue // @grant GM_setValue // @downloadURL none // ==/UserScript== (function() { 'use strict'; // 添加样式 GM_addStyle(` .settings-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #ffffff; padding: 24px; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.15); z-index: 100000; display: none; width: 460px; max-height: 85vh; overflow-y: auto; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; box-sizing: border-box; // 添加这行确保padding不会影响总宽度 } .settings-panel h3 { margin: 0 0 24px 0; color: #1a1a1a; font-size: 20px; font-weight: 600; display: flex; align-items: center; gap: 8px; } .settings-panel h3::before { content: ''; display: inline-block; width: 4px; height: 20px; background: #4CAF50; border-radius: 2px; } .settings-section { padding: 16px; background: #f8f9fa; border-radius: 8px; margin-bottom: 16px; box-sizing: border-box; } .settings-section-title { font-size: 16px; font-weight: 500; color: #2c3e50; margin-bottom: 16px; } .settings-field { margin-bottom: 20px; padding: 0 12px; box-sizing: border-box; } .settings-field label { display: block; color: #2c3e50; font-size: 14px; font-weight: 500; margin-bottom: 8px; opacity: 0.85; } .settings-field label:hover { opacity: 1; } .settings-field input[type="text"], .settings-field select { width: calc(100% - 24px); padding: 10px 12px; border: 1px solid #e0e0e0; border-radius: 6px; font-size: 14px; transition: all 0.2s ease; background: #ffffff; box-sizing: border-box; } .settings-field input[type="text"]:hover, .settings-field select:hover { border-color: #d0d0d0; } .settings-field input[type="text"]:focus, .settings-field select:focus { border-color: #4CAF50; box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); outline: none; } .select-wrapper { position: relative; width: 100%; box-sizing: border-box; } .select-wrapper::after { content: ''; position: absolute; right: 16px; // 调整箭头位置 top: 50%; transform: translateY(-50%); width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 5px solid #666; pointer-events: none; transition: all 0.2s ease; } .select-wrapper:hover::after { border-top-color: #333; } .custom-select { appearance: none; width: calc(100% - 24px) !important; padding-right: 36px !important; // 为下拉箭头留出更多空间 cursor: pointer; box-sizing: border-box; background: #ffffff; } .match-list { background: #ffffff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 16px; max-height: 180px; overflow-y: auto; } .match-item { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; padding: 4px; background: transparent; border-radius: 6px; transition: all 0.2s ease; } .match-input { flex: 1; padding: 10px 12px; border: 1px solid #e0e0e0; border-radius: 6px; font-size: 14px; transition: all 0.3s ease; background: #ffffff; } .match-input:hover { border-color: #d0d0d0; } .match-input:focus { border-color: #4CAF50; box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); outline: none; } .delete-match-btn { width: 24px; height: 24px; padding: 0; border: none; background: transparent; color: #999; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; border-radius: 4px; } .delete-match-btn:hover { color: #ff4444; background: transparent; transform: scale(1.1); } .add-match-btn { margin-top: 12px; padding: 10px; height: 40px; background: #f8f9fa; color: #666; border: 1px dashed #ddd; border-radius: 6px; cursor: pointer; width: 100%; font-size: 14px; display: flex; align-items: center; justify-content: center; gap: 8px; transition: all 0.2s ease; } .add-match-btn:hover { background: #f0f0f0; border-color: #999; color: #333; transform: translateY(-1px); } .add-match-btn svg { width: 14px; height: 14px; stroke: currentColor; transition: transform 0.2s ease; } .add-match-btn:hover svg { transform: scale(1.1); } .settings-buttons { margin-top: 24px; display: flex; justify-content: flex-end; gap: 12px; } .settings-btn { padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.3s; } .settings-btn.primary { background: #4CAF50; color: white; } .settings-btn.secondary { background: #f5f5f5; color: #333; } .settings-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .toast-notification { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; background: rgba(0, 0, 0, 0.8); color: white; border-radius: 6px; font-size: 14px; z-index: 10000; animation: slideIn 0.3s ease-out; } @keyframes slideIn { from { transform: translateY(100%); opacity: 0.2; } to { transform: translateY(0); opacity: 1; } } .timestamp-list-panel { background: rgba(24, 24, 27, 0.95); padding: 0; border-radius: 12px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); width: 300px; max-height: 400px; display: flex; flex-direction: column; overflow: hidden; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; opacity: 0.95; transition: all 0.3s ease; color: rgba(255, 255, 255, 0.9); border: 1px solid rgba(255, 255, 255, 0.1); position: fixed; bottom: 20px; right: 20px; z-index: 999999; } /* Dark Reader 支持 */ @media (prefers-color-scheme: dark) { .timestamp-list-panel { background: rgba(24, 24, 27, 0.95); color: rgba(255, 255, 255, 0.9); border: 1px solid rgba(255, 255, 255, 0.1); } } [data-darkreader-scheme="dark"] .timestamp-list-panel { background: rgba(24, 24, 27, 0.95); color: rgba(255, 255, 255, 0.9); border: 1px solid rgba(255, 255, 255, 0.1); } .timestamp-list-panel:hover, .timestamp-list-header:hover ~ * { opacity: 1 !important; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3); } .timestamp-list-header { cursor: grab; user-select: none; opacity: 1; transition: opacity 0.3s ease; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding: 12px 16px; margin-bottom: 0; display: flex; align-items: center; justify-content: space-between; background: rgba(36, 36, 42, 0.95); } .timestamp-list-header:hover { opacity: 1; } .timestamp-list-header:hover .timestamp-list-title { opacity: 1; } .timestamp-header-buttons { display: flex; gap: 8px; opacity: 1; transition: opacity 0.3s ease; } .timestamp-header-btn { opacity: 0.8; transition: all 0.2s ease; padding: 6px; background: rgba(255, 255, 255, 0.1); border: none; border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; } .timestamp-header-btn:hover { opacity: 1; background: rgba(255, 255, 255, 0.2); transform: translateY(-1px); } .timestamp-list-header:hover .timestamp-header-buttons { opacity: 1; } .timestamp-item { padding: 12px 16px; margin: 0; cursor: pointer; transition: all 0.2s; border-bottom: 1px solid rgba(255, 255, 255, 0.05); color: rgba(255, 255, 255, 0.9); display: flex; } .timestamp-item:hover { background: rgba(255, 255, 255, 0.05); } .timestamp-item.active { background: rgba(76, 175, 80, 0.15); border-left: 3px solid rgba(76, 175, 80, 0.8); } .no-timestamps { color: rgba(255, 255, 255, 0.6); text-align: center; padding: 20px; font-style: italic; } #timestamp-list { overflow-y: auto; flex: 1; max-height: 320px; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.2) transparent; } #timestamp-list::-webkit-scrollbar { width: 6px; } #timestamp-list::-webkit-scrollbar-track { background: transparent; } #timestamp-list::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.2); border-radius: 3px; } .timestamp-list-title { font-size: 14px; font-weight: 600; color: rgba(255, 255, 255, 0.9); } .timestamp-header-btn img { width: 16px; height: 16px; filter: invert(1); } .match-list-field { flex-direction: column !important; align-items: stretch !important; } .match-list { margin-top: 10px; max-height: 200px; overflow-y: auto; border: 1px solid #ddd; border-radius: 6px; padding: 8px; } .match-item { display: flex; align-items: center; margin-bottom: 8px; gap: 8px; } .match-input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; } .delete-match-btn { padding: 4px 8px; background: #ff4444; color: white; border: none; border-radius: 4px; cursor: pointer; } .delete-match-btn:hover { background: #ff6666; } .add-match-btn { margin-top: 8px; padding: 8px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; width: 100%; } .add-match-btn:hover { background: #45a049; } .timestamp-item { display: flex; padding: 8px; margin: 4px 0; border-radius: 4px; transition: all 0.2s; } .timestamp-left { flex: 1; display: flex; flex-direction: column; gap: 4px; } .timestamp-text { font-weight: 500; cursor: pointer; } .timestamp-text:hover { color: #4CAF50; } .timestamp-note-container { display: flex; align-items: center; } .timestamp-note-input { width: 100%; min-height: 24px; padding: 6px 8px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; background: rgba(0, 0, 0, 0.1); color: var(--b3-theme-on-background); font-size: 14px; line-height: 1.5; font-family: var(--b3-font-family); transition: all 0.2s ease; resize: vertical; overflow-y: hidden; box-sizing: border-box; } .timestamp-note-input:hover { border-color: rgba(255, 255, 255, 0.2); background: rgba(0, 0, 0, 0.15); } .timestamp-note-input:focus { border-color: var(--b3-theme-primary); background: rgba(0, 0, 0, 0.2); outline: none; box-shadow: 0 0 0 2px rgba(var(--b3-theme-primary-rgb), 0.1); } .timestamp-note-container { margin: 4px 0; width: 100%; position: relative; } .timestamp-note-input::placeholder { color: rgba(255, 255, 255, 0.3); font-style: italic; } .timestamp-note-input::-webkit-scrollbar { width: 4px; } .timestamp-note-input::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 2px; } .timestamp-note-input::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); } /* 拖拽相关样式 */ .drag-handle { display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.7); cursor: grab; } .timestamp-list-panel.dragging { box-shadow: 0 8px 28px rgba(0, 0, 0, 0.4) !important; opacity: 0.95 !important; } .timestamp-list-header:hover .drag-handle { color: rgba(255, 255, 255, 0.9); } /* 类似GitHub Copilot的动画效果 */ .timestamp-list-panel { animation: slide-up 0.3s ease-out; } @keyframes slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 0.95; } } /* 添加一个隐藏/显示面板的按钮和小药丸样式 */ .timestamp-toggle-btn { position: fixed; bottom: 20px; right: 20px; width: 40px; height: 40px; background: rgba(24, 24, 27, 0.95); border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 999998; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); transition: all 0.2s ease; opacity: 0; } .timestamp-pills-container { position: fixed; bottom: 20px; right: 20px; display: flex; align-items: center; z-index: 999998; gap: 10px; opacity: 0; transition: all 0.3s ease; } .timestamp-pills-container.visible { opacity: 1; } .timestamp-pill { background: rgba(24, 24, 27, 0.95); border-radius: 50px; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); display: flex; align-items: center; padding: 6px 14px; cursor: pointer; transition: all 0.2s ease; } .timestamp-pill:hover { transform: translateY(-2px); background: rgba(36, 36, 42, 0.95); } .timestamp-pill img { width: 18px; height: 18px; filter: invert(1); margin-right: 8px; } .timestamp-pill-text { color: rgba(255, 255, 255, 0.9); font-size: 13px; font-weight: 500; } .timestamp-expand-btn { background: rgba(24, 24, 27, 0.95); border-radius: 50%; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); transition: all 0.2s ease; } .timestamp-expand-btn:hover { transform: scale(1.1); background: rgba(36, 36, 42, 0.95); } .timestamp-expand-btn img { width: 16px; height: 16px; filter: invert(1); } /* 底部按钮栏 */ .timestamp-footer { display: flex; padding: 12px 16px; border-top: 1px solid rgba(255, 255, 255, 0.1); background: rgba(36, 36, 42, 0.95); justify-content: space-between; } .timestamp-footer-buttons { display: flex; gap: 8px; } .timestamp-footer-btn { opacity: 0.8; transition: all 0.2s ease; padding: 6px; background: rgba(255, 255, 255, 0.1); border: none; border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; } .timestamp-footer-btn:hover { opacity: 1; background: rgba(255, 255, 255, 0.2); transform: translateY(-1px); } .timestamp-footer-btn img { width: 16px; height: 16px; filter: invert(1); } .timestamp-panel-toggle { display: flex; align-items: center; gap: 6px; color: rgba(255, 255, 255, 0.7); font-size: 12px; cursor: pointer; padding: 4px 8px; border-radius: 4px; transition: all 0.2s ease; } .timestamp-panel-toggle:hover { color: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.05); } .timestamp-list-panel.hidden + .timestamp-toggle-btn { opacity: 1; } /* 时间戳项样式 */ .timestamp-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; cursor: pointer; } .timestamp-time { font-size: 12px; color: rgba(255, 255, 255, 0.6); background: rgba(255, 255, 255, 0.1); padding: 2px 6px; border-radius: 4px; transition: all 0.2s ease; } .timestamp-item:hover .timestamp-time { background: rgba(255, 255, 255, 0.15); color: rgba(255, 255, 255, 0.8); } .timestamp-item.active .timestamp-time { background: rgba(76, 175, 80, 0.2); color: rgba(76, 175, 80, 0.9); } `); // 配置管理 const configManager = { defaults: { API_ENDPOINT: 'http://127.0.0.1:6806', API_TOKEN: '', TARGET_DOC_ID: '', NOTEBOOK_ID: '', NOTEBOOK_NAME: '', CREATE_NOTE_HOTKEY: '', TIMESTAMP_HOTKEY: '', SCREENSHOT_HOTKEY: '', MATCH_LIST: [ 'https://www.youtube.com/watch?v=', 'https://www.bilibili.com/video/', 'https://pan.baidu.com/play/' // 添加百度网盘匹配 ] }, // 获取配置 get: function() { const config = {}; for (const [key, defaultValue] of Object.entries(this.defaults)) { config[key] = GM_getValue(key, defaultValue); } return config; }, // 保存配置 save: function(newConfig) { for (const [key, value] of Object.entries(newConfig)) { GM_setValue(key, value); } } }; // 添加缓存机制 const cache = { notebooks: null, notebookExpiry: 0, CACHE_DURATION: 5 * 60 * 1000, // 5分钟缓存 async getNotebooks() { const now = Date.now(); if (this.notebooks && now < this.notebookExpiry) { return this.notebooks; } const notebooks = await getNotebooks(); this.notebooks = notebooks; this.notebookExpiry = now + this.CACHE_DURATION; return notebooks; }, clearCache() { this.notebooks = null; this.notebookExpiry = 0; } }; // 添加重试机制的 API 调用包装器 async function retryApiCall(apiCall, maxRetries = 3, delay = 1000) { let lastError; for (let i = 0; i < maxRetries; i++) { try { return await apiCall(); } catch (error) { lastError = error; if (i < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, delay)); } } } throw lastError; } // 使用示例 async function getNotebooks() { return retryApiCall(async () => { const config = getConfig(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/notebook/lsNotebooks`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data.notebooks); } else { reject(new Error(result.msg)); } } else { reject(new Error('请求失败')); } }, onerror: reject }); }); }); } // 创建设置面板 function createSettingsPanel() { const panel = document.createElement('div'); panel.className = 'settings-panel'; const currentConfig = configManager.get(); // 创建标题 const title = document.createElement('h3'); title.textContent = '思源笔记设置'; panel.appendChild(title); // 基本设置部分 const basicSection = document.createElement('div'); basicSection.className = 'settings-section'; const basicTitle = document.createElement('div'); basicTitle.className = 'settings-section-title'; basicTitle.textContent = '基本设置'; basicSection.appendChild(basicTitle); // API 地址设置 const apiField = document.createElement('div'); apiField.className = 'settings-field'; const apiLabel = document.createElement('label'); apiLabel.textContent = 'API 地址'; const apiInput = document.createElement('input'); apiInput.type = 'text'; apiInput.id = 'api-endpoint'; apiInput.value = currentConfig.API_ENDPOINT; apiInput.placeholder = 'http://127.0.0.1:6806'; // 添加API端点输入事件监听 apiInput.addEventListener('change', async () => { // 获取当前的API端点和Token const apiEndpoint = apiInput.value.trim(); const apiToken = panel.querySelector('#api-token').value.trim(); if (apiEndpoint && apiToken) { // 临时保存配置以加载笔记本 const tempConfig = { ...currentConfig, API_ENDPOINT: apiEndpoint, API_TOKEN: apiToken }; configManager.save(tempConfig); // 清空笔记本选择器并显示加载中状态 const notebookSelect = panel.querySelector('#notebook-select'); notebookSelect.innerHTML = ''; try { // 清除缓存并重新加载笔记本列表 cache.clearCache(); await loadNotebookList(panel); showNotification('笔记本列表已更新'); } catch (error) { console.error('加载笔记本列表失败:', error); notebookSelect.innerHTML = ''; showNotification('加载笔记本列表失败: ' + error.message); } } }); apiField.appendChild(apiLabel); apiField.appendChild(apiInput); basicSection.appendChild(apiField); // API Token 设置 const tokenField = document.createElement('div'); tokenField.className = 'settings-field'; const tokenLabel = document.createElement('label'); tokenLabel.textContent = 'API Token'; const tokenInput = document.createElement('input'); tokenInput.type = 'text'; tokenInput.id = 'api-token'; tokenInput.value = currentConfig.API_TOKEN; tokenInput.placeholder = '输入你的 API Token'; // 添加API Token输入事件监听 tokenInput.addEventListener('change', async () => { // 获取当前的API端点和Token const apiEndpoint = panel.querySelector('#api-endpoint').value.trim(); const apiToken = tokenInput.value.trim(); if (apiEndpoint && apiToken) { // 临时保存配置以加载笔记本 const tempConfig = { ...currentConfig, API_ENDPOINT: apiEndpoint, API_TOKEN: apiToken }; configManager.save(tempConfig); // 清空笔记本选择器并显示加载中状态 const notebookSelect = panel.querySelector('#notebook-select'); notebookSelect.innerHTML = ''; try { // 清除缓存并重新加载笔记本列表 cache.clearCache(); await loadNotebookList(panel); showNotification('笔记本列表已更新'); } catch (error) { console.error('加载笔记本列表失败:', error); notebookSelect.innerHTML = ''; showNotification('加载笔记本列表失败: ' + error.message); } } }); tokenField.appendChild(tokenLabel); tokenField.appendChild(tokenInput); basicSection.appendChild(tokenField); // 笔记本选择 const notebookField = document.createElement('div'); notebookField.className = 'settings-field'; const notebookLabel = document.createElement('label'); notebookLabel.textContent = '选择笔记本'; const selectWrapper = document.createElement('div'); selectWrapper.className = 'select-wrapper'; const notebookSelect = document.createElement('select'); notebookSelect.id = 'notebook-select'; notebookSelect.className = 'custom-select'; const defaultOption = document.createElement('option'); defaultOption.value = ''; defaultOption.textContent = '加载中...'; notebookSelect.appendChild(defaultOption); selectWrapper.appendChild(notebookSelect); // 添加刷新笔记本按钮 const refreshButton = document.createElement('button'); refreshButton.className = 'refresh-notebooks-btn'; refreshButton.textContent = '刷新笔记本'; refreshButton.onclick = async () => { const apiEndpoint = panel.querySelector('#api-endpoint').value.trim(); const apiToken = panel.querySelector('#api-token').value.trim(); if (!apiEndpoint || !apiToken) { showNotification('请先填写API地址和Token'); return; } // 清空笔记本选择器并显示加载中状态 notebookSelect.innerHTML = ''; // 临时保存配置以加载笔记本 const tempConfig = { ...currentConfig, API_ENDPOINT: apiEndpoint, API_TOKEN: apiToken }; configManager.save(tempConfig); try { // 清除缓存并重新加载笔记本列表 cache.clearCache(); await loadNotebookList(panel); showNotification('笔记本列表已更新'); } catch (error) { console.error('加载笔记本列表失败:', error); notebookSelect.innerHTML = ''; showNotification('加载笔记本列表失败: ' + error.message); } }; selectWrapper.appendChild(refreshButton); notebookField.appendChild(notebookLabel); notebookField.appendChild(selectWrapper); basicSection.appendChild(notebookField); panel.appendChild(basicSection); // 快捷键设置部分 const hotkeySection = document.createElement('div'); hotkeySection.className = 'settings-section'; const hotkeyTitle = document.createElement('div'); hotkeyTitle.className = 'settings-section-title'; hotkeyTitle.textContent = '快捷键设置'; hotkeySection.appendChild(hotkeyTitle); // 创建笔记快捷键 const createNoteField = document.createElement('div'); createNoteField.className = 'settings-field'; const createNoteLabel = document.createElement('label'); createNoteLabel.textContent = '创建笔记快捷键'; const createNoteInput = document.createElement('input'); createNoteInput.type = 'text'; createNoteInput.id = 'create-note-hotkey'; createNoteInput.value = currentConfig.CREATE_NOTE_HOTKEY; createNoteInput.placeholder = '点击设置快捷键'; createNoteInput.readOnly = true; createNoteField.appendChild(createNoteLabel); createNoteField.appendChild(createNoteInput); hotkeySection.appendChild(createNoteField); // 时间戳快捷键 const timestampField = document.createElement('div'); timestampField.className = 'settings-field'; const timestampLabel = document.createElement('label'); timestampLabel.textContent = '时间戳快捷键'; const timestampInput = document.createElement('input'); timestampInput.type = 'text'; timestampInput.id = 'timestamp-hotkey'; timestampInput.value = currentConfig.TIMESTAMP_HOTKEY; timestampInput.placeholder = '点击设置快捷键'; timestampInput.readOnly = true; timestampField.appendChild(timestampLabel); timestampField.appendChild(timestampInput); hotkeySection.appendChild(timestampField); // 截图+时间戳快捷键 const screenshotField = document.createElement('div'); screenshotField.className = 'settings-field'; const screenshotLabel = document.createElement('label'); screenshotLabel.textContent = '截图+时间戳快捷键'; const screenshotInput = document.createElement('input'); screenshotInput.type = 'text'; screenshotInput.id = 'screenshot-hotkey'; screenshotInput.value = currentConfig.SCREENSHOT_HOTKEY; screenshotInput.placeholder = '点击设置快捷键'; screenshotInput.readOnly = true; screenshotField.appendChild(screenshotLabel); screenshotField.appendChild(screenshotInput); hotkeySection.appendChild(screenshotField); panel.appendChild(hotkeySection); // 网站匹配规则部分 const matchSection = document.createElement('div'); matchSection.className = 'settings-section'; const matchTitle = document.createElement('div'); matchTitle.className = 'settings-section-title'; matchTitle.textContent = '网站匹配规则'; matchSection.appendChild(matchTitle); const matchList = document.createElement('div'); matchList.id = 'match-list'; matchList.className = 'match-list'; // 添加现有的匹配规则 currentConfig.MATCH_LIST.forEach(match => { const matchItem = createMatchItem(match); matchList.appendChild(matchItem); }); matchSection.appendChild(matchList); // 添加规则按钮 const addMatchBtn = document.createElement('button'); addMatchBtn.className = 'add-match-btn'; const addBtnSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); addBtnSvg.setAttribute('width', '16'); addBtnSvg.setAttribute('height', '16'); addBtnSvg.setAttribute('viewBox', '0 0 16 16'); addBtnSvg.setAttribute('fill', 'none'); const addBtnPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); addBtnPath.setAttribute('d', 'M8 3v10M3 8h10'); addBtnPath.setAttribute('stroke', 'currentColor'); addBtnPath.setAttribute('stroke-width', '2'); addBtnPath.setAttribute('stroke-linecap', 'round'); addBtnSvg.appendChild(addBtnPath); addMatchBtn.appendChild(addBtnSvg); const addBtnText = document.createTextNode('添加匹配规则'); addMatchBtn.appendChild(addBtnText); matchSection.appendChild(addMatchBtn); panel.appendChild(matchSection); // 按钮组 const buttonsContainer = document.createElement('div'); buttonsContainer.className = 'settings-buttons'; const cancelBtn = document.createElement('button'); cancelBtn.id = 'cancel-settings'; cancelBtn.className = 'settings-btn secondary'; cancelBtn.textContent = '取消'; buttonsContainer.appendChild(cancelBtn); const saveBtn = document.createElement('button'); saveBtn.id = 'save-settings'; saveBtn.className = 'settings-btn primary'; saveBtn.textContent = '保存'; buttonsContainer.appendChild(saveBtn); panel.appendChild(buttonsContainer); document.body.appendChild(panel); // 加载笔记本列表 loadNotebookList(panel); // 设置事件监听 setupEventListeners(panel); // 初始化时尝试加载笔记本列表 if (currentConfig.API_ENDPOINT && currentConfig.API_TOKEN) { try { // 延迟一点加载,确保面板已完全创建 setTimeout(async () => { await loadNotebookList(panel); }, 100); } catch (error) { console.error('初始加载笔记本列表失败:', error); } } return panel; } // 修改 loadNotebookList 使用缓存 async function loadNotebookList(panel) { const select = panel.querySelector('#notebook-select'); const currentConfig = configManager.get(); try { const notebooks = await cache.getNotebooks(); // 清空现有选项 while (select.firstChild) { select.removeChild(select.firstChild); } // 添加新的选项 notebooks.forEach(notebook => { const option = document.createElement('option'); option.value = notebook.id; option.textContent = notebook.name; if (notebook.id === currentConfig.NOTEBOOK_ID) { option.selected = true; } select.appendChild(option); }); } catch (error) { // 创建错误提示选项 const errorOption = document.createElement('option'); errorOption.value = ''; errorOption.textContent = `加载失败: ${error.message}`; // 清空现有选项 while (select.firstChild) { select.removeChild(select.firstChild); } select.appendChild(errorOption); } } // 添加创建匹配项的辅助函数 function createMatchItem(value) { const item = document.createElement('div'); item.className = 'match-item'; const input = document.createElement('input'); input.type = 'text'; input.className = 'match-input'; input.value = value; input.placeholder = '输入匹配规则,例如: https://www.youtube.com/watch?v='; const deleteBtn = document.createElement('button'); deleteBtn.className = 'delete-match-btn'; deleteBtn.title = '移除规则'; // 创建 × 符号 const deleteText = document.createTextNode('×'); deleteBtn.appendChild(deleteText); // 添加删除按钮的点击事件 deleteBtn.onclick = () => { item.style.opacity = '0'; setTimeout(() => item.remove(), 200); }; item.appendChild(input); item.appendChild(deleteBtn); return item; } // 显示设置面板 async function showSettings() { const panel = createSettingsPanel(); panel.style.display = 'block'; // 加载笔记本列表 try { const notebooks = await getNotebooks(); const notebookList = panel.querySelector('#notebook-list'); const currentConfig = configManager.get(); // 清空现有列表 notebookList.innerHTML = ''; // 添加笔记本选项 notebooks.forEach(notebook => { const item = document.createElement('div'); item.className = 'notebook-item'; if (notebook.id === currentConfig.NOTEBOOK_ID) { item.classList.add('selected'); } item.dataset.id = notebook.id; item.dataset.name = notebook.name; item.textContent = notebook.name; notebookList.appendChild(item); }); // 绑定点击事件 notebookList.addEventListener('click', (e) => { const item = e.target.closest('.notebook-item'); if (item) { notebookList.querySelectorAll('.notebook-item').forEach(i => { i.classList.remove('selected'); }); item.classList.add('selected'); } }); } catch (error) { panel.querySelector('#notebook-list').innerHTML = `
加载失败: ${error.message}
`; } } // 配置项 function getConfig() { return configManager.get(); } // 添加发送到思源的函数 async function sendToSiYuan(content) { const config = getConfig(); const data = { dataType: "markdown", data: content, parentID: config.TARGET_DOC_ID }; try { const result = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/appendBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify(data), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result); } else { reject(new Error(result.msg)); } } else { reject(new Error('请求失败')); } }, onerror: reject }); }); // 获取新创建的块ID const newBlockId = result.data[0].doOperations[0].id; // 设置自定义属性 await setBlockAttrs({ id: newBlockId, attrs: { "custom-media": "timestamp" } }); return result; } catch (error) { throw error; } } // 查找匹配的视频笔记 async function findMatchingVideoNote(mediaUrl) { const config = getConfig(); const sql = `SELECT block_id FROM attributes WHERE name = 'custom-type' AND value = 'MediaNote' AND block_id IN ( SELECT block_id FROM attributes WHERE name = 'custom-mediaurl' AND value = '${mediaUrl}' )`; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/query/sql`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ stmt: sql }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0 && result.data.length > 0) { resolve(result.data[0].block_id); } else { resolve(null); } } else { reject(new Error('查询失败')); } }, onerror: reject }); }); } // 优化视频元素获取,支持不同网站的适配,包括全屏状态 function getVideoElement() { // 检查当前网站 const currentUrl = window.location.href; let videoElement = null; // 针对Bilibili的适配 if (currentUrl.includes('bilibili.com')) { // 常规模式 videoElement = document.querySelector('.bilibili-player-video video') || document.querySelector('.bpx-player-video-wrap video'); // 全屏模式 - 多种检测方式 if (!videoElement) { // 检查标准全屏 if (document.fullscreenElement) { videoElement = document.fullscreenElement.querySelector('video'); } // 检查B站特有的全屏容器 if (!videoElement) { const bpxFullscreen = document.querySelector('.bpx-player-container[data-screen="full"]'); if (bpxFullscreen) { videoElement = bpxFullscreen.querySelector('video'); } } // 检查网页全屏模式 if (!videoElement) { const webFullscreen = document.querySelector('.bilibili-player-video-web-fullscreen') || document.querySelector('.bpx-player-container[data-screen="web"]'); if (webFullscreen) { videoElement = webFullscreen.querySelector('video'); } } } } // 针对YouTube的适配 else if (currentUrl.includes('youtube.com')) { // 常规模式 videoElement = document.querySelector('.html5-main-video'); // 全屏模式 if (!videoElement && document.fullscreenElement) { videoElement = document.fullscreenElement.querySelector('video'); } // 备用查找方法 if (!videoElement) { const ytApp = document.querySelector('ytd-app'); if (ytApp && ytApp.hasAttribute('is-fullscreen')) { videoElement = document.querySelector('video'); } } } // 针对百度网盘的适配 else if (currentUrl.includes('pan.baidu.com')) { // 常规模式 videoElement = document.querySelector('.vjs-tech') || document.querySelector('.video-player video'); // 全屏模式 if (!videoElement && document.fullscreenElement) { videoElement = document.fullscreenElement.querySelector('video'); } // 百度网盘的内部全屏 if (!videoElement) { const bdFullscreen = document.querySelector('.vp-fulled'); if (bdFullscreen) { videoElement = bdFullscreen.querySelector('video'); } } } // 通用回退方案 if (!videoElement) { videoElement = document.querySelector('video'); } return videoElement; } // 修改获取时间戳功能 async function getVideoTimestamp() { const video = getVideoElement(); if (!video) { showNotification('未找到视频元素!'); return; } const cleanedUrl = cleanUrl(window.location.href); const matchingNoteId = await findMatchingVideoNote(cleanedUrl); if (!matchingNoteId) { showNotification('请先创建视频笔记!'); return; } // 更新配置中的目标文档ID const config = getConfig(); configManager.save({ ...config, TARGET_DOC_ID: matchingNoteId }); const currentTime = video.currentTime; const timestamp = formatTime(currentTime); const timeUrl = generateTimeUrl(currentTime); const markdownLink = `[${timestamp}](${timeUrl})`; try { // 获取现有时间戳 const existingTimestamps = await getExistingTimestamps(matchingNoteId); const existingTimestamp = existingTimestamps.find(ts => Math.abs(ts.time - currentTime) < 1); if (existingTimestamp) { showNotification('该时间戳已存在'); return; } // 发送到思源 const result = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/appendBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ dataType: "markdown", data: markdownLink, parentID: matchingNoteId }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result); } else { reject(new Error(result.msg)); } } else { reject(new Error('请求失败')); } }, onerror: reject }); }); // 获取新创建的块ID const newBlockId = result.data[0].doOperations[0].id; // 设置自定义属性 await setBlockAttrs({ id: newBlockId, attrs: { "custom-media": "timestamp" } }); showNotification('已添加时间戳'); await updateTimestampList(); } catch (error) { showNotification('发送失败:' + error.message); } } // 添加文件上传函数 async function uploadFile(blob, fileName) { const config = getConfig(); // 动态获取最新配置 const formData = new FormData(); formData.append('assetsDirPath', '/assets/'); formData.append('file[]', blob, fileName); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/asset/upload`, headers: { 'Authorization': `Token ${config.API_TOKEN}` }, data: formData, onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data.succMap[fileName]); } else { reject(new Error(result.msg)); } } else { reject(new Error('Upload failed')); } }, onerror: function(error) { reject(error); } }); }); } // 添加一个检查截图块是否存在的辅助函数 async function findScreenshotBlock(timestampBlockId) { const config = getConfig(); const sql = `SELECT id FROM blocks WHERE parent_id = '${timestampBlockId}' AND id IN ( SELECT block_id FROM attributes WHERE name = 'custom-media' AND value = 'tsscreenshot' )`; try { const result = await query(sql); return result.length > 0 ? result[0].id : null; } catch (error) { console.error('查询截图块失败:', error); return null; } } // 修改 isInSuperBlock 函数 async function isInSuperBlock(blockId) { const config = getConfig(); try { // 使用更简单的查询语句 const sql = ` WITH RECURSIVE parents AS ( SELECT id, parent_id, type FROM blocks WHERE id = '${blockId}' UNION ALL SELECT b.id, b.parent_id, b.type FROM blocks b JOIN parents p ON b.id = p.parent_id ) SELECT p.id FROM parents p JOIN attributes a ON p.id = a.block_id WHERE p.type = 's' AND a.name = 'custom-media' AND a.value = 'mediacard' LIMIT 1 `; const result = await query(sql); return result.length > 0; } catch (error) { console.error('检查超级块失败:', error); return false; } } // 添加一个获取父超级块ID的函数,修复SQL语法错误 async function getParentSuperBlockId(blockId) { const config = getConfig(); try { // 修复SQL语法,避免ON关键字错误 const sql = ` SELECT parent.id FROM blocks child, blocks parent, attributes attrs WHERE child.id = '${blockId}' AND child.parent_id = parent.id AND parent.id = attrs.block_id AND attrs.name = 'custom-media' AND attrs.value = 'mediacard' LIMIT 1 `; const result = await query(sql); return result.length > 0 ? result[0].id : null; } catch (error) { console.error('获取父超级块ID失败:', error); return null; } } // 修改 getParentMediaCard 函数以解决循环引用问题 async function getParentMediaCard(blockId) { const config = getConfig(); try { const sql = ` WITH RECURSIVE parents(id, parent_id, level, path) AS ( -- 基础查询:获取起始块 SELECT b.id, b.parent_id, 0 as level, b.id as path FROM blocks b WHERE b.id = '${blockId}' UNION ALL -- 递归查询:获取父块 SELECT b.id, b.parent_id, p.level + 1, p.path || ',' || b.id FROM blocks b JOIN parents p ON b.id = p.parent_id WHERE p.level < 10 -- 限制递归深度 AND p.path NOT LIKE '%' || b.id || '%' -- 防止循环 ) SELECT DISTINCT p.id FROM parents p JOIN attributes a ON p.id = a.block_id WHERE a.name = 'custom-media' AND a.value = 'mediacard' ORDER BY p.level ASC LIMIT 1 `; const result = await query(sql); return result.length > 0 ? result[0].id : null; } catch (error) { console.error('检查父块mediacard属性失败:', error); return null; } } // 修改 createMemoBlock 函数 async function createMemoBlock(timestampBlockId, content) { const config = getConfig(); try { // 1. 获取或创建超级块 const superBlockId = await createOrGetSuperBlock(timestampBlockId); // 2. 创建备注块 const memoResult = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/appendBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ dataType: "markdown", data: content, parentID: superBlockId }), onload: async function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0 && result.data && result.data.length > 0 && result.data[0].doOperations && result.data[0].doOperations.length > 0) { const newBlockId = result.data[0].doOperations[0].id; try { await setBlockAttrs({ id: newBlockId, attrs: { "custom-media": "memos" } }); resolve(newBlockId); } catch (error) { reject(error); } } else { console.error('API返回结果异常:', result); reject(new Error(result.msg || '返回数据结构异常')); } } else { reject(new Error('创建备注块失败')); } }, onerror: reject }); }); // 3. 清理临时块 await removeTemporaryBlocks(); return memoResult; } catch (error) { console.error('创建备注块失败:', error); throw error; } } // 修改 getVideoScreenshot 函数 async function getVideoScreenshot() { const video = getVideoElement(); if (!video) { showNotification('未找到视频元素!'); return; } const cleanedUrl = cleanUrl(window.location.href); const matchingNoteId = await findMatchingVideoNote(cleanedUrl); if (!matchingNoteId) { showNotification('请先创建视频笔记!'); return; } // 更新配置中的目标文档ID const config = getConfig(); configManager.save({ ...config, TARGET_DOC_ID: matchingNoteId }); const currentTime = video.currentTime; const timestamp = formatTime(currentTime); const timeUrl = generateTimeUrl(currentTime); const markdownLink = `[${timestamp}](${timeUrl})`; try { // 获取现有时间戳 const existingTimestamps = await getExistingTimestamps(matchingNoteId); const existingTimestamp = existingTimestamps.find(ts => Math.abs(ts.time - currentTime) < 1); // 创建截图相关数据 const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const blob = await new Promise(resolve => { canvas.toBlob(resolve, 'image/png'); }); const fileName = `screenshot-${Date.now()}.png`; const filePath = await uploadFile(blob, fileName); if (existingTimestamp) { try { // 查找时间戳块对应的截图块 const timestampBlockId = await findTimestampBlockId(timeUrl, matchingNoteId); if (!timestampBlockId) { showNotification('时间戳块查找失败'); return; } // 检查时间戳块是否已在超级块内 const isInSuper = await isInSuperBlock(timestampBlockId); let superBlockId; if (isInSuper) { // 如果已在超级块内,获取超级块ID superBlockId = await getParentSuperBlockId(timestampBlockId); } else { // 如果不在超级块内,创建新的超级块 superBlockId = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/insertBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ dataType: "markdown", data: `{{{row 内容\n{: custom-media="temp" }\n }}}\n{: custom-media="mediacard" }\n`, previousID: timestampBlockId }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data[0].doOperations[0].id); } else { reject(new Error(result.msg)); } } else { reject(new Error('创建超级块失败')); } }, onerror: reject }); }); // 移动时间戳块到超级块内 await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/moveBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ id: timestampBlockId, parentID: superBlockId }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(); } else { reject(new Error(result.msg)); } } else { reject(new Error('移动时间戳块失败')); } }, onerror: reject }); }); } // 添加截图块到超级块 const screenshotResult = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/appendBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ dataType: "markdown", data: `![${timestamp}](${filePath})`, parentID: superBlockId }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data[0].doOperations[0].id); } else { reject(new Error(result.msg)); } } else { reject(new Error('添加截图块失败')); } }, onerror: reject }); }); // 为截图块设置属性 await setBlockAttrs({ id: screenshotResult, attrs: { "custom-media": "tsscreenshot" } }); // 设置超级块属性 await setBlockAttrs({ id: superBlockId, attrs: { "layout": "row" } }); showNotification('已为现有时间戳添加截图'); } catch (error) { console.error('处理已有时间戳失败:', error); showNotification('处理已有时间戳失败: ' + error.message); return; } } else { // 创建新的超级块 const superBlockResult = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/appendBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ dataType: "markdown", data: `{{{row 内容\n{: custom-media="temp" }\n }}}\n{: custom-media="mediacard" }\n`, parentID: matchingNoteId }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data[0].doOperations[0].id); } else { reject(new Error(result.msg)); } } else { reject(new Error('创建超级块失败')); } }, onerror: reject }); }); // 添加时间戳块 const timestampResult = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/appendBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ dataType: "markdown", data: markdownLink, parentID: superBlockResult }), onload: async function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { const newBlockId = result.data[0].doOperations[0].id; try { // 为时间戳块设置属性 await setBlockAttrs({ id: newBlockId, attrs: { "custom-media": "timestamp" } }); resolve(newBlockId); } catch (error) { reject(error); } } else { reject(new Error(result.msg)); } } else { reject(new Error('添加时间戳块失败')); } }, onerror: reject }); }); // 添加截图块 const screenshotResult = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/appendBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ dataType: "markdown", data: `![${timestamp}](${filePath})`, parentID: superBlockResult }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data[0].doOperations[0].id); } else { reject(new Error(result.msg)); } } else { reject(new Error('添加截图块失败')); } }, onerror: reject }); }); // 为截图块设置属性 await setBlockAttrs({ id: screenshotResult, attrs: { "custom-media": "tsscreenshot" } }); // 设置超级块属性 await setBlockAttrs({ id: superBlockResult, attrs: { "layout": "row" } }); showNotification('已添加时间戳和截图'); } // 清理临时块 await removeTemporaryBlocks(); await updateTimestampList(); } catch (error) { showNotification('发送失败:' + error.message); console.error(error); } } // 生成带时间戳的URL function generateTimeUrl(seconds) { const currentUrl = window.location.href; const timeParam = Math.floor(seconds); if (currentUrl.includes('youtube.com')) { const urlObj = new URL(currentUrl); const videoId = urlObj.searchParams.get('v'); return `https://youtu.be/${videoId}?t=${timeParam}`; } else if (currentUrl.includes('bilibili.com')) { const urlObj = new URL(currentUrl); const bvidMatch = urlObj.pathname.match(/\/video\/(BV[a-zA-Z0-9]+)/); if (bvidMatch) { const bvid = bvidMatch[1]; return `https://www.bilibili.com/video/${bvid}?t=${timeParam}`; } } else if (currentUrl.includes('pan.baidu.com')) { // 保留所有必要的查询参数,只修改时间戳 const urlObj = new URL(currentUrl); const path = urlObj.searchParams.get('path'); let baseUrl = `https://pan.baidu.com/pfile/video?path=${encodeURIComponent(path)}`; // 添加其他可能需要的查询参数 if (urlObj.searchParams.get('theme')) { baseUrl += `&theme=${urlObj.searchParams.get('theme')}`; } return `${baseUrl}#t=${timeParam}`; } // 默认格式 const baseUrl = currentUrl.split('#')[0]; return `${baseUrl}#t=${timeParam}`; } // 格式化时间 function formatTime(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const ms = Math.floor((seconds % 1) * 1000); return `${padZero(hours)}:${padZero(minutes)}:${padZero(secs)}.${padZero(ms, 3)}`; } // 补零函数 function padZero(num, length = 2) { return String(num).padStart(length, '0'); } // 复制到剪贴板 function copyToClipboard(text) { const textarea = document.createElement('textarea'); textarea.value = text; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } // 添加通知函数 function showNotification(message, duration = 3000) { const toast = document.createElement('div'); toast.className = 'toast-notification'; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.style.animation = 'slideIn 0.3s ease-out reverse'; setTimeout(() => toast.remove(), 300); }, duration); } // 清理URL参数 function cleanUrl(url) { const urlObj = new URL(url); if (urlObj.hostname.includes('bilibili.com')) { const bvidMatch = urlObj.pathname.match(/\/video\/(BV[a-zA-Z0-9]+)/); if (bvidMatch) { return `https://www.bilibili.com/video/${bvidMatch[1]}`; } } else if (urlObj.hostname.includes('youtube.com')) { const videoId = urlObj.searchParams.get('v'); if (videoId) { return `https://www.youtube.com/watch?v=${videoId}`; } } else if (urlObj.hostname.includes('pan.baidu.com')) { // 保留百度网盘视频的必要参数 const path = urlObj.searchParams.get('path'); if (path) { return `https://pan.baidu.com/pfile/video?path=${encodeURIComponent(path)}`; } } return url.split('#')[0]; // 移除hash部分但保留其他查询参数 } // 添加为现有块添加属性的函数 async function addAttributesToExistingBlocks(docId) { const config = getConfig(); try { // 获取文档下的所有块 const sql = `SELECT * FROM blocks WHERE root_id = '${docId}' AND type = 'p' ORDER BY created`; const blocks = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/query/sql`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ stmt: sql }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data); } else { reject(new Error(result.msg)); } } else { reject(new Error('查询失败')); } }, onerror: reject }); }); // 遍历所有块 for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; const content = block.content; // 检查是否是时间戳块 if (content.match(/\[\d{2}:\d{2}:\d{2}\.\d{3}\]/)) { await setBlockAttrs({ id: block.id, attrs: { "custom-media": "timestamp" } }); // 检查下一个块是否是对应的截图 if (i + 1 < blocks.length && blocks[i + 1].content.startsWith('![')) { await setBlockAttrs({ id: blocks[i + 1].id, attrs: { "custom-media": "tsScreenShot" } }); } } } } catch (error) { console.error('添加属性失败:', error); throw error; } } // 修改 createVideoNote 函数中获取标题的部分 async function createVideoNote() { try { const config = getConfig(); const cleanedUrl = cleanUrl(window.location.href); // 检查当前网址是否在匹配列表中 const isUrlMatched = config.MATCH_LIST.some(pattern => cleanedUrl.includes(pattern)); if (!isUrlMatched) { showNotification('当前网址不在匹配列表中'); return; } // 检查是否已存在对应笔记 const matchingNoteId = await findMatchingVideoNote(cleanedUrl); if (matchingNoteId) { try { await addAttributesToExistingBlocks(matchingNoteId); showNotification('已为现有时间戳添加属性'); } catch (error) { handleError(error, '添加属性'); } return; } // 针对百度网盘特殊处理获取标题 let title; if (cleanedUrl.includes('pan.baidu.com')) { // 尝试从工具栏标题获取 const titleElement = document.querySelector('.vp-toolsbar__title'); if (titleElement) { title = titleElement.getAttribute('title') || titleElement.textContent; } // 如果还是获取不到,尝试从URL中获取 if (!title) { const urlParams = new URLSearchParams(window.location.search); const path = urlParams.get('path'); if (path) { title = decodeURIComponent(path.split('/').pop()); } } } else { title = document.title; } // 如果还是没有标题,使用默认标题 if (!title) { title = '未命名视频笔记'; } // 创建一个安全的文件路径(只保留基本字符) const safePath = title.replace(/[^\w\s\u4e00-\u9fa5]/g, '_'); // 创建笔记内容(保留原始标题) const content = `# ${title}\n\n> 视频链接:[${title}](${cleanedUrl})`; // 使用 retryApiCall 包装 API 调用 const response = await retryApiCall(async () => { // 创建文档 const docData = { notebook: config.NOTEBOOK_ID, path: `/视频笔记/${safePath}`, markdown: content }; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/filetree/createDocWithMd`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify(docData), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data); } else { reject(new Error(result.msg)); } } else { reject(new Error('请求失败')); } }, onerror: reject }); }); }); // 设置文档属性 await retryApiCall(async () => { const attrs = { id: response, attrs: { "custom-type": "MediaNote", "custom-mediaurl": cleanedUrl } }; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/attr/setBlockAttrs`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify(attrs), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(); } else { reject(new Error(result.msg)); } } else { reject(new Error('请求失败')); } }, onerror: reject }); }); }); // 更新配置中的目标文档ID configManager.save({ ...config, TARGET_DOC_ID: response }); showNotification('视频笔记创建成功'); // 更新时间戳列表 await debouncedUpdateTimestampList(); // 更新创建按钮状态 await updateCreateNoteButtonState(); } catch (error) { handleError(error, '创建视频笔记'); } } // 修改更新按钮状态的函数 async function updateCreateNoteButtonState() { const createNoteBtn = document.querySelector('.timestamp-header-btn[title="创建视频笔记"]'); if (!createNoteBtn) return; if (!getVideoElement()) { createNoteBtn.disabled = true; createNoteBtn.style.opacity = '0.5'; createNoteBtn.title = '未找到视频'; return; } try { const cleanedUrl = cleanUrl(window.location.href); const matchingNoteId = await findMatchingVideoNote(cleanedUrl); if (matchingNoteId) { createNoteBtn.disabled = true; createNoteBtn.style.opacity = '0.5'; createNoteBtn.title = '已存在对应笔记'; } else { createNoteBtn.disabled = false; createNoteBtn.style.opacity = '1'; createNoteBtn.title = '创建视频笔记'; } } catch (error) { console.error('检查现有笔记失败:', error); createNoteBtn.disabled = true; createNoteBtn.style.opacity = '0.5'; createNoteBtn.title = '检查失败'; } } // 初始检查按钮状态 setTimeout(updateCreateNoteButtonState, 1000); // 定期检查更新按钮状态(每30秒) setInterval(updateCreateNoteButtonState, 30000); // 当URL变化时更新按钮状态 let lastUrl = window.location.href; new MutationObserver(() => { if (lastUrl !== window.location.href) { lastUrl = window.location.href; setTimeout(updateCreateNoteButtonState, 1000); } }).observe(document, {subtree: true, childList: true}); // 修改 getExistingTimestamps 函数,添加获取备注内容的功能 async function getExistingTimestamps(docId) { const config = getConfig(); try { // 获取文档块的 kramdown 源码 const kramdownData = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/getBlockKramdown`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ id: docId }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data); } else { reject(new Error(result.msg)); } } else { reject(new Error('请求失败')); } }, onerror: reject }); }); // 使用 parseTimestampLinks 解析时间戳 const timestamps = parseTimestampLinks(kramdownData.kramdown); // 为每个时间戳获取对应的备注内容 for (const ts of timestamps) { try { // 获取时间戳块ID const timestampBlockId = await findTimestampBlockId(ts.url, docId); if (timestampBlockId) { // 获取备注块内容,添加空值检查 const memoBlocks = await findAllMemoBlocks(timestampBlockId); if (memoBlocks && memoBlocks.length > 0) { // 获取所有备注块的内容 const memoContents = await Promise.all(memoBlocks.map(async (blockId) => { try { const blockData = await getBlockKramdown(blockId); if (blockData && blockData.kramdown) { return blockData.kramdown.replace(/\{:[^\}]+\}/g, '').trim(); } return ''; } catch (error) { console.error('获取备注块内容失败:', error); return ''; } })); // 过滤掉空字符串并合并 ts.note = memoContents.filter(content => content).join('\n'); } else { ts.note = ''; // 如果没有备注块,设置为空字符串 } } } catch (error) { console.error('获取备注失败:', error); ts.note = ''; // 发生错误时设置为空字符串 } } // 按时间排序 return timestamps.sort((a, b) => a.time - b.time); } catch (error) { console.error('获取时间戳失败:', error); throw error; } } // 添加获取块内容的辅助函数 async function getBlockKramdown(blockId) { const config = getConfig(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/getBlockKramdown`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ id: blockId }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data); } else { reject(new Error(result.msg)); } } else { reject(new Error('获取块内容失败')); } }, onerror: reject }); }); } // 修改 findAllMemoBlocks 函数,添加空值检查 async function findAllMemoBlocks(timestampBlockId) { const sql = `WITH RECURSIVE parents AS ( SELECT id, parent_id, type FROM blocks WHERE id = '${timestampBlockId}' UNION ALL SELECT b.id, b.parent_id, b.type FROM blocks b JOIN parents p ON b.id = p.parent_id WHERE b.type = 's' ) SELECT b.id FROM blocks b JOIN attributes a ON b.id = a.block_id WHERE b.parent_id IN (SELECT id FROM parents) AND a.name = 'custom-media' AND a.value = 'memos' ORDER BY b.created`; try { const result = await query(sql); // 添加空值检查 if (!result) { return []; } return result.map(row => row.id || '').filter(id => id !== ''); } catch (error) { console.error('查询备注块失败:', error); return []; } } // 从test.js中提取的辅助函数 function parseTimestampLinks(kramdown) { const timestamps = []; // 按块分割内容 const blocks = kramdown.split(/\n\s*\n/); blocks.forEach(block => { // 清理块属性标记 const cleanBlock = block.replace(/\{:[^\}]+\}/g, '').trim(); if (!cleanBlock) return; // 使用更严格的正则表达式匹配链接 const regex = /(? url.hostname.includes(domain)); // 修改时间戳检查逻辑,支持百度网盘的 #t= 格式 const hasTimestamp = href.includes('?t=') || href.includes('&t=') || href.includes('#t='); return isDomainValid && hasTimestamp; } catch (e) { return false; } } // 修改 extractTime 函数 function extractTime(url) { try { const urlObj = new URL(url); // 尝试从不同位置获取时间参数 let timeStr = null; // 检查查询参数 timeStr = urlObj.searchParams.get('t'); // 检查哈希参数 if (!timeStr && urlObj.hash) { const hashMatch = urlObj.hash.match(/[?&]t=(\d+)/); if (hashMatch) { timeStr = hashMatch[1]; } else { // 处理 #t=xxx 格式 const simpleHashMatch = urlObj.hash.match(/#t=(\d+)/); if (simpleHashMatch) { timeStr = simpleHashMatch[1]; } } } // 处理时间格式 if (timeStr) { // 处理 HH:MM:SS 格式 if (timeStr.includes(':')) { const parts = timeStr.split(':').map(Number); let seconds = 0; if (parts.length === 3) { // HH:MM:SS seconds = parts[0] * 3600 + parts[1] * 60 + parts[2]; } else if (parts.length === 2) { // MM:SS seconds = parts[0] * 60 + parts[1]; } return seconds; } // 处理纯数字格式 return parseInt(timeStr, 10); } return null; } catch (e) { console.warn('解析时间戳失败:', e, url); return null; } } // 修改 normalizeUrl 函数 function normalizeUrl(url) { try { const urlObj = new URL(url); // 处理YouTube链接 if (urlObj.hostname.includes('youtube.com') || urlObj.hostname.includes('youtu.be')) { const videoId = urlObj.searchParams.get('v') || urlObj.pathname.split('/').pop(); const timestamp = extractTime(url); return `https://youtu.be/${videoId}?t=${timestamp}`; } // 处理Bilibili链接 if (urlObj.hostname.includes('bilibili.com')) { const bvid = url.match(/BV[\w]+/)?.[0]; const timestamp = extractTime(url); if (bvid) { return `https://www.bilibili.com/video/${bvid}?t=${timestamp}`; } } // 百度网盘处理 if (urlObj.hostname.includes('pan.baidu.com')) { const timestamp = extractTime(url); const path = urlObj.searchParams.get('path'); let baseUrl = `https://pan.baidu.com/pfile/video?path=${encodeURIComponent(path)}`; // 添加其他可能需要的查询参数 if (urlObj.searchParams.get('theme')) { baseUrl += `&theme=${urlObj.searchParams.get('theme')}`; } return `${baseUrl}#t=${timestamp}`; } return url; } catch (e) { return url; } } function cleanTimestampText(text) { // 移除多余空格和特殊字符 return text.trim() .replace(/\s+/g, ' ') .replace(/[\u200B-\u200D\uFEFF]/g, ''); // 移除零宽字符 } // 修改更新时间戳列表的函数 async function updateTimestampList() { // 如果正在编辑,跳过更新 if (document.querySelector('.timestamp-note-input:focus')) { return; } const video = getVideoElement(); if (!video) return; const cleanedUrl = cleanUrl(window.location.href); const matchingNoteId = await findMatchingVideoNote(cleanedUrl); const list = document.getElementById('timestamp-list'); if (!list) return; if (!matchingNoteId) { const noTimestamps = document.createElement('div'); noTimestamps.className = 'no-timestamps'; noTimestamps.textContent = '请先创建视频笔记'; list.replaceChildren(noTimestamps); return; } try { const timestamps = await getExistingTimestamps(matchingNoteId); if (timestamps.length === 0) { const noTimestamps = document.createElement('div'); noTimestamps.className = 'no-timestamps'; noTimestamps.textContent = '暂无时间戳记录'; list.replaceChildren(noTimestamps); return; } // 清空现有列表 list.replaceChildren(); // 添加新的时间戳项 timestamps.forEach(ts => { const item = document.createElement('div'); item.className = 'timestamp-item'; item.dataset.time = ts.time; // 创建左侧容器用于时间戳和备注 const leftContainer = document.createElement('div'); leftContainer.className = 'timestamp-left'; // 创建时间戳文本元素 const timestampText = document.createElement('div'); timestampText.className = 'timestamp-text'; timestampText.textContent = ts.text; // 创建时间链接 const timeLink = document.createElement('div'); timeLink.className = 'timestamp-time'; timeLink.textContent = formatTime(ts.time); // 创建时间戳标题行,包含时间戳文本和时间 const timestampHeader = document.createElement('div'); timestampHeader.className = 'timestamp-header'; timestampHeader.appendChild(timestampText); timestampHeader.appendChild(timeLink); leftContainer.appendChild(timestampHeader); // 创建备注容器 const noteContainer = document.createElement('div'); noteContainer.className = 'timestamp-note-container'; // 创建备注文本/输入框 const noteInput = document.createElement('textarea'); noteInput.className = 'timestamp-note-input'; noteInput.value = ts.note || ''; noteInput.placeholder = '添加备注... (Enter 换行, Shift+Enter 发送)'; noteInput.rows = 1; // 添加自动调整高度的函数 function adjustHeight(element) { element.style.height = 'auto'; element.style.height = (element.scrollHeight) + 'px'; } // 监听输入事件来自动调整高度 noteInput.addEventListener('input', () => { adjustHeight(noteInput); }); // 在初始化和值改变时调整高度 noteInput.addEventListener('focus', () => { adjustHeight(noteInput); }); // 添加一个小延时来确保初始高度正确设置 setTimeout(() => { adjustHeight(noteInput); }, 0); // 修改按键事件处理,交换Enter和Shift+Enter的功能 noteInput.addEventListener('keydown', async (e) => { if (e.key === 'Enter') { if (e.shiftKey) { // Shift+Enter: 保存并失去焦点 e.preventDefault(); noteInput.blur(); } else { // 普通Enter: 添加换行 return; // 允许默认的换行行为 } } }); // 修改失去焦点事件处理 noteInput.addEventListener('blur', async () => { // 分割并过滤空行,确保每行都是有效的备注内容 const newNotes = noteInput.value .split('\n') .map(note => note.trim()) .filter(note => note !== ''); try { // 获取时间戳块的ID const timestampBlockId = await findTimestampBlockId(ts.url, matchingNoteId); if (!timestampBlockId) { throw new Error('未找到对应的时间戳块'); } // 更新备注块,直接传入数组 await updateMemoBlocks(timestampBlockId, newNotes); showNotification('备注已更新'); ts.note = newNotes.join('\n'); } catch (error) { showNotification('更新备注失败:' + error.message); noteInput.value = ts.note || ''; } }); noteContainer.appendChild(noteInput); leftContainer.appendChild(noteContainer); item.appendChild(leftContainer); // 添加点击事件跳转视频 timestampHeader.addEventListener('click', () => { if (video) { video.currentTime = ts.time; list.querySelectorAll('.timestamp-item').forEach(i => i.classList.remove('active')); item.classList.add('active'); } }); list.appendChild(item); }); } catch (error) { console.error('获取时间戳失败:', error); showNotification('获取时间戳失败: ' + error.message); } } // 在创建工具栏之后添加时间戳列表面板 const timestampPanel = createTimestampListPanel(); // 保存对面板的引用 // 初始化 setTimeout(async () => { await updateTimestampList(); addVideoTimeUpdateHandler(); }, 1000); // 使用防抖优化频繁操作 function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // 优化更新时间戳列表 const debouncedUpdateTimestampList = debounce(updateTimestampList, 1000); // 优化视频时间更新处理 function addVideoTimeUpdateHandler() { const video = getVideoElement(); if (!video) return; // 增加进度节流,降低更新频率 let lastUpdateTime = 0; const throttleInterval = 500; // 毫秒 let lastHighlightedItem = null; const throttledTimeUpdate = (e) => { const now = Date.now(); if (now - lastUpdateTime < throttleInterval) return; lastUpdateTime = now; const currentTime = Math.floor(video.currentTime); const items = document.querySelectorAll('.timestamp-item'); // 清除之前的高亮 if (lastHighlightedItem) { lastHighlightedItem.classList.remove('active'); } // 寻找匹配的时间戳并高亮 let highlightedItem = null; items.forEach(item => { const itemTime = parseInt(item.dataset.time); if (Math.abs(itemTime - currentTime) <= 1) { item.classList.add('active'); highlightedItem = item; } else { item.classList.remove('active'); } }); // 滚动到高亮的时间戳 if (highlightedItem && highlightedItem !== lastHighlightedItem) { // 获取时间戳列表容器 const container = document.getElementById('timestamp-list'); if (container) { // 计算滚动位置,使高亮项居中 const itemTop = highlightedItem.offsetTop; const itemHeight = highlightedItem.offsetHeight; const containerHeight = container.offsetHeight; const scrollPosition = itemTop - (containerHeight / 2) + (itemHeight / 2); // 使用平滑滚动 container.scrollTo({ top: Math.max(0, scrollPosition), behavior: 'smooth' }); } // 更新lastHighlightedItem引用 lastHighlightedItem = highlightedItem; } }; video.addEventListener('timeupdate', throttledTimeUpdate); // 保存引用以便清理 video._timestampUpdateHandler = throttledTimeUpdate; // 添加一次性事件监听器,当视频移除时清理事件 const videoCleanup = () => { if (video._timestampUpdateHandler) { video.removeEventListener('timeupdate', video._timestampUpdateHandler); delete video._timestampUpdateHandler; } }; video.addEventListener('emptied', videoCleanup); } // 减少轮询间隔时间,以减轻性能开销 if (window._timestampUpdateInterval) { clearInterval(window._timestampUpdateInterval); } // 使用加倍的轮询间隔,减少刷新频率 window._timestampUpdateInterval = setInterval(() => { // 仅在时间戳面板可见时才更新 const panel = document.querySelector('.timestamp-list-panel'); if (panel && panel.style.display !== 'none') { const list = document.getElementById('timestamp-list'); if (list) { // 应用淡入淡出动画,使刷新看起来更加平滑 list.animate([ { opacity: 1, filter: 'blur(0px)' }, { opacity: 0.95, filter: 'blur(0.5px)' }, { opacity: 1, filter: 'blur(0px)' } ], { duration: 300, easing: 'ease' }); } // 一段时间后调用更新函数 setTimeout(() => { updateTimestampList(); }, 150); } }, 20000); // 增加到20秒更新一次 // 在发送新时间戳后立即更新列表 const originalSendToSiYuan = sendToSiYuan; sendToSiYuan = async function(content) { await originalSendToSiYuan(content); await updateTimestampList(); }; // 初始化 setTimeout(async () => { await updateTimestampList(); addVideoTimeUpdateHandler(); }, 1000); // 添加时间戳列表拖动功能 function makeDraggable(panel) { let isDragging = false; let currentX; let currentY; let initialX; let initialY; let xOffset = 0; let yOffset = 0; // 为面板添加硬件加速以提高拖拽性能 panel.style.willChange = 'transform'; panel.style.transform = 'translate(0px, 0px)'; panel.style.transformStyle = 'preserve-3d'; panel.style.backfaceVisibility = 'hidden'; // 添加拖动手柄指示器到标题 const header = panel.querySelector('.timestamp-list-header'); const dragHandle = document.createElement('div'); dragHandle.className = 'drag-handle'; // 使用DOM API创建SVG而不是innerHTML const dragSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); dragSvg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); dragSvg.setAttribute("width", "14"); dragSvg.setAttribute("height", "14"); dragSvg.setAttribute("viewBox", "0 0 24 24"); dragSvg.setAttribute("fill", "none"); dragSvg.setAttribute("stroke", "currentColor"); dragSvg.setAttribute("stroke-width", "2"); dragSvg.setAttribute("stroke-linecap", "round"); dragSvg.setAttribute("stroke-linejoin", "round"); // 创建圆点 const circles = [ {cx: "9", cy: "12", r: "1"}, {cx: "15", cy: "12", r: "1"}, {cx: "9", cy: "6", r: "1"}, {cx: "15", cy: "6", r: "1"}, {cx: "9", cy: "18", r: "1"}, {cx: "15", cy: "18", r: "1"} ]; circles.forEach(attrs => { const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); circle.setAttribute("cx", attrs.cx); circle.setAttribute("cy", attrs.cy); circle.setAttribute("r", attrs.r); dragSvg.appendChild(circle); }); dragHandle.appendChild(dragSvg); dragHandle.style.marginRight = '8px'; dragHandle.style.opacity = '0.5'; dragHandle.style.transition = 'opacity 0.2s ease'; // 当鼠标悬停在头部时显示拖动手柄 header.addEventListener('mouseenter', () => { dragHandle.style.opacity = '0.8'; }); header.addEventListener('mouseleave', () => { if (!isDragging) { dragHandle.style.opacity = '0.5'; } }); header.insertBefore(dragHandle, header.firstChild); // 移除面板的transition属性以消除拖拽延迟 header.addEventListener('mousedown', () => { // 在拖拽开始前暂时移除transform的transition panel.style.transition = 'box-shadow 0.5s ease-in-out, opacity 0.3s ease-in-out'; }); header.addEventListener('mousedown', dragStart); document.addEventListener('mousemove', drag); document.addEventListener('mouseup', dragEnd); function dragStart(e) { if (e.target.classList.contains('timestamp-item') || e.target.classList.contains('timestamp-header-btn') || e.target.tagName.toLowerCase() === 'img') { return; // 如果点击的是时间戳项或按钮或图片,不启动拖动 } initialX = e.clientX - xOffset; initialY = e.clientY - yOffset; isDragging = true; dragHandle.style.opacity = '1'; // 修改光标样式 header.style.cursor = 'grabbing'; // 添加激活样式 panel.classList.add('dragging'); } function drag(e) { if (isDragging) { e.preventDefault(); currentX = e.clientX - initialX; currentY = e.clientY - initialY; xOffset = currentX; yOffset = currentY; setTranslate(currentX, currentY, panel); } } function dragEnd() { initialX = currentX; initialY = currentY; isDragging = false; // 恢复光标样式 const header = panel.querySelector('.timestamp-list-header'); if (header) { header.style.cursor = 'grab'; } // 移除激活样式 panel.classList.remove('dragging'); // 恢复拖动手柄的状态 if (dragHandle) { dragHandle.style.opacity = '0.5'; } // 恢复transition属性 setTimeout(() => { panel.style.transition = 'box-shadow 0.5s ease-in-out, opacity 0.3s ease-in-out, transform 0.3s ease'; }, 100); } function setTranslate(xPos, yPos, el) { // 使用matrix3d变换以获得更好的硬件加速 el.style.transform = `translate3d(${xPos}px, ${yPos}px, 0)`; } } // 创建时间戳列表面板 function createTimestampListPanel() { const panel = document.createElement('div'); panel.className = 'timestamp-list-panel'; // 更新初始高亮效果和不透明度的transition,确保拖拽流畅 panel.style.transition = 'box-shadow 0.5s ease-in-out, opacity 0.3s ease-in-out, transform 0.3s ease'; panel.style.boxShadow = '0 0 20px rgba(46, 204, 113, 0.6)'; panel.style.opacity = '0.95'; // 添加硬件加速相关属性 panel.style.willChange = 'transform'; panel.style.transform = 'translate3d(0px, 0px, 0)'; panel.style.transformStyle = 'preserve-3d'; panel.style.backfaceVisibility = 'hidden'; // 添加鼠标悬停效果 panel.addEventListener('mouseenter', () => { panel.style.opacity = '1'; }); panel.addEventListener('mouseleave', () => { panel.style.opacity = '0.85'; }); // 固定位置到右下角 document.body.appendChild(panel); // 检查页面是否有视频元素来决定是否显示面板 const video = getVideoElement(); panel.style.display = video ? 'flex' : 'none'; // 在视频首次播放时移除高亮效果 if (video) { const removeHighlight = () => { panel.style.boxShadow = '0 4px 20px rgba(0, 0, 0, 0.25)'; setTimeout(() => { panel.style.opacity = '0.85'; }, 500); video.removeEventListener('play', removeHighlight); }; video.addEventListener('play', removeHighlight); } // 创建面板头部 const header = document.createElement('div'); header.className = 'timestamp-list-header'; const title = document.createElement('div'); title.className = 'timestamp-list-title'; title.textContent = '视频时间戳'; header.appendChild(title); // 创建时间戳列表容器 const list = document.createElement('div'); list.id = 'timestamp-list'; // 创建底部按钮区域 const footer = document.createElement('div'); footer.className = 'timestamp-footer'; const footerButtons = document.createElement('div'); footerButtons.className = 'timestamp-footer-buttons'; // 创建切换到药丸模式的按钮 const toggleBtn = document.createElement('div'); toggleBtn.className = 'timestamp-panel-toggle'; // 使用DOM API创建SVG元素,而不是innerHTML const toggleSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); toggleSvg.setAttribute("width", "14"); toggleSvg.setAttribute("height", "14"); toggleSvg.setAttribute("viewBox", "0 0 24 24"); toggleSvg.setAttribute("fill", "none"); toggleSvg.setAttribute("stroke", "currentColor"); toggleSvg.setAttribute("stroke-width", "2"); toggleSvg.setAttribute("stroke-linecap", "round"); toggleSvg.setAttribute("stroke-linejoin", "round"); const path1 = document.createElementNS("http://www.w3.org/2000/svg", "path"); path1.setAttribute("d", "M18 6L6 18"); const path2 = document.createElementNS("http://www.w3.org/2000/svg", "path"); path2.setAttribute("d", "M6 6l12 12"); toggleSvg.appendChild(path1); toggleSvg.appendChild(path2); const span = document.createElement('span'); span.textContent = '最小化'; toggleBtn.appendChild(toggleSvg); toggleBtn.appendChild(span); // 创建药丸容器 const pillsContainer = document.createElement('div'); pillsContainer.className = 'timestamp-pills-container'; // 创建时间戳药丸 const timestampPill = document.createElement('div'); timestampPill.className = 'timestamp-pill'; timestampPill.title = '获取时间戳'; const tsImg = document.createElement('img'); tsImg.src = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWdvYWwiPjxwYXRoIGQ9Ik0xMiAxM1YybDggNC04IDQiLz48cGF0aCBkPSJNMjAuNTYxIDEwLjIyMmE5IDkgMCAxIDEtMTIuNTUtNS4yOSIvPjxwYXRoIGQ9Ik04LjAwMiA5Ljk5N2E1IDUgMCAxIDAgOC45IDIuMDIiLz48L3N2Zz4="; tsImg.alt = "时间戳"; const tsSpan = document.createElement('span'); tsSpan.className = 'timestamp-pill-text'; tsSpan.textContent = '获取时间戳'; timestampPill.appendChild(tsImg); timestampPill.appendChild(tsSpan); timestampPill.addEventListener('click', getVideoTimestamp); // 创建截图药丸 const screenshotPill = document.createElement('div'); screenshotPill.className = 'timestamp-pill'; screenshotPill.title = '获取时间戳+截图'; const ssImg = document.createElement('img'); ssImg.src = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNhbWVyYSI+PHBhdGggZD0iTTE0LjUgNGgtNUw3IDdINGEyIDIgMCAwIDAtMiAydjlhMiAyIDAgMCAwIDIgMmgxNmEyIDIgMCAwIDAgMi0yVjlhMiAyIDAgMCAwLTItMmgtM2wtMi41LTN6Ii8+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMyIgcj0iMyIvPjwvc3ZnPg=="; ssImg.alt = "截图"; const ssSpan = document.createElement('span'); ssSpan.className = 'timestamp-pill-text'; ssSpan.textContent = '时间戳+截图'; screenshotPill.appendChild(ssImg); screenshotPill.appendChild(ssSpan); screenshotPill.addEventListener('click', getVideoScreenshot); // 创建展开按钮 const expandBtn = document.createElement('div'); expandBtn.className = 'timestamp-expand-btn'; expandBtn.title = '展开面板'; const expandImg = document.createElement('img'); expandImg.src = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWV4cGFuZCI+PHBhdGggZD0ibTE1IDMgNiA2Ii8+PHBhdGggZD0iTTIxIDMtNiA2Ii8+PHBhdGggZD0iTTMgOGw2IDYiLz48cGF0aCBkPSJNMyAyMGwxMi0xMiIvPjwvc3ZnPg=="; expandImg.alt = "展开"; expandBtn.appendChild(expandImg); // 添加药丸到容器 pillsContainer.appendChild(timestampPill); pillsContainer.appendChild(screenshotPill); pillsContainer.appendChild(expandBtn); document.body.appendChild(pillsContainer); // 添加最小化/展开功能 toggleBtn.addEventListener('click', () => { panel.classList.add('hidden'); // 隐藏面板元素 panel.style.display = 'none'; // 添加移除面板的延迟处理,确保动画完成后DOM才被移除 setTimeout(() => { // 将面板从DOM中移除而不是仅仅隐藏 if (panel.parentNode) { panel.parentNode.removeChild(panel); } // 显示药丸容器 pillsContainer.classList.add('visible'); }, 300); }); expandBtn.addEventListener('click', () => { // 如果面板已被移除,则重新添加到DOM if (!document.body.contains(panel)) { document.body.appendChild(panel); // 重新初始化拖拽 makeDraggable(panel); // 让面板的CSS属性先为隐藏状态,然后用setTimeout触发显示动画 panel.style.opacity = '0'; panel.style.transform = 'translate3d(0px, 0px, 0)'; setTimeout(() => { panel.classList.remove('hidden'); panel.style.display = 'flex'; panel.style.opacity = '0.95'; }, 50); } else { // 如果面板仍在DOM中,只是显示它 panel.classList.remove('hidden'); panel.style.display = 'flex'; } // 隐藏药丸容器 pillsContainer.classList.remove('visible'); }); // 创建功能按钮 const buttonConfigs = [ { icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNldHRpbmdzIj48cGF0aCBkPSJNMTIuMjIgMmgtLjQ0YTIgMiAwIDAgMC0yIDJ2LjE4YTIgMiAwIDAgMS0xIDEuNzNsLS40My4yNWEyIDIgMCAwIDEtMiAwbC0uMTUtLjA4YTIgMiAwIDAgMC0yLjczLjczbC0uMjIuMzhhMiAyIDAgMCAwIC43MyAyLjczbC4xNS4xYTIgMiAwIDAgMSAxIDEuNzJ2LjUxYTIgMiAwIDAgMS0xIDEuNzRsLS4xNS4wOWEyIDIgMCAwIDAtLjczIDIuNzNsLjIyLjM4YTIgMiAwIDAgMCAyLjczLjczbC4xNS0uMDhhMiAyIDAgMCAxIDIgMGwuNDMuMjVhMiAyIDAgMCAxIDEgMS43M1YyMGEyIDIgMCAwIDAgMiAyaC40NGEyIDIgMCAwIDAgMi0ydi0uMThhMiAyIDAgMCAxIDEtMS43M2wuNDMtLjI1YTIgMiAwIDAgMSAyIDBsLjE1LjA4YTIgMiAwIDAgMCAyLjczLS43M2wuMjItLjM5YTIgMiAwIDAgMC0uNzMtMi43M2wtLjE1LS4wOGEyIDIgMCAwIDEtMS0xLjc0di0uNWEyIDIgMCAwIDEgMS0xLjc0bC4xNS0uMDlhMiAyIDAgMCAwIC43My0yLjczbC0uMjItLjM4YTIgMiAwIDAgMC0yLjczLS43M2wtLjE1LjA4YTIgMiAwIDAgMS0yIDBsLS40My0uMjVhMiAyIDAgMCAxLTEtMS43M1Y0YTIgMiAwIDAgMC0yLTJ6Ii8+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIvPjwvc3ZnPg==', title: '设置', onClick: showSettings }, { icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWZpbGUtcGx1cyI+PHBhdGggZD0iTTE1IDJINmEyIDIgMCAwIDAtMiAydjE2YTIgMiAwIDAgMCAyIDJoMTJhMiAyIDAgMCAwIDItMlY3WiIvPjxwYXRoIGQ9Ik0xNCAydjRhMiAyIDAgMCAwIDIgMmg0Ii8+PHBhdGggZD0iTTkgMTVoNiIvPjxwYXRoIGQ9Ik0xMiAxOHYtNiIvPjwvc3ZnPg==', title: '创建视频笔记', onClick: createVideoNote }, { icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWdvYWwiPjxwYXRoIGQ9Ik0xMiAxM1YybDggNC04IDQiLz48cGF0aCBkPSJNMjAuNTYxIDEwLjIyMmE5IDkgMCAxIDEtMTIuNTUtNS4yOSIvPjxwYXRoIGQ9Ik04LjAwMiA5Ljk5N2E1IDUgMCAxIDAgOC45IDIuMDIiLz48L3N2Zz4=', title: '获取时间戳', onClick: getVideoTimestamp }, { icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNhbWVyYSI+PHBhdGggZD0iTTE0LjUgNGgtNUw3IDdINGEyIDIgMCAwIDAtMiAydjlhMiAyIDAgMCAwIDIgMmgxNmEyIDIgMCAwIDAgMi0yVjlhMiAyIDAgMCAwLTItMmgtM2wtMi41LTN6Ii8+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMyIgcj0iMyIvPjwvc3ZnPg==', title: '获取时间戳+截图', onClick: getVideoScreenshot } ]; buttonConfigs.forEach(config => { const button = document.createElement('button'); button.className = 'timestamp-footer-btn'; button.title = config.title; button.onclick = config.onClick; const img = document.createElement('img'); img.src = config.icon; img.alt = config.title; button.appendChild(img); footerButtons.appendChild(button); }); footer.appendChild(footerButtons); footer.appendChild(toggleBtn); panel.appendChild(header); panel.appendChild(list); panel.appendChild(footer); makeDraggable(panel); // 添加视频元素变化监听,使用更高效的监听策略 let lastVideoState = !!getVideoElement(); let observerThrottleTimer = null; const videoObserver = new MutationObserver(() => { // 使用节流来减少回调处理频率 if (observerThrottleTimer) return; observerThrottleTimer = setTimeout(() => { const video = getVideoElement(); const currentVideoState = !!video; // 只有视频状态发生变化时才更新UI if (currentVideoState !== lastVideoState) { lastVideoState = currentVideoState; if (video) { if (!panel.classList.contains('hidden')) { // 如果面板不在DOM中,重新添加 if (!document.body.contains(panel)) { document.body.appendChild(panel); makeDraggable(panel); } // 临时移除transition以避免显示延迟 const originalTransition = panel.style.transition; panel.style.transition = 'opacity 0.3s ease-in-out'; panel.style.display = 'flex'; // 在显示后短暂延时恢复transition setTimeout(() => { panel.style.transition = originalTransition; }, 50); pillsContainer.classList.remove('visible'); } else { pillsContainer.classList.add('visible'); } } else { panel.style.display = 'none'; pillsContainer.classList.remove('visible'); } // 如果发现新的视频元素,添加播放监听器 if (video) { // 先清除可能存在的旧监听器 video.removeEventListener('play', removeHighlight); const removeHighlight = () => { panel.style.boxShadow = '0 4px 20px rgba(0, 0, 0, 0.25)'; // 平滑过渡到较低透明度 const originalTransition = panel.style.transition; panel.style.transition = 'opacity 0.5s ease-in-out, box-shadow 0.5s ease-in-out'; setTimeout(() => { panel.style.opacity = '0.85'; // 恢复原始transition setTimeout(() => { panel.style.transition = originalTransition; }, 500); }, 50); video.removeEventListener('play', removeHighlight); }; video.addEventListener('play', removeHighlight); // 添加video timeupdate事件监听 addVideoTimeUpdateHandler(); } } observerThrottleTimer = null; }, 300); // 300ms节流间隔 }); // 使用更精确的观察目标和配置,减少不必要的调用 videoObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-screen'] // 特别关注B站全屏属性变化 }); // 在组件卸载时清理observer panel._videoObserver = videoObserver; panel._cleanupObserver = () => { if (panel._videoObserver) { panel._videoObserver.disconnect(); panel._videoObserver = null; } if (observerThrottleTimer) { clearTimeout(observerThrottleTimer); observerThrottleTimer = null; } }; return panel; } // 将 hotkeyHandler 定义在全局作用域 let hotkeyHandler; // 定义全局编辑状态变量 let isEditing = false; // 修改 setupHotkeys 函数 function setupHotkeys() { const config = configManager.get(); // 定义 hotkeyHandler hotkeyHandler = function(e) { const pressedKeys = []; if (e.ctrlKey) pressedKeys.push('Ctrl'); if (e.altKey) pressedKeys.push('Alt'); if (e.shiftKey) pressedKeys.push('Shift'); if (e.key !== 'Control' && e.key !== 'Alt' && e.key !== 'Shift') { pressedKeys.push(e.key.toUpperCase()); } const pressedHotkey = pressedKeys.join('+'); // 检查是否在输入框中 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { return; } if (pressedHotkey === config.CREATE_NOTE_HOTKEY || pressedHotkey === config.TIMESTAMP_HOTKEY || pressedHotkey === config.SCREENSHOT_HOTKEY) { e.preventDefault(); e.stopPropagation(); // 防止重复触发 if (e.repeat) return; // 添加防抖机制 if (window.__lastHotkeyPress && Date.now() - window.__lastHotkeyPress < 500) { return; } window.__lastHotkeyPress = Date.now(); const video = getVideoElement(); if (!video) { showNotification('未找到视频元素!'); return; } const cleanedUrl = cleanUrl(window.location.href); findMatchingVideoNote(cleanedUrl).then(matchingNoteId => { if (!matchingNoteId) { if (pressedHotkey === config.CREATE_NOTE_HOTKEY) { createVideoNote(); } else { showNotification('请先创建视频笔记!'); } } else { if (pressedHotkey === config.TIMESTAMP_HOTKEY) { getVideoTimestamp(); } else if (pressedHotkey === config.SCREENSHOT_HOTKEY) { getVideoScreenshot(); } else if (pressedHotkey === config.CREATE_NOTE_HOTKEY) { createVideoNote(); } } }); } }; // 确保只添加一次事件监听器 if (!window.__hotkeyHandlerAdded) { document.removeEventListener('keydown', hotkeyHandler); document.addEventListener('keydown', hotkeyHandler); window.__hotkeyHandlerAdded = true; } } // 添加事件监听设置函数 function setupEventListeners(panel) { // 设置快捷键事件监听 const hotkeyInputs = ['create-note-hotkey', 'timestamp-hotkey', 'screenshot-hotkey']; hotkeyInputs.forEach(id => { const input = panel.querySelector(`#${id}`); if (!input) return; input.addEventListener('focus', () => { input.value = '请按下快捷键组合...'; }); input.addEventListener('keydown', (e) => { e.preventDefault(); const keys = []; if (e.ctrlKey) keys.push('Ctrl'); if (e.altKey) keys.push('Alt'); if (e.shiftKey) keys.push('Shift'); if (e.key !== 'Control' && e.key !== 'Alt' && e.key !== 'Shift') { keys.push(e.key.toUpperCase()); } input.value = keys.join('+'); }); }); // 绑定保存按钮事件 const saveBtn = panel.querySelector('#save-settings'); if (saveBtn) { saveBtn.onclick = function() { const selectedNotebook = panel.querySelector('#notebook-select'); const matchInputs = panel.querySelectorAll('.match-input'); const apiEndpoint = panel.querySelector('#api-endpoint').value.trim(); const apiToken = panel.querySelector('#api-token').value.trim(); // 检查必填项 if (!apiToken) { showNotification('请输入 API Token'); return; } // 检查笔记本选择 - 只有在有选项可供选择时才强制要求 const hasNotebookOptions = selectedNotebook.options.length > 0 && selectedNotebook.options[0].value !== '' && !selectedNotebook.options[0].textContent.includes('加载失败') && !selectedNotebook.options[0].textContent.includes('加载中'); if (hasNotebookOptions && !selectedNotebook.value) { showNotification('请选择一个笔记本'); return; } const notebookOption = selectedNotebook.selectedOptions[0]; const newConfig = { API_ENDPOINT: apiEndpoint, API_TOKEN: apiToken, NOTEBOOK_ID: selectedNotebook.value || '', NOTEBOOK_NAME: (notebookOption && notebookOption.textContent) || '', CREATE_NOTE_HOTKEY: panel.querySelector('#create-note-hotkey').value, TIMESTAMP_HOTKEY: panel.querySelector('#timestamp-hotkey').value, SCREENSHOT_HOTKEY: panel.querySelector('#screenshot-hotkey').value, MATCH_LIST: Array.from(matchInputs) .map(input => input.value.trim()) .filter(value => value !== '') }; configManager.save(newConfig); setupHotkeys(); showNotification('设置已保存'); panel.remove(); }; } // 绑定取消按钮事件 const cancelBtn = panel.querySelector('#cancel-settings'); if (cancelBtn) { cancelBtn.onclick = function() { panel.remove(); }; } // 为匹配规则添加按钮绑定事件 const addMatchBtn = panel.querySelector('.add-match-btn'); if (addMatchBtn) { addMatchBtn.onclick = () => { const matchList = panel.querySelector('#match-list'); const matchItem = createMatchItem(''); matchList.appendChild(matchItem); }; } } // 初始化时设置快捷键 setupHotkeys(); // 添加辅助函数 async function findTimestampBlockId(url, docId) { const sql = `SELECT id FROM blocks WHERE root_id = '${docId}' AND content LIKE '%${url}%' AND type = 'p' AND id IN ( SELECT block_id FROM attributes WHERE name = 'custom-media' AND value = 'timestamp' )`; const result = await query(sql); return result.length > 0 ? result[0].id : null; } async function findMemoBlock(parentId) { const sql = `SELECT id FROM blocks WHERE parent_id = '${parentId}' AND type = 'p' AND id IN ( SELECT block_id FROM attributes WHERE name = 'custom-media' AND value = 'memos' )`; const result = await query(sql); return result.length > 0 ? result[0].id : null; } // 添加 SQL 查询函数 async function query(sql) { const config = getConfig(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/query/sql`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ stmt: sql }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data); } else { reject(new Error(result.msg)); } } else { reject(new Error('查询失败')); } }, onerror: reject }); }); } // 添加设置块属性的函数 async function setBlockAttrs(params) { const config = getConfig(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/attr/setBlockAttrs`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify(params), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data); } else { reject(new Error(result.msg)); } } else { reject(new Error('设置属性失败')); } }, onerror: reject }); }); } // 添加查找和删除临时块的函数 async function removeTemporaryBlocks() { const config = getConfig(); try { // 1. 先用SQL查询找到所有带有临时标记的块 const result = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/query/sql`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ stmt: "SELECT id FROM blocks WHERE id IN (SELECT block_id FROM attributes WHERE name = 'custom-media' AND value = 'temp')" }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data); } else { reject(new Error(result.msg)); } } else { reject(new Error('查询临时块失败')); } }, onerror: reject }); }); // 2. 删除找到的所有临时块 for (const block of result) { await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/deleteBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ id: block.id }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(); } else { reject(new Error(result.msg)); } } else { reject(new Error('删除临时块失败')); } }, onerror: reject }); }); } console.log('临时块清理完成'); } catch (error) { console.error('清理临时块失败:', error); throw error; } } // 添加更新块内容的函数 async function updateBlock(params) { const config = getConfig(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/updateBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify(params), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data); } else { reject(new Error(result.msg)); } } else { reject(new Error('更新块失败')); } }, onerror: reject }); }); } // 添加统一的错误处理函数 function handleError(error, operation) { console.error(`${operation} 失败:`, error); showNotification(`${operation}失败: ${error.message}`); throw error; } // 添加资源清理函数 function cleanup() { // 清除所有事件监听器 document.removeEventListener('keydown', hotkeyHandler); // 清除定时器 if (window._timestampUpdateInterval) { clearInterval(window._timestampUpdateInterval); window._timestampUpdateInterval = null; } // 清理视频事件监听器 const video = getVideoElement(); if (video && video._timestampUpdateHandler) { video.removeEventListener('timeupdate', video._timestampUpdateHandler); delete video._timestampUpdateHandler; } // 清除缓存 cache.clearCache(); // 移除面板和相关资源 const panel = document.querySelector('.timestamp-list-panel'); if (panel) { // 调用面板的清理函数 if (panel._cleanupObserver) { panel._cleanupObserver(); } // 移除面板 if (panel.parentNode) { panel.parentNode.removeChild(panel); } } // 移除药丸容器 const pillsContainer = document.querySelector('.timestamp-pills-container'); if (pillsContainer && pillsContainer.parentNode) { pillsContainer.parentNode.removeChild(pillsContainer); } } // 在页面卸载时清理资源 window.addEventListener('unload', cleanup); // 修改 createOrGetSuperBlock 函数,将 config 作为参数传入 async function createOrGetSuperBlock(blockId) { try { // 1. 检查是否已经在超级块内 const isInSuper = await isInSuperBlock(blockId); if (isInSuper) { // 如果已经在超级块内,返回父超级块ID return await getParentSuperBlockId(blockId); } // 2. 创建新的超级块 const superBlockId = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${getConfig().API_ENDPOINT}/api/block/insertBlock`, headers: { 'Authorization': `Token ${getConfig().API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ dataType: "markdown", data: `{{{row 内容\n{: custom-media="temp" }\n }}}\n{: custom-media="mediacard" }\n`, previousID: blockId }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0 && result.data && result.data.length > 0 && result.data[0].doOperations && result.data[0].doOperations.length > 0) { resolve(result.data[0].doOperations[0].id); } else { console.error('API返回结果异常:', result); reject(new Error(result.msg || '返回数据结构异常')); } } else { reject(new Error('创建超级块失败')); } }, onerror: reject }); }); // 3. 移动原始块到超级块内 await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${getConfig().API_ENDPOINT}/api/block/moveBlock`, headers: { 'Authorization': `Token ${getConfig().API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ id: blockId, parentID: superBlockId }), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(); } else { reject(new Error(result.msg)); } } else { reject(new Error('移动块失败')); } }, onerror: reject }); }); // 4. 设置超级块属性 await setBlockAttrs({ id: superBlockId, attrs: { "layout": "row" } }); // 5. 查找并删除临时占位块 const sql = `SELECT b.id FROM blocks b JOIN attributes a ON b.id = a.block_id WHERE b.parent_id = '${superBlockId}' AND a.name = 'custom-media' AND a.value = 'temp'`; try { const result = await query(sql); if (result && result.length > 0) { for (const row of result) { await deleteBlock({ id: row.id }); } } } catch (error) { console.error('删除临时占位块失败:', error); // 继续执行,不影响主流程 } return superBlockId; } catch (error) { console.error('创建或获取超级块失败:', error); throw error; } } // 添加删除块的函数 async function deleteBlock(params) { const config = getConfig(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${config.API_ENDPOINT}/api/block/deleteBlock`, headers: { 'Authorization': `Token ${config.API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify(params), onload: function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0) { resolve(result.data); } else { reject(new Error(result.msg)); } } else { reject(new Error('删除块失败')); } }, onerror: reject }); }); } // 添加安全获取块ID的辅助函数 function getBlockIDFromResult(result) { if (result && result.code === 0 && result.data && result.data.length > 0 && result.data[0].doOperations && result.data[0].doOperations.length > 0 && result.data[0].doOperations[0].id) { return result.data[0].doOperations[0].id; } console.error('API返回结果无法获取块ID:', result); throw new Error('返回数据结构异常,无法获取块ID'); } // 修改备注块更新逻辑 async function updateMemoBlocks(timestampBlockId, newNotes) { try { // 获取超级块 const superBlockId = await createOrGetSuperBlock(timestampBlockId); // 获取现有的备注块 const existingMemoBlocks = await findAllMemoBlocks(timestampBlockId); // 获取现有备注块的内容 const existingContents = await Promise.all(existingMemoBlocks.map(async (blockId) => { try { const blockData = await getBlockKramdown(blockId); return { id: blockId, content: blockData.kramdown.replace(/\{:[^\}]+\}/g, '').trim() }; } catch (error) { console.error('获取备注块内容失败:', error); return { id: blockId, content: '' }; } })); // 更新现有块和添加新块 const processedNotes = new Set(); // 用于跟踪已处理的备注 // 1. 首先更新已存在的块 for (let i = 0; i < Math.min(existingContents.length, newNotes.length); i++) { const note = newNotes[i].trim(); if (note && note !== existingContents[i].content) { // 只有当内容不同时才更新 await updateBlock({ id: existingContents[i].id, data: note, dataType: "markdown" }); // 确保更新后重新设置属性 await setBlockAttrs({ id: existingContents[i].id, attrs: { "custom-media": "memos" } }); } processedNotes.add(note); } // 2. 如果有新的备注,创建新的块 for (const note of newNotes) { const trimmedNote = note.trim(); if (trimmedNote && !processedNotes.has(trimmedNote)) { try { await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${getConfig().API_ENDPOINT}/api/block/appendBlock`, headers: { 'Authorization': `Token ${getConfig().API_TOKEN}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ dataType: "markdown", data: trimmedNote, parentID: superBlockId }), onload: async function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); if (result.code === 0 && result.data && result.data.length > 0 && result.data[0].doOperations && result.data[0].doOperations.length > 0) { const newBlockId = result.data[0].doOperations[0].id; try { await setBlockAttrs({ id: newBlockId, attrs: { "custom-media": "memos" } }); resolve(); } catch (error) { reject(error); } } else { console.error('API返回结果异常:', result); reject(new Error(result.msg || '返回数据结构异常')); } } else { reject(new Error('创建备注块失败')); } }, onerror: reject }); }); } catch (error) { console.error('创建备注块失败:', error); // 继续处理其他备注,不中断整个过程 } processedNotes.add(trimmedNote); } } // 3. 如果现有块数量多于新备注数量,删除多余的块 if (existingContents.length > newNotes.length) { for (let i = newNotes.length; i < existingContents.length; i++) { await deleteBlock({ id: existingContents[i].id }); } } return true; } catch (error) { console.error('更新备注块失败:', error); throw error; } } })();