From aa95990c461f0a146ebb6e871db16046e913111c Mon Sep 17 00:00:00 2001 From: Digvijay singh Date: Wed, 17 Jun 2026 20:55:25 +0530 Subject: [PATCH] feat: translate to English, default taskbar notifications, integrate smart enter and paste focus fix --- gemini-helper/geminiHelper.user.js | 6051 ++++++++++++++-------------- 1 file changed, 3013 insertions(+), 3038 deletions(-) diff --git a/gemini-helper/geminiHelper.user.js b/gemini-helper/geminiHelper.user.js index f26b10f..5c11567 100644 --- a/gemini-helper/geminiHelper.user.js +++ b/gemini-helper/geminiHelper.user.js @@ -1,9 +1,9 @@ // ==UserScript== // @name gemini-helper -// @name:zh-cn Gemini 助手 -// @name:zh-tw Gemini 助手 +// @name:zh-cn Gemini Helper +// @name:zh-tw Gemini Helper // @name:en Gemini Helper -// @name:ja Gemini ヘルパー +// @name:ja Gemini Helper // @name:ko Gemini 도우미 // @name:de Gemini Helfer // @name:fr Gemini Helper @@ -12,11 +12,11 @@ // @name:ru Gemini Помощник // @namespace http://tampermonkey.net/ // @version 999.0.1 -// @description Gemini 助手:会话管理与导出、对话大纲、提示词管理、标签页增强(状态/隐私模式/通知)、阅读历史记录与恢复、双向/手动锚点、图片水印移除、加粗修复、公式/表格复制、模型锁定、页面美化、主题切换、智能暗色模式(适配 Gemini 标准版/企业版) -// @description:zh-cn Gemini 助手:会话管理与导出、对话大纲、提示词管理、标签页增强(状态/隐私模式/通知)、阅读历史记录与恢复、双向/手动锚点、图片水印移除、加粗修复、公式/表格复制、模型锁定、页面美化、主题切换、智能暗色模式(适配 Gemini 标准版/企业版) -// @description:zh-tw Gemini 助手:會話管理與匯出、對話大綱、提示詞管理、標籤頁增強(狀態/隱私模式/通知)、閱讀歷史記錄與恢復、雙向/手動錨點、圖片浮水印移除、粗體修復、公式/表格複製、模型鎖定、頁面美化、主題切換、智慧暗色模式(適配 Gemini 標準版/企業版) +// @description Gemini Assistant: session management and export, conversation outline, prompt word management, tab page enhancement (status/privacy mode/notification), reading history and recovery, two-way/manual anchor point, picture watermark removal, bold repair, formula/table copy, model lock, page beautification, theme switching, smart dark mode (adapted to Gemini Standard Edition/Enterprise Edition) +// @description:zh-cn Gemini Assistant: session management and export, conversation outline, prompt word management, tab page enhancement (status/privacy mode/notification), reading history and recovery, two-way/manual anchor point, image watermark removal, bold repair, formula/table copy, model lock, page beautification, theme switching, smart dark mode (adapted to Gemini Standard Edition/Enterprise Edition) +// @description:zh-tw Gemini Helper: Conversation management & export, outline navigation, prompt management, tab enhancements (status/privacy/notification), reading history & restore, bidirectional/manual anchor, image watermark removal, bold fix, formula/table copy, model lock, page beautification, theme toggle, smart dark mode (Gemini/Gemini Enterprise) // @description:en Gemini Helper: Conversation management & export, outline navigation, prompt management, tab enhancements (status/privacy/notification), reading history & restore, bidirectional/manual anchor, image watermark removal, bold fix, formula/table copy, model lock, page beautification, theme toggle, smart dark mode (Gemini/Gemini Enterprise) -// @description:ja Gemini ヘルパー:会話管理とエクスポート、アウトラインナビ、プロンプト管理、タブ拡張(ステータス/プライバシー/通知)、閲覧履歴と復元、双方向/手動アンカー、画像透かし除去、太字修正、数式/テーブルコピー、モデルロック、ページ美化、テーマ切替、スマートダークモード(Gemini/Gemini Enterprise対応) +// @description:ja Gemini Helper: Conversation management & export, outline navigation, prompt management, tab enhancements (status/privacy/notification), reading history & restore, bidirectional/manual anchor, image watermark removal, bold fix, formula/table copy, model lock, page beautification, theme toggle, smart dark mode (Gemini/Gemini Enterprise) // @description:ko Gemini 도우미: 대화 관리 및 내보내기, 개요 탐색, 프롬프트 관리, 탭 향상(상태/개인정보/알림), 읽기 기록 및 복원, 양방향/수동 앵커, 이미지 워터마크 제거, 굵게 수정, 수식/표 복사, 모델 잠금, 페이지 미화, 테마 전환, 스마트 다크 모드(Gemini/Gemini Enterprise 지원) // @description:de Gemini Helfer: Konversationsverwaltung & Export, Gliederungsnavigation, Prompt-Verwaltung, Tab-Erweiterungen (Status/Datenschutz/Benachrichtigung), Leseverlauf & Wiederherstellung, bidirektionaler/manueller Anker, Bildwasserzeichen-Entfernung, Fettschrift-Fix, Formel/Tabellen-Kopie, Modellsperre, Seitenverschönerung, Theme-Wechsel, Smart Dark Mode (Gemini/Gemini Enterprise) // @description:fr Gemini Helper : Gestion et export des conversations, navigation par plan, gestion des prompts, améliorations des onglets (statut/confidentialité/notification), historique de lecture et restauration, ancre bidirectionnelle/manuelle, suppression du filigrane, correction du gras, copie formule/tableau, verrouillage de modèle, embellissement de page, changement de thème, mode sombre intelligent (Gemini/Gemini Enterprise) @@ -25,7 +25,7 @@ // @description:ru Gemini Помощник: Управление и экспорт диалогов, навигация по структуре, управление промптами, улучшения вкладок (статус/конфиденциальность/уведомления), история чтения и восстановление, двунаправленный/ручной якорь, удаление водяных знаков, исправление жирного текста, копирование формул/таблиц, блокировка модели, украшение страницы, переключение темы, умный тёмный режим (Gemini/Gemini Enterprise) // @author urzeye // @homepage https://github.com/urzeye -// @note 参考 https://linux.do/t/topic/925110 的代码与UI布局拓展实现 +// @note Refer to https://linux.do/t/topic/925110 for code and UI layout expansion implementation // @match https://gemini.google.com/* // @match https://business.gemini.google/* // @icon https://raw.githubusercontent.com/gist/urzeye/8d1d3afbbcd0193dbc8a2019b1ba54d3/raw/f7113d329a259963ed1b1ab8cb981e8f635d4cea/gemini.svg @@ -52,18 +52,18 @@ (function () { 'use strict'; - // 防止在 iframe 中执行(图文并茂模式等场景) + // Prevent execution in iframe (scenarios such as graphic and text mode) if (window.top !== window.self) { return; } - // 防止重复初始化 + // Prevent repeated initialization if (window.geminiHelperInitialized) { return; } window.geminiHelperInitialized = true; - // ==================== 设置项与多语言 ==================== + // ==================== Settings and multi-language ==================== const SETTING_KEYS = { CLEAR_TEXTAREA_ON_SEND: 'gemini_business_clear_on_send', @@ -84,7 +84,7 @@ MARKDOWN_FIX: 'gemini_markdown_fix', }; - // 默认 Tab 顺序 + //Default tab order const DEFAULT_TAB_ORDER = ['prompts', 'outline', 'conversations']; const DEFAULT_PROMPTS_SETTINGS = { enabled: true }; const DEFAULT_READING_HISTORY_SETTINGS = { @@ -93,68 +93,72 @@ cleanupDays: 30, }; const DEFAULT_TAB_SETTINGS = { - openInNewTab: true, // 新标签页打开新对话 - autoRenameTab: true, // 自动重命名标签页 - renameInterval: 3, // 检测频率(秒) - showStatus: true, // 显示生成状态图标 (⏳/✅) - showNotification: false, // 发送桌面通知 - notificationSound: false, // 通知声音(默认关闭) - notificationVolume: 0.5, // 通知声音音量 (0.1-1.0) - notifyWhenFocused: false, // 前台时也通知(默认关闭) - autoFocus: false, // 生成完成后自动将窗口置顶 - privacyMode: false, // 隐私模式 - privacyTitle: 'Google', // 隐私模式下的伪装标题 - titleFormat: '{status}{title}-{model}', // 自定义标题格式,支持 {status}、{title}、{model} + openInNewTab: true, // Open a new conversation in a new tab + autoRenameTab: true, // Automatically rename tabs + renameInterval: 3, // Detection frequency (seconds) + showStatus: true, // Show build status icon (⏳/✅) + showNotification: true, //Send desktop notification + notificationSound: false, // notification sound (off by default) + notificationVolume: 0.5, // notification sound volume (0.1-1.0) + notifyWhenFocused: true, // Also notify when in the foreground (closed by default) + autoFocus: false, // Automatically put the window on top after the generation is completed + privacyMode: false, // privacy mode + privacyTitle: 'Google', // Disguised title in privacy mode + titleFormat: '{status}{title}-{model}', // Custom title format, supports {status}, {title}, {model} + smartEnter: true, // Queue Enter key and auto-submit once upload completes + showScrollBtn: true, // Show floating scroll to bottom button + hideDisclaimer: true, // Hide footer disclaimer + pasteFocusFix: true, // Aggressively focus input box after image paste }; - // 默认会话数据结构 + //Default session data structure const DEFAULT_CONVERSATION_DATA = { - folders: [{ id: 'inbox', name: '📥 收件箱', icon: '📥', isDefault: true }], - tags: [], // 标签定义数组 { id, name, color } - conversations: {}, // 会话数据,key 为 conversationId + folders: [{ id: 'inbox', name: '📥 Inbox', icon: '📥', isDefault: true }], + tags: [], // tag definition array { id, name, color } + conversations: {}, //Conversation data, key is conversationId lastUsedFolderId: 'inbox', }; - // Markdown 渲染修复设置 + // Markdown rendering repair settings const DEFAULT_MARKDOWN_FIX_SETTINGS = { - enabled: true, // 默认开启(仅在 Gemini 普通版生效) + enabled: true, // Enabled by default (only effective in Gemini regular version) }; - // 预设标签颜色 (30色 - 中国传统色精选 - 优化对比度) + //Default label color (30 colors - Chinese traditional color selection - optimized contrast) const TAG_COLORS = [ - '#ff461f', // 朱 - '#e35c64', // 桃夭 - '#db5a6b', // 海棠红 - '#f2481b', // 榴花红 - '#9d2933', // 胭脂 - '#ffa631', // 杏黄 - '#d6a01d', // 姜黄 - '#f0c239', // 缃色 - '#d9b611', // 秋香色 - '#8cc540', // 柳绿 - '#0eb83a', // 葱绿 - '#227d51', // 官绿 - '#789262', // 竹青 - '#29b7cb', // 湖蓝 - '#177cb0', // 靛蓝 - '#1685a9', // 石青 - '#4b5cc4', // 宝蓝 - '#2e4e7e', // 藏蓝 - '#b088d1', // 丁香 - '#b359ab', // 雪青 - '#8d4bbb', // 紫罗兰 - '#4c221b', // 紫檀 - '#a88462', // 驼色 - '#ca6924', // 琥珀 - '#845a33', // 赭石 - '#75878a', // 苍色 - '#57c3c2', // 天水碧 - '#ce97a8', // 藕荷 - '#5d513c', // 墨灰 - '#9b95c9', // 长春花 + '#ff461f', // Zhu + '#e35c64', // Taoyao + '#db5a6b', // Begonia Red + '#f2481b', // pomegranate red + '#9d2933', // Rouge + '#ffa631', // apricot yellow + '#d6a01d', // Turmeric + '#f0c239', // gold + '#d9b611', // Autumn color + '#8cc540', // Liulu + '#0eb83a', // green + '#227d51', // official green + '#789262', // Zhuqing + '#29b7cb', // lake blue + '#177cb0', // indigo + '#1685a9', // Shi Qing + '#4b5cc4', // sapphire blue + '#2e4e7e', // navy blue + '#b088d1', // lilac + '#b359ab', // Xue Qing + '#8d4bbb', // violet + '#4c221b', // rosewood + '#a88462', // camel + '#ca6924', // Amber + '#845a33', // ocher + '#75878a', // pale color + '#57c3c2', // Tianshui Bi + '#ce97a8', // Ouhe + '#5d513c', // ink gray + '#9b95c9', // Periwinkle ]; - // Tab 定义(用于渲染和显示) + // Tab definition (for rendering and display) const TAB_DEFINITIONS = { prompts: { id: 'prompts', labelKey: 'tabPrompts', icon: '✏️' }, outline: { id: 'outline', labelKey: 'tabOutline', icon: '📋' }, @@ -162,8 +166,8 @@ settings: { id: 'settings', labelKey: 'tabSettings', icon: '⚙️' }, }; - // 折叠面板按钮定义 - // isPanelOnly: true 表示仅在面板折叠时显示,false 表示常显 + // Collapse panel button definition + // isPanelOnly: true means it will only be displayed when the panel is folded, false means it will always be displayed. const COLLAPSED_BUTTON_DEFS = { scrollTop: { icon: '⬆', labelKey: 'scrollTop', canToggle: false, isPanelOnly: false }, panel: { icon: '✨', labelKey: 'panelTitle', canToggle: false, isPanelOnly: true }, @@ -183,651 +187,653 @@ const I18N = { 'zh-CN': { - panelTitle: 'Gemini 助手', - tabPrompts: '提示词', - tabSettings: '设置', - searchPlaceholder: '搜索提示词...', - addPrompt: '添加新提示词', - allCategory: '全部', - manageCategory: '⚙ 管理', - currentPrompt: '当前提示词:', - scrollTop: '顶部', - scrollBottom: '底部', - refresh: '刷新', - collapse: '收起', - edit: '编辑', - delete: '删除', - copy: '复制', - drag: '拖动', - save: '保存', - cancel: '取消', - add: '添加', - anchorPoint: '锚点', - updateAnchor: '更新锚点', - title: '标题', - category: '分类', - categoryPlaceholder: '例如:编程、翻译', - content: '提示词内容', - editPrompt: '编辑提示词', - addNewPrompt: '添加新提示词', - fillTitleContent: '请填写标题和内容', - promptUpdated: '提示词已更新', - promptAdded: '提示词已添加', - deleted: '已删除', - copied: '已复制到剪贴板', - cleared: '已清除内容', - refreshed: '已刷新', - orderUpdated: '已更新排序', - inserted: '已插入提示词', - scrolling: '页面正在滚动,请稍后...', - noTextarea: '未找到输入框,请点击输入框后重试', - - confirmDelete: '确定删除?', - scrollContainerNotFound: '未找到滚动容器', - noPrompts: '暂无提示词', - inboxName: '收件箱', - imageProcessingFailed: '图片处理失败,将使用原始链接导出', - copyFailed: '复制失败', - autoThemeFailed: '自动切换主题失败,请尝试在网页设置中手动切换', - noReadingAnchor: '暂无阅读锚点 (点击顶部/底部按钮可自动生成)', - promptScrolling: '页面正在滚动,请稍后再选择提示词', - batchMoveSuccess: '已移动 {count} 个会话到 {folder}', - batchDeleteSuccess: '已删除 {count} 个会话', - categoryRenamedSuccess: '分类已重命名为"{newName}"', - categoryDeletedSuccess: '分类"{name}"已删除', - // 设置面板 - settingsTitle: '通用设置', - panelSettingsTitle: '面板设置', - clearOnSendLabel: '发送后自动修复中文输入', - clearOnSendDesc: '发送消息后插入零宽字符,修复下次输入首字母问题(仅 Gemini Business)', - settingOn: '开', - settingOff: '关', - // 模型锁定 - modelLockTitle: '模型锁定', - modelLockLabel: '自动锁定模型', - modelLockDesc: '进入页面后自动切换到指定模型', - modelKeywordLabel: '模型关键字', - modelKeywordPlaceholder: '例如:3 Pro', - modelKeywordDesc: '用于匹配目标模型名称', - // 分类管理 - categoryManage: '分类管理', - categoryEmpty: '暂无分类,添加提示词时会自动创建分类', - rename: '重命名', - newCategoryName: '请输入新的分类名称:', - categoryRenamed: '分类已重命名', - confirmDeleteCategory: '确定删除该分类吗?关联的提示词将移至"未分类"', - categoryDeleted: '分类已删除', - // 语言设置 - languageLabel: '界面语言', - languageDesc: '设置面板显示语言,即时生效', - languageAuto: '跟随系统', - languageZhCN: '简体中文', - languageZhTW: '繁體中文', + panelTitle: 'Gemini Assistant', + tabPrompts: 'prompt word', + tabSettings: 'Settings', + searchPlaceholder: 'Search prompt word...', + addPrompt: 'Add new prompt word', + allCategory: 'all', + uncategorized: 'Uncategorized', + manageCategory: '⚙ MANAGEMENT', + currentPrompt: 'Current prompt word:', + scrollTop: 'top', + scrollBottom: 'Bottom', + refresh: 'refresh', + collapse: 'collapse', + edit: 'edit', + delete: 'delete', + copy: 'copy', + drag: 'drag', + save: 'save', + cancel: 'cancel', + add: 'Add', + anchorPoint: 'anchor point', + updateAnchor: 'Update anchor', + title: 'title', + category: 'category', + categoryPlaceholder: 'For example: programming, translation', + content: 'Prompt word content', + editPrompt: 'Edit prompt word', + addNewPrompt: 'Add new prompt word', + fillTitleContent: 'Please fill in the title and content', + promptUpdated: 'The prompt word has been updated', + promptAdded: 'Prompt word has been added', + deleted: 'deleted', + copied: 'Copied to clipboard', + cleared: 'cleared content', + refreshed: 'Refreshed', + orderUpdated: 'Sort updated', + inserted: 'Prompt word has been inserted', + scrolling: 'The page is scrolling, please wait...', + noTextarea: 'Input box not found, please click the input box and try again', + + confirmDelete: 'Are you sure to delete?', + scrollContainerNotFound: 'Scroll container not found', + noPrompts: 'No prompt words', + inboxName: 'Inbox', + imageProcessingFailed: 'Image processing failed, the original link will be used to export', + copyFailed: 'Copy failed', + autoThemeFailed: 'Automatically switching the theme failed, please try to switch manually in the web page settings', + noReadingAnchor: 'No reading anchor (click the top/bottom button to automatically generate)', + promptScrolling: 'The page is scrolling, please select the prompt word again later', + batchMoveSuccess: '{count} sessions have been moved to {folder}', + batchDeleteSuccess: '{count} sessions have been deleted', + categoryRenamedSuccess: 'The category has been renamed to "{newName}"', + categoryDeletedSuccess: 'Category "{name}" has been deleted', + // Settings panel + settingsTitle: 'General settings', + panelSettingsTitle: 'Panel Settings', + clearOnSendLabel: 'Automatically repair Chinese input after sending', + clearOnSendDesc: 'Insert zero-width characters after sending the message to fix the problem of inputting the first letter next time (Gemini Business only)', + settingOn: 'on', + settingOff: 'off', + //Model lock + modelLockTitle: 'Model lock', + modelLockLabel: 'Automatically lock model', + modelLockDesc: 'Automatically switch to the specified model after entering the page', + modelKeywordLabel: 'Model Keyword', + modelKeywordPlaceholder: 'For example: 3 Pro', + modelKeywordDesc: 'Used to match the target model name', + // Classification management + categoryManage: 'Category Management', + categoryEmpty: 'No category yet, a category will be automatically created when adding prompt words', + rename: 'rename', + newCategoryName: 'Please enter a new category name:', + categoryRenamed: 'Category has been renamed', + confirmDeleteCategory: 'Are you sure you want to delete this category? The associated prompt words will be moved to "Uncategorized"', + categoryDeleted: 'Category deleted', + // language settings + languageLabel: 'interface language', + languageDesc: 'Set the panel display language, effective immediately', + languageAuto: 'Follow the system', + languageZhCN: 'Simplified Chinese', + languageZhTW: 'Traditional Chinese', languageEn: 'English', - // 页面宽度设置 - pageWidthLabel: '页面宽度', - pageWidthDesc: '调整聊天页面的宽度,即时生效', - enablePageWidth: '启用页面加宽', - widthValue: '宽度值', - widthUnit: '单位', - unitPx: '像素 (px)', - unitPercent: '百分比 (%)', - // 标签页设置 - tabSettingsTitle: '标签页设置', - openNewTabLabel: '新标签页打开新对话', - openNewTabDesc: '在面板顶部添加按钮,点击后在新标签页打开新对话', - newTabTooltip: '新标签页开启对话', - autoRenameTabLabel: '自动重命名标签页', - autoRenameTabDesc: '将浏览器标签页名称改为当前对话名称', - renameIntervalLabel: '检测频率', - renameIntervalDesc: '检测对话名称变化的间隔时间', - secondsSuffix: '秒', - showStatusLabel: '显示生成状态', - toggleTheme: '切换亮/暗主题', - // 面板设置 - showStatusDesc: '在标签页标题中显示生成状态图标(⏳/✅)', - showNotificationLabel: '发送桌面通知', - showNotificationDesc: '生成完成时发送系统通知', - notificationSoundLabel: '通知声音', - notificationSoundDesc: '生成完成时播放提示音', - notificationVolumeLabel: '声音音量', - notifyWhenFocusedLabel: '前台时也通知', - notifyWhenFocusedDesc: '当前页面可见时也发送通知,而不仅在后台时', - autoFocusLabel: '自动窗口置顶', - autoFocusDesc: '生成完成时自动将窗口带回前台', - privacyModeLabel: '隐私模式', - privacyModeDesc: '隐藏真实对话标题,显示伪装标题(双击面板标题可快速切换)', - privacyTitleLabel: '伪装标题', - privacyTitlePlaceholder: '如:Google、工作文档', - titleFormatLabel: '标题格式', - titleFormatDesc: '自定义标题格式,支持占位符:{status}、{title}、{model}', - notificationTitle: '✅ {site} 生成完成', - notificationBody: '点击查看结果', - // 大纲功能 - tabOutline: '大纲', - outlineEmpty: '暂无大纲内容', - outlineRefresh: '刷新', - outlineSettings: '大纲设置', - enableOutline: '启用大纲', - outlineMaxLevel: '显示标题级别', - outlineLevelAll: '全部 (1-6级)', - outlineLevel1: '仅 1 级', - outlineLevel2: '至 2 级', - outlineLevel3: '至 3 级', - refreshPrompts: '刷新提示词', - refreshOutline: '刷新大纲', - refreshSettings: '刷新设置', - jumpToAnchor: '返回跳转前位置', - anchorUpdated: '锚点已更新', - // 大纲高级工具栏 - outlineScrollBottom: '滚动到底部', - outlineScrollTop: '滚动到顶部', - outlineExpandAll: '展开全部', - outlineCollapseAll: '折叠全部', - outlineLocateCurrent: '定位到当前位置', - outlineSearch: '搜索大纲...', - outlineSearchResult: '个结果', - outlineLevelHint: '级标题', - // Tab 顺序设置 - tabOrderSettings: '界面排版', - tabOrderDesc: '调整面板 Tab 的显示顺序', - moveUp: '上移', - moveDown: '下移', - // 阅读导航设置 - readingNavigationSettings: '阅读导航', - readingHistorySettings: '阅读历史', - readingHistoryPersistence: '启用阅读历史', - readingHistoryPersistenceDesc: '自动记录阅读位置,下次打开时恢复', - autoRestore: '自动跳转', - autoRestoreDesc: '打开页面时自动跳转到上次位置', - readingHistoryCleanup: '历史保留时间', - readingHistoryCleanupDesc: '只保留最近几天的阅读进度 (-1 为永久)', - daysSuffix: '天', - cleanupInfinite: '永久', - restoredPosition: '已恢复上次阅读位置', - cleanupDone: '已清理过期数据', - // 大纲高级设置 - outlineAutoUpdateLabel: '对话期间自动更新大纲', - outlineAutoUpdateDesc: 'AI 生成内容时自动刷新目录结构', - outlineUpdateIntervalLabel: '更新检测间隔 (秒)', - outlineShowUserQueries: '展示用户提问', - outlineShowUserQueriesTooltip: '展示用户提问', - outlineOnlyUserQueries: '提问', - outlineIntervalUpdated: '间隔已设为 {val} 秒', - outlineSyncScrollLabel: '同步滚动', - outlineSyncScrollDesc: '页面滚动时自动高亮对应的大纲项', - // 页面显示设置 - pageDisplaySettings: '页面显示', - // 其他设置 - otherSettingsTitle: '其他设置', - showCollapsedAnchorLabel: '锚点', - showCollapsedAnchorDesc: '当面板收起时,在侧边浮动条中显示锚点按钮', - showCollapsedThemeLabel: '主题', - showCollapsedThemeDesc: '当面板收起时,在侧边浮动条中显示主题切换按钮', - collapsedButtonsOrderDesc: '调整折叠面板按钮的显示顺序', - preventAutoScrollLabel: '防止自动滚动', - preventAutoScrollDesc: '当 AI 生成长内容时,阻止页面自动滚动到底部,方便阅读上文', - markdownFixLabel: 'Markdown 加粗修复', - markdownFixDesc: '修复 Gemini 响应中未正确渲染的 **加粗** 语法', - // 界面排版开关 - defaultPanelStateLabel: '默认显示面板', - defaultPanelStateDesc: '刷新页面后面板默认保持展开状态', - autoHidePanelLabel: '自动隐藏面板', - autoHidePanelDesc: '点击面板外部(如左侧侧边栏、聊天区、输入框)时自动隐藏', - - // 界面排版开关 - disableOutline: '禁用大纲', - togglePrompts: '启用/禁用提示词', - toggleConversations: '启用/禁用会话', - // 会话功能 - tabConversations: '会话', - conversationsEmpty: '暂无会话数据', - conversationsEmptyHint: '点击上方同步按钮从侧边栏导入会话', - conversationsSync: '同步会话', - conversationsSyncing: '正在同步...', - conversationsSynced: '同步完成', - conversationsAddFolder: '新建文件夹', - conversationsRename: '重命名', - conversationsDelete: '删除', - conversationsDeleteConfirm: '确定删除此文件夹吗?其中的会话将移到收件箱。', - conversationsFolderCreated: '文件夹已创建', - conversationsFolderRenamed: '文件夹已重命名', - conversationsFolderDeleted: '文件夹已删除', - conversationsCannotDeleteDefault: '无法删除默认文件夹', - conversationsIcon: '图标', - conversationsFolderName: '名称', - conversationsFolderNamePlaceholder: '输入文件夹名称', - confirm: '确定', - conversationsSyncEmpty: '未找到会话', - conversationsSyncNoChange: '无新会话', - conversationsLocate: '定位当前对话', - conversationsLocateSuccess: '已定位到当前对话', - conversationsLocateNotFound: '当前对话未收录,正在同步...', - conversationsLocateNewChat: '当前是新对话,尚未保存', - conversationsLocateSyncFailed: '同步后仍未找到该对话', - justNow: '刚刚', - minutesAgo: '分钟前', - hoursAgo: '小时前', - daysAgo: '天前', - conversationsSelectFolder: '选择同步目标文件夹', - conversationsMoveTo: '移动到...', - conversationsMoved: '已移动到', - conversationsSyncDeleteTitle: '同步删除', - conversationsSyncDeleteMsg: '检测到 {count} 个会话已在云端删除,是否同步删除本地记录?', - conversationsDeleted: '已移除', - // 会话设置 - conversationsSettingsTitle: '会话设置', - conversationsSyncUnpinLabel: '同步时更新取消置顶', - conversationsSyncUnpinDesc: '同步时,将云端未置顶的会话在本地也取消置顶', - folderRainbowLabel: '文件夹彩虹色', - folderRainbowDesc: '为每个文件夹分配不同的背景颜色,关闭后使用统一纯色', - conversationsSyncDeleteLabel: '删除时同步删除云端', - conversationsSyncDeleteDesc: '删除本地会话记录时,同时从 {site} 云端删除', - conversationsSyncRenameLabel: '重命名时同步云端', - conversationsSyncRenameDesc: '修改会话标题时,同时在 {site} 侧边栏更新标题', - conversationsCustomIcon: '自定义图标', - batchSelected: '已选 {n} 个', - batchMove: '移动', - batchDelete: '删除', - batchExit: '退出', - batchExport: '导出', + // Page width settings + pageWidthLabel: 'Page width', + pageWidthDesc: 'Adjust the width of the chat page, effective immediately', + enablePageWidth: 'Enable page widening', + widthValue: 'width value', + widthUnit: 'unit', + unitPx: 'pixel (px)', + unitPercent: 'Percent (%)', + //Tab settings + tabSettingsTitle: 'Tab settings', + openNewTabLabel: 'Open a new conversation in a new tab', + openNewTabDesc: 'Add a button at the top of the panel and click it to open a new conversation in a new tab', + newTabTooltip: 'Open conversation in new tab', + autoRenameTabLabel: 'Automatically rename tab', + autoRenameTabDesc: 'Change the browser tab name to the current conversation name', + renameIntervalLabel: 'Detection frequency', + renameIntervalDesc: 'Interval time to detect changes in conversation name', + secondsSuffix: 'seconds', + showStatusLabel: 'Show generation status', + toggleTheme: 'Switch light/dark theme', + //Panel settings + showStatusDesc: 'Show the build status icon (⏳/✅) in the tab title', + showNotificationLabel: 'Send desktop notification', + showNotificationDesc: 'Send system notification when generation is completed', + notificationSoundLabel: 'Notification Sound', + notificationSoundDesc: 'Play a prompt sound when the generation is completed', + notificationVolumeLabel: 'Voice volume', + notifyWhenFocusedLabel: 'Also notify when in the foreground', + notifyWhenFocusedDesc: 'Send a notification when the current page is visible, not just in the background', + autoFocusLabel: 'Automatic window on top', + autoFocusDesc: 'Automatically bring the window back to the foreground when the generation is completed', + privacyModeLabel: 'privacy mode', + privacyModeDesc: 'Hide the real conversation title and display the disguised title (double-click the panel title to quickly switch)', + privacyTitleLabel: 'Disguise title', + privacyTitlePlaceholder: 'Such as: Google, work documents', + titleFormatLabel: 'Title format', + titleFormatDesc: 'Custom title format, supports placeholders: {status}, {title}, {model}', + notificationTitle: '✅ {site} generation completed', + notificationBody: 'Click to view results', + //outline function + tabOutline: 'outline', + outlineEmpty: 'No outline content yet', + outlineRefresh: 'Refresh', + outlineSettings: 'Outline Settings', + enableOutline: 'Enable outline', + outlineMaxLevel: 'Show title level', + outlineLevelAll: 'All (Level 1-6)', + outlineLevel1: 'Level 1 only', + outlineLevel2: 'To level 2', + outlineLevel3: 'To level 3', + refreshPrompts: 'Refresh prompt word', + refreshOutline: 'Refresh outline', + refreshSettings: 'Refresh settings', + jumpToAnchor: 'Return to the position before jump', + anchorUpdated: 'Anchor point has been updated', + // Outline advanced toolbar + outlineScrollBottom: 'Scroll to the bottom', + outlineScrollTop: 'Scroll to top', + outlineExpandAll: 'Expand all', + outlineCollapseAll: 'Collapse all', + outlineLocateCurrent: 'Locate to current location', + outlineSearch: 'Search outline...', + outlineSearchResult: 'results', + outlineLevelHint: 'level title', + //Tab order setting + tabOrderSettings: 'Interface layout', + tabOrderDesc: 'Adjust the display order of panel Tabs', + moveUp: 'Move up', + moveDown: 'Move down', + //Read navigation settings + readingNavigationSettings: 'Reading Navigation', + readingHistorySettings: 'Reading History', + readingHistoryPersistence: 'Enable reading history', + readingHistoryPersistenceDesc: 'Automatically record the reading position and restore it the next time you open it', + autoRestore: 'Automatic jump', + autoRestoreDesc: 'Automatically jump to the last location when opening the page', + readingHistoryCleanup: 'History retention time', + readingHistoryCleanupDesc: 'Only keep the reading progress of the last few days (-1 is permanent)', + daysSuffix: 'day', + cleanupInfinite: 'permanent', + restoredPosition: 'The last reading position has been restored', + cleanupDone: 'Expired data has been cleaned', + // Advanced outline settings + outlineAutoUpdateLabel: 'Automatically update outline during conversation', + outlineAutoUpdateDesc: 'Automatically refresh the directory structure when AI generates content', + outlineUpdateIntervalLabel: 'Update detection interval (seconds)', + outlineShowUserQueries: 'Show user questions', + outlineShowUserQueriesTooltip: 'Show user questions', + outlineOnlyUserQueries: 'Question', + outlineIntervalUpdated: 'The interval has been set to {val} seconds', + outlineSyncScrollLabel: 'Synchronized scrolling', + outlineSyncScrollDesc: 'Automatically highlight the corresponding outline items when the page scrolls', + // Page display settings + pageDisplaySettings: 'Page display', + // Other settings + otherSettingsTitle: 'Other Settings', + showCollapsedAnchorLabel: 'anchor', + showCollapsedAnchorDesc: 'When the panel is collapsed, display the anchor button in the side floating bar', + showCollapsedThemeLabel: 'theme', + showCollapsedThemeDesc: 'When the panel is collapsed, display the theme switching button in the side floating bar', + collapsedButtonsOrderDesc: 'Adjust the display order of collapsed panel buttons', + preventAutoScrollLabel: 'Prevent automatic scrolling', + preventAutoScrollDesc: 'When AI generates long content, prevent the page from automatically scrolling to the bottom to facilitate reading the above', + markdownFixLabel: 'Markdown bold repair', + markdownFixDesc: 'Fix incorrectly rendered **bold** syntax in Gemini responses', + //Interface layout switch + defaultPanelStateLabel: 'Default display panel', + defaultPanelStateDesc: 'The panel remains expanded by default after refreshing the page', + autoHidePanelLabel: 'Automatically hide the panel', + autoHidePanelDesc: 'Automatically hide when clicking outside the panel (such as the left sidebar, chat area, input box)', + + //Interface layout switch + disableOutline: 'Disable outline', + togglePrompts: 'Enable/disable prompt words', + toggleConversations: 'Enable/Disable Conversations', + // Session function + tabConversations: 'Conversations', + conversationsEmpty: 'No conversation data yet', + conversationsEmptyHint: 'Click the sync button above to import conversations from the sidebar', + conversationsSync: 'Sync conversations', + conversationsSyncing: 'Synchronizing...', + conversationsSynced: 'Synchronization completed', + conversationsAddFolder: 'New folder', + conversationsRename: 'rename', + conversationsDelete: 'Delete', + conversationsDeleteConfirm: 'Are you sure you want to delete this folder? Conversations there will be moved to your inbox. ', + conversationsFolderCreated: 'Folder created', + conversationsFolderRenamed: 'The folder has been renamed', + conversationsFolderDeleted: 'Folder deleted', + conversationsCannotDeleteDefault: 'Cannot delete default folder', + conversationsIcon: 'icon', + conversationsFolderName: 'name', + conversationsFolderNamePlaceholder: 'Enter folder name', + confirm: 'OK', + conversationsSyncEmpty: 'Conversation not found', + conversationsSyncNoChange: 'No new conversations', + conversationsLocate: 'Locate the current conversation', + conversationsLocateSuccess: 'The current conversation has been located', + conversationsLocateNotFound: 'The current conversation is not included and is being synchronized...', + conversationsLocateNewChat: 'This is a new conversation and has not been saved yet', + conversationsLocateSyncFailed: 'The conversation was not found after synchronization', + justNow: 'just', + minutesAgo: 'minutes ago', + hoursAgo: 'hours ago', + daysAgo: 'days ago', + conversationsSelectFolder: 'Select the synchronization target folder', + conversationsMoveTo: 'Move to...', + conversationsMoved: 'Moved to', + conversationsSyncDeleteTitle: 'Sync delete', + conversationsSyncDeleteMsg: 'It has been detected that {count} conversations have been deleted in the cloud. Do you want to delete local records simultaneously? ', + conversationsDeleted: 'Deleted', + //Session settings + conversationsSettingsTitle: 'Conversation Settings', + conversationsSyncUnpinLabel: 'Unpin when updated during synchronization', + conversationsSyncUnpinDesc: 'During synchronization, unpin conversations that are not pinned on the cloud to the top locally', + folderRainbowLabel: 'Folder rainbow color', + folderRainbowDesc: 'Assign a different background color to each folder and use a unified solid color after closing', + conversationsSyncDeleteLabel: 'Synchronically delete the cloud when deleting', + conversationsSyncDeleteDesc: 'When deleting local conversation records, delete them from {site} cloud at the same time', + conversationsSyncRenameLabel: 'Sync to cloud when renaming', + conversationsSyncRenameDesc: 'When modifying the conversation title, update the title in the {site} sidebar', + conversationsCustomIcon: 'Custom icon', + batchSelected: '{n} selected', + batchMove: 'Move', + batchDelete: 'Delete', + batchExit: 'Exit', + batchExport: 'Export', exportToMarkdown: 'Markdown', exportToJSON: 'JSON', - exportLoading: '正在加载对话历史...', - exportSuccess: '导出成功', - exportFailed: '导出失败', - exportNoContent: '未找到对话内容', - copySuccess: '已复制到剪贴板', - exportNeedOpenFirst: '请先打开要导出的会话', - exportUserLabel: '用户', - exportMetaTitle: '导出信息', - exportMetaConvTitle: '会话标题', - exportMetaTime: '导出时间', - exportMetaSource: '来源', - exportNotSupported: '当前站点不支持导出', + exportLoading: 'Loading conversation history...', + exportSuccess: 'Export successful', + exportFailed: 'Export failed', + exportNoContent: 'Conversation content not found', + copySuccess: 'Copied to clipboard', + exportNeedOpenFirst: 'Please open the session to be exported first', + exportUserLabel: 'user', + exportMetaTitle: 'Export information', + exportMetaConvTitle: 'Session title', + exportMetaTime: 'Export time', + exportMetaSource: 'source', + exportNotSupported: 'The current site does not support export', exportToTXT: 'TXT', - exportMetaUrl: '链接', - exportToClipboard: '复制 Markdown', - conversationsRefresh: '刷新会话列表', - conversationsSearchPlaceholder: '搜索会话...', - conversationsSearchResult: '个结果', - conversationsNoSearchResult: '未找到匹配结果', - conversationsSetTags: '设置标签', - conversationsNewTag: '新建标签', - conversationsTagName: '标签名称', - conversationsTagColor: '标签颜色', - conversationsFilterByTags: '按标签筛选', - conversationsClearTags: '清除筛选', - conversationsTagCreated: '标签已创建', - conversationsTagUpdated: '标签已更新', - conversationsTagDeleted: '标签已删除', - conversationsTagExists: '标签名称已存在', - conversationsUpdateTag: '更新标签', - conversationsNoTags: '暂无标签', - conversationsManageTags: '管理标签', - conversationsPin: '置顶📌', - conversationsUnpin: '取消置顶', - conversationsPinned: '已置顶', - conversationsUnpinned: '已取消置顶', - conversationsFilterPinned: '筛选置顶', - conversationsClearAll: '清除所有筛选', - conversationsBatchMode: '批量操作', - // 历史加载 - loadingHistory: '正在加载历史记录...', - historyLoaded: '历史记录加载完成', - stopLoading: '停止加载', - loadingHint: '保持页面静止,完成后将自动停留在顶部', - // 边缘吸附 - edgeSnapHideLabel: '边缘吸附隐藏', - edgeSnapHideDesc: '拖动面板到屏幕边缘时自动隐藏,悬停显示', - // 手动锚点 - setAnchor: '设置锚点', - setAnchorToast: '已设置锚点', - backToAnchor: '返回锚点', - noAnchor: '暂无锚点', - clearAnchor: '清除锚点', - clearAnchorToast: '已清除锚点', - manualAnchorLabel: '手动锚点', - manualAnchorDesc: '在快捷工具栏显示手动锚点按钮', - // 水印移除 - watermarkRemovalLabel: '移除图片水印', - watermarkRemovalDesc: '自动移除 Gemini AI 生成图像中的 NanoBanana 水印', - watermarkProcessing: '正在处理图片...', - watermarkProcessed: '水印已移除', - watermarkFailed: '处理失败', - // 内容设置 - contentExportSettingsTitle: '内容设置', - exportImagesToBase64Label: '导出时图片转 Base64', - exportImagesToBase64Desc: '将对话中的图片转换为 Base64 编码嵌入 Markdown,方便离线查看', - formulaCopyLabel: '双击复制公式', - formulaCopyDesc: '双击数学公式可复制 LaTeX 源码(仅 Gemini 标准版)', - formulaCopied: '公式已复制', - formulaDelimiterLabel: '复制时添加分隔符', - formulaDelimiterDesc: '根据公式类型自动添加 $ 或 $$ 分隔符', - tableCopyLabel: '表格复制 Markdown', - tableCopyDesc: '在表格右上角添加复制按钮,直接复制 Markdown 格式', - tableCopied: '表格已复制', + exportMetaUrl: 'Link', + exportToClipboard: 'Copy Markdown', + conversationsRefresh: 'Refresh conversation list', + conversationsSearchPlaceholder: 'Search conversations...', + conversationsSearchResult: 'results', + conversationsNoSearchResult: 'No matching result found', + conversationsSetTags: 'Set Tags', + conversationsNewTag: 'New tag', + conversationsTagName: 'tag name', + conversationsTagColor: 'Tag color', + conversationsFilterByTags: 'Filter by tags', + conversationsClearTags: 'Clear filters', + conversationsTagCreated: 'Tag has been created', + conversationsTagUpdated: 'Tag has been updated', + conversationsTagDeleted: 'Tag has been deleted', + conversationsTagExists: 'Tag name already exists', + conversationsUpdateTag: 'Update tag', + conversationsNoTags: 'No tags yet', + conversationsManageTags: 'Manage tags', + conversationsPin: 'Pin 📌', + conversationsUnpin: 'Unpin', + conversationsPinned: 'Pinned', + conversationsUnpinned: 'Unpinned', + conversationsFilterPinned: 'Filter pinned', + conversationsClearAll: 'Clear all filters', + conversationsBatchMode: 'Batch operation', + //History loading + loadingHistory: 'Loading history...', + historyLoaded: 'History loading completed', + stopLoading: 'Stop loading', + loadingHint: 'Keep the page still, it will automatically stay at the top when completed', + // edge adsorption + edgeSnapHideLabel: 'Edge snapping hide', + edgeSnapHideDesc: 'Automatically hide when dragging the panel to the edge of the screen and display it on hover', + // Manual anchor point + setAnchor: 'Set anchor point', + setAnchorToast: 'Anchor point has been set', + backToAnchor: 'Return to anchor point', + noAnchor: 'No anchor yet', + clearAnchor: 'Clear anchor point', + clearAnchorToast: 'Anchor point cleared', + manualAnchorLabel: 'Manual anchor', + manualAnchorDesc: 'Show manual anchor button on shortcut toolbar', + // watermark removal + watermarkRemovalLabel: 'Remove image watermark', + watermarkRemovalDesc: 'Automatically remove NanoBanana watermarks from images generated by Gemini AI', + watermarkProcessing: 'Processing pictures...', + watermarkProcessed: 'Watermark has been removed', + watermarkFailed: 'Processing failed', + //Content settings + contentExportSettingsTitle: 'Content Settings', + exportImagesToBase64Label: 'Convert images to Base64 when exporting', + exportImagesToBase64Desc: 'Convert the images in the conversation to Base64 encoding and embed them in Markdown for offline viewing', + formulaCopyLabel: 'Double-click to copy formula', + formulaCopyDesc: 'Double-click the mathematical formula to copy the LaTeX source code (Gemini Standard Edition only)', + formulaCopied: 'Formula has been copied', + formulaDelimiterLabel: 'Add delimiter when copying', + formulaDelimiterDesc: 'Automatically add $ or $$ delimiter according to formula type', + tableCopyLabel: 'Table copy Markdown', + tableCopyDesc: 'Add a copy button in the upper right corner of the table to directly copy the Markdown format', + tableCopied: 'Table has been copied', }, 'zh-TW': { - panelTitle: 'Gemini 助手', - tabPrompts: '提示詞', - tabSettings: '設置', - searchPlaceholder: '搜尋提示詞...', - addPrompt: '新增提示詞', - allCategory: '全部', - manageCategory: '⚙ 管理', - currentPrompt: '當前提示詞:', - scrollTop: '頂部', - scrollBottom: '底部', - refresh: '刷新', - collapse: '收起', - edit: '編輯', - delete: '刪除', - copy: '複製', - drag: '拖動', - save: '保存', - cancel: '取消', - add: '新增', - title: '標題', - category: '分類', - categoryPlaceholder: '例如:程式設計、翻譯', - content: '提示詞內容', - editPrompt: '編輯提示詞', - addNewPrompt: '新增提示詞', - fillTitleContent: '請填寫標題和內容', - promptUpdated: '提示詞已更新', - promptAdded: '提示詞已新增', - deleted: '已刪除', - copied: '已複製到剪貼簿', - cleared: '已清除內容', - refreshed: '已刷新', - orderUpdated: '已更新排序', - inserted: '已插入提示詞', - scrolling: '頁面正在捲動,請稍後...', - noTextarea: '未找到輸入框,請點擊輸入框後重試', - - confirmDelete: '確定刪除?', - scrollContainerNotFound: '未找到捲動容器', - noPrompts: '暫無提示詞', - inboxName: '收件箱', - imageProcessingFailed: '圖片處理失敗,將使用原始連結導出', - copyFailed: '複製失敗', - autoThemeFailed: '自動切換主題失敗,請嘗試在網頁設定中手動切換', - noReadingAnchor: '暫無閱讀錨點 (點擊頂部/底部按鈕可自動生成)', - promptScrolling: '頁面正在捲動,請稍後再選擇提示詞', - batchMoveSuccess: '已移動 {count} 個會話到 {folder}', - batchDeleteSuccess: '已刪除 {count} 個會話', - categoryRenamedSuccess: '分類已重新命名為"{newName}"', - categoryDeletedSuccess: '分類"{name}"已刪除', - // 設置面板 - settingsTitle: '通用設置', - panelSettingsTitle: '面板設置', - clearOnSendLabel: '發送後自動修復中文輸入', - clearOnSendDesc: '發送訊息後插入零寬字元,修復下次輸入首字母問題(僅 Gemini Business)', - settingOn: '開', - settingOff: '關', - // 模型鎖定 - modelLockTitle: '模型鎖定', - modelLockLabel: '自動鎖定模型', - modelLockDesc: '進入頁面後自動切換到指定模型', - modelKeywordLabel: '模型關鍵字', - modelKeywordPlaceholder: '例如:3 Pro', - modelKeywordDesc: '用於匹配目標模型名稱', - // 分類管理 - categoryManage: '分類管理', - categoryEmpty: '暫無分類,新增提示詞時會自動建立分類', - rename: '重新命名', - newCategoryName: '請輸入新的分類名稱:', - categoryRenamed: '分類已重新命名', - confirmDeleteCategory: '確定刪除該分類嗎?關聯的提示詞將移至「未分類」', - categoryDeleted: '分類已刪除', - // 語言設置 - languageLabel: '介面語言', - languageDesc: '設定面板顯示語言,即時生效', - languageAuto: '跟隨系統', - languageZhCN: '简体中文', - languageZhTW: '繁體中文', + panelTitle: 'Gemini Assistant', + tabPrompts: 'prompt word', + tabSettings: 'Settings', + searchPlaceholder: 'Search prompt word...', + addPrompt: 'Add prompt word', + allCategory: 'all', + uncategorized: 'Uncategorized', + manageCategory: '⚙ MANAGEMENT', + currentPrompt: 'Current prompt word:', + scrollTop: 'top', + scrollBottom: 'Bottom', + refresh: 'refresh', + collapse: 'collapse', + edit: 'edit', + delete: 'delete', + copy: 'copy', + drag: 'drag', + save: 'save', + cancel: 'cancel', + add: 'New', + title: 'title', + category: 'category', + categoryPlaceholder: 'Example: Programming, Translation', + content: 'Prompt word content', + editPrompt: 'Edit prompt word', + addNewPrompt: 'Add new prompt word', + fillTitleContent: 'Please fill in the title and content', + promptUpdated: 'The prompt word has been updated', + promptAdded: 'The prompt word has been added', + deleted: 'deleted', + copied: 'Copied to clipboard', + cleared: 'cleared content', + refreshed: 'Refreshed', + orderUpdated: 'Sort updated', + inserted: 'Prompt word has been inserted', + scrolling: 'The page is scrolling, please wait...', + noTextarea: 'Input box not found, please click the input box and try again', + + confirmDelete: 'Are you sure to delete?', + scrollContainerNotFound: 'Scroll container not found', + noPrompts: 'No prompt words', + inboxName: 'Inbox', + imageProcessingFailed: 'Image processing failed, the original link will be used to export', + copyFailed: 'Copy failed', + autoThemeFailed: 'Automatically switching the theme failed, please try to switch manually in the web page settings', + noReadingAnchor: 'No reading anchor (click the top/bottom button to automatically generate)', + promptScrolling: 'The page is scrolling, please select the prompt word again later', + batchMoveSuccess: '{count} sessions have been moved to {folder}', + batchDeleteSuccess: '{count} sessions have been deleted', + categoryRenamedSuccess: 'The category has been renamed to "{newName}"', + categoryDeletedSuccess: 'Category "{name}" has been deleted', + // Settings panel + settingsTitle: 'General settings', + panelSettingsTitle: 'Panel Settings', + clearOnSendLabel: 'Automatically repair Chinese input after sending', + clearOnSendDesc: 'Insert zero-width characters after sending the message to fix the problem of inputting the first letter next time (Gemini Business only)', + settingOn: 'on', + settingOff: 'off', + //Model lock + modelLockTitle: 'Model lock', + modelLockLabel: 'Automatically lock model', + modelLockDesc: 'Automatically switch to the specified model after entering the page', + modelKeywordLabel: 'Model Keyword', + modelKeywordPlaceholder: 'For example: 3 Pro', + modelKeywordDesc: 'Used to match the target model name', + // Classification management + categoryManage: 'Category Management', + categoryEmpty: 'There is no category yet, a category will be automatically created when new prompt words are added', + rename: 'rename', + newCategoryName: 'Please enter a new category name:', + categoryRenamed: 'Category has been renamed', + confirmDeleteCategory: 'Are you sure you want to delete this category? The associated prompt words will be moved to "Uncategorized"', + categoryDeleted: 'Category deleted', + // language settings + languageLabel: 'interface language', + languageDesc: 'Set the panel display language, effective immediately', + languageAuto: 'Follow the system', + languageZhCN: 'Simplified Chinese', + languageZhTW: 'Traditional Chinese', languageEn: 'English', - // 頁面寬度設置 - pageWidthLabel: '頁面寬度', - pageWidthDesc: '調整聊天頁面的寬度,即時生效', - enablePageWidth: '啟用頁面加寬', - widthValue: '寬度值', - widthUnit: '單位', - unitPx: '像素 (px)', - unitPercent: '百分比 (%)', - // 標籤頁設置 - tabSettingsTitle: '標籤頁設置', - openNewTabLabel: '新分頁開啟新對話', - openNewTabDesc: '在面板頂部新增按鈕,點擊後在新分頁開啟新對話', - newTabTooltip: '新分頁開啟對話', - autoRenameTabLabel: '自動重新命名標籤頁', - autoRenameTabDesc: '將瀏覽器標籤頁名稱改為當前對話名稱', - renameIntervalLabel: '檢測頻率', - renameIntervalDesc: '檢測對話名稱變化的間隔時間', - secondsSuffix: '秒', - showStatusLabel: '顯示生成狀態', - showStatusDesc: '在標籤頁標題中顯示生成狀態圖示(⏳/✅)', - showNotificationLabel: '傳送桌面通知', - showNotificationDesc: '生成完成時傳送系统通知', - notificationSoundLabel: '通知聲音', - notificationSoundDesc: '生成完成時播放提示音', - notificationVolumeLabel: '聲音音量', - notifyWhenFocusedLabel: '前台時也通知', - notifyWhenFocusedDesc: '當前頁面可見時也發送通知,而不僅在後台時', - autoFocusLabel: '自動視窗置頂', - autoFocusDesc: '生成完成時自動將視窗帶回前台', - privacyModeLabel: '隱私模式', - privacyModeDesc: '隱藏真實對話標題,顯示偽裝標題(雙擊面板標題可快速切換)', - privacyTitleLabel: '偽裝標題', - privacyTitlePlaceholder: '如:Google、工作文件', - titleFormatLabel: '標題格式', - titleFormatDesc: '自訂標題格式,支援佔位符:{status}、{title}、{model}', - notificationTitle: '✅ {site} 生成完成', - notificationBody: '點擊查看結果', - // 大綱功能 - tabOutline: '大綱', - outlineEmpty: '暫無大綱內容', - outlineRefresh: '刷新', - outlineSettings: '大綱設置', - enableOutline: '啟用大綱', - outlineMaxLevel: '顯示標題級別', - outlineLevelAll: '全部 (1-6級)', - outlineLevel1: '僅 1 級', - outlineLevel2: '至 2 級', - outlineLevel3: '至 3 級', - // 刷新按鈕提示 - refreshPrompts: '刷新提示詞', - refreshOutline: '刷新大綱', - refreshSettings: '刷新設置', - jumpToAnchor: '返回跳轉前位置', - // 大綱高級工具欄 - outlineScrollBottom: '滾動到底部', - outlineScrollTop: '滾動到頂部', - outlineExpandAll: '展開全部', - outlineCollapseAll: '折疊全部', - outlineLocateCurrent: '定位到當前位置', - outlineSearch: '搜尋大綱...', - outlineSearchResult: '個結果', - outlineLevelHint: '級標題', - // Tab 顺序设置 - tabOrderSettings: '介面排版', - tabOrderDesc: '調整面板 Tab 的顯示順序', - moveUp: '上移', - moveDown: '下移', - // 阅读导航設置 - readingNavigationSettings: '閱讀導航', - readingHistorySettings: '閱讀歷史', - readingHistoryPersistence: '啟用閱讀歷史', - readingHistoryPersistenceDesc: '自動記錄閱讀位置,下次開啟時恢復', - autoRestore: '自動跳轉', - autoRestoreDesc: '開啟頁面時自動跳轉到上次位置', - readingHistoryCleanup: '歷史保留時間', - readingHistoryCleanupDesc: '只保留最近幾天的閱讀進度 (-1 為永久)', - daysSuffix: '天', - cleanupInfinite: '永久', - restoredPosition: '已恢復上次閱讀位置', - cleanupDone: '已清理過期數據', - // 大綱高級設置 - outlineAutoUpdateLabel: '對話期間自動更新大綱', - outlineAutoUpdateDesc: 'AI 生成內容時自動刷新目錄結構', - outlineUpdateIntervalLabel: '更新檢測間隔 (秒)', - outlineShowUserQueries: '展示用戶提問', - outlineShowUserQueriesTooltip: '展示用戶提問', - outlineOnlyUserQueries: '提問', - outlineIntervalUpdated: '間隔已設為 {val} 秒', - outlineSyncScrollLabel: '同步滾動', - outlineSyncScrollDesc: '頁面滾動時自動高亮對應的大綱項', - // 頁面顯示設置 - pageDisplaySettings: '頁面顯示', - // 其他設置 - otherSettingsTitle: '其他設置', - showCollapsedAnchorLabel: '錨點', - showCollapsedAnchorDesc: '當面板收起時,在側邊浮動條中顯示錨點按鈕', - showCollapsedThemeLabel: '主題', - showCollapsedThemeDesc: '當面板收起時,在側邊浮動條中顯示主題切換按鈕', - collapsedButtonsOrderDesc: '調整折疊面板按鈕的顯示順序', - preventAutoScrollLabel: '防止自動滾動', - preventAutoScrollDesc: '當 AI 生成長內容時,阻止頁面自動滾動到底部,方便閱讀上文', - markdownFixLabel: 'Markdown 加粗修復', - markdownFixDesc: '修復 Gemini 響應中未正確渲染的 **加粗** 語法', - // 面板設置 - defaultPanelStateLabel: '預設顯示面板', - defaultPanelStateDesc: '重新整理頁面後面板預設保持展開狀態', - autoHidePanelLabel: '自動隱藏面板', - autoHidePanelDesc: '點擊面板外部(如左側側邊欄、聊天區、輸入框)時自動隱藏', - // 介面排版開關 - disableOutline: '禁用大綱', - togglePrompts: '啟用/禁用提示詞', - toggleConversations: '啟用/禁用會話', - // 會話功能 - tabConversations: '會話', - conversationsEmpty: '暫無會話數據', - conversationsEmptyHint: '點擊上方同步按鈕從側邊欄導入會話', - conversationsSync: '同步會話', - conversationsSyncing: '正在同步...', - conversationsSynced: '同步完成', - conversationsAddFolder: '新建資料夾', - conversationsRename: '重命名', - conversationsDelete: '刪除', - conversationsDeleteConfirm: '確定刪除此資料夾嗎?其中的會話將移到收件箱。', - conversationsFolderCreated: '資料夾已創建', - conversationsFolderRenamed: '資料夾已重命名', - conversationsFolderDeleted: '資料夾已刪除', - conversationsCannotDeleteDefault: '無法刪除預設資料夾', - conversationsIcon: '圖標', - conversationsFolderName: '名稱', - conversationsFolderNamePlaceholder: '輸入資料夾名稱', - confirm: '確定', - conversationsSyncEmpty: '未找到會話', - conversationsSyncNoChange: '無新會話', - conversationsLocate: '定位當前對話', - conversationsLocateSuccess: '已定位到當前對話', - conversationsLocateNotFound: '當前對話未收錄,正在同步...', - conversationsLocateNewChat: '當前是新對話,尚未保存', - conversationsLocateSyncFailed: '同步後仍未找到該對話', - justNow: '剛剛', - minutesAgo: '分鐘前', - hoursAgo: '小時前', - daysAgo: '天前', - conversationsSelectFolder: '選擇同步目標資料夾', - conversationsMoveTo: '移動到...', - conversationsMoved: '已移動到', - conversationsSyncDeleteTitle: '同步刪除', - conversationsSyncDeleteMsg: '檢測到 {count} 個會話已在雲端刪除,是否同步刪除本地記錄?', - conversationsDeleted: '已移除', - // 會話設置 - conversationsSettingsTitle: '會話設置', - conversationsSyncUnpinLabel: '同步時更新取消置頂', - conversationsSyncUnpinDesc: '同步時,將雲端未置頂的會話在本地也取消置頂', - folderRainbowLabel: '資料夾彩虹色', - folderRainbowDesc: '為每個資料夾分配不同的背景顏色,關閉後使用統一純色', - conversationsSyncDeleteLabel: '刪除時同步刪除雲端', - conversationsSyncDeleteDesc: '刪除本地會話記錄時,同時從 {site} 雲端刪除', - conversationsSyncRenameLabel: '重命名時同步雲端', - conversationsSyncRenameDesc: '修改會話標題時,同時在 {site} 側邊欄更新標題', - conversationsCustomIcon: '自定義圖示', - batchSelected: '已選 {n} 個', - batchMove: '移動', - batchDelete: '刪除', - batchExit: '退出', - batchExport: '匯出', + // Page width settings + pageWidthLabel: 'Page width', + pageWidthDesc: 'Adjust the width of the chat page, effective immediately', + enablePageWidth: 'Enable page widening', + widthValue: 'width value', + widthUnit: 'unit', + unitPx: 'pixel (px)', + unitPercent: 'Percent (%)', + //Tab settings + tabSettingsTitle: 'Tab settings', + openNewTabLabel: 'New tab opens new conversation', + openNewTabDesc: 'Add a new button at the top of the panel and click it to open a new conversation in a new tab', + newTabTooltip: 'Open conversation in new tab', + autoRenameTabLabel: 'Automatically rename tab', + autoRenameTabDesc: 'Change the browser tab name to the current conversation name', + renameIntervalLabel: 'Detection frequency', + renameIntervalDesc: 'Interval time to detect changes in conversation name', + secondsSuffix: 'seconds', + showStatusLabel: 'Show generation status', + showStatusDesc: 'Show the generation status icon in the tab title (⏳/✅)', + showNotificationLabel: 'Send desktop notification', + showNotificationDesc: 'Send system notification when generation is completed', + notificationSoundLabel: 'Notification Sound', + notificationSoundDesc: 'Play a prompt sound when the generation is completed', + notificationVolumeLabel: 'Voice volume', + notifyWhenFocusedLabel: 'Also notify when in the foreground', + notifyWhenFocusedDesc: 'Send a notification when the current page is visible, not just in the background', + autoFocusLabel: 'Automatic window on top', + autoFocusDesc: 'Automatically bring the window back to the foreground when the generation is completed', + privacyModeLabel: 'privacy mode', + privacyModeDesc: 'Hide the real conversation title and display the disguised title (double-click the panel title to quickly switch)', + privacyTitleLabel: 'Disguise title', + privacyTitlePlaceholder: 'Such as: Google, work documents', + titleFormatLabel: 'Title format', + titleFormatDesc: 'Customized title format, supports placeholders: {status}, {title}, {model}', + notificationTitle: '✅ {site} generation completed', + notificationBody: 'Click to view results', + //outline function + tabOutline: 'Outline', + outlineEmpty: 'No outline content yet', + outlineRefresh: 'Refresh', + outlineSettings: 'Outline Settings', + enableOutline: 'Enable outline', + outlineMaxLevel: 'Show title level', + outlineLevelAll: 'All (Level 1-6)', + outlineLevel1: 'Level 1 only', + outlineLevel2: 'To level 2', + outlineLevel3: 'To level 3', + // Refresh button prompt + refreshPrompts: 'Refresh prompt word', + refreshOutline: 'Refresh outline', + refreshSettings: 'Refresh settings', + jumpToAnchor: 'Return to the position before jump', + // Outline advanced toolbar + outlineScrollBottom: 'Scroll to the bottom', + outlineScrollTop: 'Scroll to top', + outlineExpandAll: 'Expand all', + outlineCollapseAll: 'Collapse all', + outlineLocateCurrent: 'Locate to current location', + outlineSearch: 'Search outline...', + outlineSearchResult: 'results', + outlineLevelHint: 'level title', + //Tab order setting + tabOrderSettings: 'Interface layout', + tabOrderDesc: 'Adjust the display order of panel Tabs', + moveUp: 'Move up', + moveDown: 'Move down', + //Read navigation settings + readingNavigationSettings: 'Reading Navigation', + readingHistorySettings: 'Reading History', + readingHistoryPersistence: 'Enable reading history', + readingHistoryPersistenceDesc: 'Automatically record the reading position and restore it the next time it is turned on', + autoRestore: 'Automatic jump', + autoRestoreDesc: 'Automatically jump to the last location when opening the page', + readingHistoryCleanup: 'History retention time', + readingHistoryCleanupDesc: 'Only keep the reading progress of the last few days (-1 is permanent)', + daysSuffix: 'day', + cleanupInfinite: 'permanent', + restoredPosition: 'The last reading position has been restored', + cleanupDone: 'Expired data has been cleaned', + // Advanced outline settings + outlineAutoUpdateLabel: 'Automatically update outline during conversation', + outlineAutoUpdateDesc: 'Automatically refresh the directory structure when AI generates content', + outlineUpdateIntervalLabel: 'Update detection interval (seconds)', + outlineShowUserQueries: 'Show user questions', + outlineShowUserQueriesTooltip: 'Show user questions', + outlineOnlyUserQueries: 'Question', + outlineIntervalUpdated: 'The interval has been set to {val} seconds', + outlineSyncScrollLabel: 'Synchronized scrolling', + outlineSyncScrollDesc: 'Automatically highlight the corresponding outline items when the page scrolls', + // Page display settings + pageDisplaySettings: 'Page display', + // Other settings + otherSettingsTitle: 'Other Settings', + showCollapsedAnchorLabel: 'anchor', + showCollapsedAnchorDesc: 'When the panel is collapsed, display the anchor button in the side floating bar', + showCollapsedThemeLabel: 'theme', + showCollapsedThemeDesc: 'When the panel is collapsed, display the theme switching button in the side floating bar', + collapsedButtonsOrderDesc: 'Adjust the display order of collapsed panel buttons', + preventAutoScrollLabel: 'Prevent automatic scrolling', + preventAutoScrollDesc: 'When AI generates long content, prevent the page from automatically scrolling to the bottom to facilitate reading the above', + markdownFixLabel: 'Markdown bold repair', + markdownFixDesc: 'Fix incorrectly rendered **bold** syntax in Gemini responses', + //Panel settings + defaultPanelStateLabel: 'Default display panel', + defaultPanelStateDesc: 'The panel default remains expanded after reorganizing the page', + autoHidePanelLabel: 'Automatically hide the panel', + autoHidePanelDesc: 'Automatically hide when clicking outside the panel (such as the left sidebar, chat area, input box)', + //Interface layout switch + disableOutline: 'Disable outline', + togglePrompts: 'Enable/disable prompt words', + toggleConversations: 'Enable/Disable Conversations', + // Session function + tabConversations: 'Conversations', + conversationsEmpty: 'No conversation data yet', + conversationsEmptyHint: 'Click the sync button above to import conversations from the sidebar', + conversationsSync: 'Sync conversations', + conversationsSyncing: 'Synchronizing...', + conversationsSynced: 'Synchronization completed', + conversationsAddFolder: 'New folder', + conversationsRename: 'rename', + conversationsDelete: 'Delete', + conversationsDeleteConfirm: 'Are you sure you want to delete this folder? Conversations there will be moved to your inbox. ', + conversationsFolderCreated: 'Folder created', + conversationsFolderRenamed: 'The folder has been renamed', + conversationsFolderDeleted: 'Folder deleted', + conversationsCannotDeleteDefault: 'Cannot delete default folder', + conversationsIcon: 'icon', + conversationsFolderName: 'name', + conversationsFolderNamePlaceholder: 'Enter folder name', + confirm: 'OK', + conversationsSyncEmpty: 'Conversation not found', + conversationsSyncNoChange: 'No new conversations', + conversationsLocate: 'Locate the current conversation', + conversationsLocateSuccess: 'The current conversation has been located', + conversationsLocateNotFound: 'The current conversation is not included and is being synchronized...', + conversationsLocateNewChat: 'This is a new conversation and has not been saved yet', + conversationsLocateSyncFailed: 'The conversation was not found after synchronization', + justNow: 'just', + minutesAgo: 'minutes ago', + hoursAgo: 'hours ago', + daysAgo: 'days ago', + conversationsSelectFolder: 'Select the synchronization target folder', + conversationsMoveTo: 'Move to...', + conversationsMoved: 'Moved to', + conversationsSyncDeleteTitle: 'Sync delete', + conversationsSyncDeleteMsg: 'It has been detected that {count} conversations have been deleted in the cloud. Do you want to delete local records simultaneously? ', + conversationsDeleted: 'Deleted', + //Session settings + conversationsSettingsTitle: 'Conversation Settings', + conversationsSyncUnpinLabel: 'Unpin when updated during synchronization', + conversationsSyncUnpinDesc: 'During synchronization, unpin conversations that are not pinned on the cloud to the top locally', + folderRainbowLabel: 'folder rainbow color', + folderRainbowDesc: 'Assign a different background color to each folder and use a unified solid color after closing', + conversationsSyncDeleteLabel: 'Synchronically delete the cloud when deleting', + conversationsSyncDeleteDesc: 'When deleting local conversation records, delete them from {site} cloud at the same time', + conversationsSyncRenameLabel: 'Sync to cloud when renaming', + conversationsSyncRenameDesc: 'When modifying the conversation title, update the title in the {site} sidebar', + conversationsCustomIcon: 'Custom icon', + batchSelected: '{n} selected', + batchMove: 'Move', + batchDelete: 'Delete', + batchExit: 'Exit', + batchExport: 'Export', exportToMarkdown: 'Markdown', exportToJSON: 'JSON', - exportLoading: '正在載入對話歷史...', - exportSuccess: '匯出成功', - exportFailed: '匯出失敗', - exportNoContent: '未找到對話內容', - copySuccess: '已複製到剪貼簿', - exportNeedOpenFirst: '請先打開要匯出的會話', - exportUserLabel: '用戶', - exportMetaTitle: '匯出資訊', - exportMetaConvTitle: '會話標題', - exportMetaTime: '匯出時間', - exportMetaSource: '來源', - exportNotSupported: '目前站點不支援匯出', + exportLoading: 'Loading conversation history...', + exportSuccess: 'Export successful', + exportFailed: 'Export failed', + exportNoContent: 'Conversation content not found', + copySuccess: 'Copied to clipboard', + exportNeedOpenFirst: 'Please open the session to be exported first', + exportUserLabel: 'user', + exportMetaTitle: 'Export information', + exportMetaConvTitle: 'Session title', + exportMetaTime: 'Export time', + exportMetaSource: 'source', + exportNotSupported: 'The current site does not support export', exportToTXT: 'TXT', - exportMetaUrl: '連結', - exportToClipboard: '複製 Markdown', - conversationsRefresh: '刷新會話列表', - conversationsSearchPlaceholder: '搜尋會話...', - conversationsSearchResult: '個結果', - conversationsNoSearchResult: '未找到匹配結果', - conversationsSetTags: '設定標籤', - conversationsNewTag: '新建標籤', - conversationsTagName: '標籤名稱', - conversationsTagColor: '標籤顏色', - conversationsFilterByTags: '按標籤篩選', - conversationsClearTags: '清除篩選', - conversationsTagCreated: '標籤已建立', - conversationsTagUpdated: '標籤已更新', - conversationsTagDeleted: '標籤已刪除', - conversationsTagExists: '標籤名稱已存在', - conversationsUpdateTag: '更新標籤', - conversationsNoTags: '暫無標籤', - conversationsManageTags: '管理標籤', - conversationsPin: '置頂📌', - conversationsUnpin: '取消置頂', - conversationsPinned: '已置頂', - conversationsUnpinned: '已取消置頂', - conversationsFilterPinned: '篩選置頂', - conversationsClearAll: '清除所有篩選', - conversationsBatchMode: '批次操作', - // 歷史載入 - loadingHistory: '正在載入歷史記錄...', - historyLoaded: '歷史記錄載入完成', - stopLoading: '停止載入', - loadingHint: '保持頁面靜止,完成後將自動停留在頂部', - // 邊緣吸附 - edgeSnapHideLabel: '邊緣吸附隱藏', - edgeSnapHideDesc: '拖動面板到螢幕邊緣時自動隱藏,懸停顯示', - // 手動錨點 - setAnchor: '設置錨點', - setAnchorToast: '已設置錨點', - backToAnchor: '返回錨點', - noAnchor: '暫無錨點', - clearAnchor: '清除錨點', - clearAnchorToast: '已清除錨點', - manualAnchorLabel: '手動錨點', - manualAnchorDesc: '在快捷工具欄顯示手動錨點按鈕', - // 浮水印移除 - watermarkRemovalLabel: '移除圖片浮水印', - watermarkRemovalDesc: '自動移除 Gemini AI 生成圖像中的 NanoBanana 浮水印', - watermarkProcessing: '正在處理圖片...', - watermarkProcessed: '浮水印已移除', - watermarkFailed: '處理失敗', - // 內容設置 - contentExportSettingsTitle: '內容設置', - exportImagesToBase64Label: '匯出時圖片轉 Base64', - exportImagesToBase64Desc: '將對話中的圖片轉換為 Base64 編碼嵌入 Markdown,方便離線查看', - formulaCopyLabel: '雙擊複製公式', - formulaCopyDesc: '雙擊數學公式可複製 LaTeX 原始碼(僅 Gemini 標準版)', - formulaCopied: '公式已複製', - formulaDelimiterLabel: '複製時添加分隔符', - formulaDelimiterDesc: '根據公式類型自動添加 $ 或 $$ 分隔符', - tableCopyLabel: '表格複製 Markdown', - tableCopyDesc: '在表格右上角添加複製按鈕,直接複製 Markdown 格式', - tableCopied: '表格已複製', + exportMetaUrl: 'Link', + exportToClipboard: 'Copy Markdown', + conversationsRefresh: 'Refresh conversation list', + conversationsSearchPlaceholder: 'Search conversations...', + conversationsSearchResult: 'results', + conversationsNoSearchResult: 'No matching result found', + conversationsSetTags: 'Set Tags', + conversationsNewTag: 'New tag', + conversationsTagName: 'tag name', + conversationsTagColor: 'Tag color', + conversationsFilterByTags: 'Filter by tags', + conversationsClearTags: 'Clear filters', + conversationsTagCreated: 'Tag has been created', + conversationsTagUpdated: 'Tag has been updated', + conversationsTagDeleted: 'Tag has been deleted', + conversationsTagExists: 'Tag name already exists', + conversationsUpdateTag: 'Update tag', + conversationsNoTags: 'No tags yet', + conversationsManageTags: 'Manage tags', + conversationsPin: 'Pin 📌', + conversationsUnpin: 'Unpin', + conversationsPinned: 'Pinned', + conversationsUnpinned: 'Unpinned', + conversationsFilterPinned: 'Filter pinned', + conversationsClearAll: 'Clear all filters', + conversationsBatchMode: 'batch operation', + //History loading + loadingHistory: 'Loading history...', + historyLoaded: 'History loading completed', + stopLoading: 'Stop loading', + loadingHint: 'Keep the page still, it will automatically stay at the top when completed', + // edge adsorption + edgeSnapHideLabel: 'Edge snapping hide', + edgeSnapHideDesc: 'Automatically hide when dragging the panel to the edge of the screen and display it on hover', + // Manual anchor point + setAnchor: 'Set anchor point', + setAnchorToast: 'Anchor point has been set', + backToAnchor: 'Return to anchor point', + noAnchor: 'No anchor yet', + clearAnchor: 'Clear anchor point', + clearAnchorToast: 'Anchor point cleared', + manualAnchorLabel: 'Manual anchor', + manualAnchorDesc: 'Show manual anchor button on shortcut toolbar', + // Watermark removal + watermarkRemovalLabel: 'Remove picture watermark', + watermarkRemovalDesc: 'Automatically remove NanoBanana watermark from images generated by Gemini AI', + watermarkProcessing: 'Processing pictures...', + watermarkProcessed: 'Watermark has been removed', + watermarkFailed: 'Processing failed', + //Content settings + contentExportSettingsTitle: 'Content Settings', + exportImagesToBase64Label: 'Convert images to Base64 when exporting', + exportImagesToBase64Desc: 'Convert the images in the conversation to Base64 encoding and embed them in Markdown for offline viewing', + formulaCopyLabel: 'Double-click to copy formula', + formulaCopyDesc: 'Double-click the mathematical formula to copy the LaTeX source code (Gemini Standard Edition only)', + formulaCopied: 'Formula has been copied', + formulaDelimiterLabel: 'Add delimiter when copying', + formulaDelimiterDesc: 'Automatically add $ or $$ delimiter according to formula type', + tableCopyLabel: 'Table copy Markdown', + tableCopyDesc: 'Add a copy button in the upper right corner of the table to directly copy the Markdown format', + tableCopied: 'Table has been copied', }, en: { panelTitle: 'Gemini Helper', @@ -836,6 +842,7 @@ searchPlaceholder: 'Search prompts...', addPrompt: 'Add New Prompt', allCategory: 'All', + uncategorized: 'Uncategorized', manageCategory: '⚙ Manage', currentPrompt: 'Current: ', scrollTop: 'Top', @@ -893,8 +900,8 @@ languageLabel: 'Language', languageDesc: 'Set panel display language, takes effect immediately', languageAuto: 'Auto', - languageZhCN: '简体中文', - languageZhTW: '繁體中文', + languageZhCN: 'Simplified Chinese', + languageZhTW: 'Traditional Chinese', languageEn: 'English', // Page width settings pageWidthLabel: 'Page Width', @@ -1149,59 +1156,57 @@ }, }; - // ============= 默认提示词库 ============= + // ============= Default prompt dictionary ============= const DEFAULT_PROMPTS = [ { id: 'default_1', - title: '代码优化', - content: '请帮我优化以下代码,提高性能和可读性:\n\n', - category: '编程', + title: 'Code Optimization', + content: 'Please optimize the following code to improve performance and readability:\n\n', + category: 'Coding', }, { id: 'default_2', - title: '翻译助手', - content: '请将以下内容翻译成中文,保持专业术语的准确性:\n\n', - category: '翻译', + title: 'Translation Assistant', + content: 'Please translate the following content into English, maintaining the accuracy of professional terminology:\n\n', + category: 'Translation', }, ]; - // ============= 页面宽度默认配置 ============= + // ============= Page width default configuration ============= const DEFAULT_WIDTH_SETTINGS = { gemini: { enabled: false, value: '70', unit: '%' }, 'gemini-business': { enabled: false, value: '1600', unit: 'px' }, }; - // ============= 大纲功能默认配置 ============= + // ============= Default configuration of outline function ============= const DEFAULT_OUTLINE_SETTINGS = { enabled: true, - maxLevel: 6, // 显示到几级标题 (1-6) + maxLevel: 6, // How many levels of titles are displayed (1-6) autoUpdate: true, updateInterval: 3, - showUserQueries: false, // 展示用户提问,按对话轮次分组 - syncScroll: true, // 页面滚动时自动高亮大纲项 + showUserQueries: false, // Display user questions, grouped by conversation round + syncScroll: true, // Automatically highlight outline items when the page scrolls }; - // ================ i18n方法 ================== - // 语言检测函数(支持手动设置) + // ================ i18n method ================== + // Language detection function (supports manual setting) function detectLanguage() { - // 优先使用用户手动设置的语言 - const savedLang = GM_getValue(SETTING_KEYS.LANGUAGE, 'auto'); - if (savedLang !== 'auto' && I18N[savedLang]) { - return savedLang; - } - // 自动检测 - const lang = navigator.language || navigator.userLanguage || 'en'; - if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK') || lang.startsWith('zh-Hant')) { + const settingLang = GM_getValue(SETTING_KEYS.LANGUAGE, 'en'); + if (settingLang !== 'auto') { + return settingLang; + } + const navLang = navigator.language || navigator.userLanguage || 'en'; + if (navLang.startsWith('zh-TW') || navLang.startsWith('zh-HK')) { return 'zh-TW'; } - if (lang.startsWith('zh')) { + if (navLang.startsWith('zh')) { return 'zh-CN'; } return 'en'; } - // 全局 i18n 翻译函数 - // 缓存语言检测结果,避免每次调用都重新检测 + //Global i18n translation function + //Cache language detection results to avoid re-detection for each call let _cachedLang = null; function t(key, params = {}) { @@ -1210,7 +1215,7 @@ } let text = I18N[_cachedLang]?.[key] || I18N['en']?.[key] || key; - // 替换参数 + //Replace parameters if (params) { Object.keys(params).forEach((k) => { text = text.replace(new RegExp(`{${k}}`, 'g'), params[k]); @@ -1219,90 +1224,72 @@ return text; } - // 语言变更时需要调用此函数清除缓存 + // This function needs to be called to clear the cache when the language is changed. function resetLanguageCache() { _cachedLang = null; } - // ==================== 站点适配器模式 (Site Adapter Pattern) ==================== + // ==================== Site Adapter Pattern ==================== /** - * 站点适配器基类 - * 添加新站点时,继承此类并实现所有抽象方法 - */ + * site adapter base class * When adding a new site, inherit this class and implement all abstract methods*/ class SiteAdapter { constructor() { this.textarea = null; } /** - * 检测当前页面是否匹配该站点 - * @returns {boolean} - */ + * Check whether the current page matches this site * @returns {boolean} */ match() { - throw new Error('必须实现 match()'); + throw new Error('match() must be implemented'); } /** - * 返回站点标识符(用于配置存储) - * @returns {string} - */ + * Returns the site identifier (for configuration storage) * @returns {string} */ getSiteId() { - throw new Error('必须实现 getSiteId()'); + throw new Error('getSiteId() must be implemented'); } /** - * 返回站点显示名称 - * @returns {string} - */ + * Return site display name * @returns {string} */ getName() { - throw new Error('必须实现 getName()'); + throw new Error('getName() must be implemented'); } /** - * 获取当前会话ID (用于锚点持久化) - * @returns {string} Session ID - */ + * Get the current session ID (for anchor persistence) * @returns {string} Session ID */ getSessionId() { - // 优化实现:先去除 URL 中的查询参数 (?及后面内容),再获取最后一段 + // Optimization implementation: first remove the query parameters (? and subsequent content) in the URL, and then obtain the last paragraph const urlWithoutQuery = window.location.href.split('?')[0]; const parts = urlWithoutQuery.split('/').filter((p) => p); return parts.length > 0 ? parts[parts.length - 1] : 'default'; } /** - * 是否支持在新标签页打开新对话 - * @returns {boolean} - */ + * Whether to support opening a new conversation in a new tab * @returns {boolean} */ supportsNewTab() { return true; } /** - * 获取新标签页打开的 URL - * @returns {string} - */ + * Get the URL opened by the new tab * @returns {string} */ getNewTabUrl() { return window.location.origin; } /** - * 是否支持标签页重命名 - * @returns {boolean} - */ + * Whether to support tab renaming * @returns {boolean} */ supportsTabRename() { return true; } /** - * 获取当前会话/对话名称(用于标签页重命名) - * @returns {string|null} - */ + * Get the current session/conversation name (for tab renaming) * @returns {string|null} */ getSessionName() { - // 默认实现:尝试从 document.title 中提取 + // Default implementation: attempts to extract from document.title const title = document.title; if (title) { - // 去除站点名称后缀,如 "对话标题 - Gemini" + // Remove the site name suffix, such as "Conversation Title - Gemini" const parts = title.split(' - '); if (parts.length > 1) { return parts.slice(0, -1).join(' - ').trim(); @@ -1313,64 +1300,44 @@ } /** - * 判断当前是否处于新对话页面(未发起任何对话) - * 新对话页面不应使用旧会话标题更新标签页、不应记录阅读历史 - * @returns {boolean} - */ + * Determine whether you are currently on a new conversation page (no conversation has been initiated) * New conversation pages should not update tabs with old conversation titles and should not record reading history * @returns {boolean} */ isNewConversation() { return false; } /** - * 获取侧边栏会话列表 - * 子类应覆盖此方法从站点 DOM 提取会话数据 - * @returns {Array<{id: string, title: string, url: string, isActive: boolean}>} - */ + * Get the sidebar session list * Subclasses should override this method to extract session data from the site DOM * @returns {Array<{id: string, title: string, url: string, isActive: boolean}>} */ getConversationList() { return []; } /** - * 获取侧边栏可滚动容器 - * 子类应覆盖此方法返回侧边栏的可滚动容器元素 - * @returns {Element|null} - */ + * Get the scrollable container of the sidebar * Subclasses should override this method to return the scrollable container element of the sidebar * @returns {Element|null} */ getSidebarScrollContainer() { return null; } /** - * 获取会话观察器配置(用于侧边栏实时监听) - * 子类应覆盖此方法提供站点特定的配置 - * @returns {{ - * selector: string, // 会话元素 CSS 选择器 - * shadow: boolean, // 是否需要 Shadow DOM 穿透 - * extractInfo: function(Element): Object|null, // 从元素提取会话信息 - * getTitleElement: function(Element): Element // 获取标题元素(用于监听变化) - * }|null} 返回 null 表示不支持 - */ + * Get session observer configuration (for sidebar real-time monitoring) * Subclasses should override this method to provide site-specific configuration * @returns {{ * selector: string, // Session element CSS selector * shadow: boolean, // Whether Shadow DOM penetration is required * extractInfo: function(Element): Object|null, // Extract session information from the element * getTitleElement: function(Element): Element // Get the title element (used to monitor changes) * }|null} Return null to indicate not supported*/ getConversationObserverConfig() { return null; } /** - * 滚动加载全部会话 - * 模拟滚动侧边栏到底部,直到所有会话都加载完成 - * @returns {Promise} - */ + * Scroll to load all sessions * Simulate scrolling the sidebar to the bottom until all sessions have loaded * @returns {Promise} */ async loadAllConversations() { const container = this.getSidebarScrollContainer(); if (!container) return; let lastCount = 0; let stableRounds = 0; - const maxStableRounds = 3; // 连续3次无新增则停止 + const maxStableRounds = 3; // Stop if there is no new addition for 3 consecutive times while (stableRounds < maxStableRounds) { container.scrollTop = container.scrollHeight; await new Promise((r) => setTimeout(r, 500)); - // 使用 DOMToolkit 穿透 Shadow DOM 查询会话数量 + // Use DOMToolkit to penetrate Shadow DOM to query the number of sessions const conversations = DOMToolkit.query('.conversation', { all: true, shadow: true }) || []; const currentCount = conversations.length; if (currentCount === lastCount) { @@ -1383,73 +1350,52 @@ } /** - * 检测 AI 是否正在生成响应 - * @returns {boolean} - */ + * Detect whether the AI is generating a response * @returns {boolean} */ isGenerating() { - // 默认实现:子类应覆盖此方法 + // Default implementation: subclasses should override this method return false; } /** - * 获取当前使用的模型名称 - * @returns {string|null} - */ + * Get the currently used model name * @returns {string|null} */ getModelName() { - // 默认实现:子类应覆盖此方法 + // Default implementation: subclasses should override this method return null; } /** - * 获取网络监控配置(用于后台任务完成检测) - * 子类可覆盖此方法提供站点特定的配置 - * @returns {{ - * urlPatterns: string[], // 要监控的 URL 模式(包含匹配) - * silenceThreshold: number // 静默判定时间(毫秒) - * }|null} 返回 null 表示不启用网络监控 - */ + * Get network monitoring configuration (for background task completion detection) * Subclasses can override this method to provide site-specific configuration * @returns {{ * urlPatterns: string[], // URL patterns to monitor (including matches) * silenceThreshold: number // Silence determination time (milliseconds) * }|null} Return null to disable network monitoring.*/ getNetworkMonitorConfig() { return null; } /** - * 返回站点主题色 - * @returns {{primary: string, secondary: string}} - */ + * Returns the site theme color * @returns {{primary: string, secondary: string}} */ getThemeColors() { - throw new Error('必须实现 getThemeColors()'); + throw new Error('getThemeColors() must be implemented'); } /** - * 返回需要加宽的CSS选择器列表 - * @returns {Array<{selector: string, property: string}>} - */ + * Returns a list of CSS selectors that need to be widened * @returns {Array<{selector: string, property: string}>} */ getWidthSelectors() { return []; } /** - * 返回输入框选择器列表 - * @returns {string[]} - */ + * Return the input box selector list * @returns {string[]} */ getTextareaSelectors() { return []; } /** - * 获取提交按钮选择器,可以匹配ID、类名、属性等选择器 - * - * @returns 提交按钮选择器 - */ + * Get the submit button selector, which can match selectors such as ID, class name, attributes, etc. * + * @returns submit button selector */ getSubmitButtonSelectors() { return []; } /** - * 查找输入框元素 - * 默认实现:遍历选择器查找 - * @returns {HTMLElement|null} - */ + * Find the input box element * Default implementation: traverse the selector to find * @returns {HTMLElement|null} */ findTextarea() { for (const selector of this.getTextareaSelectors()) { const elements = document.querySelectorAll(selector); @@ -1464,26 +1410,21 @@ } /** - * 验证输入框是否有效 - * @param {HTMLElement} element - * @returns {boolean} + * Verify whether the input box is valid * @param {HTMLElement} element * @returns {boolean} */ isValidTextarea(element) { return element.offsetParent !== null; } /** - * 向输入框插入内容 - * @param {string} content - * @returns {Promise|boolean} + * Insert content into the input box * @param {string} content * @returns {Promise|boolean} */ insertPrompt(content) { - throw new Error('必须实现 insertPrompt()'); + throw new Error('insertPrompt() must be implemented'); } /** - * 清空输入框内容 - */ + * Clear the input box content*/ clearTextarea() { if (this.textarea) { this.textarea.value = ''; @@ -1509,64 +1450,60 @@ } /** - * 获取滚动容器 - * @returns {HTMLElement} - */ + * Get the scroll container * @returns {HTMLElement} */ getScrollContainer() { - // 精确匹配滚动容器,找不到就返回 null(不 fallback 到 body) - // 这对于同步滚动很重要:必须绑定到正确的容器 + // Exactly matches the scrolling container, returns null if not found (does not fallback to body) + // This is important for synchronous scrolling: must be bound to the correct container const selectors = [ - 'infinite-scroller.chat-history', // Gemini 主对话滚动容器 + 'infinite-scroller.chat-history', // Gemini main dialog scrolling container '.chat-mode-scroller', 'main', '[role="main"]', '.conversation-container', '.chat-container', - 'div.content-container', // Gemini 分享页面滚动容器 + 'div.content-container', // Gemini share page scrolling container ]; for (const selector of selectors) { const container = document.querySelector(selector); if (container && container.scrollHeight > container.clientHeight) { - // 如果找到普通容器,清除 Flutter 容器缓存 + // If a normal container is found, clear the Flutter container cache this._cachedFlutterScrollContainer = null; return container; } } - // 检查缓存的 Flutter 容器是否仍然有效 + // Check if cached Flutter container is still valid if (this._cachedFlutterScrollContainer && this._cachedFlutterScrollContainer.isConnected) { return this._cachedFlutterScrollContainer; } - // 尝试在 iframe 中查找(Gemini 图文并茂模式) - // iframe 有 allow-same-origin,可以跨域访问其内部 DOM + // Try to find in iframe (Gemini graphic mode) + // iframe has allow-same-origin and can access its internal DOM across domains const iframes = document.querySelectorAll('iframe[sandbox*="allow-same-origin"]'); for (const iframe of iframes) { try { const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; if (iframeDoc) { - // 在 flutter-view 内查找滚动容器 + // Find scroll container inside flutter-view const scrollContainer = iframeDoc.querySelector('flt-semantics[style*="overflow-y: scroll"]:not([style*="overflow-x: scroll"])'); if (scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight) { - // 缓存找到的 Flutter 容器 + // Cache found Flutter containers this._cachedFlutterScrollContainer = scrollContainer; return scrollContainer; } } } catch (e) { - // 跨域 iframe 会抛出错误,忽略 + // Cross-domain iframes will throw an error, ignore console.warn('[GeminiHelper] Failed to access iframe:', e.message); } } - // 容器可能还未加载(SPA 动态渲染),返回 null 让调用者决定重试 + // The container may not be loaded yet (SPA dynamic rendering), return null to let the caller decide to try again return null; } /** - * 获取当前视口中可见的锚点元素信息 (用于精准定位) - * @returns {Object|null} { selector, offset, index } - */ + * Get the anchor element information visible in the current viewport (for precise positioning) * @returns {Object|null} { selector, offset, index } */ getVisibleAnchorElement() { const container = this.getScrollContainer(); if (!container) return null; @@ -1575,7 +1512,7 @@ const selectors = this.getChatContentSelectors(); if (!selectors.length) return null; - // 查找所有候选元素 + // Find all candidate elements const candidates = Array.from(container.querySelectorAll(selectors.join(', '))); if (!candidates.length) return null; @@ -1585,11 +1522,11 @@ const el = candidates[i]; const top = el.offsetTop; - // 策略:找到最后一个"顶部"位于视口上方(或刚露出)的元素 = 用户当前正在阅读的起始元素 + // Strategy: Find the last "top" element above (or just above) the viewport = the starting element the user is currently reading if (top <= scrollTop + 100) { bestElement = el; } else { - // 后续元素都在视口下方,停止 + // Subsequent elements are all below the viewport, stop break; } } @@ -1608,7 +1545,7 @@ } else { const globalIndex = candidates.indexOf(bestElement); if (globalIndex !== -1) { - // 增强:记录文本指纹,防止历史加载导致索引偏移 + // Enhancement: Record text fingerprints to prevent index offset caused by historical loading const textSignature = (bestElement.textContent || '').trim().substring(0, 50); return { type: 'index', index: globalIndex, offset: offset, textSignature: textSignature }; } @@ -1618,10 +1555,7 @@ } /** - * 根据保存的锚点信息恢复滚动 - * @param {Object} anchorData - * @returns {boolean} 是否成功恢复 - */ + * Resume scrolling based on saved anchor information * @param {Object} anchorData * @returns {boolean} Whether the recovery is successful*/ restoreScroll(anchorData) { const container = this.getScrollContainer(); if (!container || !anchorData) return false; @@ -1634,15 +1568,15 @@ const selectors = this.getChatContentSelectors(); const candidates = Array.from(container.querySelectorAll(selectors.join(', '))); - // 优先尝试使用索引 + // Try using indexes first if (candidates[anchorData.index]) { targetElement = candidates[anchorData.index]; - // 如果有文本指纹,进行校验 + // If there is a text fingerprint, verify it if (anchorData.textSignature) { const currentText = (targetElement.textContent || '').trim().substring(0, 50); - // 如果文本不匹配,说明索引可能偏移了(例如加载了历史消息) - // 此时尝试全列表搜索 + // If the text does not match, the index may be offset (e.g. historical messages are loaded) + // Try a full list search at this time if (currentText !== anchorData.textSignature) { // console.log('Anchor index mismatch, searching by text signature...'); const found = candidates.find((c) => (c.textContent || '').trim().substring(0, 50) === anchorData.textSignature); @@ -1650,7 +1584,7 @@ } } } else { - // 索引越界(可能消息被删了?),尝试文本搜索 + // Index out of bounds (maybe the message was deleted?), try text search if (anchorData.textSignature) { const found = candidates.find((c) => (c.textContent || '').trim().substring(0, 50) === anchorData.textSignature); if (found) targetElement = found; @@ -1667,12 +1601,10 @@ } /** - * 页面加载完成后执行 - * @param {Object} options - 配置项 { clearOnInit: boolean, lockModel: boolean } - */ + * Executed after the page is loaded * @param {Object} options - configuration items { clearOnInit: boolean, lockModel: boolean }*/ afterPropertiesSet(options = {}) { const { modelLockConfig } = options; - // 默认初始化逻辑:如果有模型锁定配置且启用,尝试锁定模型 + // Default initialization logic: If model locking is configured and enabled, try to lock the model if (modelLockConfig && modelLockConfig.enabled) { console.log(`[${this.getName()}] Triggering auto model lock:`, modelLockConfig.keyword); this.lockModel(modelLockConfig.keyword); @@ -1680,111 +1612,81 @@ } /** - * 判断是否应该将样式注入到指定的 Shadow Host 中 - * 用于解决 Shadow DOM 样式污染问题 - */ + * Determine whether styles should be injected into the specified Shadow Host * Used to solve the problem of Shadow DOM style pollution*/ shouldInjectIntoShadow(host) { return true; } /** - * 获取对话历史容器的选择器 - * @returns {string} CSS 选择器 - */ + * Get the selector of the conversation history container * @returns {string} CSS selector*/ getResponseContainerSelector() { return ''; } /** - * 获取聊天内容元素的选择器列表 - * 用于 MutationObserver 检测新消息,配合滚动锁定功能 - * @returns {string[]} CSS 选择器列表 - */ + * Get the selector list of chat content elements * Used for MutationObserver to detect new messages and cooperate with the scroll lock function * @returns {string[]} CSS selector list*/ getChatContentSelectors() { return []; } /** - * 获取用户提问元素的选择器(用于大纲分组功能) - * @returns {string|null} CSS 选择器,返回 null 表示不支持 - */ + * Get the selector of the user question element (used for outline grouping function) * @returns {string|null} CSS selector, returning null means not supported*/ getUserQuerySelector() { return null; } /** - * 从用户提问元素中提取文本(用于大纲分组功能) - * @param {Element} element 用户提问的 DOM 元素 - * @returns {string} 用户提问的文本内容 - */ + * Extract text from the user question element (for outline grouping function) * @param {Element} element DOM element of the user question * @returns {string} Text content of the user question*/ extractUserQueryText(element) { return element.textContent?.trim() || ''; } /** - * 从页面提取大纲(标题列表) - * @param {number} maxLevel 最大标题级别 (1-6) - * @param {boolean} includeUserQueries 是否包含用户提问(作为 level 0 节点) - * @returns {Array<{level: number, text: string, element: Element|null, isUserQuery?: boolean}>} - */ + * Extract the outline (title list) from the page * @param {number} maxLevel Maximum title level (1-6) * @param {boolean} includeUserQueries Whether to include user questions (as a level 0 node) * @returns {Array<{level: number, text: string, element: Element|null, isUserQuery?: boolean}>} */ extractOutline(maxLevel = 6, includeUserQueries = false) { return []; } /** - * 是否支持滚动锁定功能 - * @returns {boolean} - */ + * Whether to support scroll lock function * @returns {boolean} */ supportsScrollLock() { - return false; // 默认不支持,除非子类明确声明 + return false; // Not supported by default unless explicitly declared by a subclass } /** - * 获取导出配置(用于会话导出功能) - * 子类应覆盖此方法提供站点特定的配置 - * @returns {{ - * userQuerySelector: string, // 用户提问元素选择器 - * assistantResponseSelector: string, // AI回复元素选择器 - * turnSelector: string|null, // 对话轮次容器选择器(可选) - * useShadowDOM: boolean // 是否需要穿透 Shadow DOM - * }|null} 返回 null 表示不支持导出 - */ + * Get export configuration (for session export functionality) * Subclasses should override this method to provide site-specific configuration * @returns {{ * userQuerySelector: string, // User question element selector * assistantResponseSelector: string, // AI reply element selector * turnSelector: string|null, // Conversation turn container selector (optional) * useShadowDOM: boolean // Whether to penetrate Shadow DOM * }|null} Return null to indicate that export is not supported*/ getExportConfig() { return null; } - // ============= 新对话监听 ============= + // ============= New conversation monitoring ============= /** - * 获取“新对话”按钮的选择器列表 - * @returns {string[]} - */ + * Get the selector list for the "New Conversation" button * @returns {string[]} */ getNewChatButtonSelectors() { return []; } /** - * 绑定新对话触发事件(点击按钮或快捷键) - * @param {Function} callback - 触发时的回调函数 - */ + * Bind new dialogue trigger event (click button or shortcut key) * @param {Function} callback - callback function when triggered*/ bindNewChatListeners(callback) { - // 1. 快捷键监听 (Ctrl + Shift + O) + // 1. Shortcut key monitoring (Ctrl + Shift + O) document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && (e.key === 'o' || e.key === 'O')) { console.log(`[${this.getName()}] New chat shortcut detected.`); - // 给予一点延迟等待页面响应 + // Give a little delay to wait for the page to respond setTimeout(callback, 500); } }); - // 2. 按钮点击监听 + // 2. Button click monitoring document.addEventListener( 'click', (e) => { const selectors = this.getNewChatButtonSelectors(); if (selectors.length === 0) return; - // 使用 composedPath() 以支持 Shadow DOM 中的元素匹配 + // Use composedPath() to support element matching in Shadow DOM const path = e.composedPath(); for (const target of path) { if (target === document || target === window) break; @@ -1799,43 +1701,26 @@ } }, true, - ); // 使用捕获阶段确保捕获 + ); // Use capture phase to ensure capture } - // ============= 模型锁定功能(抽象接口) ============= + // ============= Model locking function (abstract interface) ============= /** - * 获取默认的模型锁定设置(每个站点可覆盖) - * @returns {{ enabled: boolean, keyword: string }} - */ + * Get the default model locking settings (overridable per site) * @returns {{ enabled: boolean, keyword: string }} */ getDefaultLockSettings() { return { enabled: false, keyword: '' }; } /** - * 获取模型锁定配置 - * 子类需要覆盖此方法提供具体配置 - * @param {string} keyword - 目标模型关键字(由设置传入) - * @returns {{ - * targetModelKeyword: string, // 目标模型名称关键字(用于匹配) - * selectorButtonSelectors: string[], // 模型选择器按钮的 CSS 选择器列表 - * menuItemSelector: string, // 菜单项的 CSS 选择器 - * checkInterval: number, // 检查间隔(毫秒) - * maxAttempts: number, // 最大尝试次数 - * menuRenderDelay: number // 菜单渲染等待时间(毫秒) - * }|null} - */ + * Get model lock configuration * Subclasses need to override this method to provide specific configuration * @param {string} keyword - target model keyword (passed in by settings) * @returns {{ * targetModelKeyword: string, // Target model name keyword (for matching) * selectorButtonSelectors: string[], // CSS selector list for model selector buttons * menuItemSelector: string, // CSS selector for menu items * checkInterval: number, // Check interval (milliseconds) * maxAttempts: number, // Maximum number of attempts * menuRenderDelay: number //Menu rendering waiting time (milliseconds) * }|null} */ getModelSwitcherConfig(keyword) { return null; } /** /** - * 通用模型锁定实现 - * 基于 getModelSwitcherConfig() 返回的配置执行锁定逻辑 - * @param {string} keyword - 目标模型关键字 - * @param {Function} onSuccess 成功后的回调(可选) - */ + * Generic model locking implementation * Execute locking logic based on the configuration returned by getModelSwitcherConfig() * @param {string} keyword - target model keyword * @param {Function} onSuccess callback after success (optional)*/ lockModel(keyword, onSuccess = null) { const config = this.getModelSwitcherConfig(keyword); if (!config) return; @@ -1844,7 +1729,7 @@ let attempts = 0; let isSelecting = false; - // 辅助函数:标准化文本(小写 + 去空) + // Helper function: normalize text (lowercase + remove spaces) const normalize = (str) => (str || '').toLowerCase().trim(); const target = normalize(targetModelKeyword); @@ -1858,11 +1743,11 @@ if (isSelecting) return; - // 1. 查找模型选择器按钮 + // 1. Find the model selector button const selectorBtn = this.findElementBySelectors(selectorButtonSelectors); if (!selectorBtn) return; - // 2. 检查当前是否已经是目标模型(不区分大小写) + // 2. Check whether the current model is already the target model (not case sensitive) const currentText = selectorBtn.textContent || selectorBtn.innerText || ''; if (normalize(currentText).includes(target)) { console.log(`Gemini Helper: Model is already locked to "${targetModelKeyword}"`); @@ -1871,29 +1756,29 @@ return; } - // 3. 标记正在选择 + // 3. Mark is being selected isSelecting = true; - // 4. 点击展开菜单 + // 4. Click to expand the menu selectorBtn.click(); - // 5. 等待菜单渲染后查找并点击目标项 + // 5. Wait for the menu to render and then find and click the target item setTimeout(() => { const menuItems = this.findAllElementsBySelector(menuItemSelector); - // 如果找到了菜单项,说明菜单已渲染 + // If the menu item is found, the menu has been rendered if (menuItems.length > 0) { let found = false; for (const item of menuItems) { const itemText = item.textContent || item.innerText || ''; - // 不区分大小写匹配 + // Case-insensitive matching if (normalize(itemText).includes(target)) { item.click(); found = true; clearInterval(timer); console.log(`Gemini Helper: Switched to model "${targetModelKeyword}"`); - // 延迟关闭菜单面板 + // Delay closing menu panel setTimeout(() => { document.body.click(); if (onSuccess) onSuccess(); @@ -1903,45 +1788,40 @@ } if (!found) { - // 菜单已打开但没有找到目标模型,停止重试以避免死循环闪烁 + // The menu is open but the target model is not found, stop retrying to avoid infinite loop flickering console.warn(`Gemini Helper: Target model "${targetModelKeyword}" not found in menu. Aborting.`); - clearInterval(timer); // 关键:停止定时器 - document.body.click(); // 关闭菜单 + clearInterval(timer); // Key: Stop the timer + document.body.click(); // Close menu isSelecting = false; } } else { - // 菜单可能未渲染或选择器不匹配,允许重试(直到超时) + // Menu may not be rendering or selectors may not match, allow retries (until timeout) isSelecting = false; - document.body.click(); // 尝试关闭以重置状态 + document.body.click(); // Try closing to reset status } }, menuRenderDelay); }, checkInterval); } /** - * 通过选择器列表查找单个元素(支持 Shadow DOM) - * @param {string[]} selectors - * @returns {Element|null} + * Find a single element through a list of selectors (Shadow DOM supported) * @param {string[]} selectors * @returns {Element|null} */ findElementBySelectors(selectors) { - // 使用 DOMToolkit 进行 Shadow DOM 穿透查找 + // Using DOMToolkit for Shadow DOM penetration search return DOMToolkit.query(selectors, { shadow: true }); } /** - * 通过选择器查找所有元素(支持 Shadow DOM) - * @param {string} selector - * @returns {Element[]} + * Find all elements by selector (Shadow DOM supported) * @param {string} selector * @returns {Element[]} */ findAllElementsBySelector(selector) { - // 使用 DOMToolkit 进行 Shadow DOM 穿透查找(返回所有匹配) + // Shadow DOM penetration lookup using DOMToolkit (returns all matches) return DOMToolkit.query(selector, { all: true, shadow: true }); } } /** - * Gemini 适配器(gemini.google.com) - */ + * Gemini adapter (gemini.google.com) */ class GeminiAdapter extends SiteAdapter { match() { return window.location.hostname.includes('gemini.google') && !window.location.hostname.includes('business.gemini.google'); @@ -1969,28 +1849,24 @@ } /** - * 检测是否为分享页面(只读) - * @returns {boolean} - */ + * Check whether it is a shared page (read-only) * @returns {boolean} */ isSharePage() { return window.location.pathname.startsWith('/share/'); } /** - * 从侧边栏提取会话列表 - * @returns {Array<{id: string, title: string, url: string, isActive: boolean}>} - */ + * Extract conversation list from sidebar * @returns {Array<{id: string, title: string, url: string, isActive: boolean}>} */ getConversationList() { const items = DOMToolkit.query('.conversation', { all: true }) || []; return Array.from(items) .map((el) => { - // 从 jslog 属性中提取会话 ID (Use safer regex that allows dashes/underscores) + // Extract session ID from jslog attribute (Use safer regex that allows dashes/underscores) const jslog = el.getAttribute('jslog') || ''; const idMatch = jslog.match(/\["c_([^"]+)"/); const id = idMatch ? idMatch[1] : ''; const title = el.querySelector('.conversation-title')?.textContent?.trim() || ''; - // 检测是否为云端置顶会话(检测实际的 push_pin 图标,而非容器) + // Detect if it is a cloud pinned session (detect the actual push_pin icon, not the container) const isPinned = !!el.querySelector('mat-icon[fonticon="push_pin"]'); return { @@ -2001,20 +1877,17 @@ isPinned: isPinned, }; }) - .filter((c) => c.id); // 过滤掉没有 ID 的项 + .filter((c) => c.id); // Filter out items without ID } /** - * 获取侧边栏可滚动容器 - * @returns {Element|null} - */ + * Get the sidebar scrollable container * @returns {Element|null} */ getSidebarScrollContainer() { return DOMToolkit.query('infinite-scroller[scrollable="true"]') || DOMToolkit.query('infinite-scroller'); } /** - * 获取会话观察器配置(用于侧边栏实时监听) - */ + * Get session observer configuration (for sidebar real-time monitoring)*/ getConversationObserverConfig() { return { selector: '.conversation', @@ -2024,9 +1897,9 @@ const idMatch = jslog.match(/\["c_([^"]+)"/); const id = idMatch ? idMatch[1] : ''; if (!id) return null; - // 使用精确选择器提取标题,避免包含"固定的对话"等隐藏文字 + // Use precise selectors to extract titles and avoid hidden text such as "Fixed dialogue" const title = el.querySelector('.conversation-title')?.textContent?.trim() || ''; - // 检测是否为云端置顶会话 + // Detect whether it is a pinned session in the cloud const isPinned = !!el.querySelector('mat-icon[fonticon="push_pin"]'); return { id, @@ -2040,13 +1913,13 @@ } getSessionName() { - // 从侧边栏活动对话标题获取 + // Get from sidebar activity conversation title const titleEl = document.querySelector('.conversation-title'); if (titleEl) { const name = titleEl.textContent?.trim(); if (name) return name; } - // 回退到基类默认实现(从 document.title 提取) + // Fallback to base class default implementation (extracted from document.title) return super.getSessionName(); } @@ -2055,14 +1928,14 @@ '.new-chat-button', '.chat-history-new-chat-button', '[aria-label="New chat"]', - '[aria-label="新对话"]', - '[aria-label="发起新对话"]', + '[aria-label="\u65b0\u5bf9\u8bdd"]', + '[aria-label="\u53d1\u8d77\u65b0\u5bf9\u8bdd"]', '[data-testid="new-chat-button"]', '[data-test-id="new-chat-button"]', '[data-test-id="expanded-button"]', - // 临时对话按钮 + // Temporary chat button '[data-test-id="temp-chat-button"]', - 'button[aria-label="临时对话"]', + 'button[aria-label="\u4e34\u65f6\u5bf9\u8bdd"]', ]; } @@ -2070,7 +1943,7 @@ return [ { selector: '.conversation-container', property: 'max-width' }, { selector: '.input-area-container', property: 'max-width' }, - // 用户消息右对齐 + // User message right aligned { selector: 'user-query', property: 'max-width', @@ -2093,15 +1966,15 @@ } getSubmitButtonSelectors() { - return ['button[aria-label*="Send"]', 'button[aria-label*="发送"]', '.send-button', '[data-testid*="send"]']; + return ['button[aria-label*="Send"]', 'button[aria-label*="\u53d1\u9001"]', '.send-button', '[data-testid*="send"]']; } isValidTextarea(element) { - // 必须是可见的 contenteditable 元素 + // Must be a visible contenteditable element if (element.offsetParent === null) return false; const isContentEditable = element.getAttribute('contenteditable') === 'true'; const isTextbox = element.getAttribute('role') === 'textbox'; - // 排除脚本自身的 UI + // Exclude the UI of the script itself if (element.closest('#gemini-helper-panel')) return false; return isContentEditable || isTextbox || element.classList.contains('ql-editor'); @@ -2111,29 +1984,29 @@ const editor = this.textarea; if (!editor) return false; - // 验证元素仍在 DOM 中 + // Verify that the element is still in the DOM if (!editor.isConnected) { this.textarea = null; return false; } this.focusElement(editor); - // 验证 focus 是否成功(防止在 textarea 失效时 selectAll 选中整个文档) + // Verify whether focus is successful (prevent selectAll from selecting the entire document when textarea is invalid) if (document.activeElement !== editor && !editor.contains(document.activeElement)) { console.warn('[GeminiHelper] insertPrompt: focus failed, skipping execCommand'); return false; } try { - // 先全选 + // Select all first document.execCommand('selectAll', false, null); - // 然后插入新内容 + // then insert new content const success = document.execCommand('insertText', false, content); if (!success) { throw new Error('execCommand returned false'); } } catch (e) { - // 降级方案:直接替换内容,不叠加 + // Downgrade plan: directly replace content without overlay editor.textContent = content; editor.dispatchEvent(new Event('input', { bubbles: true })); editor.dispatchEvent(new Event('change', { bubbles: true })); @@ -2144,14 +2017,14 @@ clearTextarea() { if (!this.textarea) return; - // 验证元素仍在 DOM 中 + // Verify that the element is still in the DOM if (!this.textarea.isConnected) { this.textarea = null; return; } this.focusElement(this.textarea); - // 验证 focus 是否成功(防止在 textarea 失效时 selectAll 选中整个文档) + // Verify whether focus is successful (prevent selectAll from selecting the entire document when textarea is invalid) if (document.activeElement !== this.textarea && !this.textarea.contains(document.activeElement)) { console.warn('[GeminiHelper] clearTextarea: focus failed, skipping execCommand'); return; @@ -2162,7 +2035,7 @@ } getResponseContainerSelector() { - // 分享页面使用不同的容器 + // Share pages using different containers if (this.isSharePage()) { return 'div.content-container'; } @@ -2178,7 +2051,7 @@ } extractUserQueryText(element) { - // 从 user-query 元素中提取 .query-text 的文本 + // Extract the text of .query-text from the user-query element const queryText = element.querySelector('.query-text'); if (queryText) { return queryText.textContent?.trim() || ''; @@ -2204,7 +2077,7 @@ const container = document.querySelector(this.getResponseContainerSelector()); if (!container) return outline; - // 如果不需要用户提问,走原有逻辑 + // If there is no need for users to ask questions, follow the original logic if (!includeUserQueries) { const headingSelectors = []; for (let i = 1; i <= maxLevel; i++) { @@ -2226,34 +2099,34 @@ return outline; } - // 开启用户提问分组模式:按 DOM 顺序遍历 + // Enable user question grouping mode: traverse in DOM order const userQuerySelector = this.getUserQuerySelector(); const headingSelectors = []; for (let i = 1; i <= maxLevel; i++) { headingSelectors.push(`h${i}`); } - // 构建合并选择器 + // Build a merge selector const combinedSelector = `${userQuerySelector}, ${headingSelectors.join(', ')}`; - // 使用 querySelectorAll 按 DOM 顺序获取所有匹配元素 + // Use querySelectorAll to get all matching elements in DOM order const allElements = container.querySelectorAll(combinedSelector); allElements.forEach((element) => { const tagName = element.tagName.toLowerCase(); if (tagName === 'user-query') { - // 提取用户提问文本 + // Extract user question text let queryText = this.extractUserQueryText(element); - // 截断长文本(最多 30 字符) + // Truncate long text (up to 30 characters) let isTruncated = false; if (queryText.length > 30) { queryText = queryText.substring(0, 30) + '...'; isTruncated = true; } - // 添加用户提问节点(即使没有后续标题也显示) + // Add user question node (displayed even if there is no subsequent title) outline.push({ level: 0, text: queryText, @@ -2262,7 +2135,7 @@ isTruncated: isTruncated, }); } else if (/^h[1-6]$/.test(tagName)) { - // 标题元素 + // title element const level = parseInt(tagName.charAt(1), 10); if (level <= maxLevel) { outline.push({ @@ -2278,23 +2151,20 @@ } /** - * 检测 AI 是否正在生成响应 - * Gemini 标准版:检查输入框右下角是否显示停止图标 - * @returns {boolean} - */ + * Detect whether the AI is generating a response * Gemini Standard Edition: Check whether the stop icon is displayed in the lower right corner of the input box * @returns {boolean} */ isGenerating() { - // 检查是否存在 fonticon="stop" 的 mat-icon(停止按钮) - const stopIcon = document.querySelector('mat-icon[fonticon="stop"]'); - return stopIcon && stopIcon.offsetParent !== null; + const stopBtn = document.querySelector('button[aria-label*="Stop"], button[aria-label*="\\u505c\\u6b62"], mat-icon[fonticon="stop"], [data-test-id="stop-button"], .stop-button'); + if (stopBtn && stopBtn.offsetParent !== null) { + return true; + } + const spinner = document.querySelector('mat-spinner, md-spinner, .loading-spinner, [role="progressbar"], .generating-indicator, .response-loading'); + return spinner && spinner.offsetParent !== null; } /** - * 获取当前使用的模型名称 - * Gemini 标准版:从页面 UI 中提取模型名称 - * @returns {string|null} - */ + * Get the currently used model name * Gemini Standard Edition: Extract the model name from the page UI * @returns {string|null} */ getModelName() { - // 从 .input-area-switch-label 的第一个 span 获取模型名称 + // Get the model name from the first span of .input-area-switch-label const switchLabel = document.querySelector('.input-area-switch-label'); if (switchLabel) { const firstSpan = switchLabel.querySelector('span'); @@ -2308,21 +2178,19 @@ return null; } - // ============= 网络监控配置(用于后台任务完成检测) ============= + // ============= Network monitoring configuration (for background task completion detection) ============= /** - * Gemini 普通版的网络监控配置 - * 由于浏览器对后台标签页的 DOM 渲染节流,需要通过 Hook Fetch 从网络层检测任务完成 - */ + * Gemini normal version network monitoring configuration * Since the browser throttles DOM rendering of background tabs, it is necessary to complete the detection task from the network layer through Hook Fetch*/ getNetworkMonitorConfig() { return { - // 注意:不要使用 batchexecute,它是通用 RPC 方法,会在后台频繁调用 + // Note: Do not use batchexecute, it is a general RPC method and will be called frequently in the background. urlPatterns: ['BardFrontendService', 'StreamGenerate'], silenceThreshold: 3000, }; } - // ============= 模型锁定配置 ============= + // ============= Model Lock Configuration ============= getDefaultLockSettings() { return { enabled: false, keyword: '' }; } @@ -2330,7 +2198,7 @@ getModelSwitcherConfig(keyword) { return { targetModelKeyword: keyword, - // 尝试匹配 Gemini 普通版的模型选择器 + // Try to match model selector of Gemini regular version selectorButtonSelectors: ['.input-area-switch-label', '.model-selector', '[data-test-id="model-selector"]', '[aria-label*="model"]', 'button[aria-haspopup="menu"]'], menuItemSelector: '.mode-title, [role="menuitem"], [role="option"]', checkInterval: 1000, @@ -2341,8 +2209,7 @@ } /** - * Gemini Business 适配器(business.gemini.google) - */ + * Gemini Business adapter (business.gemini.google) */ class GeminiBusinessAdapter extends SiteAdapter { match() { return window.location.hostname.includes('business.gemini.google'); @@ -2377,20 +2244,17 @@ } /** - * 获取当前会话名称(用于标签页重命名) - * 从 Shadow DOM 中的侧边栏获取当前活动会话的标题 - * @returns {string|null} - */ + * Get the current session name (for tab renaming) * Get the title of the currently active session from the sidebar in Shadow DOM * @returns {string|null} */ getSessionName() { - // DOMToolkit 在 Shadow DOM 穿透时,复杂后代选择器可能不生效 - // 所以遍历所有会话,找到活动的那个 + // When DOMToolkit penetrates Shadow DOM, complex descendant selectors may not take effect. + // So iterate through all sessions and find the active one const conversations = DOMToolkit.query('.conversation', { all: true, shadow: true }); for (const conv of conversations) { const button = conv.querySelector('button.list-item') || conv.querySelector('button'); if (!button) continue; - // 检查是否为活动会话 + // Check if it is an active session const isActive = button.classList.contains('selected') || button.classList.contains('active') || button.getAttribute('aria-selected') === 'true'; if (isActive) { @@ -2402,13 +2266,12 @@ } } - // 回退到基类默认实现(从 document.title 提取) + // Fallback to base class default implementation (extracted from document.title) return super.getSessionName(); } /** - * 获取当前的团队 - */ + * Get the current team */ getCurrentCid() { const currentPath = window.location.pathname; const cidMatch = currentPath.match(/\/home\/cid\/([^\/]+)/); @@ -2416,46 +2279,44 @@ } /** - * 从侧边栏提取会话列表 - * @returns {Array<{id: string, title: string, url: string, isActive: boolean}>} - */ + * Extract conversation list from sidebar * @returns {Array<{id: string, title: string, url: string, isActive: boolean}>} */ getConversationList() { - // 1. 获取当前 Team ID (CID) + // 1. Get the current Team ID (CID) let cid = this.getCurrentCid(); - // 2. 查找会话列表 - // 注意:DOMToolkit 在 Shadow DOM 穿透时,后代选择器可能不生效 - // 所以使用简单选择器 + 后续过滤来排除智能体 + // 2. Find the conversation list + // Note: When DOMToolkit penetrates Shadow DOM, the descendant selector may not take effect. + // So use simple selector + subsequent filtering to exclude agents const items = DOMToolkit.query('.conversation', { all: true, shadow: true }); return Array.from(items) .map((el) => { - // 注意:DOMToolkit.query 使用 parent 参数,不是 root + // Note: DOMToolkit.query uses the parent parameter, not root const button = el.querySelector('button.list-item') || el.querySelector('button'); if (!button) return null; - // 从操作菜单按钮 ID 提取 Session ID - // 会话格式: menu-8823153884416423953 (纯数字) - // 智能体格式: menu-deep_research (包含字母/下划线) + // Extract Session ID from action menu button ID + // Session format: menu-8823153884416423953 (pure numbers) + // Agent format: menu-deep_research (contains letters/underlines) const menuBtn = button.querySelector('.conversation-action-menu-button'); let id = ''; if (menuBtn && menuBtn.id && menuBtn.id.startsWith('menu-')) { id = menuBtn.id.replace('menu-', ''); } - // 关键过滤:真正的会话 ID 是纯数字,智能体 ID 包含字母 - // 例如:会话 ID = "452535969834780805",智能体 ID = "deep_research" + // Key filtering: real session IDs are pure numbers, agent IDs contain letters + // For example: Session ID = "452535969834780805", Agent ID = "deep_research" if (!id || !/^\d+$/.test(id)) return null; - // 获取标题 + // Get title const titleEl = button.querySelector('.conversation-title'); const title = titleEl ? titleEl.textContent.trim() : ''; const isActive = button.classList.contains('selected') || button.classList.contains('active') || button.getAttribute('aria-selected') === 'true'; - // 构建完整 URL - // 格式: https://business.gemini.google/home/cid/{cid}/r/session/{id} - let url = `https://business.gemini.google/session/${id}`; // 默认(如果没 cid) + // Build full URL + // Format: https://business.gemini.google/home/cid/{cid}/r/session/{id} + let url = `https://business.gemini.google/session/${id}`; // Default (if no cid) if (cid) { url = `https://business.gemini.google/home/cid/${cid}/r/session/${id}`; } @@ -2468,37 +2329,31 @@ cid: cid, }; }) - .filter((c) => c); // 过滤掉 null + .filter((c) => c); // filter out null } /** - * 获取侧边栏可滚动容器 - * @returns {Element|null} - */ + * Get the sidebar scrollable container * @returns {Element|null} */ getSidebarScrollContainer() { return DOMToolkit.query('.conversation-list', { shadow: true }) || DOMToolkit.query('mat-sidenav', { shadow: true }); } /** - * 获取主内容区滚动容器 (Gemini Business) - * 重写基类方法,避免与侧边栏混淆 - * @returns {HTMLElement} - */ + * Get the scroll container of the main content area (Gemini Business) * Override the base class method to avoid confusion with the sidebar * @returns {HTMLElement} */ getScrollContainer() { - // 使用 .chat-mode-scroller 精确选择器,排除侧边栏 + // Use .chat-mode-scroller precise selector to exclude sidebar const container = DOMToolkit.query('.chat-mode-scroller', { shadow: true }); if (container && container.scrollHeight > container.clientHeight) { return container; } - // 回退到基类 + // Fallback to base class return super.getScrollContainer(); } /** - * 获取会话观察器配置(用于侧边栏实时监听) - */ + * Get session observer configuration (for sidebar real-time monitoring)*/ getConversationObserverConfig() { const self = this; return { @@ -2512,7 +2367,7 @@ if (!menuBtn || !menuBtn.id?.startsWith('menu-')) return null; const id = menuBtn.id.replace('menu-', ''); - if (!/^\d+$/.test(id)) return null; // 排除智能体 + if (!/^\d+$/.test(id)) return null; // exclude agents const titleEl = button.querySelector('.conversation-title'); const title = titleEl?.textContent?.trim() || ''; @@ -2530,59 +2385,56 @@ } /** - * 加载所有会话 - * 通过点击"展开"按钮来加载更多会话,而不是滚动 - * @returns {Promise} - */ + * Load all sessions * Load more sessions by clicking the "expand" button instead of scrolling * @returns {Promise} */ async loadAllConversations() { let expandedCount = 0; - const maxIterations = 20; // 防止无限循环 + const maxIterations = 20; // Prevent infinite loops for (let i = 0; i < maxIterations; i++) { - // 查找所有按钮(穿透 Shadow DOM) + // Find all buttons (penetrating Shadow DOM) const allBtns = DOMToolkit.query('button.show-more', { all: true, shadow: true }) || []; - // 过滤出未展开的按钮(icon 没有 more-visible class) + // Filter out unexpanded buttons (icon does not have a more-visible class) const expandBtns = allBtns.filter((btn) => { const icon = btn.querySelector('.show-more-icon'); - // 已展开的按钮 icon 有 more-visible class + // The expanded button icon has more-visible class return icon && !icon.classList.contains('more-visible'); }); if (expandBtns.length === 0) { - break; // 没有更多需要展开的按钮 + break; // No more buttons to expand } - // 点击所有展开按钮 + // Click all expand buttons for (const btn of expandBtns) { btn.click(); expandedCount++; } - // 等待会话加载 + // Wait for session to load await new Promise((r) => setTimeout(r, 300)); } if (expandedCount > 0) { - console.log(`[GeminiBusinessAdapter] 展开了 ${expandedCount} 个会话分组`); + console.log(`[GeminiBusinessAdapter] Expanded ${expandedCount} session groups`); } } - // 排除侧边栏 (mat-sidenav, mat-drawer) 中的 Shadow DOM + // Exclude Shadow DOM in sidebar (mat-sidenav, mat-drawer) shouldInjectIntoShadow(host) { return !(host.closest('mat-sidenav') || host.closest('mat-drawer') || host.closest('[class*="bg-sidebar"]')); } getNewChatButtonSelectors() { - return ['.chat-button.list-item', 'button[aria-label="New chat"]', 'button[aria-label="新对话"]']; + return ['.chat-button.list-item', 'button[aria-label="New chat"]', 'button[aria-label="\u65b0\u5bf9\u8bdd"]']; } getWidthSelectors() { - // 辅助函数:生成带 scoped globalSelector 的配置 - // noCenter: 不添加 margin-left/right: auto(用于容器类元素) + // Helper function: generate configuration with scoped globalSelector + // noCenter: Do not add margin-left/right: auto (for container elements) const config = (selector, value, extraCss, noCenter = false) => ({ selector, - globalSelector: `mat-sidenav-content ${selector}`, // 全局样式只针对主内容区 + globalSelector: `mat-sidenav-content ${selector}`, // Global styles only apply to the main content area property: 'max-width', value, extraCss, @@ -2590,18 +2442,18 @@ }); return [ - // 容器强制 100%,不需要居中(它们应该填充可用空间) + // Containers are forced to 100% and do not need to be centered (they should fill the available space) config('mat-sidenav-content', '100%', undefined, true), config('.main.chat-mode', '100%', undefined, true), - // 内容区域跟随配置(需要居中) + // Content area follows configuration (needs to be centered) config('ucs-summary'), config('ucs-conversation'), config('ucs-search-bar'), config('.summary-container.expanded'), config('.conversation-container'), - // 输入框容器:不居中,使用 left/right 定位 + // Input box container: not centered, use left/right positioning config('.input-area-container', undefined, 'left: 0 !important; right: 0 !important;', true), ]; } @@ -2611,21 +2463,21 @@ } getSubmitButtonSelectors() { - return ['button[aria-label*="Submit"]', 'button[aria-label*="提交"]', '.send-button', '[data-testid*="send"]']; + return ['button[aria-label*="Submit"]', 'button[aria-label*="\u63d0\u4ea4"]', '.send-button', '[data-testid*="send"]']; } isValidTextarea(element) { - // 排除搜索框 + // exclude search box if (element.type === 'search') return false; if (element.classList.contains('main-input')) return false; - if (element.getAttribute('aria-label')?.includes('搜索')) return false; - if (element.placeholder?.includes('搜索')) return false; - // 排除脚本自己的 UI + if (element.getAttribute('aria-label')?.includes('\u641c\u7d22')) return false; + if (element.placeholder?.includes('\u641c\u7d22')) return false; + // Exclude script's own UI if (element.classList.contains('prompt-search-input')) return false; if (element.id === 'prompt-search') return false; if (element.closest('#gemini-helper-panel')) return false; - // 必须是 contenteditable 或者 ProseMirror + // Must be contenteditable or ProseMirror const isVisible = element.offsetParent !== null; const isContentEditable = element.getAttribute('contenteditable') === 'true'; const isProseMirror = element.classList.contains('ProseMirror'); @@ -2633,8 +2485,8 @@ } findTextarea() { - // 使用 DOMToolkit.query + filter 在 Shadow DOM 中查找 - // filter 参数实现了 isValidTextarea 的验证逻辑 + // Find in Shadow DOM using DOMToolkit.query + filter + // The filter parameter implements the verification logic of isValidTextarea const element = DOMToolkit.query(this.getTextareaSelectors(), { shadow: true, filter: (el) => this.isValidTextarea(el), @@ -2650,7 +2502,7 @@ insertPrompt(content) { return new Promise((resolve) => { const tryInsert = () => { - // 重新获取一下,以防切页面后元素失效 + // Get it again to prevent the elements from becoming invalid after cutting the page. const editor = this.textarea || this.findTextarea(); if (!editor) { @@ -2659,21 +2511,21 @@ return; } - this.textarea = editor; // 更新引用 + this.textarea = editor; // Update citation editor.click(); this.focusElement(editor); - // 等待一小段时间后尝试插入 + // Wait a short while and then try inserting setTimeout(() => { try { - // 先全选 + // Select all first document.execCommand('selectAll', false, null); - // 插入新内容 + // Insert new content const success = document.execCommand('insertText', false, content); if (!success) throw new Error('execCommand returned false'); resolve(true); } catch (e) { - // 方法2: 直接操作 DOM (降级方案) + // Method 2: Directly manipulate the DOM (downgrade solution) let p = editor.querySelector('p'); if (!p) { p = document.createElement('p'); @@ -2682,7 +2534,7 @@ p.textContent = content; - // 触发各种事件以通知 ProseMirror 更新 + // Trigger various events to notify ProseMirror updates const inputEvent = new InputEvent('input', { bubbles: true, cancelable: true, @@ -2692,7 +2544,7 @@ editor.dispatchEvent(inputEvent); editor.dispatchEvent(new Event('change', { bubbles: true })); - // 尝试触发 keyup 事件 + // Try to trigger keyup event editor.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); resolve(true); } @@ -2702,7 +2554,7 @@ if (this.textarea && document.body.contains(this.textarea)) { tryInsert(); } else { - // 轮询等待元素出现 + // Polling to wait for an element to appear let attempts = 0; const maxAttempts = 15; const checkInterval = setInterval(() => { @@ -2722,61 +2574,61 @@ clearTextarea() { if (!this.textarea) return; - // 验证元素仍在 DOM 中 + // Verify that the element is still in the DOM if (!this.textarea.isConnected) { this.textarea = null; return; } this.focusElement(this.textarea); - // Shadow DOM 场景:不做严格的焦点检查,只检查元素是否仍在 DOM 中 - // isConnected 已经检查过,直接执行 + // Shadow DOM scenario: no strict focus checking, just checking whether the element is still in the DOM + // isConnected has been checked and executed directly document.execCommand('selectAll', false, null); - // 插入零宽空格替换旧内容(修复中文输入首字母问题) + // Insert zero-width spaces to replace old content (fix the problem of inputting first letters in Chinese) document.execCommand('insertText', false, '\u200B'); } - // 普通清空(不插入零宽字符) + // Normal clear (no zero-width characters inserted) clearTextareaNormal() { if (!this.textarea) return; - // 验证元素仍在 DOM 中 + // Verify that the element is still in the DOM if (!this.textarea.isConnected) { this.textarea = null; return; } this.textarea.focus(); - // Shadow DOM 场景:不做严格的焦点检查,只检查元素是否仍在 DOM 中 - // isConnected 已经检查过,直接执行 + // Shadow DOM scenario: no strict focus checking, just checking whether the element is still in the DOM + // isConnected has been checked and executed directly document.execCommand('selectAll', false, null); document.execCommand('delete', false, null); } afterPropertiesSet(options = {}) { - // 保存配置状态供其他方法使用 + // Save configuration state for use by other methods this.clearOnInit = options.clearOnInit; - // 1. 调用基类通用逻辑(处理模型锁定) + // 1. Call the base class general logic (processing model locking) super.afterPropertiesSet(options); - // 2. 处理企业版特有的初始化清除(如果未启用模型锁定或模型已锁定,这里先执行一次以防万一) - // 注意:如果 trigger 了 lockModel,lockModel 回调里会再次执行。 + // 2. Handle the initial cleanup unique to the Enterprise Edition (if model locking is not enabled or the model is locked, perform it here first just in case) + // Note: If lockModel is triggered, the lockModel callback will be executed again. if (this.clearOnInit) { this.clearTextarea(); } } - // 覆盖 lockModel 以处理锁定后的清理 + // Override lockModel to handle post-lock cleanup lockModel(keyword, onSuccess = null) { super.lockModel(keyword, () => { - // 执行传入的回调 + // Execute the incoming callback if (onSuccess) onSuccess(); - // 执行企业版特定的清理:锁定模型后,重新插入零宽字符修复中文输入 - // 这里的延迟是为了等待 UI 刷新(切换模型会导致输入框重建或重置) + // Perform enterprise-specific cleanup: Re-insert zero-width characters after locking model Fix Chinese input + // The delay here is to wait for the UI to refresh (switching models will cause the input box to be rebuilt or reset) if (this.clearOnInit) { setTimeout(() => this.clearTextarea(), 300); } @@ -2784,17 +2636,14 @@ } /** - * 检测 AI 是否正在生成响应 - * Gemini Business:检查 Shadow DOM 中的 "Stop" 按钮或 loading 指示器 - * @returns {boolean} - */ + * Detect if the AI is generating a response * Gemini Business: Check for "Stop" button or loading indicator in Shadow DOM * @returns {boolean} */ isGenerating() { - // 递归在 Shadow DOM 中搜索 + // Search the Shadow DOM recursively const findInShadow = (root, depth = 0) => { if (depth > 10) return false; - // 检查当前层级 - const stopButton = root.querySelector('button[aria-label*="Stop"], button[aria-label*="停止"], ' + '[data-test-id="stop-button"], .stop-button, md-icon-button[aria-label*="Stop"]'); + // Check current level + const stopButton = root.querySelector('button[aria-label*="Stop"], button[aria-label*="\u505c\u6b62"], ' + '[data-test-id="stop-button"], .stop-button, md-icon-button[aria-label*="Stop"]'); if (stopButton && stopButton.offsetParent !== null) { return true; } @@ -2804,7 +2653,7 @@ return true; } - // 递归搜索 Shadow DOM + // Recursive Search Shadow DOM const elements = root.querySelectorAll('*'); for (const el of elements) { if (el.shadowRoot) { @@ -2820,24 +2669,21 @@ } /** - * 获取当前使用的模型名称 - * Gemini Business:从 Shadow DOM 中提取模型名称 - * @returns {string|null} - */ + * Get the currently used model name * Gemini Business: Extract the model name from Shadow DOM * @returns {string|null} */ getModelName() { - // 递归在 Shadow DOM 中搜索模型选择器 + // Recursively search the Shadow DOM for a model selector const findInShadow = (root, depth = 0) => { if (depth > 10) return null; - // 检查模型选择器 + // Check model selector const modelSelectors = ['#model-selector-menu-anchor', '.action-model-selector', '.model-selector', '[data-test-id="model-selector"]', '.current-model']; for (const selector of modelSelectors) { const el = root.querySelector(selector); if (el && el.textContent) { const text = el.textContent.trim(); - // 提取模型关键字(支持带版本号的如"2.5 Pro",也支持不带版本号的如"自动") - const modelMatch = text.match(/(\d+\.?\d*\s*)?(Pro|Flash|Ultra|Nano|Gemini|auto|自动)/i); + // Extract model keywords (supports those with version numbers such as "2.5 Pro", and also supports those without version numbers such as "automatic") + const modelMatch = text.match(/(\d+\.\?\d*\s*)?(Pro|Flash|Ultra|Nano|Gemini|auto|\u81ea\u52a8)/i); if (modelMatch) { return modelMatch[0].trim(); } @@ -2847,7 +2693,7 @@ } } - // 递归搜索 Shadow DOM + // Recursive Search Shadow DOM const elements = root.querySelectorAll('*'); for (const el of elements) { if (el.shadowRoot) { @@ -2861,7 +2707,7 @@ return findInShadow(document); } - // ============= 模型锁定配置 ============= + // ============= Model Lock Configuration ============= getDefaultLockSettings() { return { enabled: true, keyword: '3 Pro' }; @@ -2879,7 +2725,7 @@ } getResponseContainerSelector() { - // Gemini Business 使用 Shadow DOM,返回空字符串表示需要特殊处理 + // Gemini Business uses Shadow DOM, returning an empty string indicates that special processing is required return ''; } @@ -2887,50 +2733,44 @@ return [ '.model-response-container', '.message-content', - '[data-message-id]', // 常见消息标识 - 'ucs-conversation-message', // 企业版特定 + '[data-message-id]', // Common message identifiers + 'ucs-conversation-message', // Enterprise Edition specific '.conversation-message', ]; } /** - * 获取导出配置 - * Gemini Business 使用 Shadow DOM,需要特殊处理 - */ + * Get export configuration * Gemini Business uses Shadow DOM, which requires special processing*/ getExportConfig() { return { userQuerySelector: '.question-block', assistantResponseSelector: 'ucs-summary', turnSelector: '.turn', useShadowDOM: true, - // 自定义提取函数(因为 Shadow DOM 嵌套结构复杂) + // Custom extraction function (because of the complex nested structure of Shadow DOM) extractUserText: (el) => this.extractUserQueryText(el), extractAssistantContent: (el) => this.extractSummaryContent(el), }; } /** - * 从 ucs-summary 元素中提取可用于 htmlToMarkdown 的 DOM 元素 - * Gemini Business 使用多层 Shadow DOM,需要递归查找 - * @param {Element} ucsSummary - ucs-summary 元素 - * @returns {Element|null} - 可用于 htmlToMarkdown 的 DOM 元素 - */ + * Extract DOM elements from ucs-summary elements that can be used in htmlToMarkdown * Gemini Business uses multi-layer Shadow DOM and requires recursive search * @param {Element} ucsSummary - ucs-summary element * @returns {Element|null} - DOM element available for htmlToMarkdown*/ extractSummaryContent(ucsSummary) { - // 递归在 Shadow DOM 中查找 .markdown-document + // Recursively find .markdown-document in Shadow DOM const findMarkdownDocument = (root, depth = 0) => { if (depth > 10 || !root) return null; - // 如果 root 本身有 shadowRoot,先进入它 + // If root itself has a shadowRoot, enter it first const shadowRoot = root.shadowRoot || (root.nodeType === 11 ? root : null); const searchRoot = shadowRoot || root; - // 在当前层级查找 .markdown-document + // Find .markdown-document at the current level if (searchRoot.querySelector) { const markdownDoc = searchRoot.querySelector('.markdown-document'); if (markdownDoc) return markdownDoc; } - // 递归搜索子元素的 Shadow DOM + // Recursively search the Shadow DOM for child elements const elements = searchRoot.querySelectorAll?.('*') || []; for (const el of elements) { if (el.shadowRoot) { @@ -2946,29 +2786,23 @@ } /** - * 获取用户提问元素的选择器 - * Gemini Business: .question-block 是用户提问的容器 - */ + * Get the selector of the user question element * Gemini Business: .question-block is a container for user questions*/ getUserQuerySelector() { return '.question-block'; } /** - * 从用户提问元素中提取文本 - * Gemini Business: 文本在 ucs-fast-markdown 的 Shadow DOM 中 - * @param {Element} element .question-block 元素 - * @returns {string} - */ + * Extract text from user question element * Gemini Business: Text is in Shadow DOM of ucs-fast-markdown * @param {Element} element .question-block element * @returns {string} */ extractUserQueryText(element) { - // 查找 ucs-fast-markdown 元素 + // Find ucs-fast-markdown elements const markdown = element.querySelector('ucs-fast-markdown'); if (!markdown || !markdown.shadowRoot) { return element.textContent?.trim() || ''; } - // 在 Shadow DOM 中查找完整文本 - // 结构:

文本

...
- // 注意:用户问题可能包含多个

段落,需要获取所有文本 + // Find full text in Shadow DOM + // Structure:

Text

...
+ // NOTE: User questions may contain multiple

paragraphs, all text needs to be fetched const markdownDoc = markdown.shadowRoot.querySelector('.markdown-document'); if (markdownDoc) { return markdownDoc.textContent?.trim() || ''; @@ -2978,33 +2812,29 @@ } /** - * 从页面提取大纲(标题列表) - * @param {number} maxLevel 最大标题级别 (1-6) - * @param {boolean} includeUserQueries 是否包含用户提问 - * @returns {Array<{level: number, text: string, element: Element|null, isUserQuery?: boolean}>} - */ + * Extract the outline (title list) from the page * @param {number} maxLevel Maximum title level (1-6) * @param {boolean} includeUserQueries Whether to include user questions * @returns {Array<{level: number, text: string, element: Element|null, isUserQuery?: boolean}>} */ extractOutline(maxLevel = 6, includeUserQueries = false) { const outline = []; if (!includeUserQueries) { - // 原有逻辑:只提取标题 + // Original logic: only extract the title this.findHeadingsInShadowDOM(document, outline, maxLevel, 0); return outline; } - // 开启用户提问分组模式 - // 策略:按轮次遍历。结构为 ucs-conversation -> shadowRoot -> .main -> .turn - // 每个 .turn 包含 .question-block(用户提问)和 ucs-summary(AI 回复) + // Enable user question grouping mode + // Strategy: Traverse in rounds. The structure is ucs-conversation -> shadowRoot -> .main -> .turn + // Each .turn contains .question-block (user question) and ucs-summary (AI reply) - // 1. 找到 ucs-conversation 元素 + // 1. Find the ucs-conversation element const ucsConversation = DOMToolkit.query('ucs-conversation', { shadow: true }); if (!ucsConversation || !ucsConversation.shadowRoot) { - // 回退:如果找不到 ucs-conversation,使用原有逻辑 + // Fallback: If ucs-conversation is not found, use the original logic this.findHeadingsInShadowDOM(document, outline, maxLevel, 0); return outline; } - // 2. 在 ucs-conversation 的 Shadow Root 中查找 .main 下的所有 .turn + // 2. Find all .turn under .main in the Shadow Root of ucs-conversation const main = ucsConversation.shadowRoot.querySelector('.main'); if (!main) { this.findHeadingsInShadowDOM(document, outline, maxLevel, 0); @@ -3013,9 +2843,9 @@ const turnContainers = main.querySelectorAll('.turn'); - // 3. 遍历每个轮次 + // 3. Traverse each round turnContainers.forEach((turn) => { - // 3.1 在轮次中查找用户提问 (.question-block) + // 3.1 Find user questions in rounds (.question-block) const questionBlock = turn.querySelector('.question-block'); if (questionBlock) { let queryText = this.extractUserQueryText(questionBlock); @@ -3033,7 +2863,7 @@ }); } - // 3.2 在轮次的 ucs-summary 中查找标题(递归进入 Shadow DOM) + // 3.2 Find title in round's ucs-summary (recursively into Shadow DOM) const ucsSummary = turn.querySelector('ucs-summary'); if (ucsSummary) { const turnHeadings = []; @@ -3045,24 +2875,24 @@ return outline; } - // 在 Shadow DOM 中递归查找标题 + // Find headers recursively in Shadow DOM findHeadingsInShadowDOM(root, outline, maxLevel, depth) { if (depth > 15) return; - // 如果传入的是一个有 shadowRoot 的元素(如 ucs-summary),先进入其 Shadow Root + // If an element with shadowRoot is passed in (such as ucs-summary), first enter its Shadow Root if (root.shadowRoot) { this.findHeadingsInShadowDOM(root.shadowRoot, outline, maxLevel, depth); - return; // 已经在 shadowRoot 中递归,不需要再处理 root 本身 + return; // Already recursive in shadowRoot, no need to deal with root itself } - // 在当前层级查找标题(h1-h6) + // Find headings (h1-h6) at the current level if (root !== document) { const headingSelector = Array.from({ length: maxLevel }, (_, i) => `h${i + 1}`).join(', '); try { const headings = root.querySelectorAll(headingSelector); headings.forEach((heading) => { - // 只匹配包含 data-markdown-start-index 的标题(排除 logo 等非 AI 回复内容) - // 标题内可能包含多个 span,需要遍历所有 span 并拼接文本 + // Only match titles containing data-markdown-start-index (excluding non-AI reply content such as logos) + // The title may contain multiple spans, and you need to traverse all spans and splice the text const spans = heading.querySelectorAll('span[data-markdown-start-index]'); if (spans.length > 0) { const level = parseInt(heading.tagName[1], 10); @@ -3075,11 +2905,11 @@ } }); } catch (e) { - // 忽略选择器错误 + // Ignore selector errors } } - // 递归查找 Shadow DOM + // Recursively search Shadow DOM const allElements = root.querySelectorAll('*'); for (const el of allElements) { if (el.shadowRoot) { @@ -3089,26 +2919,25 @@ } /** - * 模拟点击原生设置切换主题 (针对 Gemini Business) - * @param {'light'|'dark'} targetMode + * Simulate clicking native settings to switch themes (for Gemini Business) * @param {'light'|'dark'} targetMode */ async toggleTheme(targetMode) { console.log(`[GeminiBusinessAdapter] Attempting to switch theme to: ${targetMode}`); - // 1. 启动暴力隐身模式 (JS 每一帧强制隐藏) - // CSS 注入可能因优先级或 Shadow DOM 隔离失效,JS 强制修改内联样式是最稳妥的 + // 1. Start violent stealth mode (JS forces hiding every frame) + // CSS injection may fail due to priority or Shadow DOM isolation. JS is the safest way to force modification of inline styles. let stopSuppression = false; const suppressMenu = () => { if (stopSuppression) return; - // 查找所有可能的菜单容器 + // Find all possible menu containers try { const menus = DOMToolkit.query('.menu[popover], md-menu-surface, .mat-menu-panel, [role="menu"]', { all: true, shadow: true, }); menus.forEach((el) => { - // 强制隐藏,不留余地 + // Forced to hide, no room left if (el.style.opacity !== '0') { el.style.setProperty('opacity', '0', 'important'); el.style.setProperty('visibility', 'hidden', 'important'); @@ -3123,11 +2952,11 @@ }; suppressMenu(); - // 全局也加一个保险 + // Also add an insurance policy to the whole situation document.body.classList.add('gh-stealth-mode'); try { - // 2. 找到并点击设置按钮 + // 2. Find and click the Settings button const settingsBtn = DOMToolkit.query('#settings-menu-anchor', { shadow: true }); if (!settingsBtn) { @@ -3160,7 +2989,7 @@ } } - // 3. 等待菜单弹出并点击目标 + // 3. Wait for the menu to pop up and click on the target let attempts = 0; const findAndClickOption = () => { const targetIcon = targetMode === 'dark' ? 'dark_mode' : 'light_mode'; @@ -3201,9 +3030,9 @@ }, 100); }); } finally { - // 停止暴力抑制 + // stop violence suppression stopSuppression = true; - // 延迟移除隐身模式 + // Delay removal of incognito mode setTimeout(() => { document.body.classList.remove('gh-stealth-mode'); }, 200); @@ -3212,9 +3041,7 @@ } /** - * 标签页重命名管理器 - * 根据当前对话名称自动更新浏览器标签页标题 - */ + * Tab Rename Manager * Automatically update browser tab titles based on the current conversation name*/ class TabRenameManager { constructor(adapter, settings, i18nFunc = null) { this.adapter = adapter; @@ -3225,19 +3052,20 @@ this.networkMonitor = null; this.isRunning = false; - // AI 生成状态(简化的状态机) + // AI generated state (simplified state machine) // 'idle' | 'generating' | 'completed' this._aiState = 'idle'; this._lastAiState = 'idle'; - // 用户是否在前台看到过生成完成(用于避免误发通知) + // Whether the user has seen the generation completion in the foreground (used to avoid false notifications) this._userSawCompletion = false; this._boundVisibilityHandler = this._onVisibilityChange.bind(this); + this._blinkInterval = null; + this._originalTitle = ''; } /** - * 启动自动重命名 - */ + * Start automatic renaming */ start() { if (this.isRunning) return; if (!this.adapter.supportsTabRename()) return; @@ -3245,22 +3073,21 @@ this.isRunning = true; this.updateTabName(); - // 启动网络监控(用于后台检测) + // Start network monitoring (for background detection) this._networkConfig = this.adapter.getNetworkMonitorConfig?.(); if (typeof NetworkMonitor !== 'undefined' && this._networkConfig) { this._initNetworkMonitor(); - // 监听页面可见性变化,用于追踪用户是否看到完成状态 + // Monitor page visibility changes to track whether the user sees the completion status document.addEventListener('visibilitychange', this._boundVisibilityHandler); } - // 定时更新标签页标题 + // Update tab title regularly const intervalMs = (this.settings.tabSettings?.renameInterval || 5) * 1000; this.intervalId = setInterval(() => this.updateTabName(), intervalMs); } /** - * 初始化网络监控 - */ + * Initialize network monitoring*/ _initNetworkMonitor() { if (this.networkMonitor || !this._networkConfig) return; @@ -3274,52 +3101,47 @@ } /** - * 设置 AI 状态 - */ + * Set AI status*/ _setAiState(state) { this._lastAiState = this._aiState; this._aiState = state; } /** - * 页面可见性变化处理 - * 用于追踪用户是否在前台看到过生成完成 - */ + * Page visibility change processing * Used to track whether the user has seen the generation completion in the foreground*/ _onVisibilityChange() { - // 用户切换页面时(无论进入还是离开),检查 DOM 状态 - // 如果正在生成但 DOM 显示已完成,说明用户看到了完成状态 + // When the user switches pages (whether entering or leaving), check the DOM state + // If it's being generated but the DOM shows it's completed, the user sees the completion status if (this._aiState === 'generating' && !this.adapter.isGenerating()) { this._userSawCompletion = true; } } /** - * AI 任务完成处理(由 NetworkMonitor 触发) - */ + * AI task completion processing (triggered by NetworkMonitor)*/ _onAiComplete() { const wasGenerating = this._aiState === 'generating'; this._setAiState('completed'); - // 检查是否应当发送通知 - // 1. 必须是从生成状态完成 - // 2. 用户没有在前台看到过完成状态 - // 3. 要么在后台,要么开启了「前台时也通知」 + // Check if notification should be sent + // 1. Must be completed from the generated state + // 2. The user has not seen the completion status in the foreground + // 3. Either in the background, or with "Also notified in the foreground" turned on const notifyWhenFocused = this.settings.tabSettings?.notifyWhenFocused; const shouldNotify = wasGenerating && !this._userSawCompletion && (document.hidden || notifyWhenFocused); if (shouldNotify) { this._sendCompletionNotification(); } - // 重置状态 + // reset state this._userSawCompletion = false; - // 强制更新标签页标题 + // Force update of tab title this.updateTabName(true); } /** - * 发送完成通知 - */ + * Send completion notification */ _sendCompletionNotification() { const tabSettings = this.settings.tabSettings || {}; @@ -3329,12 +3151,17 @@ text: this.lastSessionName || this.t('notificationBody'), timeout: 5000, highlight: true, - silent: true, // 禁用系统通知声音,由"通知声音"开关单独控制 + silent: true, // Disable system notification sounds, controlled separately by the "Notification Sound" switch onclick: () => window.focus(), }); } - // 播放通知声音(独立于桌面通知,即时生效无需刷新) + // Blink tab title when hidden to flash taskbar + if (document.hidden) { + this._startTitleBlinking(); + } + + // Play notification sounds (independent of desktop notifications, effective immediately without refreshing) if (tabSettings.notificationSound) { this._playNotificationSound(); } @@ -3345,19 +3172,45 @@ } /** - * 播放通知声音 - * 使用 GM_xmlhttpRequest 绕过 CSP 限制 - */ + * Start flashing the tab title when the tab is in the background */ + _startTitleBlinking() { + if (this._blinkInterval) return; + + this._originalTitle = document.title; + let showAlert = true; + const notificationTitle = this.t('notificationTitle').replace('{site}', this.adapter.getName()); + + this._blinkInterval = setInterval(() => { + document.title = showAlert ? `🔔 ${notificationTitle}` : this._originalTitle; + showAlert = !showAlert; + }, 1000); + + // Visibility / focus event listener to stop blinking + const stopBlinking = () => { + if (!document.hidden) { + clearInterval(this._blinkInterval); + this._blinkInterval = null; + document.title = this._originalTitle; + document.removeEventListener('visibilitychange', stopBlinking); + window.removeEventListener('focus', stopBlinking); + } + }; + document.addEventListener('visibilitychange', stopBlinking); + window.addEventListener('focus', stopBlinking); + } + + /** + * Play notification sounds * Use GM_xmlhttpRequest to bypass CSP restrictions*/ _playNotificationSound() { const SOUND_URL = 'https://v0.app/chat-static/assets/sfx/streaming-complete-v2.mp3'; - // 如果已有缓存的 Blob URL,直接播放 + // If there is already a cached blob URL, play it directly if (this._notificationAudioBlobUrl) { this._playAudioFromUrl(this._notificationAudioBlobUrl); return; } - // 首次:使用 GM_xmlhttpRequest 下载音频绕过 CSP + // First time: Using GM_xmlhttpRequest to download audio to bypass CSP if (typeof GM_xmlhttpRequest !== 'undefined') { GM_xmlhttpRequest({ method: 'GET', @@ -3365,52 +3218,49 @@ responseType: 'blob', onload: (response) => { if (response.status === 200 && response.response) { - // 创建 Blob URL 并缓存 + // Create Blob URL and cache this._notificationAudioBlobUrl = URL.createObjectURL(response.response); this._playAudioFromUrl(this._notificationAudioBlobUrl); } }, onerror: () => { - // 下载失败,静默处理 + // Download failed, processed silently }, }); } } /** - * 从 URL 播放音频 - */ + * Play audio from URL*/ _playAudioFromUrl(url) { try { if (!this._notificationAudio) { this._notificationAudio = new Audio(); } - // 使用用户设置的音量,默认 0.5 + // Use user-set volume, default 0.5 const volume = this.settings.tabSettings?.notificationVolume ?? 0.5; this._notificationAudio.volume = Math.max(0.1, Math.min(1.0, volume)); this._notificationAudio.src = url; this._notificationAudio.currentTime = 0; this._notificationAudio.play().catch(() => { - // 忽略播放失败 + // Ignore playback failure }); } catch (e) { - // 忽略错误 + // ignore errors } } /** - * 获取当前是否正在生成 - */ + * Get whether it is currently being generated*/ _isGenerating() { - // 如果已确认完成,返回 false + // If completion is confirmed, return false if (this._aiState === 'completed') return false; - // 否则结合网络状态和 DOM 检测 + // Otherwise combine network status and DOM detection return this._aiState === 'generating' || this.adapter.isGenerating(); } /** - * 停止网络监控 - */ + * Stop network monitoring*/ _stopNetworkMonitor() { if (this.networkMonitor) { this.networkMonitor.stop(); @@ -3419,8 +3269,7 @@ } /** - * 停止自动重命名 - */ + * Stop automatic renaming*/ stop() { if (!this.isRunning) return; @@ -3431,15 +3280,14 @@ this.intervalId = null; } - // 移除可见性监听 + // Remove visibility listener document.removeEventListener('visibilitychange', this._boundVisibilityHandler); this._stopNetworkMonitor(); } /** - * 更新检测频率 - */ + * Update detection frequency*/ setInterval(intervalSeconds) { if (!this.isRunning) return; @@ -3451,8 +3299,7 @@ } /** - * 切换隐私模式 - */ + * Switch privacy mode*/ togglePrivacyMode() { const tabSettings = this.settings.tabSettings || {}; tabSettings.privacyMode = !tabSettings.privacyMode; @@ -3462,32 +3309,32 @@ } /** - * 更新标签页名称 - */ + * Update tab name*/ updateTabName(force = false) { if (!this.adapter.supportsTabRename()) return; const tabSettings = this.settings.tabSettings || {}; - // 隐私模式 + // privacy mode if (tabSettings.privacyMode) { document.title = tabSettings.privacyTitle || 'Google'; return; } - // 获取会话名称(防止读取被污染的 title) + // Get the session name (prevents reading tainted titles) const sessionName = this._getCleanSessionName(tabSettings); - // 检查生成状态 + // Check build status const isGenerating = this._isGenerating(); - // DOM 检测的状态变更通知(仅用于没有网络监控的站点) - if (this._lastAiState === 'generating' && !isGenerating && document.hidden && this._aiState !== 'completed') { + // Status change notification for DOM detection (only for sites without network monitoring) + const notifyWhenFocused = tabSettings.notifyWhenFocused; + if (this._lastAiState === 'generating' && !isGenerating && (document.hidden || notifyWhenFocused) && this._aiState !== 'completed') { this._sendCompletionNotification(); } this._lastAiState = isGenerating ? 'generating' : 'idle'; - // 构建标题 + // Build title const statusPrefix = tabSettings.showStatus !== false ? (isGenerating ? '⏳ ' : '✅ ') : ''; const format = tabSettings.titleFormat || '{status}{title}'; @@ -3506,10 +3353,9 @@ } /** - * 获取干净的会话名称(过滤被污染的标题) - */ + * Get clean session names (filter tainted titles)*/ _getCleanSessionName(tabSettings) { - // 新对话页面:清除旧会话标题,避免使用之前的标题 + // New conversation page: Clear old conversation titles to avoid using previous titles if (this.adapter.isNewConversation()) { this.lastSessionName = null; return null; @@ -3517,7 +3363,7 @@ let sessionName = this.adapter.getSessionName(); - // 检测污染 + // Detect contamination const isPolluted = (name) => { if (!name) return false; if (/^[⏳✅]/.test(name)) return true; @@ -3526,40 +3372,37 @@ return false; }; - // 如果获取到有效且非污染的标题,更新缓存并返回 + // If a valid and non-polluted header is obtained, update the cache and return if (sessionName && !isPolluted(sessionName)) { this.lastSessionName = sessionName; return sessionName; } - // 否则返回缓存的标题(可能为 null) + // Otherwise return the cached header (may be null) return this.lastSessionName; } /** - * 获取当前状态 - */ + * Get current status*/ isActive() { return this.isRunning; } } /** - * 站点注册表 - * 管理所有站点适配器,提供统一的访问接口 - */ + * Site Registry * Manages all site adapters and provides a unified access interface*/ class SiteRegistry { constructor() { this.adapters = []; this.currentAdapter = null; } - // 注册适配器 + // Register adapter register(adapter) { this.adapters.push(adapter); } - // 检测并返回匹配的适配器 + // Detect and return matching adapters detect() { for (const adapter of this.adapters) { if (adapter.match()) { @@ -3570,29 +3413,26 @@ return null; } - // 获取当前适配器 + // Get the current adapter getCurrent() { return this.currentAdapter; } } - // ==================== 核心逻辑 ==================== + // ==================== Core logic ==================== - // HTML 创建函数 (使用 DOMToolkit) + // HTML creation function (using DOMToolkit) function createElement(tag, properties = {}, textContent = '') { return DOMToolkit.create(tag, properties, textContent); } - // 清空元素内容 (使用 DOMToolkit) + // Clear element content (using DOMToolkit) function clearElement(element) { DOMToolkit.clear(element); } /** - * 全局 Toast 提示函数 - * @param {string} message 提示信息 - * @param {number} duration 显示时长 (ms) - */ + * Global Toast prompt function * @param {string} message prompt message * @param {number} duration display duration (ms)*/ function showToast(message, duration = 2000) { const existing = document.querySelector('.gemini-toast'); if (existing) existing.remove(); @@ -3605,9 +3445,7 @@ } /** - * 页面宽度样式管理器 - * 负责动态注入和移除页面宽度样式,支持 Shadow DOM - */ + * Page width style manager * Responsible for dynamically injecting and removing page width styles, supporting Shadow DOM*/ class WidthStyleManager { constructor(siteAdapter, widthConfig) { this.siteAdapter = siteAdapter; @@ -3619,7 +3457,7 @@ } apply() { - // 1. 处理主文档样式 + // 1. Process the main document style if (this.styleElement) { this.styleElement.remove(); this.styleElement = null; @@ -3633,10 +3471,10 @@ this.styleElement.textContent = css; document.head.appendChild(this.styleElement); - // 启动 Shadow DOM 注入逻辑 + // Start Shadow DOM injection logic this.startShadowInjection(css); } else { - // 如果禁用了,也要清理 Shadow DOM 中的样式 + // If disabled, also clean up the styles in Shadow DOM this.stopShadowInjection(); this.clearShadowStyles(); } @@ -3650,7 +3488,7 @@ const { selector, globalSelector, property, value, extraCss, noCenter } = config; const params = { finalWidth: value || globalWidth, - targetSelector: globalSelector || selector, // 优先使用全局特定选择器 + targetSelector: globalSelector || selector, // Prefer using global specific selectors property, extra: extraCss || '', centerCss: noCenter ? '' : 'margin-left: auto !important; margin-right: auto !important;', @@ -3665,17 +3503,17 @@ this.apply(); } - // ============= Shadow DOM 支持 ============= + // ============= Shadow DOM support ============= startShadowInjection(css) { - // Shadow CSS 需要重新生成,因为不能使用带 ancestor 的 globalSelector - // Shadow DOM 内部必须使用原始 selector,但包含同样的样式规则 + // Shadow CSS needs to be regenerated because globalSelector with ancestor cannot be used + // Shadow DOM must use the original selector internally, but contain the same style rules const shadowCss = this.generateShadowCSS(); - // 立即执行一次全量检查 + // Perform a full inspection immediately this.injectToAllShadows(shadowCss); - // 使用定时器定期检查 + // Use a timer to check periodically if (this.shadowCheckInterval) clearInterval(this.shadowCheckInterval); this.shadowCheckInterval = setInterval(() => { this.injectToAllShadows(shadowCss); @@ -3688,7 +3526,7 @@ return selectors .map((config) => { const { selector, property, value, extraCss, noCenter } = config; - // Shadow DOM 中只使用原始 selector (不带父级限定),靠 JS 过滤来保证安全 + // Only original selectors (without parent restrictions) are used in Shadow DOM, and security is ensured by JS filtering. const finalWidth = value || globalWidth; const extra = extraCss || ''; const centerCss = noCenter ? '' : 'margin-left: auto !important; margin-right: auto !important;'; @@ -3710,14 +3548,14 @@ const siteAdapter = this.siteAdapter; const processedShadowRoots = this.processedShadowRoots; - // 使用 DOMToolkit.walkShadowRoots 遍历所有 Shadow Root + // Use DOMToolkit.walkShadowRoots to traverse all Shadow Roots DOMToolkit.walkShadowRoots((shadowRoot, host) => { - // 检查是否应该注入到该 Shadow DOM(通过 Adapter 过滤,例如排除侧边栏) + // Check if it should be injected into this Shadow DOM (filtered via Adapter, e.g. to exclude sidebar) if (host && !siteAdapter.shouldInjectIntoShadow(host)) { return; } - // 使用 DOMToolkit.cssToShadow 注入样式 + // Use DOMToolkit.cssToShadow to inject styles DOMToolkit.cssToShadow(shadowRoot, css, 'gemini-helper-width-shadow-style'); processedShadowRoots.add(shadowRoot); }); @@ -3728,7 +3566,7 @@ const processedShadowRoots = this.processedShadowRoots; - // 使用 DOMToolkit.walkShadowRoots 遍历所有 Shadow Root + // Use DOMToolkit.walkShadowRoots to traverse all Shadow Roots DOMToolkit.walkShadowRoots((shadowRoot) => { const style = shadowRoot.getElementById('gemini-helper-width-shadow-style'); if (style) style.remove(); @@ -3737,41 +3575,34 @@ } } - // ==================== Markdown 渲染修复器 ==================== + // ==================== Markdown rendering fixer ==================== /** - * Markdown 加粗渲染修复器 - * 修复 Gemini 普通版响应中 **text** 未正确渲染为加粗的问题 - * 使用 DOM API 操作 TextNode - */ + * Markdown bold rendering fixer * Fix the problem that **text** is not correctly rendered as bold in the response of Gemini normal version * Use DOM API to operate TextNode*/ class MarkdownFixer { #processedNodes = new WeakSet(); #stopObserver = null; #enabled = false; - constructor() {} + constructor() { } /** - * 启动修复器 - * 1. 修复所有已存在的段落(历史消息) - * 2. 监听新增的段落(新消息/流式输出) - */ + * Start the repairer * 1. Repair all existing paragraphs (history messages) * 2. Listen for new paragraphs (new messages/streaming output)*/ start() { if (this.#enabled) return; this.#enabled = true; - // 修复所有已存在的段落 + // Repair all existing paragraphs const paragraphs = DOMToolkit.query('message-content p', { all: true }); paragraphs.forEach((p) => this.fixParagraph(p)); - // 监听新增的段落 + // Monitor new paragraphs this.#stopObserver = DOMToolkit.each('message-content p', (p, isNew) => { if (isNew) this.fixParagraph(p); }); } /** - * 停止修复器 - */ + * Stop the fixer*/ stop() { if (!this.#enabled) return; this.#enabled = false; @@ -3782,17 +3613,15 @@ } /** - * 修复单个段落 - * @param {HTMLElement} p 段落元素 - */ + * Fix a single paragraph * @param {HTMLElement} p paragraph element*/ fixParagraph(p) { if (this.#processedNodes.has(p)) return; this.#processedNodes.add(p); - // 先尝试跨节点修复(处理 ** 跨越 标签的情况) + // Try the cross-node fix first (handling the case where ** spans tags) this.fixCrossNodeBold(p); - // 再处理单节点内的加粗(未被跨节点处理的部分) + // Then process the bold within a single node (the part that is not processed across nodes) const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT, null, false); const nodesToProcess = []; @@ -3808,49 +3637,42 @@ } /** - * 修复跨节点加粗 - * 策略1:将 text 展开为 **text** - * 策略2:处理 **text** 这种跨元素的加粗标记 - * @param {HTMLElement} p 段落元素 - */ + * Fix cross-node bolding * Strategy 1: Expand text to **text** * Strategy 2: Handle **text** such cross-element bold mark * @param {HTMLElement} p paragraph element*/ fixCrossNodeBold(p) { - // 策略1: 查找段落中所有的 标签,展开为 **text** + // Strategy 1: Find all tags in the paragraph and expand them to **text** const boldTags = Array.from(p.querySelectorAll('b')); boldTags.forEach((bTag) => { - // 跳过 code/pre 内的 标签 + // Skip tags within code/pre if (this.isInsideProtectedArea(bTag)) return; try { - // 创建文档片段: ** + 原内容 + ** + // Create document fragment: ** + original content + ** const fragment = document.createDocumentFragment(); fragment.appendChild(document.createTextNode('**')); - // 将 的所有子节点移到片段中 + // Move all child nodes of into the fragment while (bTag.firstChild) { fragment.appendChild(bTag.firstChild); } fragment.appendChild(document.createTextNode('**')); - // 用片段替换 标签 + // Replace tag with fragment bTag.parentNode.replaceChild(fragment, bTag); } catch (e) { console.warn('[MarkdownFixer] Failed to unwrap tag:', e); } }); - // 规范化段落,合并相邻的文本节点 + // Normalize paragraphs and merge adjacent text nodes p.normalize(); - // 策略2: 处理 **text** 这种跨元素的加粗标记 + // Strategy 2: Deal with cross-element bold tags like **text** this.fixCrossElementBold(p); } /** - * 处理跨元素的加粗标记 - * 通用策略:扫描所有 ** 标记位置,按顺序配对并包裹 - * @param {HTMLElement} p 段落元素 - */ + * Handling bold tags across elements * General strategy: scan all ** tag positions, pair in order and wrap * @param {HTMLElement} p paragraph element*/ fixCrossElementBold(p) { let modified = true; let iterations = 0; @@ -3860,18 +3682,18 @@ modified = false; iterations++; - // 收集所有 ** 标记的位置 + // Collect all ** marked locations const markers = this.collectBoldMarkers(p); if (markers.length < 2) break; - // 按顺序配对处理 + // Pair processing in order for (let i = 0; i < markers.length - 1; i += 2) { const start = markers[i]; const end = markers[i + 1]; if (!start || !end) break; - // 检查是否可以包裹 + // Check if it can be packaged if (this.canWrapMarkers(start, end, p)) { if (this.wrapBoldMarkers(start, end, p)) { modified = true; @@ -3883,9 +3705,7 @@ } /** - * 收集段落中所有 ** 标记的位置 - * @returns {Array<{node: Text, offset: number}>} - */ + * Collect the positions of all ** tags in the paragraph * @returns {Array<{node: Text, offset: number}>} */ collectBoldMarkers(p) { const markers = []; const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT, null, false); @@ -3907,15 +3727,14 @@ } /** - * 检查两个标记之间是否可以包裹 - */ + * Check if it is possible to wrap between two markers*/ canWrapMarkers(start, end, container) { - // 同一节点内的情况交给 processTextNode 处理 + // The situation within the same node is handled by processTextNode if (start.node === end.node) { return false; } - // 检查两个标记之间是否只有内联元素 + // Check if there are only inline elements between two tags try { const range = document.createRange(); range.setStart(start.node, start.offset + 2); @@ -3924,7 +3743,7 @@ const fragment = range.cloneContents(); const inlineTags = ['span', 'a', 'em', 'i', 'strong', 'b', 'code', 'mark', 'cite']; - // 检查片段中是否只有内联元素 + // Check if there are only inline elements in a fragment const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_ELEMENT, null, false); while (walker.nextNode()) { const tag = walker.currentNode.tagName?.toLowerCase(); @@ -3940,8 +3759,7 @@ } /** - * 包裹两个 ** 标记之间的内容 - */ + * Wraps content between two ** tags */ wrapBoldMarkers(start, end, container) { try { const startNode = start.node; @@ -3949,60 +3767,60 @@ const startOffset = start.offset; const endOffset = end.offset; - // 分割开始节点:[前面的文本][**][后面的文本] + // Split start node: [previous text][**][following text] const startText = startNode.textContent; const beforeStart = startText.slice(0, startOffset); const afterStart = startText.slice(startOffset + 2); - // 分割结束节点:[前面的文本][**][后面的文本] + // Split end node: [previous text][**][following text] const endText = endNode.textContent; const beforeEnd = endText.slice(0, endOffset); const afterEnd = endText.slice(endOffset + 2); - // 创建 Range 选中要加粗的内容 + // Create a Range and select the content to be bold const range = document.createRange(); - // 更新开始节点内容并设置 range 起点 + // Update the start node content and set the range starting point if (afterStart) { - // 开始节点在 ** 后面还有内容 + // The start node has content after ** startNode.textContent = beforeStart; const afterStartNode = document.createTextNode(afterStart); startNode.parentNode.insertBefore(afterStartNode, startNode.nextSibling); range.setStartBefore(afterStartNode); } else { - // ** 在开始节点末尾 + // ** at the end of the start node startNode.textContent = beforeStart; range.setStartAfter(startNode); } - // 更新结束节点内容并设置 range 终点 + // Update the end node content and set the range end point if (beforeEnd) { - // 结束节点在 ** 前面还有内容 + // The end node has content before ** endNode.textContent = afterEnd; const beforeEndNode = document.createTextNode(beforeEnd); endNode.parentNode.insertBefore(beforeEndNode, endNode); range.setEndAfter(beforeEndNode); } else { - // ** 在结束节点开头 + // ** at the beginning of the end node endNode.textContent = afterEnd; range.setEndBefore(endNode); } - // 提取内容 + // Extract content const contents = range.extractContents(); - // 展开提取内容中的 标签(只保留其内容) + // Expand the tag in the extracted content (keep only its content) this.unwrapBoldTags(contents); - // 创建 元素 + // Create a element const strong = document.createElement('strong'); strong.dataset.originalMarkdown = '**'; strong.appendChild(contents); - // 插入 + // Insert range.insertNode(strong); - // 清理空节点 + // Clean up empty nodes if (startNode.textContent === '') { startNode.parentNode?.removeChild(startNode); } @@ -4010,7 +3828,7 @@ endNode.parentNode?.removeChild(endNode); } - // 规范化 + // Standardize container.normalize(); return true; @@ -4021,8 +3839,7 @@ } /** - * 展开片段中的 标签,只保留其子内容 - */ + * Expand the tag in the fragment, keeping only its child content*/ unwrapBoldTags(fragment) { const boldTags = fragment.querySelectorAll('b'); boldTags.forEach((b) => { @@ -4035,28 +3852,26 @@ } /** - * 检查两个文本节点之间是否可以被包裹为加粗 - * 只允许内联元素(span, a, em, i, code 等) - */ + * Check whether two text nodes can be wrapped in bold * Only inline elements (span, a, em, i, code, etc.) are allowed*/ canWrapBetween(startNode, endNode, container) { - // 简单检查:两个节点必须在同一父容器内(直接或通过内联元素嵌套) - // 获取从 startNode 到 endNode 之间的所有节点 + // Simple check: both nodes must be within the same parent container (either directly or nested via inline elements) + // Get all nodes from startNode to endNode const startParent = startNode.parentNode; const endParent = endNode.parentNode; - // 如果在同一父节点下,检查中间是否只有内联元素 + // If under the same parent node, check whether there are only inline elements in the middle if (startParent === endParent) { return this.hasOnlyInlinesBetween(startNode, endNode); } - // 复杂情况:不同父节点时,需要检查是否都在同一行内 - // 这里简化处理,只处理父节点是内联元素的情况 + // Complications: When there are different parent nodes, you need to check whether they are all in the same row. + // The processing is simplified here and only the case where the parent node is an inline element is processed. const inlineTags = ['span', 'a', 'em', 'i', 'strong', 'b', 'code', 'mark', 'cite']; const startParentTag = startParent.tagName?.toLowerCase(); const endParentTag = endParent.tagName?.toLowerCase(); if (inlineTags.includes(startParentTag) || inlineTags.includes(endParentTag)) { - // 检查两个父节点是否相邻或只有内联元素间隔 + // Check if two parent nodes are adjacent or separated by only inline elements return this.areNodesClose(startNode, endNode, container); } @@ -4064,8 +3879,7 @@ } /** - * 检查同一父节点下两个节点之间是否只有内联元素 - */ + * Check if there are only inline elements between two nodes under the same parent node */ hasOnlyInlinesBetween(startNode, endNode) { const inlineTags = ['span', 'a', 'em', 'i', 'strong', 'b', 'code', 'mark', 'cite', '#text']; let current = startNode.nextSibling; @@ -4082,10 +3896,9 @@ } /** - * 检查两个节点是否足够"接近"可以被视为一对 - */ + * Checks if two nodes are "close enough" to be considered a pair */ areNodesClose(startNode, endNode, container) { - // 使用 Range 检查两个节点之间的内容 + // Use Range to check the content between two nodes try { const range = document.createRange(); range.setStartAfter(startNode); @@ -4094,7 +3907,7 @@ const fragment = range.cloneContents(); const inlineTags = ['span', 'a', 'em', 'i', 'strong', 'b', 'code', 'mark', 'cite', '#text']; - // 检查片段中是否只有内联元素 + // Check if there are only inline elements in a fragment const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_ELEMENT, null, false); while (walker.nextNode()) { const tag = walker.currentNode.tagName?.toLowerCase(); @@ -4110,10 +3923,7 @@ } /** - * 检查元素是否在受保护区域内(code/pre/MathJax) - * @param {HTMLElement} element 要检查的元素 - * @returns {boolean} - */ + * Check if the element is within the protected area (code/pre/MathJax) * @param {HTMLElement} element The element to check * @returns {boolean} */ isInsideProtectedArea(element) { let parent = element.parentNode; while (parent && parent !== document.body) { @@ -4127,20 +3937,17 @@ } /** - * 判断是否应该跳过该节点 - * 保护 code/pre/MathJax 等区域 - * @param {Text} textNode - * @returns {boolean} + * Determine whether the node should be skipped * Protect code/pre/MathJax and other areas * @param {Text} textNode * @returns {boolean} */ shouldSkip(textNode) { let parent = textNode.parentNode; while (parent && parent !== document.body) { const tag = parent.tagName?.toLowerCase(); - // 跳过 code/pre 标签和 MathJax 区域 + // Skip code/pre tags and MathJax areas if (tag === 'code' || tag === 'pre' || parent.classList?.contains('MathJax')) { return true; } - // 已经是加粗元素,跳过 + // Already a bold element, skip it if (tag === 'strong' || tag === 'b') { return true; } @@ -4150,57 +3957,53 @@ } /** - * 处理单个 TextNode,拆分并包裹加粗部分 - * @param {Text} textNode - */ + * Process a single TextNode, split and wrap the bold part * @param {Text} textNode */ processTextNode(textNode) { const text = textNode.textContent; const regex = /\*\*(.+?)\*\*/g; if (!regex.test(text)) return; - regex.lastIndex = 0; // 重置正则状态 + regex.lastIndex = 0; // Reset regular state const fragment = document.createDocumentFragment(); let lastIndex = 0; let match; while ((match = regex.exec(text)) !== null) { - // 添加匹配前的普通文本 + // Add normal text before matching if (match.index > lastIndex) { fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); } - // 创建加粗元素 + // Create bold elements const strong = document.createElement('strong'); - strong.textContent = match[1]; // 去掉 ** - strong.dataset.originalMarkdown = match[0]; // 保留原始格式,用于导出还原 + strong.textContent = match[1]; // Remove ** + strong.dataset.originalMarkdown = match[0]; // Preserve original format for export restoration fragment.appendChild(strong); lastIndex = regex.lastIndex; } - // 添加剩余文本 + // Add remaining text if (lastIndex < text.length) { fragment.appendChild(document.createTextNode(text.slice(lastIndex))); } - // 替换原节点 + // Replace original node textNode.parentNode.replaceChild(fragment, textNode); } } - // ==================== 水印移除引擎 ==================== + // ==================== Watermark removal engine ==================== /** - * NanoBanana 水印移除引擎 - * 基于 journey-ad 的 Gemini NanoBanana Watermark Remover 脚本 - * https://greasyfork.org/scripts/559574 + * NanoBanana watermark removal engine * Gemini NanoBanana Watermark Remover script based on journey-ad * https://greasyfork.org/scripts/559574 */ class WatermarkRemover { - // 水印背景图片 Base64 (48x48) + // Watermark background image Base64 (48x48) static BG_48 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAGVElEQVR4nMVYvXIbNxD+FvKMWInXmd2dK7MTO7sj9QKWS7qy/Ab2o/gNmCp0JyZ9dHaldJcqTHfnSSF1R7kwlYmwKRYA93BHmkrseMcjgzgA++HbH2BBxhhmBiB/RYgo+hkGSFv/ZOY3b94w89u3b6HEL8JEYCYATCAi2JYiQ8xMDADGWsvMbfVagm6ZLxKGPXr0qN/vJ0mSpqn0RzuU//Wu9MoyPqxmtqmXJYwxxpiAQzBF4x8/fiyN4XDYoZLA5LfEhtg0+glMIGZY6wABMMbs4CaiR8brkYIDwGg00uuEMUTQ1MYqPBRRYZjZ+q42nxEsaYiV5VOapkmSSLvX62VZprUyM0DiQACIGLCAESIAEINAAAEOcQdD4a+2FJqmhDd/YEVkMpmEtrU2igCocNHW13swRBQYcl0enxbHpzEhKo0xSZJEgLIsC4Q5HJaJ2Qg7kKBjwMJyCDciBBcw7fjSO4tQapdi5vF43IZ+cnISdh9Y0At2RoZWFNtLsxr8N6CUTgCaHq3g+Pg4TVO1FACSaDLmgMhYC8sEQzCu3/mQjNEMSTvoDs4b+nXny5cvo4lBJpNJmKj9z81VrtNhikCgTsRRfAklmurxeKx9JZIsy548eeITKJgAQwzXJlhDTAwDgrXkxxCD2GfqgEPa4rnBOlApFUC/39fR1CmTyWQwGAQrR8TonMRNjjYpTmPSmUnC8ODgQHqSJDk7O9uNBkCv15tOp4eHh8SQgBICiCGu49YnSUJOiLGJcG2ydmdwnRcvXuwwlpYkSabTaZS1vyimc7R2Se16z58/f/jw4Z5LA8iy7NmzZ8J76CQ25F2UGsEAJjxo5194q0fn9unp6fHx8f5oRCQ1nJ+fbxtA3HAjAmCMCaGuAQWgh4eH0+k0y7LGvPiU3CVXV1fz+by+WQkCJYaImKzL6SEN6uMpjBVMg8FgOp3GfnNPQADqup79MLv59AlWn75E/vAlf20ibmWg0Pn06dPJZNLr9e6nfLu8//Ahv/gFAEdcWEsgZnYpR3uM9KRpOplMGmb6SlLX9Ww2q29WyjH8+SI+pD0GQJIkJycn/8J/I4mWjaQoijzPb25uJJsjmAwqprIsG4/HbVZ2L/1fpCiKoijKqgTRBlCWZcPhcDQafUVfuZfUdb1cLpfL5cePf9Lr16/3zLz/g9T1quNy+F2FiYjSNB0Oh8Ph8HtRtV6vi6JYLpdVVbmb8t3dnSAbjUbRNfmbSlmWeZ6XHytEUQafEo0xR0dHUdjvG2X3Sd/Fb0We56t6BX8l2mTq6BCVnqOjo7Ozs29hRGGlqqrOr40CIKqeiGg8Hn/xcri/rG/XeZ7/evnrjjGbC3V05YC/BSRJ8urVq36/3zX7Hjaq63o+n19fX/upUqe5VxFok7UBtQ+T6XQ6GAz2Vd6Ssizn8/nt7a3ay1ZAYbMN520XkKenpx0B2E2SLOo+FEWxWPwMgMnC3/adejZMYLLS42r7oH4LGodpsVgURdHQuIcURbFYLDYlVKg9sCk5wpWNiHym9pUAEQGG6EAqSxhilRQWi0VZVmrz23yI5cPV1dX5TwsmWGYrb2TW36OJGjdXhryKxEeHvjR2Fgzz+bu6XnVgaHEmXhytEK0W1aUADJPjAL6CtPZv5rsGSvUKtv7r8/zdj+v1uoOUpsxms7qunT6+g1/TvTQCxE6XR2kBqxjyZo6K66gsAXB1fZ3neQdJSvI8X61WpNaMWCFuKNrkGuGGmMm95fhpvPkn/f6lAgAuLy/LstyGpq7r9+8d4rAr443qaln/ehHt1siv3dvt2B/RDpJms5lGE62gEy9az0XGcQCK3DL4DTPr0pPZEjPAZVlusoCSoihWqzpCHy7ODRXhbUTJly9oDr4fKDaV9NZJUrszPOjsI0a/FzfwNt4eHH+BSyICqK7rqqo0u0VRrFYridyN87L3pBYf7qvq3wqc3DMldJmiK06pgi8uLqQjAAorRG+p+zLUxks+z7rOkOzlIUy8yrAcQFVV3a4/ywBPmJsVMcTM3l/h9xDlLga4I1PDGaD7UNBPuCKBleUfy2gd+DOrPWubGHJJyD+L+LCTjEXEgH//2uSxhu1/Xzocy+VSL+2cUhrqLVZ/jTYL0IMtQEklT3/iWCutzUljDDNXVSVHRFWW7SOtccHag6V/AF1/slVRyOkZAAAAAElFTkSuQmCC'; - // 水印背景图片 Base64 (96x96) + // Watermark background image Base64 (96x96) static BG_96 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAIAAABt+uBvAAAfrElEQVR4nJV9zXNc15Xf75zXIuBUjG45M7GyEahFTMhVMUEvhmQqGYJeRPTG1mokbUL5v5rsaM/CkjdDr4b2RqCnKga9iIHJwqCyMCgvbG/ibparBGjwzpnF+bjnvm7Q9isU2Hj93r3nno/f+bgfJOaZqg4EJfglSkSXMtLAKkRETKqqRMM4jmC1Z5hZVZEXEylUiYgAISKBf8sgiKoqDayqIkJEKBeRArh9++7BwcHn558/+8XRz//30cDDOI7WCxGBCYCIZL9EpKoKEKCqzFzpr09aCzZAb628DjAAggBin5UEBCPfuxcRiIpIG2+On8TuZ9Ot9eg+Pxt9+TkIIDBZL9lU/yLv7Czeeeedra2txWLxzv948KXtL9WxGWuS1HzRvlKAFDpKtm8yGMfRPmc7diVtRcA+8GEYGqMBEDEgIpcABKqkSiIMgYoIKQjCIACqojpmQ+v8IrUuRyVJ9pk2qY7Gpon0AIAAJoG+8Z/eaGQp9vb2UloCFRWI6igQJQWEmGbeCBGI7DMpjFpmBhPPBh/zbAATRCEKZSgn2UzEpGyM1iZCKEhBopzq54IiqGqaWw5VtXAkBl9V3dlUpG2iMD7Yncpcex7eIO/tfb3IDbu7u9kaFTv2Xpi1kMUAmJi5ERDWnZprJm/jomCohjJOlAsFATjJVcIwzFgZzNmKqIg29VNVIiW2RkLD1fGo2hoRQYhBAInAmBW/Z0SD9y9KCmJ9663dVB8o3n77bSJ7HUQ08EBEzMxGFyuxjyqErwLDt1FDpUzfBU6n2w6JYnRlrCCljpXMDFUEv9jZFhDoRAYo8jDwMBiVYcwAYI0Y7xuOAvW3KS0zM7NB5jAMwdPR/jSx77555ny+qGqytbV1/fr11Oscnph+a1PDqphErjnGqqp0eYfKlc1mIz4WdStxDWJms8+0IITdyeWoY2sXgHFalQBiEClctswOBETqPlEASXAdxzGG5L7JsA/A/q1bQDEkAoAbN27kDbN6/1FVHSFjNyS3LKLmW1nVbd9NHsRwxBCoYaKqmpyUREl65IYzKDmaVo1iO0aEccHeGUdXnIo4CB+cdpfmrfHA5eVlEXvzdNd3dxtF4V/39/cFKujIJSIaWMmdReqFjGO2ZpaCUGRXc1COvIIOhbNL3acCQDb2Es5YtIIBI3SUgZw7Ah1VBKpQmH0RlCAQ81noVd16UnKMpOBa93twRbvx9t5ivnC1MQ4Rwaxsd7eyu36wUQzkxDMxmd9Rl6uxyaU+du6/sEBERkMrUmSgY97DyGN7pwlc4UqUuq1q0Cgi6LlrHtY0yNQnv5qMZ/23iHexf/OmhXr5ajZycHC/oklqsT1BAYK1lxy/RtCUNphW0uDCZUdJP3UBCgAwmEYVoiEBmyBEauFJ0w4JnGdWSvCHJHK5TimY3BW5hUqNnoxpNkYiWuzM927sdWakjUfXd3cX83mMzBVcRaAGgo0wOA5YvGZdiMjo5sZEA4NLMK2SKAZpumZDViWMgBjgFoHXq0p7YpberAgA5iC0iMgF7r4fKX/nZDSmqvfu3attrne0f+tWCsmxdhhSlao/yp5SkZkpoj6dtN/rshANptFVfZgtsHAJSKYmREqkDNWxSYM5GjWvpIAoGIJIgkR1lPBrEQCqQiwzM91G+ACGYLHz+q39W5UlTkC5c/f2nWvXrjnQBLKk3WlkdqRQESIGKPwdjxp4Fw4XmaVYKKUQqKE+GEqw4COIIZHwYqkpqtpsLeJOs50ItFpgYoJJL1Dl74lEoobLChbqARiGYX9/XzHV3OzU/tza2rp7925VE44rlcJlTi2VqcplXWeQMfVTmg63Cak+UIIXVQXzbHAzjywnHhsQTtSkoapE3GJiu6Tpp/VYs1PjkcHBl+c7+/v7BKoaQ2SOCCDNb27fuX1t65qJmgYWBIIw0eDphRJM8lr426ROMABSQs3FwAB5EDMMM+ZZlXc+gprFQDnMm2salYFGdQEosU+2aFmuMdX+ybdM8kb3/YP788WihUONJiViTVgnbG9/6c7du0Q0ljCKIoJvFBY3VEU2USuQELdMkJhNhKZiGmlTY5CZTyZyImLGLlBNpRUikKmRB2/mHUM7Mj50iYWXcUMI6YmKBX47Ozs3b36jKg4oYgKFNUupWap3bt+Z7+xYDigiSiygcRyppNkM0lHM1ZICMjJUVCz4NtlbVcfZqgohHaEQwUgtlyoYJ9KKT6lKIpLp/LpbMV3wBKIm0OKZoaq/raOM/3qJgkQUEj44OLCRh4ynvjLU2f/c3tp68OBBakcx2FYkMDmJiNmIB3PULjT1j7ciQKnxXQ2UeBgYUHMzAEQvFSNYlYQwQFrEGVA1dE2IQERMAgMEYjCRDzPPKmX2+e0be/vfuBkKktgIoqaGwbMmmL29vTff3I1xewUqC0Cq5nOK6TFqrquqyqoOUi11hPnZsUV8FLHiQAxRRoG0asNExMNg+XdVv57TbQAWR4hLz6Dh0kJEVU0LB/BO6MJEObuakY2td3Hvfvfd7e1t6omMyAUAtBaOyxUm1hHfY5NbwBClC2Sg51qmYJANzx2JjtAxogZk7uspj3PNQx6DYCJmmmkEqESkKqZlKfaDeweL+VxrvFwGktwBoAnU4c4W88X9gwNS8TqBR+3+UGW4KQcR7GGyorcIhyKnETAzgxkDqZKKoZiqZNbUkm/K8K5wfRIUVAiotfcUiKpSqwB6Vqnq6PPVr3713r17zfLXL+rvR9ICdSC/ffvO7u51J52b+mdklLDNnNoRH/q6lUZoHmQjm2UmzUpGhElehIZ0fHE8F4XoQDOGFRXJ80e28iKrEmGQEYl/RMqzGZhFHC/mX955/72/s8jMR7+RR21U8bV9DA159913t7f/HdEAZVI2s4o40Avno14Gs9j9aY1CGth7nsjMEX+LYIQQKUcVqahAKkhyN0EhYajoUfMpLWpwf+/Ba7mDg4OD+c7CzCgUr5MwjCkGF9IqCl0pjTBfLL77ne8YiQ0uu8C6hdfVRWRMv24Wlo4F9Gg+Q0RliqMRMdjT1fWYfKxCmDcBj1kAWADmwAYmZfMCYFXC3x7cu7l/s3aSvxQgTutWr5umi4sPYWoAsHdj787f3CZS1bFiykAzCBGxjKo0jIFKqqPIZdR61GZZmBkggM39JdYyD9mmiLAqVDDhKFFXh88Xwr6iqoQWQVRWpg4CgOj169cP7h1URdCsKJKDVGOcexxMwoCJur3zzjtvvvlmEWpTZx3B/BplfBQSjVG0cC+RyzNEbSqGzPtIiSnQziom7AVgcJ+2mYoSaPAqTxbx3PGJVtS3Mtt8/vr7f/felWijUFFMHFpGiRWzC2Db9f7777/++rwW5y/FFEqho1uHKBMDnGhrHj39jE8ujqqqIMdsq4VZENfGU6UBQGS0e7XMXJ9J866/VTNphkB3dnYePny4tbVV360aMf1btUEzrX3f5+vb29sPH364mM9TZw1rndpWq3HK1wsAOQoeuijRO7Q2lUSQDlut7mPqbNZYp5KJyGZfqjVx5Htl1ghgnr8+//B7Hy4WiylrvK3yO3lAoLCyyENexdT54vXvffi9+Zd3krzWPCmjhoJUw+6cNVNVUlYlJcEwad7wNN8n8vpGIr/VSqg9AAf5Rk1KI8DbMkVsb29/+DC4c7U77741gK55WSIRNXY2ZbTocbH44IMPtra2mNnTV3fBha/FRyNYv0mp1+4ARAOriAXDSqIK5kEtrFQwD5k0O/sJsNS5xARtxYUCTPPXd95/7/2v/sc3oo/SNSHgxP5qk/QETy+d1sI4f4DQyiB5RwFguVz94B9+sFwumVkuPd2hCBpVRxXYDGiUotlm7pQ8MRAoiAY0F6SjqcXANjBVtaUtEQwrs8fvlgTGMwT48pc6Z5D8ev311x9++HA+n1OIpDGIHEpy6M6g6uJTa6x8BlKrqCO8WyffxrXVavXo0aPVapVZVap/zBrYSNtnJWmCV62fAZByA+nIGxiIUiBskYy7ZGtLCb5GoiS3KOoa3FkAJXGpHrrVEBUTPbcgsY83jF+K9dpspmz+13w+//Dhhzs7O4YGCYh1MqrhdLzV1i6VycUasvgaEcN80ybEjBUNHDBkDnxQ7bhjgsolI2+99dZ77723tbUVaw7Mhf8lFxUdydBR+/trPKJ4CsD5+fnHH398dnZm34dTK1ojwp57kJJHaomzFafYqoLD7Jqqyviv5iOTQV3oSMX02yxeV/S8fef2tx98GxvB7y+6NvJigkf9Y+Ytar+Hh4eHP3uao1ARtnRd1Tz1RschyGURREQDzVSViGeqHllVDVJV046CTVZAaBUr++e1115799139/b2/oIB/5nf+3dmlpFuxFfUMwW9ChyfHB8+fbparXzsANEACKACxxq7HD3JEk57nckKzRRrEOr0rk+o2qPsXPeyb/gvr5Ardnd3v/Pud82dV/q6QeJP8GjKkfyNeHddg9Y4st77arX64ccf/f73v4cID1CBxMIdtizMWSMI7xzYxMmBzFAasqShWdBd4uP2GoBr167dPzi4fefOnzvsyajSneczsAC8Wk7vuSjuqm7UoI3COPzZ039+eig2HUDwWg+8dgxEEkIWqDqDEJ6deDYQKcTr8LGMzCbsWwJBRKphVord3d3vfue788V8M3HNbVOSEXyJxyYMqhxZG2TXxeSP3g9ufHH1cvlPT56cnp5G+JmFSDe9EqmIGVchakDeyuds2seZyTyOl4AHkPOdnQcPvr1344ZFfH0E6ExxRhRV8BrN1CG194nR0qwW9BbDqdwpZjjVIwoaqvYRYKj0yeHy5UvYmuVSFOw6goeOnq/Nrr3WKo9j1ZqWyAhGAFuvbd+9e/f2ndvb29ubHA2Zs82eJpy6Mthr/KXmrjc/ENyZ3J+E6Y2hrsDEbfAnJ8efHD5dLpdMM1UFCW2EToB8RqPN0rj9ZyUo37y2de3u3Tt3bt/1GOcV+l+tqR+AM+iqd5uou/rQn8GgK9halcsTDn9/uVwdnxwf//JfVqsVD6gFE9iyX26RdHPtlkZYSgHAErSdxfyb3/zm7dt/s7W1vWlkV4/zFWpy1firt9qoTVfx6CpyOvPsX1aAcHJ8cnh4uFqtmFnkkpkrr+CxDDvuGu6kHu2++ebBwf3d67vxKLDuNeqw1z3OVfHeK4Zn6sCEUcG2WGYtpvuL4tA1oytNOGT/6lenJycnn356CkDEc4OEFwJ7+AdAFbu71/f29m7d2u9UpoYnVw3sFXrRkRufuupUfEFrjVwdBF3ZC2LsiKrAelSl3TvM/Ic//OHs7Ozk5P+enZ3lYigzMWxtbb99Y+/69et7e3tXmhKV1oMEb4XNvF2DpgBUjSX5EP62Mah5/U2hzSsYtNFsJ8C0Rnx8pUmMmkmKrlarFy/Onj9//tvf/na5XNKd/3rnwTsPGgUdCnh+0cF87SZ1ta2gaBR2JE/AuwsCE8ZfwQWahpT55JW2TNMQqQ6qNexfhKQ6Mf/0pz/lO7dbKFwmgaxbLVyaEFy7105lJhFyzyqvJKxHwGVSrNKdXXR8mejZ5FnP4LXeL2sl2jYDiqmaYE0Tvjnxe/fuzba3m02VMnCIND53I6qmUc1nSjQBWise6WiNYi39IZEh6JtyhLLmuHZV9TRnIvF6amqngGZPhgzkAiZE+wbJpIrPzy/48OnTJpM1BEAKk6b369gmH6+6GXpBU4doItA11KgtaNPojV2o1yK5GW8PfOtXgE+17q7jo6NnRAN/5Stf+ev/8Fdf//rXd3enm0omUeYr/Nhffl0BORS68oqoEuXVDS5s7ZWNnNoI4UrnFxfPT391dnZ2enp6cXER6yBdD8fd3es3b+6/9dZb8/l8I+VY49qfc00z1Y6u9ac3RxUdmmn/cG1yveUJg7Sgftw8Pz8/Pjk+PX3+4uw3sdRHPZImanXZTMG+duNrt27t3/jaXhJxZbmno6/knzUXWwvSYClSK25c4Yw6gIdepcSb4G/DY5PnCQDOzl4cPj08++zXICLL46XlsV6Trjuw/GJV1fmXF/fv379586bfs2nDnBhZj32ok0/mX5EuUoQejJgNmPJi3aP/ycG/ysSom0FC082Li4ufPzs6OTlZLpeAwFKuEcaNnA0lWxgdjQ0gYZBqrIwQArCzmO/v79+6ub9YLCpTYOFPDuwqkitY2AjDH13hl4IxtBbLKCZhgze6ITQl0HqmQoCen58/Ozo6Ojq6uDi3u5ZmCSmJTe359AQREc+GtqJFGSQQJfKikk2ejSrMvPPvv3z//v2b+zfTrVYoVcvjwoF0SlyVCx3FmxiU4fb6yHsG1cFr90wPN63li4vznx/9/Ojo6PKLL2SSmDIJKSuRwnbrkA9zKLPPZWrQ9gXaQit7wOrQO/Odb33rW9/4L9+oGjSpARGzqnS2UEOVdW5sMCKsffEnUKWZ/BXX6enzJz958vLlS1X1FQheWeS0GFtCZ3X3WIo5+KKY5stiupaI6opMz3GZANz4z1978ODBYrFoeUKfgmX9xW+/gkEbsXnCkbU7V3iM4v+K7qxWy388/Pizz37TrwwE9X3ABoheurcimRtXaJBnEiWf4GSQ1Wvd58XmGYQ23bt3r+1n2ui101w2lUr6Ofu+KDEpg1IkhH0jU/ZuigmPnh09fXp4fn6eKzU2XsoKUQjIdkBlyZVn4c/iVkxoxzrNXL9xOdb5eHvrjTfe+OCDDyp4b2SQm6F/bgtLu2pHA/5N0L0mgA0S6Rm0XC4f//jxixdnceNKBhGR2L567eaWYRoEoJ/0aK95Md+wRpQAHmw7kACggSG6WCwODg5u7u9vcM9XaRCF9+3jvaicYN15rcfWVzDIGz09ff74x48vLi4A9FseNzNLWZNB1KHqAIqDSMLq6mDK/pmOr6Q2ly+qqsMw/Le//e8H9w4azYRalNow9+AimUxaxCsVa9KR2/Kq0Pe4vcYz4MmTJ89+8YtCrU4MPKew2h0SU6QEk4yk850oWnmtk0EEjHmmi/VRS/q5CMaM8vr16++/957PeRBitdhVCzNcI7qAux+nZ4/UsQxTEXZQdH5+/tGPPn7x4oWq5GxwQQ+NhWXJoDjxhe2Ui6G0HBPWRCTSlpo7BCkTs+olgG4e0rkZGsfJaVLVxWLx8H8+XMznyEmFcCydEoW+ELKy8cqSGLCBy0hccxnYEqHly1UObxPuCMfydj91Bc2LDTSrs/CqI2EGYFMtmOx+S2VhSUZZ4u9QLQS2A1QEwM7O3BffrYWF6YIzBdkQ2uGK53WNWzViUl2ulo++/2i5XKLUQNOOTIQiYqbEakstxRb2JINIbXkU5wrGXGmPbAgZJdcVMOl3y0Ly/M3lWJ9VEkrTMJ84Qu0WW1MutfBV7dO3+ue7y5RTAf3d73//6PuPVqsl+c4aSiKnjdTRZgUvky3/t+zUj09TmjBFNcc5W31suyL8RCHKw3B8N81yufz7//X3v/vd79aGWWq36zqbVW2DHu0fs5ps7GktjdByufqHH/zgjy//qLEsNVdC2+4dKqXV2oCtb23jL1LPq+UZlUrPRAqDc7N0ZVY04SqtfpKJEuHi4vyjH320XC2nbGj+qTXXfdW7+ahBxsq9CMqT0cvl8tH3H33++YWI5BkYuTbQ9rvVrQGq+SFsIltTtYAmFwnDViSWJasEMCnn+o/c/7O+oc46U4UgVGno9GK1XD569Gi5XPYimVgdHGK1vFt4qCV8d0ii6JuwXK3MnAVj2TuWg9dRR49gYhE086BKNVMloE1Lw/fca9jWZJ10YAqocrrpZ2RYkQAUi7EZ2u78L1qtlo8ePfr88/PKlLoDeO3qgc9/ty4pC+SE8/PzR99/9PLly/SheS5FwWYQkc2419XubaRxpd1pH0O0fQwASGEnvqgqg9HtAnEzti0yOQoiUoIyUZyhkZdt0lwtlx9/9BEZpqjz28ZNayq5XpmncFXFLJxzH/3wRy9Xf6y8HmjI0AwA0WDrEicupfQ2ilzqeGknGZF6WFwpKkd0qdoJQxOZNlQKh1/QqY1wcpiGxoJGIrx4cfbkyZP1Nifkls/Ni657Hvv+8PDwsxcv1llsM+vWRJtij73y651edeUzTCozbh5RMAqUZ4PtpFcdY3NGxKDEqcLKUKaBZmzbHdqPeZA2tl8cPXt+ejrhjmqBmG5uVpsfy3XVoYBQHP/yl08PnyLO74PFYoCq2lqvcpnDFekPb/SKDw2qJJ1c/SQT1VFVBlsK3JxixIe2/WCC9iJQ6jCrEqL98QLsx9IN7tmZ/vHx4+VyOZGSa3QN+Vro539NnOZqtfrZz35GsRLOVDt3E0a/1K3QoC4di3NrbPd4t0esrSVXEEFE2OM7AdFA4ExG1NYMeZ1ogLRtjxZIqCorsfp+USJqG/YNgFiVxM4bEugXX3zx+PHjwh7TIMkAoxO8OlxXL2aG98OPP1q+XNnhlVHbU8VIZPu8eojlmalJ4qwL2z2vY/BAea7MyGz5w8DMEWUrQCSxtb1qR9TSNFfJUnDHuCCSu+3HtSCgk7wSPvvss2fPnrW/C+iU9xqUhsdsPvjw6WGNP3PxYI58EkOPl7a6su2P7i9XpWyHSlo7jgrf9MJ22EoXCnpQBLYzUbrWc9QM2DlDMqqVckQYHnl5A/aGuK89PDy06JGyJOQA07kYNbCpnRKtVsunh/88EA/E0QsZPtr+2BybBXuqo51t1vsZCtJtpKNvs40f5pkveGYCD75OkcrG4Xq5JKk75mEiCe9U1SBIPaPoQIqIbLnkxcXF4x//GBQ1HXRtBkpXvrTf//Tkie10HscxZ2JUDZvrTrHkVAviaqSS4p1koFouS/dlHNk2/ChBMJop+k876ETJjpKFxQm2J3qwmDsxi5RFkpUAQCqx9wgqlyFJefHrs+enzwGN0zO7ALlX0XYdnxx/+umnNEQXwyw5q6o0wE5wycsLOHYOCakhDhHleYl+PlnQ7D9gUX/G9rt2WpMMrla9LoHq3aoEXC6bAmWeDRqbEYnoyZMn5+clvHY3EcoySU0IAA4/+aSBURwYpKWGV0liP/CttNLTHF4vM7/UJQGVPd0A2zG/REqkdi6inT4QN4nIj5AzjTBtyvOk1eq4QhAdiAEWOy3DXBwx+dFhY+44U8Ly5erZs6OOhZG71KSMfFETjk9OVqs/QuPssHIsj/q2d/LN3d6bbXGiyBNINY7osfMa1N8gZtsCh/YT3AQrnNNpqE2iVV9SPnX/Uy1RZ0K/rlP+LkesF/WaOvNL7Jm69vhj7S2Xq6dPn5psiwV1dfjCL53NZgapWYGwr7rTZXoie4WX2jjXpzUOJwzAUyUZ9dJ0x2S1TpOI5L4FirMw86AuWPBZKl7G988vzn9+dGQG1ZG9hkLHx79cLv+/siprFKFaO86XEYhzPBKnS17aVMPxxVro9mQ0r+L+SkeCdBhERDU7GwbWmKrLYwZrpBCPDQlSE1fIE9nUkA84enbUIdHkCh6d/Mux1vSvBPf5mW2XUwQ1Odqr9LoqeK24Z+SVLbTxiHSFIiWMowBkx1dmKXNUyd0L1p4hgB/22icc4eDayKwr1ZGBL87PjwyJJl6rGNrxyfFqtWImUmYvALIhZh9JiOrY7acFkba9uDl7wxgMNEnZbFbgAbMQyI9pkIx789gYSz1aME7M5Afx+AL9DZYfR12lrDJCSe5svPKb4+NjoAt2Jn8eHh5WfcmcK1WDqK3+Sl02SiZHLayTRJlzAwrGpm85lMrYDFX4nP5ovPAT4jTP/kIjCAZAZZ6kqnRV2u6ID3CcKc4vly9fnL3oyon+Mgg4PT19+XIVMS6SNZE65MYJrsgdWqyqY0bYSR5EGWTxkZNqft1nt9rJs65B9kdh9rQqmNdEbtXOq21TXwN2ppe0oz4J4JNPPuk1p0XVx8fH6TRblWf0//7AQJB51o7RXkvNxnL8Y3XKG7V7ctOMI3IQ0ZhBHcAzRVffWX/Z74jmUXTrWFjY5xFtHMLWziFSwovffHZ+cR4ZmbMGhOVydfr/Ts1DEClIBaPIZZFfqFU4xzykzjggInZOq/HOUQk6qV4nUJLC4MlwygWAUB8ugOLlPO6CgGwxFSo9yEQyhcrW/bpw0iKOT46zn+AQXrx4kTcA+LKuiVeMRLQ5nYghM5LOqvNGEebYs5HJk8FysjMiRxHBCBKCHUQIAH7y+ERFs3UpR20nFjYbDIBnxH9+ArZKQtJ6evo8JZpx0Mnx/4Hk+fmceUGG4wz1gmHQlrGPqsLOktI4KiKQiJllHHWU/CFVHS8l0heL4DJA4RSy/VscZ5V2A51kSnLBGjUFro4jPgAS/jGqSxM3d3Z2dn5+UaeqV6vl2dlZfdi/KuR5Hk1NHimk6jqqXsOKpakvDg5O8ETq4cVKZEl21LglbDqa9O0ANCOl7vSdzWZZu0SEHhmJ+JKPPINXAIniKwXeNBPW0+e/qkHlr389FosuOs/o+Q3Zrv8WYRANFHBhg7RgbRgGK/INQwisnAOJQC6jqtkBtUUZXcmiqFLnsCYHu6U2orr52NTpZxFwpyP5n3mkVKuSEuHs12f1zumnz52zExQzhBRHfrMA0qYmteWkTbU7T7o9Foe4V12bqN5MR2Do4y772ghXVgiYRUfyVRCggWNWgDRiVq0g2tkp217+MtfsJ+ygDOn09LQG0L/77W+pLSrxBIIpAMGgnAReEgUgtovFqLLsUMNSfAkCQ3IFK1GS6px3LhtIj83iiHydXWVt8wHBzDijwqcE8j9eco+WI1ZLm6zM7RP2Whxfrzit34svzn/ykyfLPyzPz8+f/OTJ6uVLNLrF9qsbd2owXSWan6U73q47YXrioeqVEF4fBvBvwZvfB2giLLAAAAAASUVORK5CYII='; @@ -4217,8 +4020,7 @@ } /** - * 计算 Alpha Map - */ + * Calculate Alpha Map*/ calculateAlphaMap(imageData) { const { width, height, data } = imageData; const alphaMap = new Float32Array(width * height); @@ -4234,8 +4036,7 @@ } /** - * 移除水印核心算法 - */ + * Watermark removal core algorithm*/ removeWatermark(imageData, alphaMap, position) { const { x, y, width, height } = position; for (let row = 0; row < height; row++) { @@ -4256,8 +4057,7 @@ } /** - * 检测水印配置 - */ + * Detect watermark configuration*/ detectWatermarkConfig(imageWidth, imageHeight) { if (imageWidth > 1024 && imageHeight > 1024) { return { logoSize: 96, marginRight: 64, marginBottom: 64 }; @@ -4266,8 +4066,7 @@ } /** - * 计算水印位置 - */ + * Calculate watermark position*/ calculateWatermarkPosition(imageWidth, imageHeight, config) { const { logoSize, marginRight, marginBottom } = config; return { @@ -4279,8 +4078,7 @@ } /** - * 加载背景图片 - */ + * Load background image*/ async loadBgImage(size) { if (this.bgImages[size]) return this.bgImages[size]; return new Promise((resolve, reject) => { @@ -4295,8 +4093,7 @@ } /** - * 获取 Alpha Map - */ + * Get Alpha Map*/ async getAlphaMap(size) { if (this.alphaMaps[size]) return this.alphaMaps[size]; const bgImage = await this.loadBgImage(size); @@ -4312,8 +4109,7 @@ } /** - * 处理图片移除水印 - */ + * Process images to remove watermarks*/ async processImage(image) { const canvas = document.createElement('canvas'); canvas.width = image.width; @@ -4330,15 +4126,13 @@ } /** - * 替换为原始尺寸 URL - */ + * Replace with original size URL*/ replaceWithNormalSize(src) { return src.replace(/=s\d+(?=[-?#]|$)/, '=s0'); } /** - * 加载图片 - */ + * Load images */ loadImage(src) { return new Promise((resolve, reject) => { const img = new Image(); @@ -4350,22 +4144,19 @@ } /** - * Canvas 转 Blob - */ + * Canvas to Blob*/ canvasToBlob(canvas, type = 'image/png') { return new Promise((resolve) => canvas.toBlob(resolve, type)); } /** - * 判断是否是有效的 Gemini 生成图片 - */ + * Determine whether it is a valid Gemini generated image*/ isValidGeminiImage(img) { return img.closest('generated-image,.generated-image-container') !== null; } /** - * 查找所有 Gemini 生成的图片 - */ + * Find all Gemini generated images */ findGeminiImages() { return [...document.querySelectorAll('img[src*="googleusercontent.com"]')].filter( (img) => this.isValidGeminiImage(img) && img.dataset.watermarkProcessed !== 'true' && img.dataset.watermarkProcessed !== 'processing', @@ -4373,8 +4164,7 @@ } /** - * 通过 GM_xmlhttpRequest 获取图片 Blob - */ + * Get image blob through GM_xmlhttpRequest */ fetchBlob(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ @@ -4392,8 +4182,7 @@ } /** - * 处理单个图片元素 - */ + * Process individual image elements*/ async processImageElement(imgElement) { if (this.processingQueue.has(imgElement)) return; this.processingQueue.add(imgElement); @@ -4421,8 +4210,7 @@ } /** - * 处理所有图片 - */ + * Process all images */ processAllImages() { const images = this.findGeminiImages(); if (images.length === 0) return; @@ -4431,17 +4219,16 @@ } /** - * 启动水印移除 - */ + * Start watermark removal*/ start() { if (this.enabled) return; this.enabled = true; console.log('[Gemini Helper] Watermark Remover started'); - // 处理已有图片 + // Process existing images this.processAllImages(); - // 监听新增图片 + // Monitor new pictures const debounce = (func, wait) => { let timeout; return (...args) => { @@ -4456,8 +4243,7 @@ } /** - * 停止水印移除 - */ + * Stop watermark removal*/ stop() { if (!this.enabled) return; this.enabled = false; @@ -4469,11 +4255,9 @@ } } - // ==================== 滚动锁定管理器 ==================== + // ==================== Scroll Lock Manager ==================== /** - * 滚动锁定管理器 - * 通过劫持原生滚动 API 和 MutationObserver 修正来实现防自动滚动 - */ + * Scroll Lock Manager * Anti-autoscrolling by hijacking native scrolling API and MutationObserver fixes*/ class ScrollLockManager { constructor(siteAdapter) { this.siteAdapter = siteAdapter; @@ -4552,9 +4336,9 @@ } hijackApis() { - if (this.originalApis) return; // 已经劫持 + if (this.originalApis) return; // Already hijacked - // 保存原始 API + // Save original API this.originalApis = { scrollIntoView: Element.prototype.scrollIntoView, scrollTo: window.scrollTo, @@ -4562,32 +4346,32 @@ elementScrollTo: Element.prototype.scrollTo, elementScrollBy: Element.prototype.scrollBy, elementScroll: Element.prototype.scroll, - // 保存属性描述符以便恢复 + // Save property descriptor for restoration scrollTopDescriptor: Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop') || Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'), }; const self = this; - // 1. 劫持 Element.prototype.scrollIntoView + // 1. Hijack Element.prototype.scrollIntoView Element.prototype.scrollIntoView = function (options) { - // 检查是否包含绕过锁定的标志 (即使是 boolean or object) + // Check if a flag to bypass locking is included (even if it is a boolean or object) const shouldBypass = self.shouldBypassLock(options, this); if (self.enabled && self.shouldBlockScroll() && !shouldBypass) { // console.log('Gemini Helper: Blocked scrollIntoView'); return; } - // 移除自定义属性以防传给原生 API 报错(虽然通常不会) + // Remove custom attributes to prevent errors from being passed to the native API (although this usually is not the case) if (shouldBypass) { - // 克隆 options 以免修改原对象,或者直接删除 key - // 原生 scrollIntoView 会忽略未知属性 + // Clone options to avoid modifying the original object, or delete the key directly + // Native scrollIntoView ignores unknown properties } return self.originalApis.scrollIntoView.call(this, options); }; - // 2. 劫持 window.scrollTo + // 2. Hijack window.scrollTo window.scrollTo = function (x, y) { - // 有时 y 可能是 options 对象 + // Sometimes y may be an options object let targetY = y; let options = null; if (typeof x === 'object' && x !== null) { @@ -4595,8 +4379,8 @@ targetY = x.top; } - // 只有当向下大幅滚动时才拦截 (防止系统自动拉到底) - // 阈值设为 50px,避免误杀微小调整 + // Only intercept when scrolling down significantly (preventing the system from automatically scrolling to the bottom) + // The threshold is set to 50px to avoid accidentally killing small adjustments. const isWindowScroll = self.isMainScrollElement(document.scrollingElement); if (self.enabled && self.shouldBlockScroll() && isWindowScroll && !self.shouldBypassLock(options, null) && typeof targetY === 'number' && targetY > window.scrollY + 50) { // console.log('Gemini Helper: Blocked window.scrollTo (Auto-scroll attempt)'); @@ -4605,7 +4389,7 @@ return self.originalApis.scrollTo.apply(this, arguments); }; - // 3. 劫持 window.scrollBy + // 3. Hijack window.scrollBy if (this.originalApis.scrollBy) { window.scrollBy = function (x, y) { let deltaY = y; @@ -4622,7 +4406,7 @@ }; } - // 4. 劫持 Element.prototype.scrollTo / scroll + // 4. Hijack Element.prototype.scrollTo / scroll if (this.originalApis.elementScrollTo) { Element.prototype.scrollTo = function (x, y) { let targetY = y; @@ -4667,7 +4451,7 @@ }; } - // 5. 劫持 Element.prototype.scrollBy + // 5. Hijack Element.prototype.scrollBy if (this.originalApis.elementScrollBy) { Element.prototype.scrollBy = function (x, y) { let deltaY = y; @@ -4683,7 +4467,7 @@ }; } - // 6. 劫持 scrollTop setter (许多框架通过设置 scrollTop 来滚动) + // 6. Hijack scrollTop setter (many frameworks scroll by setting scrollTop) if (this.originalApis.scrollTopDescriptor) { Object.defineProperty(Element.prototype, 'scrollTop', { get: function () { @@ -4720,25 +4504,25 @@ this.originalApis = null; } - // 判断是否应该阻止滚动 - // 核心逻辑:虽然功能开启,但如果用户已经滚到底部了,我们其实应该允许跟随(就像终端一样) - // 不过根据用户需求,既然叫 "防止自动滚动",还是激进一点:只要开启就尽量阻止非用户触发的大幅向下滚动 + // Determine whether scrolling should be prevented + // Core logic: Although the function is enabled, if the user has scrolled to the bottom, we should actually allow following (just like the terminal) + // However, according to user needs, since it is called "preventing automatic scrolling", it is better to be more radical: as long as it is turned on, try to prevent large downward scrolling triggered by non-users. shouldBlockScroll() { - // 只有当我们不在底部时,才强力阻止?或者一直阻止? - // 为了最好的体验:如果用户已经在底部,应该允许新内容把页面撑长,但不应该发生"跳跃" - // 用户的脚本逻辑很简单:开启就阻止。我们保持一致。 + // Only strong blocking if we're not at the bottom? Or keep blocking? + // For the best experience: If the user is already at the bottom, new content should be allowed to stretch the page, but no "jumps" should occur. + // The user's script logic is simple: enable it and block it. We stay consistent. return true; } startScrollListener() { - // 记录用户最后滚动位置,用于自动修正 + // Record the user's last scroll position for automatic correction const onScroll = () => { - // 如果是用户手动滚动(或者未被劫持的滚动),更新位置 - // 这里很难区分,但我们主要通过 MutationObserver 来回滚异常位置 + // If the user is manually scrolling (or scrolling is not hijacked), update the position + // It's hard to tell the difference here, but we mainly roll back the exception position via MutationObserver if (this.enabled) { - // 只有在未被拦截的情况下,我们才认为这是"合法"的位置更新 - // 在 scroll 事件中很难拦截,只能事后修正 - // 这里我们只更新 lastScrollTop,具体修正在 Observer 中 + // We only consider this a "legitimate" location update if it is not intercepted + // It is difficult to intercept in the scroll event and can only be corrected afterwards. + // Here we only update lastScrollTop, the specific correction is in Observer this.lastScrollTop = this.getCurrentScrollTop(); } }; @@ -4761,7 +4545,7 @@ } startObserver() { - // 监听 DOM 变化,如果发现非用户意图的滚动跳变,强制回滚 + // Monitor DOM changes and force rollback if unintentional scrolling transitions are found. this.observer = new MutationObserver((mutations) => { if (!this.enabled) return; @@ -4771,11 +4555,11 @@ mutations.forEach((mutation) => { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { - // 检查是否有新消息节点 + // Check if there is a new message node for (const node of mutation.addedNodes) { if (node.nodeType === 1) { // Element - // 使用适配器提供的选择器判断 + // Use the selector provided by the adapter to determine for (const sel of contentSelectors) { if ((node.matches && node.matches(sel)) || (node.querySelector && node.querySelector(sel))) { hasNewContent = true; @@ -4789,18 +4573,18 @@ }); if (hasNewContent) { - // 如果有新内容插入,立刻检查滚动位置是否发生了非预期的改变 - // 这里的逻辑是:如果当前位置比记录的 lastScrollTop 大了很多,说明发生了自动滚动 - // 我们强制滚回去 + // If new content is inserted, immediately check whether the scroll position has changed unexpectedly. + // The logic here is: if the current position is much larger than the recorded lastScrollTop, automatic scrolling has occurred. + // We are forced to go back const container = this.getScrollContainer(); if (!container) return; const currentScroll = container.scrollTop; - // 阈值 100px + // Threshold 100px if (currentScroll > this.lastScrollTop + 100) { // console.log('Gemini Helper: Detected unblocked auto-scroll, changing back.'); container.scrollTop = this.lastScrollTop; - // 我们的劫持逻辑是阻止"向下"滚动。如果是"向上"回滚 (current > last, so set to last is moving up),是被允许的。 - // 稍微解释:lastScrollTop 是 1000,current 是 2000。滚动回 1000 是向上,允许。 + // Our hijacking logic is to prevent "down" scrolling. If it is "up" rollback (current > last, so set to last is moving up), it is allowed. + // A little explanation: lastScrollTop is 1000 and current is 2000. Rolling back to 1000 is up, allowed. } } }); @@ -4810,7 +4594,7 @@ subtree: true, }); - // 定时器保底 + // timer guarantee this.cleanupInterval = setInterval(() => { if (this.enabled) { this.refreshContainerListener(); @@ -4818,10 +4602,10 @@ if (!container) return; const current = container.scrollTop; if (current > this.lastScrollTop + 200) { - // 大幅跳变,回滚 + // Big jump, rollback container.scrollTop = this.lastScrollTop; } else { - // 小幅变动,认为是合法阅读,更新基准(防止页面慢慢变长后滚不下去) + // Small changes, considered to be legal reading, update the baseline (to prevent the page from becoming longer and unable to scroll) this.lastScrollTop = current; } } @@ -4840,19 +4624,17 @@ } } - // ==================== 核心管理类 ==================== + // ==================== Core management class ==================== /** - * 滚动管理器 - * 抽象不同站点的滚动容器差异 - */ + * Scroll Manager * Abstracts the differences in scroll containers for different sites*/ class ScrollManager { constructor(siteAdapter) { this.siteAdapter = siteAdapter; } get container() { - // 确保获取的是最新的容器实例 + // Make sure you get the latest container instance return this.siteAdapter.getScrollContainer(); } @@ -4880,7 +4662,7 @@ try { container.scrollTo(options); } catch (e) { - // 兼容部分旧浏览器不支持 options 对象 + // Compatible with some old browsers that do not support the options object if (options.top !== undefined) { container.scrollTop = options.top; } @@ -4890,7 +4672,7 @@ } } - // 检查是否在底部区域 + // Check if it is in the bottom area isAtBottom(threshold = 100) { const c = this.container; if (!c) return false; @@ -4899,9 +4681,7 @@ } /** - * 历史加载管理器 - * 负责加载全部历史记录并滚动到真正顶部 - */ + * History loading manager * Responsible for loading all history records and scrolling to the real top*/ class HistoryLoader { constructor(scrollManager, i18nFunc) { this.scrollManager = scrollManager; @@ -4913,9 +4693,7 @@ } /** - * 核心方法:加载全部历史并滚动到顶部 - * 采用延迟显示遮罩策略:前 2 轮(约 2.4 秒)不显示遮罩 - */ + * Core method: load the entire history and scroll to the top * Adopt a delayed display mask strategy: do not display the mask in the first 2 rounds (about 2.4 seconds)*/ async loadAllAndScrollTop() { if (this.isLoading) { showToast(this.t('loadingHistory')); @@ -4931,21 +4709,21 @@ this.isLoading = true; this.aborted = false; - // 配置参数 - const WAIT_MS = 800; // 每轮等待时间(从 1200ms 降到 800ms) - const MAX_NO_CHANGE_ROUNDS = 3; // 连续 N 次无变化判定完成(从 5 降到 3) - const MAX_TOTAL_ROUNDS = 50; // 超时保护:最多 50 轮(约 40 秒) - const OVERLAY_DELAY_MS = 1600; // 遮罩延迟显示时间(约 2 轮) + // Configuration parameters + const WAIT_MS = 800; // Waiting time per round (reduced from 1200ms to 800ms) + const MAX_NO_CHANGE_ROUNDS = 3; // N consecutive no-change judgments completed (reduced from 5 to 3) + const MAX_TOTAL_ROUNDS = 50; // Timeout protection: up to 50 rounds (approximately 40 seconds) + const OVERLAY_DELAY_MS = 1600; // Mask delay display time (about 2 rounds) const initialHeight = container.scrollHeight; let lastHeight = initialHeight; let noChangeCount = 0; let loopCount = 0; - // 快速检测:如果已经在顶部附近,先跳到顶部看看有没有更多内容 + // Quick check: If you are already near the top, jump to the top first to see if there is more content container.scrollTop = 0; - // 延迟显示遮罩的定时器 + // Delay timer for display mask this.overlayTimeout = setTimeout(() => { if (this.isLoading && !this.aborted) { this.showOverlay(); @@ -4960,16 +4738,16 @@ loopCount++; - // 超时保护:防止无限循环 + // Timeout protection: prevent infinite loops if (loopCount >= MAX_TOTAL_ROUNDS) { console.warn('HistoryLoader: max rounds reached, force completing'); this.finish(true); return; } - // 跳到顶部 + // jump to top container.scrollTop = 0; - // 触发 wheel 事件以激活懒加载 + // Trigger wheel event to activate lazy loading container.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, bubbles: true })); setTimeout(() => { @@ -4981,25 +4759,25 @@ const currentHeight = container.scrollHeight; if (currentHeight > lastHeight) { - // 高度增加,说明还在加载 + // The height increases, indicating that it is still loading. lastHeight = currentHeight; noChangeCount = 0; this.updateOverlayText(`${this.t('loadingHistory')} (${Math.round(currentHeight / 1000)}k)`); loadLoop(); } else { noChangeCount++; - // 首轮就没变化且已在顶部,快速完成(短对话优化) + // There is no change in the first round and it is already at the top. It can be completed quickly (short dialogue optimization) const isAtTop = container.scrollTop < 10; const isFirstRoundNoChange = loopCount === 1 && currentHeight === initialHeight; if (isFirstRoundNoChange && isAtTop) { - // 短对话,直接完成,不显示完成 toast + // Short conversation, complete directly without displaying the completion toast this.finish(false, true); // silent = true } else if (noChangeCount >= MAX_NO_CHANGE_ROUNDS) { - // 加载完成 + // Loading completed this.finish(true); } else { - // 继续确认 + // Continue to confirm this.updateOverlayText(`${this.t('loadingHistory')} (${noChangeCount}/${MAX_NO_CHANGE_ROUNDS})`); loadLoop(); } @@ -5007,20 +4785,17 @@ }, WAIT_MS); }; - // 开始加载循环 + // Start loading cycle loadLoop(); } /** - * 完成加载 - * @param {boolean} success - 是否成功 - * @param {boolean} silent - 是否静默(不显示 toast) - */ + * Completed loading * @param {boolean} success - whether it was successful * @param {boolean} silent - whether it was silent (no toast displayed)*/ finish(success, silent = false) { this.isLoading = false; this.aborted = false; - // 清除遮罩延迟定时器 + // Clear mask delay timer if (this.overlayTimeout) { clearTimeout(this.overlayTimeout); this.overlayTimeout = null; @@ -5034,22 +4809,20 @@ } /** - * 中止加载 - */ + * Abort loading*/ abort() { this.aborted = true; } /** - * 显示加载遮罩 - */ + * show loading mask */ showOverlay() { if (this.overlay) return; const overlay = document.createElement('div'); overlay.id = 'gemini-helper-loading-overlay'; - // 使用 DOM API 创建元素,避免 innerHTML + // Create elements using DOM API, avoiding innerHTML const spinner = document.createElement('div'); spinner.className = 'loading-spinner'; spinner.textContent = '⏳'; @@ -5081,8 +4854,7 @@ } /** - * 隐藏加载遮罩 - */ + * Hide loading mask*/ hideOverlay() { if (this.overlay) { this.overlay.remove(); @@ -5091,8 +4863,7 @@ } /** - * 更新遮罩文本 - */ + * Update mask text*/ updateOverlayText(text) { if (this.overlay) { const textEl = this.overlay.querySelector('#gemini-helper-loading-text'); @@ -5102,16 +4873,14 @@ } /** - * 阅读进度管理器 (Auto-Resume) - * 负责自动保存和恢复阅读位置 - */ + * Reading Progress Manager (Auto-Resume) * Responsible for automatically saving and restoring reading positions*/ class ReadingProgressManager { constructor(settings, scrollManager, i18nFunc) { - this.settings = settings; // 引用传递,保持最新 + this.settings = settings; // Pass by reference, keep up to date this.scrollManager = scrollManager; this.t = i18nFunc; this.lastSaveTime = 0; - this.isRecording = false; // 默认为 false,通过 startRecording 开启 + this.isRecording = false; // The default is false, enabled by startRecording } startRecording() { @@ -5120,13 +4889,13 @@ this.scrollHandler = () => this.handleScroll(); - // 监听真正的滚动容器(各站点通过 SiteAdapter 适配) + // Listen to the real scrolling container (each site is adapted through SiteAdapter) const container = this.scrollManager.container; if (container) { container.addEventListener('scroll', this.scrollHandler, { passive: true }); - this.listeningContainer = container; // 保存引用以便移除 + this.listeningContainer = container; // Save references for removal } - // 同时保留 window 监听作为兜底(某些站点可能用 window 滚动) + // At the same time, keep window monitoring as a backup (some sites may use window scrolling) window.addEventListener('scroll', this.scrollHandler, { capture: true, passive: true }); } @@ -5134,20 +4903,19 @@ if (!this.isRecording) return; this.isRecording = false; if (this.scrollHandler) { - // 移除容器监听 + // Remove container listener if (this.listeningContainer) { this.listeningContainer.removeEventListener('scroll', this.scrollHandler); this.listeningContainer = null; } - // 移除 window 监听 + // Remove window listening window.removeEventListener('scroll', this.scrollHandler, { capture: true }); this.scrollHandler = null; } } /** - * 重启记录(用于会话切换时重新绑定滚动容器) - */ + * Restart recording (used to rebind the scroll container when switching sessions) */ restartRecording() { this.stopRecording(); this.startRecording(); @@ -5164,7 +4932,7 @@ } getKey() { - // 使用 siteAdapter 提供的统一 Session ID,保持 Key 简洁且与其他功能逻辑一致 + // Use the unified Session ID provided by siteAdapter to keep the Key simple and logically consistent with other functions const sessionId = this.scrollManager.siteAdapter.getSessionId(); const siteId = this.scrollManager.siteAdapter.getSiteId(); return `${siteId}:${sessionId}`; @@ -5172,7 +4940,7 @@ saveProgress() { if (!this.isRecording) return; - // 新对话页面不记录阅读历史 + // New conversation pages do not record reading history if (this.scrollManager.siteAdapter.isNewConversation()) return; const scrollTop = this.scrollManager.scrollTop; @@ -5180,7 +4948,7 @@ const key = this.getKey(); - // 获取基于内容的锚点信息 (增强准确性) + // Get content-based anchor information (enhanced accuracy) let anchorInfo = {}; try { if (this.scrollManager.siteAdapter.getVisibleAnchorElement) { @@ -5202,10 +4970,7 @@ } /** - * 恢复阅读进度 (包含智能回溯逻辑) - * @param {Function} showToastFunc - 用于显示进度提示的回调 - * @returns {Promise} 是否恢复成功 - */ + * Restore reading progress (including intelligent backtracking logic) * @param {Function} showToastFunc - callback for displaying progress prompts * @returns {Promise} Whether the recovery is successful*/ async restoreProgress(showToastFunc) { if (!this.settings.readingHistory.autoRestore) return false; @@ -5215,19 +4980,19 @@ if (!data) return false; - // scrollManager.container 是 getter,每次访问自动获取最新容器 + // scrollManager.container is a getter and automatically gets the latest container every time it is accessed. const scrollContainer = this.scrollManager.container; if (!scrollContainer) return false; - // 智能回溯恢复逻辑 + // Intelligent backtracking recovery logic return new Promise((resolve) => { let historyLoadAttempts = 0; const maxHistoryLoadAttempts = 5; - let lastScrollHeight = 0; // 用于检测历史是否加载成功 + let lastScrollHeight = 0; // Used to check whether the history is loaded successfully const tryScroll = (attempts = 0) => { if (attempts > 30) { - // 超过最大尝试次数,使用像素位置作为最终降级 + // Maximum number of attempts exceeded, using pixel position as final downgrade if (data.top !== undefined && scrollContainer.scrollHeight >= data.top) { this.scrollManager.scrollTo({ top: data.top, behavior: 'instant', __bypassLock: true }); this.restoredTop = data.top; @@ -5238,7 +5003,7 @@ return; } - // 1. 尝试基于内容的精准恢复 + // 1. Try precise content-based recovery let contentRestored = false; try { if (data.type && this.scrollManager.siteAdapter.restoreScroll) { @@ -5249,42 +5014,42 @@ } if (contentRestored) { - // 内容恢复成功 + // Content restored successfully this.restoredTop = scrollContainer.scrollTop; resolve(true); return; } - // 2. 内容恢复失败,需要尝试加载更多历史 + // 2. Content recovery failed, you need to try to load more history const currentScrollHeight = scrollContainer.scrollHeight; const heightChanged = currentScrollHeight !== lastScrollHeight; lastScrollHeight = currentScrollHeight; - // 判断是否需要/可以继续加载历史 + // Determine whether it is necessary/can continue to load history const hasContentAnchor = data.type && (data.textSignature || data.selector); const needsMoreHistory = hasContentAnchor || (data.top !== undefined && currentScrollHeight < data.top); const canLoadMore = historyLoadAttempts < maxHistoryLoadAttempts; if (needsMoreHistory && canLoadMore) { - // 触发历史加载 - if (showToastFunc) showToastFunc(`正在加载历史会话 (${historyLoadAttempts + 1}/${maxHistoryLoadAttempts})...`); + // Trigger history loading + if (showToastFunc) showToastFunc(`Loading conversation history (${historyLoadAttempts + 1}/${maxHistoryLoadAttempts})...`); - // 滚动到顶部触发懒加载 + // Scroll to top triggers lazy loading this.scrollManager.scrollTo({ top: 0, behavior: 'instant', __bypassLock: true }); historyLoadAttempts++; - // 等待页面加载新内容 + // Wait for the page to load new content setTimeout(() => tryScroll(attempts + 1), 2000); } else if (data.top !== undefined && currentScrollHeight >= data.top) { - // 没有内容锚点或已用尽回溯机会,但像素位置可用 + // No content anchor or lookback opportunities exhausted, but pixel position available this.scrollManager.scrollTo({ top: data.top, behavior: 'instant', __bypassLock: true }); this.restoredTop = data.top; resolve(true); } else if (!canLoadMore && hasContentAnchor) { - // 回溯机会用尽但仍有内容锚点,尝试最后一次快速重试 + // Backtracking opportunities exhausted but content anchor still available, try one last quick retry setTimeout(() => tryScroll(attempts + 1), 500); } else { - // 无法恢复 + // Unable to recover resolve(false); } }; @@ -5293,11 +5058,11 @@ }); } - // 清理逻辑 + // Clean up logic cleanup() { const lastRun = GM_getValue('gemini_progress_cleanup_last_run', 0); const now = Date.now(); - if (now - lastRun < 24 * 60 * 60 * 1000) return; // 每天一次 + if (now - lastRun < 24 * 60 * 60 * 1000) return; // once a day const days = this.settings.readingHistory.cleanupDays || 7; if (days === -1) return; @@ -5319,36 +5084,32 @@ } /** - * 智能锚点管理器 (Smart Session Anchor) - * 负责会话内的临时跳转锚点 - */ + * Smart Session Anchor * Responsible for temporary jump anchors within the session*/ /** - * 智能锚点管理器 (Smart Session Anchor) - * 负责会话内的临时跳转锚点 - */ + * Smart Session Anchor * Responsible for temporary jump anchors within the session*/ class AnchorManager { constructor(scrollManager, i18nFunc) { this.scrollManager = scrollManager; this.t = i18nFunc; - // 双位置交换:类似 git switch - - this.previousAnchor = null; // 上一个位置(跳转前) - this.currentAnchor = null; // 当前锚点(跳转目标) - this.onAnchorChange = null; // UI 更新回调 + // Dual position swap: similar to git switch - + this.previousAnchor = null; // Previous position (before jump) + this.currentAnchor = null; // Current anchor point (jump target) + this.onAnchorChange = null; // UI update callback } - // 设置回调 + // Set callback bindUI(callback) { this.onAnchorChange = callback; } - // 获取当前位置的完整锚点信息 + // Get complete anchor point information for the current location _captureCurrentPosition() { let anchorInfo = {}; try { if (this.scrollManager.siteAdapter.getVisibleAnchorElement) { anchorInfo = this.scrollManager.siteAdapter.getVisibleAnchorElement(); } - } catch (err) {} + } catch (err) { } return { top: this.scrollManager.scrollTop, @@ -5357,16 +5118,16 @@ }; } - // 记录锚点 (跳转前调用,保存当前位置) + // Record anchor point (called before jumping, save current position) setAnchor(top) { let anchorInfo = {}; try { if (this.scrollManager.siteAdapter.getVisibleAnchorElement) { anchorInfo = this.scrollManager.siteAdapter.getVisibleAnchorElement(); } - } catch (err) {} + } catch (err) { } - // 保存当前位置为"上一个锚点" + // Save current position as "Previous Anchor Point" this.previousAnchor = { top: top, ts: Date.now(), @@ -5376,20 +5137,20 @@ if (this.onAnchorChange) this.onAnchorChange(true); } - // 跳转到锚点(同时实现位置交换,支持来回跳转) + // Jump to the anchor point (implementing position exchange at the same time, supporting jump back and forth) backToAnchor() { if (!this.previousAnchor) return false; const scrollContainer = this.scrollManager.container; if (!scrollContainer) return false; - // 1. 先保存当前位置(跳转后可以再跳回来) + // 1. Save the current position first (you can jump back after jumping) const currentPos = this._captureCurrentPosition(); - // 2. 尝试跳转到 previousAnchor + // 2. Try to jump to previousAnchor let jumped = false; - // 2.1 尝试基于内容的精准恢复 + // 2.1 Try content-based accurate recovery try { if (this.previousAnchor.type && this.scrollManager.siteAdapter.restoreScroll) { jumped = this.scrollManager.siteAdapter.restoreScroll(this.previousAnchor); @@ -5398,16 +5159,16 @@ console.error('Error restoring anchor:', err); } - // 2.2 降级:像素位置 + // 2.2 Downgrade: Pixel Position if (!jumped && this.previousAnchor.top !== undefined) { this.scrollManager.scrollTo({ top: this.previousAnchor.top, behavior: 'instant', __bypassLock: true }); jumped = true; } if (jumped) { - // 3. 交换位置:实现来回跳转 - // 原来的 previousAnchor 变成 currentAnchor(备用) - // 刚才的位置变成新的 previousAnchor(下次跳回去) + // 3. Swap positions: jump back and forth + // The original previousAnchor becomes currentAnchor (backup) + // The previous position becomes the new previousAnchor (jump back next time) this.currentAnchor = this.previousAnchor; this.previousAnchor = currentPos; } @@ -5415,12 +5176,12 @@ return jumped; } - // 检查是否有锚点 + // Check if there is an anchor point hasAnchor() { return this.previousAnchor !== null; } - // 重置锚点(用于会话切换) + // Reset anchor point (for session switching) reset() { this.previousAnchor = null; this.currentAnchor = null; @@ -5429,10 +5190,7 @@ } /** - * 通用会话管理器 - * 负责会话列表的 UI 渲染、文件夹管理和交互 - * Phase 1: 骨架版本,仅显示占位内容 - */ + * Universal session manager * Responsible for UI rendering, folder management and interaction of the session list * Phase 1: Skeleton version, only showing placeholder content*/ class ConversationManager { constructor(config) { this.container = config.container; @@ -5440,12 +5198,12 @@ this.siteAdapter = config.siteAdapter; this.t = config.i18n || ((k) => k); this.isActive = false; - this.data = null; // 会话数据 - this.expandedFolderId = null; // 记忆当前展开的文件夹(手风琴模式,只展开一个) - this.selectedIds = new Set(); // 批量选中的会话 ID - this.batchMode = false; // 批量模式开关 - this.searchQuery = ''; // 搜索关键词 - this.searchResult = null; // 搜索结果 { folderMatches, conversationMatches, totalCount } + this.data = null; // session data + this.expandedFolderId = null; // Remember the currently expanded folder (accordion mode, only expand one) + this.selectedIds = new Set(); // Bulk selected session IDs + this.batchMode = false; // Batch mode switch + this.searchQuery = ''; // Search keywords + this.searchResult = null; // Search results { folderMatches, conversationMatches, totalCount } this.init(); } @@ -5457,54 +5215,52 @@ } /** - * 启动侧边栏实时监听 - * 使用 DOMToolkit.each 监听新会话添加 - */ + * Start sidebar real-time monitoring * Use DOMToolkit.each to listen for new session additions*/ startSidebarObserver() { - if (this.sidebarObserverStop) return; // 已经在监听 + if (this.sidebarObserverStop) return; // Already listening - // 获取适配器提供的配置 + // Get the configuration provided by the adapter const config = this.siteAdapter.getConversationObserverConfig(); - if (!config) return; // 站点不支持侧边栏监听 + if (!config) return; // The site does not support sidebar monitoring - // 保存配置供其他方法使用 + // Save configuration for use by other methods this.observerConfig = config; - // 延迟启动函数(等待侧边栏 DOM 加载完成) + // Delayed startup function (waiting for the sidebar DOM to load) const startObserver = (retryCount = 0) => { - const maxRetries = 5; // 最多重试5次 - const retryDelay = 1000; // 每次重试间隔1秒 + const maxRetries = 5; // Retry up to 5 times + const retryDelay = 1000; // 1 second between retries - // 确定监听起点:始终使用最精确的容器,让 Observer 能监听 Shadow DOM 内部的变化 + // Determine the starting point of monitoring: always use the most accurate container so that the Observer can monitor changes inside the Shadow DOM const sidebarContainer = this.siteAdapter.getSidebarScrollContainer() || document; - // 对于需要 Shadow DOM 穿透的站点,检查侧边栏容器是否已加载 - // 如果返回的是 document,说明没找到特定容器,可能还要等待 (除非原本就是 document) + // For sites that require Shadow DOM penetration, check if the sidebar container is loaded + // If a document is returned, it means that the specific container was not found and you may have to wait (unless it was originally a document) if (config.shadow && retryCount < maxRetries) { const foundContainer = this.siteAdapter.getSidebarScrollContainer(); if (!foundContainer) { - // 侧边栏还未加载,延迟重试 + // The sidebar has not loaded yet, please delay and try again. setTimeout(() => startObserver(retryCount + 1), retryDelay); return; } } - // 保存当前从属的容器,用于后续存活检测 (Zombie Check) + // Save the current slave container for subsequent survival detection (Zombie Check) this.observerContainer = sidebarContainer; - // 侧边栏已加载或达到最大重试次数,开始监听 + // The sidebar has been loaded or the maximum number of retries has been reached. Start listening. this.sidebarObserverStop = DOMToolkit.each( config.selector, (el, isNew) => { - // 尝试提取 ID,如果失败则重试(因为新会话可能属性延迟生成) + // Try to extract the ID, and try again if it fails (because new session properties may be generated with delay) const tryAdd = (retries = 5) => { const info = config.extractInfo(el); if (info?.id) { const existing = this.data.conversations[info.id]; - // 仅对新发现的元素尝试添加到数据(如果是全新的会话) + // Only attempts to add to the data are made for newly discovered elements (if this is a completely new session) if (isNew && !existing) { - // 自动添加新会话到当前选中文件夹 + // Automatically add new sessions to the currently selected folder const folderId = this.data.lastUsedFolderId || 'inbox'; this.data.conversations[info.id] = { id: info.id, @@ -5513,23 +5269,23 @@ title: info.title || 'New Conversation', url: info.url, folderId: folderId, - pinned: info.isPinned || false, // 同步云端置顶状态 + pinned: info.isPinned || false, // Sync cloud pin status createdAt: Date.now(), updatedAt: Date.now(), }; this.saveData(); - // 轻量级更新计数(避免重建整个 UI 丢失展开状态) + // Lightweight update count (avoids rebuilding entire UI losing expanded state) this.updateFolderCount(folderId); } else if (existing) { - // 对已存在的会话,同步 pinned 状态变化 + // For existing sessions, synchronize pinned status changes if (info.isPinned && !existing.pinned) { - // 云端置顶 -> 本地也置顶 + // Pin on cloud -> pin on local existing.pinned = true; existing.updatedAt = Date.now(); this.saveData(); this.createUI(); } else if (!info.isPinned && existing.pinned && this.settings?.conversations?.syncUnpin) { - // 云端取消置顶且开启了 syncUnpin -> 本地也取消置顶 + // The cloud pin is unpinned and syncUnpin is enabled -> the local pin is also unpinned existing.pinned = false; existing.updatedAt = Date.now(); this.saveData(); @@ -5537,7 +5293,7 @@ } } - // 对所有会话(无论新旧)启动标题变更监听 + // Start title change listening on all sessions (old and new) this.monitorConversationTitle(el, info.id); } else if (retries > 0) { setTimeout(() => tryAdd(retries - 1), 500); @@ -5550,39 +5306,35 @@ ); }; - // 启动观察器 + // Start the observer startObserver(); - // 补充:仅对 Shadow DOM 站点启用轮询(Observer 可能因 DOM 复用/替换而失效) - // 普通站点的 Observer 工作正常,无需轮询 + // Added: Enable polling only for Shadow DOM sites (Observer may fail due to DOM reuse/replacement) + // Observers for normal sites work fine without polling if (config.shadow) { this.pollNewConversations(); } } /** - * 检查侧边栏监听器是否仍然有效 (Zombie Check) - * 如果容器被销毁(Detached),则重启监听器 - */ + * Check if the sidebar listener is still valid (Zombie Check) * If the container is destroyed (Detached), restart the listener */ checkObserverStatus() { - // 如果监听器已停止,不需要检查 + // No need to check if the listener is stopped if (!this.sidebarObserverStop) return; - // 如果容器存在但已失去连接 (isConnected === false),说明变成了僵尸监听器 + // If the container exists but has lost connection (isConnected === false), it becomes a zombie listener if (this.observerContainer && !this.observerContainer.isConnected) { console.log('Gemini Helper: Sidebar container detached. Restarting observer...'); this.stopSidebarObserver(); - // 给予一点延迟等待新容器就绪 + // Give a little delay to wait for the new container to be ready setTimeout(() => this.startSidebarObserver(), 500); } } /** - * 轮询检测新会话 - * 作为 MutationObserver 的补充机制 - */ + * Polling to detect new sessions * as a complementary mechanism to MutationObserver*/ pollNewConversations() { - if (this.pollInterval) return; // 已在轮询 + if (this.pollInterval) return; // Already polling this.pollInterval = setInterval(() => { if (!this.observerConfig) return; @@ -5593,7 +5345,7 @@ elements.forEach((el) => { const info = config.extractInfo(el); if (info?.id && !this.data.conversations[info.id]) { - // 发现未记录的会话 + // Unrecorded session found const folderId = this.data.lastUsedFolderId || 'inbox'; this.data.conversations[info.id] = { id: info.id, @@ -5607,7 +5359,7 @@ }; this.saveData(); this.updateFolderCount(folderId); - // 启动标题监听 + // Start title listening this.monitorConversationTitle(el, info.id); } }); @@ -5615,8 +5367,7 @@ } /** - * 停止轮询 - */ + * Stop polling*/ stopPolling() { if (this.pollInterval) { clearInterval(this.pollInterval); @@ -5625,30 +5376,26 @@ } /** - * 监听会话标题和 pin 状态变化 - * 使用共享的 watchMultiple 减少 Observer 数量 - * @param {HTMLElement} el 会话元素 - * @param {string} id 会话ID - */ + * Listen for session title and pin status changes * Reduce the number of Observers using a shared watchMultiple * @param {HTMLElement} el session element * @param {string} id session ID*/ monitorConversationTitle(el, id) { - // 防止重复监听 + // Prevent repeated monitoring if (el.dataset.ghTitleObserver) return; el.dataset.ghTitleObserver = 'true'; - // 确保共享 watcher 已初始化 + // Make sure the shared watcher is initialized if (!this.titleWatcher) { const container = this.siteAdapter.getSidebarScrollContainer() || document.body; this.titleWatcher = DOMToolkit.watchMultiple(container, { debounce: 500 }); } - // 监听整个会话元素(以便检测标题和 pin 状态变化) + // Listen to the entire session element (to detect title and pin state changes) this.titleWatcher.add(el, () => { - // 每次回调时重新从元素提取信息,确保 ID 匹配 + // Re-extract information from the element on each callback to ensure IDs match const currentInfo = this.observerConfig?.extractInfo?.(el); const currentId = currentInfo?.id; if (!currentId || currentId !== id) { - // ID 不匹配则跳过(防止元素被复用时错误更新) + // If the ID does not match, skip it (to prevent incorrect updates when the element is reused) return; } @@ -5658,7 +5405,7 @@ let needsSave = false; let needsUIRefresh = false; - // 检测标题变化 + // Detect title changes const currentTitle = currentInfo?.title; if (currentTitle && stored.title !== currentTitle) { console.log(`[Gemini Helper] Title changed for ${currentId}: "${stored.title}" -> "${currentTitle}". Updating local copy.`); @@ -5668,17 +5415,17 @@ needsUIRefresh = true; } - // 检测 pin 状态变化 + // Detect pin status changes const currentPinned = currentInfo?.isPinned || false; if (currentPinned && !stored.pinned) { - // 云端置顶 -> 本地也置顶 + // Pin on cloud -> pin on local console.log(`[Gemini Helper] Pinned status changed for ${currentId}: unpinned -> pinned. Updating local copy.`); stored.pinned = true; stored.updatedAt = Date.now(); needsSave = true; needsUIRefresh = true; } else if (!currentPinned && stored.pinned && this.settings?.conversations?.syncUnpin) { - // 云端取消置顶且开启了 syncUnpin -> 本地也取消置顶 + // The cloud pin is unpinned and syncUnpin is enabled -> the local pin is also unpinned console.log(`[Gemini Helper] Pinned status changed for ${currentId}: pinned -> unpinned. Updating local copy (syncUnpin enabled).`); stored.pinned = false; stored.updatedAt = Date.now(); @@ -5696,39 +5443,36 @@ } /** - * 停止侧边栏监听 - */ + * Stop sidebar listening*/ stopSidebarObserver() { if (this.sidebarObserverStop) { this.sidebarObserverStop(); this.sidebarObserverStop = null; } - // 清理容器引用 + // Clean up container references this.observerContainer = null; - // 清理共享的标题监听器 + // Clean up shared title listeners if (this.titleWatcher) { this.titleWatcher.stop(); this.titleWatcher = null; } - // 清理轮询 + // Cleanup polling this.stopPolling(); } /** - * 轻量级更新文件夹计数(不重建 UI) - * 同时刷新已展开文件夹的会话列表 - */ + * Lightweight update of folder count (without rebuilding UI) * Also refreshes session list of expanded folders*/ updateFolderCount(folderId) { const folderItem = this.container?.querySelector(`.conversations-folder-item[data-folder-id="${folderId}"]`); if (folderItem) { - // 获取当前 CID(仅 Gemini Business 有效) + // Get the current CID (valid only for Gemini Business) const currentCid = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null; const count = Object.values(this.data.conversations).filter((c) => c.folderId === folderId && this.matchesCid(c, currentCid)).length; const countSpan = folderItem.querySelector('.conversations-folder-count'); if (countSpan) countSpan.textContent = `(${count})`; - // 如果该文件夹已展开,同时刷新会话列表 + // If the folder is expanded, also refresh the session list if (folderItem.classList.contains('expanded')) { const conversationList = this.container?.querySelector(`.conversations-list[data-folder-id="${folderId}"]`); if (conversationList) { @@ -5739,32 +5483,27 @@ } /** - * 激活会话 Tab 时调用 - */ + * Called when a session Tab is activated */ activate() { this.isActive = true; - this.syncConversations(null, true); // 切换进来时静默同步一次 + this.syncConversations(null, true); // Silently sync once when switching in this.createUI(); } /** - * 停用会话 Tab 时调用 - */ + * Called when the session Tab is deactivated*/ deactivate() { this.isActive = false; } /** - * 获取全局存储键 - * 注意:文件夹和标签全局共用,会话通过 cid 字段区分不同团队 - */ + * Get the global storage key * Note: Folders and labels are shared globally, and sessions use the cid field to distinguish different teams*/ getStorageKey() { return SETTING_KEYS.CONVERSATIONS; } /** - * 加载会话数据 - */ + * Load session data */ loadData() { const key = this.getStorageKey(); const saved = GM_getValue(key, null); @@ -5776,46 +5515,37 @@ } /** - * 保存会话数据 - */ + * Save session data*/ saveData() { const key = this.getStorageKey(); GM_setValue(key, this.data); } /** - * 上移文件夹 - * @param {string} folderId 文件夹 ID - */ + * Move folder up * @param {string} folderId folder ID*/ moveFolderUp(folderId) { const index = this.data.folders.findIndex((f) => f.id === folderId); - // index 0 是收件箱(固定),index 1 是第一个可移动的 + // index 0 is the inbox (fixed), index 1 is the first movable if (index <= 1) return; - // 与上一个交换位置 + // Swap position with previous one [this.data.folders[index - 1], this.data.folders[index]] = [this.data.folders[index], this.data.folders[index - 1]]; this.saveData(); this.createUI(); } /** - * 下移文件夹 - * @param {string} folderId 文件夹 ID - */ + * Move folder down * @param {string} folderId folder ID*/ moveFolderDown(folderId) { const index = this.data.folders.findIndex((f) => f.id === folderId); if (index <= 0 || index >= this.data.folders.length - 1) return; - // 与下一个交换位置 + // Swap place with next [this.data.folders[index], this.data.folders[index + 1]] = [this.data.folders[index + 1], this.data.folders[index]]; this.saveData(); this.createUI(); } /** - * 创建文件夹 - * @param {string} name 文件夹名称 - * @param {string} icon 图标 emoji - * @returns {object} 新创建的文件夹 - */ + * Create folder * @param {string} name Folder name * @param {string} icon icon emoji * @returns {object} newly created folder*/ createFolder(name, icon = '📁') { const folder = { id: 'folder_' + Date.now(), @@ -5829,11 +5559,7 @@ } /** - * 重命名文件夹 - * @param {string} folderId 文件夹 ID - * @param {string} newName 新名称 - * @param {string} newIcon 新图标 - */ + * Rename folder * @param {string} folderId folder ID * @param {string} newName new name * @param {string} newIcon new icon*/ renameFolder(folderId, newName, newIcon = null) { const folder = this.data.folders.find((f) => f.id === folderId); if (folder && !folder.isDefault) { @@ -5846,17 +5572,14 @@ } /** - * 删除文件夹 - * @param {string} folderId 文件夹 ID - * @returns {boolean} 是否删除成功 - */ + * Delete folder * @param {string} folderId folder ID * @returns {boolean} Whether the deletion is successful*/ deleteFolder(folderId) { const folder = this.data.folders.find((f) => f.id === folderId); if (!folder || folder.isDefault) { - showToast(this.t('conversationsCannotDeleteDefault') || '无法删除默认文件夹'); + showToast(this.t('conversationsCannotDeleteDefault') || 'Cannot delete default folder'); return false; } - // 将文件夹内的会话移到收件箱 + // Move conversations in a folder to your inbox Object.values(this.data.conversations).forEach((conv) => { if (conv.folderId === folderId) { conv.folderId = 'inbox'; @@ -5868,28 +5591,24 @@ } /** - * 从侧边栏同步会话(增量) - * @param {string} targetFolderId 可选,指定目标文件夹 - * @param {boolean} silent 是否静默同步(不显示 Toast) - * @param {boolean} checkForDeletions 是否检查并删除失效会话(仅全量同步时启用) - */ + * Synchronize sessions from the sidebar (incremental) * @param {string} targetFolderId Optional, specify the target folder * @param {boolean} silent Whether to synchronize silently (not display Toast) * @param {boolean} checkForDeletions Whether to check and delete expired sessions (enabled only for full synchronization)*/ syncConversations(targetFolderId = null, silent = false, checkForDeletions = false) { const sidebarItems = this.siteAdapter.getConversationList(); if (!sidebarItems || sidebarItems.length === 0) { - if (!silent) showToast(this.t('conversationsSyncEmpty') || '未找到会话'); + if (!silent) showToast(this.t('conversationsSyncEmpty') || 'No conversations found'); return; } - // 获取当前 CID(仅 Gemini Business 有效) + // Get the current CID (valid only for Gemini Business) const currentCid = sidebarItems[0]?.cid || null; - // 检查是否有已保存的会话(初次同步判断) - // 注意:需要按当前 CID 过滤,避免其他团队的数据干扰判断 + // Check if there are saved sessions (first synchronization judgment) + // Note: It is necessary to filter by the current CID to avoid data from other teams interfering with the judgment. const existingConvCount = Object.values(this.data.conversations).filter((c) => this.matchesCid(c, currentCid)).length; const isFirstSync = existingConvCount === 0; - // 初次同步且未指定目标文件夹:弹窗让用户选择 + // First synchronization and no target folder specified: a pop-up window allows the user to choose if (isFirstSync && !targetFolderId) { if (!silent) { this.showFolderSelectDialog((selectedFolderId) => { @@ -5905,42 +5624,42 @@ const folderId = targetFolderId || this.data.lastUsedFolderId || 'inbox'; sidebarItems.forEach((item) => { - // Key 始终用 sessionId(cid 和 siteId 存储在对象属性中) + // Key is always sessionId (cid and siteId are stored in object properties) const storageKey = item.id; const existing = this.data.conversations[storageKey]; if (existing) { - // 更新已有会话的标题(可能被用户修改) + // Update the title of an existing session (may be modified by the user) if (existing.title !== item.title) { existing.title = item.title; existing.updatedAt = now; updatedCount++; } - // 同步云端置顶状态 + // Sync cloud pin status if (item.isPinned && !existing.pinned) { - // 云端置顶 -> 本地也置顶 + // Pin on cloud -> pin on local existing.pinned = true; existing.updatedAt = now; updatedCount++; } else if (!item.isPinned && existing.pinned && this.settings?.conversations?.syncUnpin) { - // 云端未置顶且开启了 syncUnpin -> 本地取消置顶 + // The cloud is not pinned and syncUnpin is turned on -> unpin locally existing.pinned = false; existing.updatedAt = now; updatedCount++; } - // 确保 siteId 和 cid 是最新的 + // Make sure siteId and cid are up to date if (!existing.siteId) existing.siteId = this.siteAdapter.getSiteId(); if (item.cid && !existing.cid) existing.cid = item.cid; } else { - // 新会话:添加到指定文件夹 + // New session: added to specified folder this.data.conversations[storageKey] = { id: item.id, - siteId: this.siteAdapter.getSiteId(), // 记录所属站点 - cid: item.cid || null, // 记录所属团队(Gemini Business) + siteId: this.siteAdapter.getSiteId(), // Record the site it belongs to + cid: item.cid || null, // Record the team you belong to (Gemini Business) title: item.title, url: item.url, folderId: folderId, - pinned: item.isPinned || false, // 同步云端置顶状态 + pinned: item.isPinned || false, // Sync cloud pin status createdAt: now, updatedAt: now, }; @@ -5948,83 +5667,78 @@ } }); - // 记住用户选择 + // Remember user selections if (targetFolderId) { this.data.lastUsedFolderId = targetFolderId; } - // 有变更才保存和刷新 + // Save and refresh only if changes are made if (newCount > 0 || updatedCount > 0) { this.saveData(); this.createUI(); } - // 检查已删除的会话(仅检查当前站点+CID 下的会话) + // Check deleted sessions (only sessions under current site+CID) if (checkForDeletions) { - // 远程会话的 ID 集合 + // A collection of remote session IDs const remoteIds = new Set(sidebarItems.map((item) => item.id)); - // 本地当前站点+CID 的会话 ID(通过对象属性过滤) + // Session ID of local current site + CID (filtered by object properties) const localIdsForCurrentContext = Object.entries(this.data.conversations) .filter(([, conv]) => this.matchesCid(conv, currentCid)) .map(([key]) => key); - // 找出本地有但远程没有的(当前站点+CID 范围内) + // Find out what is available locally but not remotely (within the current site + CID range) const missingIds = localIdsForCurrentContext.filter((id) => !remoteIds.has(id)); if (missingIds.length > 0) { - const msg = (this.t('conversationsSyncDeleteMsg') || '检测到 {count} 个会话已在云端删除,是否同步删除本地记录?').replace('{count}', missingIds.length); - this.showConfirmDialog(this.t('conversationsSyncDeleteTitle') || '同步删除', msg, () => { + const msg = (this.t('conversationsSyncDeleteMsg') || '{count} conversation(s) have been deleted from cloud. Remove local records?').replace('{count}', missingIds.length); + this.showConfirmDialog(this.t('conversationsSyncDeleteTitle') || 'Sync Deletion', msg, () => { missingIds.forEach((id) => delete this.data.conversations[id]); this.saveData(); this.createUI(); - showToast(`${this.t('conversationsDeleted') || '已移除'} ${missingIds.length}`); + showToast(`${this.t('conversationsDeleted') || 'Removed'} ${missingIds.length}`); }); } } if (!silent) { if (newCount > 0 || updatedCount > 0) { - showToast(`${this.t('conversationsSynced') || '同步完成'}:+${newCount} ↻${updatedCount}`); + showToast(`${this.t('conversationsSynced') || 'Synced'}:+${newCount} ↻${updatedCount}`); } else { - showToast(this.t('conversationsSyncNoChange') || '无新会话'); + showToast(this.t('conversationsSyncNoChange') || 'No new conversations'); } } } /** - * 检查会话是否属于当前站点和团队 - * @param {Object} conv 会话对象 - * @param {string|null} currentCid 当前团队 ID (Gemini Business) - * @returns {boolean} - */ + * Check if the session belongs to the current site and team * @param {Object} conv session object * @param {string|null} currentCid Current team ID (Gemini Business) * @returns {boolean} */ matchesCid(conv, currentCid) { - // 1. 首先检查站点匹配 + // 1. First check site matching const currentSiteId = this.siteAdapter.getSiteId(); - // 如果会话有 siteId 且不匹配当前站点,排除 + // If the session has a siteId and does not match the current site, exclude if (conv.siteId && conv.siteId !== currentSiteId) { return false; } - // 2. 检查 CID 匹配 - // 如果当前无 CID(非 Gemini Business 或无团队),显示无 CID 的会话和旧数据 + // 2. Check CID match + // If there is currently no CID (non-Gemini Business or no team), displays sessions and old data without CID if (!currentCid) return !conv.cid; - // 如果会话没有 cid(旧数据),显示它 + // If the session has no cid (old data), show it if (!conv.cid) return true; - // 否则严格匹配 CID + // Otherwise strictly match CID return conv.cid === currentCid; } /** - * 显示文件夹选择对话框 - */ + * Show folder selection dialog*/ showFolderSelectDialog(onSelect) { const overlay = createElement('div', { className: 'conversations-dialog-overlay' }); const dialog = createElement('div', { className: 'conversations-dialog' }); - dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, this.t('conversationsSelectFolder') || '选择同步目标文件夹')); + dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, this.t('conversationsSelectFolder') || 'Select sync folder')); - // 文件夹列表 + // folder list const list = createElement('div', { className: 'conversations-folder-select-list' }); this.data.folders.forEach((folder) => { const item = createElement( @@ -6043,9 +5757,9 @@ }); dialog.appendChild(list); - // 取消按钮 + // Cancel button const btns = createElement('div', { className: 'conversations-dialog-buttons' }); - const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消'); + const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || 'Cancel'); cancelBtn.addEventListener('click', () => overlay.remove()); btns.appendChild(cancelBtn); dialog.appendChild(btns); @@ -6055,8 +5769,7 @@ } /** - * 显示确认对话框 - */ + * Show confirmation dialog*/ showConfirmDialog(title, message, onConfirm) { const overlay = createElement('div', { className: 'conversations-dialog-overlay' }); @@ -6066,14 +5779,14 @@ const msgDiv = createElement('div', { className: 'conversations-dialog-message' }, message); dialog.appendChild(msgDiv); - // 按钮 + // button const btns = createElement('div', { className: 'conversations-dialog-buttons' }); - const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消'); + const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || 'Cancel'); cancelBtn.addEventListener('click', () => overlay.remove()); btns.appendChild(cancelBtn); - const confirmBtn = createElement('button', { className: 'conversations-dialog-btn confirm' }, this.t('confirm') || '确定'); + const confirmBtn = createElement('button', { className: 'conversations-dialog-btn confirm' }, this.t('confirm') || 'Confirm'); confirmBtn.addEventListener('click', () => { overlay.remove(); onConfirm(); @@ -6086,13 +5799,12 @@ } /** - * 创建会话面板 UI - */ + * Create session panel UI*/ createUI() { const container = this.container; clearElement(container); - // 注入样式修复 + // Injection style fixes const fixStyle = createElement('style'); fixStyle.textContent = ` .conversations-folder-item.expanded { @@ -6109,7 +5821,7 @@ border-top: none !important; } - /* 统一工具栏按钮风格 (Ghost Button - 仿大纲 Tab) */ + /* Unify toolbar button style (Ghost Button - imitation outline Tab) */ .conversations-toolbar-btn { background: transparent !important; border: 1px solid transparent !important; @@ -6117,13 +5829,13 @@ color: var(--gh-text-secondary, #6b7280) !important; border-radius: 6px !important; transition: all 0.2s ease !important; - min-width: 28px !important; /* 更紧凑的尺寸 */ + min-width: 28px !important; /* More compact size */ height: 28px !important; - margin: 0 !important; /* 移除额外间距 */ + margin: 0 !important; /* Remove extra spacing */ padding: 0 !important; } .conversations-toolbar-btn:hover { - background: rgba(127, 127, 127, 0.15) !important; /* 通用半透明背景,适配深浅色 */ + background: rgba(127, 127, 127, 0.15) !important; /* Universal translucent background, suitable for dark and light colors */ color: var(--gh-text, #374151) !important; } .conversations-toolbar-btn.active { @@ -6131,10 +5843,10 @@ color: white !important; border-color: var(--gh-primary, #3b82f6) !important; } - /* 修复 SVG 颜色 */ + /* Fix SVG colors */ .conversations-toolbar-btn svg { fill: currentColor !important; - width: 16px !important; /* 稍微调小图标以适配紧凑按钮 */ + width: 16px !important; /* Slightly make the icon smaller to fit the compact button */ height: 16px !important; } `; @@ -6142,17 +5854,17 @@ const content = createElement('div', { className: 'conversations-content' }); - // 工具栏 + // Toolbar const toolbar = createElement('div', { className: 'conversations-toolbar' }); - // 1. 同步目标选择 (左侧) + // 1. Sync target selection (left) const folderSelect = createElement('select', { className: 'conversations-folder-select', id: 'conversations-folder-select', title: this.t('conversationsSelectFolder') || 'Select folder', }); this.data.folders.forEach((folder) => { - // 截断过长的文件夹名称,避免下拉菜单溢出 + // Truncate overly long folder names to prevent drop-down menus from overflowing const truncatedName = folder.name.length > 20 ? folder.name.slice(0, 20) + '...' : folder.name; const option = createElement('option', { value: folder.id, title: folder.name }, truncatedName); if (folder.id === (this.data.lastUsedFolderId || 'inbox')) { @@ -6166,7 +5878,7 @@ }); toolbar.appendChild(folderSelect); - // 定义局部 helper 创建 SVG + // Define local helpers to create SVG const createSVG = (pathData) => { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); @@ -6187,7 +5899,7 @@ 'M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm8.94 3A8.994 8.994 0 0 0 13 3.06V1h-2v2.06A8.994 8.994 0 0 0 3.06 11H1v2h2.06A8.994 8.994 0 0 0 11 20.94V23h2v-2.06A8.994 8.994 0 0 0 20.94 13H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z'; const ADD_FOLDER_PATH = 'M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 8h-3v3h-2v-3h-3v-2h3V9h2v3h3v2z'; - // 2. 同步按钮 + // 2. Sync button const syncBtn = createElement('button', { className: 'conversations-toolbar-btn sync', id: 'conversations-sync-btn', @@ -6209,7 +5921,7 @@ }); toolbar.appendChild(syncBtn); - // 3. 定位当前对话按钮 + // 3. Locate the current conversation button const locateBtn = createElement('button', { className: 'conversations-toolbar-btn locate', id: 'conversations-locate-btn', @@ -6220,10 +5932,10 @@ locateBtn.addEventListener('click', () => this.locateCurrentConversation()); toolbar.appendChild(locateBtn); - // 4. 批量模式按钮 + // 4. Batch mode button const batchModeBtn = createElement('button', { className: 'conversations-toolbar-btn batch-mode' + (this.batchMode ? ' active' : ''), - title: this.t('conversationsBatchMode') || '批量操作', + title: this.t('conversationsBatchMode') || 'Batch Mode', id: 'conversations-batch-mode-btn', style: 'display: flex; align-items: center; justify-content: center;', }); @@ -6231,7 +5943,7 @@ batchModeBtn.addEventListener('click', () => this.toggleBatchMode()); toolbar.appendChild(batchModeBtn); - // 5. 新建文件夹按钮 + // 5. New folder button const addFolderBtn = createElement('button', { className: 'conversations-toolbar-btn add-folder', title: this.t('conversationsAddFolder') || 'New Folder', @@ -6243,7 +5955,7 @@ content.appendChild(toolbar); - // 搜索栏 + // search bar const searchBar = createElement('div', { className: 'conversations-search-bar' }); const searchWrapper = createElement('div', { className: 'conversations-search-wrapper' }); @@ -6251,11 +5963,11 @@ type: 'text', className: 'conversations-search-input', id: 'conversations-search-input', - placeholder: this.t('conversationsSearchPlaceholder') || '搜索会话...', + placeholder: this.t('conversationsSearchPlaceholder') || 'Search conversations...', value: this.searchQuery || '', }); - // 注入 placeholder 防选中样式 + // Inject placeholder anti-selection style const placeholderStyle = document.createElement('style'); placeholderStyle.textContent = ` .conversations-search-input::-webkit-input-placeholder { user-select: none; } @@ -6263,7 +5975,7 @@ `; searchWrapper.appendChild(placeholderStyle); - // 搜索输入防抖处理 + // Search input anti-shake processing let searchTimeout = null; searchInput.addEventListener('input', () => { updateClearBtn(); @@ -6278,12 +5990,12 @@ inputGroup.appendChild(searchInput); searchWrapper.appendChild(inputGroup); - // 置顶筛选按钮 + // Pinned filter button const pinFilterBtn = createElement( 'div', { className: 'conversations-pin-filter-btn' + (this.filterPinned ? ' active' : ''), - title: this.t('conversationsFilterPinned') || '筛选置顶', + title: this.t('conversationsFilterPinned') || 'Filter Pinned', style: 'user-select: none;', }, '📌', @@ -6296,13 +6008,13 @@ }); searchWrapper.appendChild(pinFilterBtn); - // 标签筛选按钮 + // Tag filter button const isTagFiltering = this.filterTagIds && this.filterTagIds.size > 0; const tagFilterBtn = createElement( 'div', { className: 'conversations-tag-search-btn' + (this.data.tags && this.data.tags.length > 0 ? '' : ' empty') + (isTagFiltering ? ' active' : ''), - title: this.t('conversationsFilterByTags') || '按标签筛选', + title: this.t('conversationsFilterByTags') || 'Filter by Tags', style: 'user-select: none;', }, '🏷️', @@ -6326,10 +6038,10 @@ }); const list = createElement('div', { className: 'conversations-tag-filter-list' }); // Scrollable area - // 清除选项 + // Clear options if (this.filterTagIds && this.filterTagIds.size > 0) { const clearItem = createElement('div', { className: 'conversations-tag-filter-item' }); - clearItem.textContent = this.t('conversationsClearTags') || '清除筛选'; + clearItem.textContent = this.t('conversationsClearTags') || 'Clear Filter'; clearItem.addEventListener('click', (e) => { e.stopPropagation(); this.filterTagIds.clear(); @@ -6384,7 +6096,7 @@ className: 'conversations-tag-filter-item', style: 'color:#9ca3af; cursor:default;', }); - emptyItem.textContent = this.t('conversationsNoTags') || '暂无标签'; + emptyItem.textContent = this.t('conversationsNoTags') || 'No Tags'; list.appendChild(emptyItem); } @@ -6394,7 +6106,7 @@ const footer = createElement('div', { className: 'conversations-tag-filter-footer' }); const manageItem = createElement('div', { className: 'conversations-tag-filter-item conversations-tag-filter-action' }); - manageItem.textContent = this.t('conversationsManageTags') || '管理标签'; + manageItem.textContent = this.t('conversationsManageTags') || 'Manage Tags'; manageItem.addEventListener('click', () => { menu.remove(); this.showTagManagerDialog(); @@ -6416,14 +6128,14 @@ }); searchWrapper.appendChild(tagFilterBtn); - // 清空按钮 (Global Clear) - Moved to far right + // Global Clear - Moved to far right // Re-use clearBtn but change its element type/class logic const clearBtn = createElement( 'div', { className: 'conversations-search-clear', // Style updated in CSS id: 'conversations-search-clear', - title: this.t('conversationsClearAll') || '清除所有筛选', + title: this.t('conversationsClearAll') || 'Clear all filters', }, '×', ); @@ -6455,50 +6167,50 @@ searchBar.appendChild(searchWrapper); - // 搜索结果计数条 + // Search result count bar const resultBar = createElement('div', { className: 'conversations-result-bar', id: 'conversations-result-bar', }); if (this.searchQuery && this.searchResult) { - resultBar.textContent = `${this.searchResult.totalCount} ${this.t('conversationsSearchResult') || '个结果'}`; + resultBar.textContent = `${this.searchResult.totalCount} ${this.t('conversationsSearchResult') || 'result(s)'}`; resultBar.classList.add('visible'); } searchBar.appendChild(resultBar); content.appendChild(searchBar); - // 文件夹列表 + // folder list const folderList = this.createFolderListUI(); content.appendChild(folderList); - // 底部批量操作栏(仅批量模式下显示) + // Bottom batch operation bar (only displayed in batch mode) if (this.batchMode) { const batchBar = createElement('div', { className: 'conversations-batch-bar', id: 'conversations-batch-bar', }); - // 根据选中数量决定是否显示 + // Determine whether to display based on the selected number batchBar.style.display = this.selectedIds.size > 0 ? 'flex' : 'none'; const batchInfo = createElement( 'span', { className: 'conversations-batch-info', id: 'conversations-batch-info' }, - (this.t('batchSelected') || '已选 {n} 个').replace('{n}', this.selectedIds.size), + (this.t('batchSelected') || 'Selected {n}').replace('{n}', this.selectedIds.size), ); batchBar.appendChild(batchInfo); const batchBtns = createElement('div', { className: 'conversations-batch-btns' }); - // 统一的图标按钮样式 + // Unified icon button style const iconBtnStyle = 'padding: 4px 6px; min-width: auto; margin-left: 4px;'; - // 1. 复制 Markdown (高频、安全) + // 1. Copy Markdown (high frequency, safe) const batchCopyBtn = createElement( 'button', { className: 'conversations-batch-btn', - title: this.t('exportToClipboard') || '复制 Markdown', + title: this.t('exportToClipboard') || 'Copy Markdown', style: iconBtnStyle, }, '📋', @@ -6506,12 +6218,12 @@ batchCopyBtn.addEventListener('click', () => this.exportConversations('clipboard')); batchBtns.appendChild(batchCopyBtn); - // 2. 导出菜单 (高频、安全) + // 2. Export menu (high frequency, security) const batchExportBtn = createElement( 'button', { className: 'conversations-batch-btn', - title: this.t('batchExport') || '导出', + title: this.t('batchExport') || 'Export', style: iconBtnStyle, }, '📤', @@ -6519,12 +6231,12 @@ batchExportBtn.addEventListener('click', (e) => this.showExportMenu(e.target)); batchBtns.appendChild(batchExportBtn); - // 3. 移动 (管理) + // 3. Mobile (Management) const batchMoveBtn = createElement( 'button', { className: 'conversations-batch-btn', - title: this.t('batchMove') || '移动', + title: this.t('batchMove') || 'Move', style: iconBtnStyle, }, '📂', @@ -6532,12 +6244,12 @@ batchMoveBtn.addEventListener('click', () => this.batchMove()); batchBtns.appendChild(batchMoveBtn); - // 4. 删除 + // 4. Delete const batchDeleteBtn = createElement( 'button', { className: 'conversations-batch-btn danger', - title: this.t('batchDelete') || '删除', + title: this.t('batchDelete') || 'Delete', style: iconBtnStyle, }, '🗑️', @@ -6545,12 +6257,12 @@ batchDeleteBtn.addEventListener('click', () => this.batchDelete()); batchBtns.appendChild(batchDeleteBtn); - // 退出按钮 + // exit button const batchCancelBtn = createElement( 'button', { className: 'conversations-batch-btn cancel', - title: this.t('batchExit') || '退出', + title: this.t('batchExit') || 'Exit', style: iconBtnStyle, }, '❌', @@ -6566,8 +6278,7 @@ } /** - * 创建文件夹列表 UI - */ + * Create folder list UI*/ createFolderListUI() { const container = createElement('div', { className: 'conversations-folder-list' }); @@ -6577,11 +6288,11 @@ return container; } - // 搜索模式下的过滤逻辑 + // Filtering logic in search mode const isSearching = !!this.searchResult; const { folderMatches, conversationMatches, conversationFolderMap } = this.searchResult || {}; - // 计算搜索时哪些文件夹有匹配的会话(需要展开父级) + // Calculate which folders have matching sessions when searching (needs to expand the parent) const foldersWithMatchedConversations = new Set(); if (isSearching && conversationFolderMap) { conversationFolderMap.forEach((folderId) => { @@ -6592,26 +6303,26 @@ let hasVisibleItems = false; this.data.folders.forEach((folder, index) => { - // 搜索过滤:判断文件夹是否应该显示 + // Search filtering: Determine whether a folder should be displayed if (isSearching) { const folderDirectMatch = folderMatches?.has(folder.id); const hasMatchedChildren = foldersWithMatchedConversations.has(folder.id); if (!folderDirectMatch && !hasMatchedChildren) { - return; // 跳过不匹配的文件夹 + return; // Skip unmatched folders } } hasVisibleItems = true; - // 文件夹项 + // folder item const folderItem = this.createFolderItem(folder, index); container.appendChild(folderItem); - // 搜索时:如果有匹配的会话则自动展开,否则只显示文件夹 + // When searching: If there is a matching session, it will be automatically expanded, otherwise only the folder will be displayed. const hasMatchedConvs = isSearching && foldersWithMatchedConversations.has(folder.id); const shouldExpand = isSearching ? hasMatchedConvs : this.expandedFolderId === folder.id; - // 会话列表容器 + // Session list container const conversationList = createElement('div', { className: 'conversations-list', 'data-folder-id': folder.id, @@ -6619,17 +6330,17 @@ }); container.appendChild(conversationList); - // 如果需要展开,渲染会话列表 + // If expanded, render the session list if (shouldExpand) { folderItem.classList.add('expanded'); this.renderConversationList(folder.id, conversationList); } - // 绑定展开逻辑(非搜索模式下或搜索结果中点击可切换) + // Bind expansion logic (can be switched in non-search mode or by clicking in the search results) folderItem.addEventListener('click', (e) => { - if (e.target.closest('button')) return; // 避免点击按钮触发 + if (e.target.closest('button')) return; // Avoid clicking button triggers - // 折叠其他文件夹,并更新记忆 + // Collapse other folders and update memory container.querySelectorAll('.conversations-folder-item.expanded').forEach((el) => { if (el !== folderItem) { el.classList.remove('expanded'); @@ -6639,11 +6350,11 @@ }); const isExpanded = folderItem.classList.toggle('expanded'); - // 记忆展开状态 + // Memory expanded state this.expandedFolderId = isExpanded ? folder.id : null; if (isExpanded) { - // 刷新计数(确保与实际会话数一致,按站点+CID 过滤) + // Refresh count (make sure it matches the actual number of sessions, filter by site+CID) const currentCid = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null; const count = Object.values(this.data.conversations).filter((c) => c.folderId === folder.id && this.matchesCid(c, currentCid)).length; const countSpan = folderItem.querySelector('.conversations-folder-count'); @@ -6657,9 +6368,9 @@ }); }); - // 搜索无结果显示 + // Search results show no results if (isSearching && !hasVisibleItems) { - const noResult = createElement('div', { className: 'conversations-empty' }, this.t('conversationsNoSearchResult') || '未找到匹配结果'); + const noResult = createElement('div', { className: 'conversations-empty' }, this.t('conversationsNoSearchResult') || 'No matching results'); container.appendChild(noResult); } @@ -6667,12 +6378,10 @@ } /** - * 创建单个文件夹项 - * @param {number} index 文件夹在数组中的索引 - */ + * Create a single folder item * @param {number} index The index of the folder in the array*/ createFolderItem(folder, index) { - // 使用 CSS 变量以支持暗色模式 - // 彩虹色开关:默认开启,关闭后使用统一纯色 (--gh-bg) + // Use CSS variables to support dark mode + // Rainbow color switch: On by default, use unified solid color when turned off (--gh-bg) const useRainbow = this.settings.conversations?.folderRainbow !== false; const bgVar = folder.isDefault ? 'var(--gh-folder-bg-default)' : useRainbow ? `var(--gh-folder-bg-${index % 8})` : 'var(--gh-bg)'; @@ -6682,18 +6391,18 @@ style: `background: ${bgVar};`, }); - // 文件夹信息(图标 + 名称) + // Folder information (icon + name) let folderName = folder.name.replace(folder.icon, '').trim(); if (folder.id === 'inbox') { folderName = this.t('inboxName'); } const info = createElement('div', { className: 'conversations-folder-info' }); - // 全选复选框(仅批量模式下显示) + // Select all checkbox (only displayed in batch mode) if (this.batchMode) { - // 获取当前 CID(仅 Gemini Business 有效) + // Get the current CID (valid only for Gemini Business) const currentCid = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null; - // 搜索模式下只处理匹配的会话(同时按 CID 过滤) + // Only process matching sessions in search mode (also filter by CID) let conversationsInFolder = Object.values(this.data.conversations).filter((c) => c.folderId === folder.id && this.matchesCid(c, currentCid)); if (this.searchResult) { conversationsInFolder = conversationsInFolder.filter((c) => this.searchResult.conversationMatches?.has(c.id)); @@ -6712,13 +6421,13 @@ checkbox.addEventListener('click', (e) => e.stopPropagation()); checkbox.addEventListener('change', () => { if (checkbox.checked) { - // 全选(仅匹配项) + // Select all (matches only) conversationsInFolder.forEach((c) => this.selectedIds.add(c.id)); } else { - // 全不选(仅匹配项) + // Select none (matches only) conversationsInFolder.forEach((c) => this.selectedIds.delete(c.id)); } - this.createUI(); // 使用 createUI 重绘以更新状态 + this.createUI(); // Redraw using createUI to update state }); info.appendChild(checkbox); } @@ -6734,7 +6443,7 @@ ), ); - // 文件夹名称(支持搜索高亮) + // Folder name (supports search highlighting) const nameSpan = createElement('span', { className: 'conversations-folder-name', title: folderName, @@ -6746,7 +6455,7 @@ } info.appendChild(nameSpan); - // 上下排序按钮(悬浮时在名称区域右侧显示,不占空间) + // Up and down sort buttons (displayed on the right side of the name area when suspended, taking up no space) if (!folder.isDefault) { const orderBtns = createElement('div', { className: 'conversations-folder-order-btns', @@ -6757,7 +6466,7 @@ 'button', { className: 'conversations-folder-order-btn', - title: this.t('moveUp') || '上移', + title: this.t('moveUp') || 'Move Up', }, '↑', ); @@ -6771,7 +6480,7 @@ 'button', { className: 'conversations-folder-order-btn', - title: this.t('moveDown') || '下移', + title: this.t('moveDown') || 'Move Down', }, '↓', ); @@ -6788,19 +6497,19 @@ item.appendChild(info); - // 右侧控制区域(计数 + 菜单按钮) + // Right control area (count + menu button) const controls = createElement('div', { className: 'conversations-folder-controls' }); - // 获取当前 CID(仅 Gemini Business 有效)- 复用上面的变量或重新获取 + // Get the current CID (only valid for Gemini Business) - reuse the variables above or get it again const cidForCount = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null; - // 会话计数(搜索模式下显示匹配数量,同时按 CID 过滤) + // Session count (shows the number of matches in search mode while filtering by CID) let count = Object.values(this.data.conversations).filter((c) => c.folderId === folder.id && this.matchesCid(c, cidForCount)).length; if (this.searchResult) { count = Object.values(this.data.conversations).filter((c) => c.folderId === folder.id && this.matchesCid(c, cidForCount) && this.searchResult.conversationMatches?.has(c.id)).length; } controls.appendChild(createElement('span', { className: 'conversations-folder-count' }, `(${count})`)); - // 操作菜单按钮(始终渲染以保持对齐,默认文件夹隐藏) + // Action menu button (always rendered to maintain alignment, folder hidden by default) const menuBtn = createElement( 'button', { @@ -6811,7 +6520,7 @@ ); if (folder.isDefault) { menuBtn.style.visibility = 'hidden'; - menuBtn.style.pointerEvents = 'none'; // 避免阻挡点击 + menuBtn.style.pointerEvents = 'none'; // Avoid blocking clicks } else { menuBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -6826,9 +6535,7 @@ } /** - * 获取侧边栏会话顺序 - * @returns {Array} 会话 ID 数组,按侧边栏 DOM 顺序排列 - */ + * Get the sidebar session order * @returns {Array} array of session IDs, arranged in sidebar DOM order*/ getSidebarConversationOrder() { const config = this.siteAdapter.getConversationObserverConfig?.(); if (!config) return []; @@ -6840,18 +6547,17 @@ } /** - * 渲染文件夹下的会话列表 - */ + * Session list under render folder*/ renderConversationList(folderId, container) { clearElement(container); - // 获取当前 CID(仅 Gemini Business 有效) + // Get the current CID (valid only for Gemini Business) const currentCid = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null; - // 获取该文件夹下的会话(按 CID 过滤) + // Get the sessions under this folder (filtered by CID) let conversations = Object.values(this.data.conversations).filter((c) => c.folderId === folderId && this.matchesCid(c, currentCid)); - // 搜索模式下过滤不匹配的会话 + // Filter unmatched sessions in search mode const isSearching = !!this.searchResult; if (isSearching) { const { conversationMatches } = this.searchResult; @@ -6859,24 +6565,24 @@ } if (conversations.length === 0) { - const empty = createElement('div', { className: 'conversations-list-empty' }, this.t('conversationsEmpty') || '暂无会话'); + const empty = createElement('div', { className: 'conversations-list-empty' }, this.t('conversationsEmpty') || 'No conversations yet'); container.appendChild(empty); return; } - // 获取侧边栏顺序 + // Get sidebar order const sidebarOrder = this.getSidebarConversationOrder(); - // 排序:置顶优先,其余按侧边栏顺序 + // Sorting: Top priority, the rest in sidebar order conversations.sort((a, b) => { - // 置顶优先 + // Top priority if (a.pinned && !b.pinned) return -1; if (!a.pinned && b.pinned) return 1; - // 按侧边栏顺序 + // Order by sidebar const indexA = sidebarOrder.indexOf(a.id); const indexB = sidebarOrder.indexOf(b.id); - // 不在侧边栏的放到最后 + // Put items that are not in the sidebar last if (indexA === -1 && indexB === -1) return (b.updatedAt || 0) - (a.updatedAt || 0); if (indexA === -1) return 1; if (indexB === -1) return -1; @@ -6890,8 +6596,7 @@ } /** - * 创建单个会话项 - */ + * Create a single session item*/ createConversationItem(conv) { const item = createElement('div', { className: 'conversations-item', 'data-id': conv.id }); // New Layout: Flex Column @@ -6922,11 +6627,11 @@ title: conv.title, style: 'user-select: none;', }); - // 置顶标识 - const displayTitle = conv.pinned ? `📌 ${conv.title || '无标题'}` : conv.title || '无标题'; + // Top logo + const displayTitle = conv.pinned ? `📌 ${conv.title || 'Untitled'}` : conv.title || 'Untitled'; if (this.searchQuery && this.searchResult?.conversationMatches?.has(conv.id)) { if (conv.pinned) title.appendChild(document.createTextNode('📌 ')); - title.appendChild(this.highlightText(conv.title || '无标题', this.searchQuery)); + title.appendChild(this.highlightText(conv.title || 'Untitled', this.searchQuery)); } else { title.textContent = displayTitle; } @@ -6939,11 +6644,11 @@ } return; } - // 尝试在侧边栏中查找并点击(支持 Shadow DOM 穿透) - // 方法1: 通过 jslog 属性查找(Gemini 标准版) + // Try finding and clicking in the sidebar (supports Shadow DOM penetration) + // Method 1: Search by jslog attribute (Gemini Standard Edition) let sidebarItem = DOMToolkit.query(`.conversation[jslog*="${conv.id}"]`, { shadow: true }); - // 方法2: 遍历所有会话元素,通过菜单按钮 ID 匹配(Gemini Business) - // 注意:closest() 在 Shadow DOM 中可能失效,所以需要遍历 + // Method 2: Traverse all session elements and match by menu button ID (Gemini Business) + // Note: closest() may be invalid in Shadow DOM, so it needs to be traversed if (!sidebarItem) { const conversations = DOMToolkit.query('.conversation', { all: true, shadow: true }); for (const convEl of conversations) { @@ -7014,16 +6719,15 @@ } /** - * 显示会话操作菜单 - */ + * Show session action menu*/ showConversationMenu(conv, anchorEl) { - // 移除已有菜单 + // Remove existing menu document.querySelectorAll('.conversations-item-menu').forEach((m) => m.remove()); const menu = createElement('div', { className: 'conversations-item-menu' }); - // 重命名 - const renameBtn = createElement('button', {}, this.t('conversationsRename') || '重命名'); + // Rename + const renameBtn = createElement('button', {}, this.t('conversationsRename') || 'Rename'); renameBtn.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); @@ -7031,8 +6735,8 @@ }); menu.appendChild(renameBtn); - // 置顶/取消置顶 - const pinText = conv.pinned ? this.t('conversationsUnpin') || '取消置顶' : this.t('conversationsPin') || '📌 置顶'; + // Pin/unpin + const pinText = conv.pinned ? this.t('conversationsUnpin') || 'Unpin' : this.t('conversationsPin') || 'Pin📌'; const pinBtn = createElement('button', {}, pinText); pinBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -7041,8 +6745,8 @@ }); menu.appendChild(pinBtn); - // 设置标签 - const tagBtn = createElement('button', {}, this.t('conversationsSetTags') || '设置标签'); + // Set label + const tagBtn = createElement('button', {}, this.t('conversationsSetTags') || 'Set Tags'); tagBtn.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); @@ -7050,8 +6754,8 @@ }); menu.appendChild(tagBtn); - // 移动到... - const moveBtn = createElement('button', {}, this.t('conversationsMoveTo') || '移动到...'); + // Move to... + const moveBtn = createElement('button', {}, this.t('conversationsMoveTo') || 'Move to...'); moveBtn.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); @@ -7059,8 +6763,8 @@ }); menu.appendChild(moveBtn); - // 删除 - const deleteBtn = createElement('button', { className: 'danger' }, this.t('conversationsDelete') || '删除'); + // Delete + const deleteBtn = createElement('button', { className: 'danger' }, this.t('conversationsDelete') || 'Delete'); deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); @@ -7068,7 +6772,7 @@ }); menu.appendChild(deleteBtn); - // 定位菜单 + // Locate menu const rect = anchorEl.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.top = `${rect.bottom + 4}px`; @@ -7076,7 +6780,7 @@ document.body.appendChild(menu); - // 点击外部关闭 + // Click outside to close const closeHandler = (e) => { if (!menu.contains(e.target) && e.target !== anchorEl) { menu.remove(); @@ -7087,8 +6791,7 @@ } /** - * 切换会话置顶状态 - */ + * Toggle conversation top status*/ toggleConversationPin(conv) { const stored = this.data.conversations[conv.id]; if (!stored) return; @@ -7097,42 +6800,41 @@ stored.updatedAt = Date.now(); this.saveData(); - // 刷新 UI + // Refresh UI this.createUI(); - // 显示提示 - const message = stored.pinned ? this.t('conversationsPinned') || '已置顶' : this.t('conversationsUnpinned') || '已取消置顶'; + // show hint + const message = stored.pinned ? this.t('conversationsPinned') || 'Pinned' : this.t('conversationsUnpinned') || 'Unpinned'; showToast(message); } /** - * 显示重命名会话对话框 - */ + * Show the rename session dialog box*/ showRenameConversationDialog(conv) { const overlay = createElement('div', { className: 'conversations-dialog-overlay' }); const dialog = createElement('div', { className: 'conversations-dialog' }); - // 标题 - dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, this.t('conversationsRename') || '重命名')); + // Title + dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, this.t('conversationsRename') || 'Rename')); - // 输入框区域 + // Input box area const inputSection = createElement('div', { className: 'conversations-dialog-section' }); - inputSection.appendChild(createElement('label', {}, this.t('conversationsFolderName') || '名称')); + inputSection.appendChild(createElement('label', {}, this.t('conversationsFolderName') || 'Name')); const nameInput = createElement('input', { type: 'text', className: 'conversations-dialog-input', value: conv.title || '', - placeholder: this.t('conversationsFolderNamePlaceholder') || '输入会话标题', + placeholder: this.t('conversationsFolderNamePlaceholder') || 'Enter folder name', }); inputSection.appendChild(nameInput); dialog.appendChild(inputSection); - // 按钮 + // button const buttons = createElement('div', { className: 'conversations-dialog-buttons' }); - const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消'); + const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || 'Cancel'); cancelBtn.addEventListener('click', () => overlay.remove()); - const confirmBtn = createElement('button', { className: 'conversations-dialog-btn confirm' }, this.t('confirm') || '确定'); + const confirmBtn = createElement('button', { className: 'conversations-dialog-btn confirm' }, this.t('confirm') || 'Confirm'); confirmBtn.addEventListener('click', () => { const newTitle = nameInput.value.trim(); if (newTitle && newTitle !== conv.title) { @@ -7148,11 +6850,11 @@ overlay.appendChild(dialog); document.body.appendChild(overlay); - // 聚焦并全选 + // Focus and select all nameInput.focus(); nameInput.select(); - // ESC 关闭 + // ESC close overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') overlay.remove(); if (e.key === 'Enter') confirmBtn.click(); @@ -7160,8 +6862,7 @@ } /** - * 重命名会话 - */ + * Rename session*/ renameConversation(convId, newTitle) { const conv = this.data.conversations[convId]; if (!conv) return; @@ -7171,38 +6872,35 @@ conv.updatedAt = Date.now(); this.saveData(); this.createUI(); - showToast(this.t('conversationsFolderRenamed') || '已重命名'); + showToast(this.t('conversationsFolderRenamed') || 'Folder renamed'); - // 根据设置决定是否同步云端 + // Determine whether to sync to the cloud based on settings if (this.settings?.conversations?.syncRenameToCloud) { this.syncRenameToCloud(convId, newTitle, oldTitle); } } /** - * 同步重命名到云端(侧边栏) - */ + * Synchronize rename to cloud (sidebar) */ syncRenameToCloud(convId, newTitle, oldTitle) { - // 尝试在侧边栏找到对应会话并触发重命名 + // Try to find the corresponding session in the sidebar and trigger the rename const sidebarItem = DOMToolkit.query(`.conversation[jslog*="${convId}"]`); if (sidebarItem) { - // 尝试模拟右键菜单或编辑操作 - // 由于侧边栏结构复杂,这里暂时只打印提示 - console.log(`[ConversationManager] 云端同步重命名:${oldTitle} -> ${newTitle}`); - // TODO: 实现实际的侧边栏重命名操作 + // Try to simulate right-click menu or editing operation + // Due to the complex structure of the sidebar, only the prompts are printed here for the time being. + console.log(`[ConversationManager] Cloud sync rename: ${oldTitle} -> ${newTitle}`); + // TODO: Implement actual sidebar renaming operation } } /** - * 确认删除会话 - */ + * Confirm session deletion */ confirmDeleteConversation(conv) { - this.showConfirmDialog(this.t('conversationsDelete') || '删除', `确定删除会话 "${conv.title}" 吗?`, () => this.deleteConversation(conv.id)); + this.showConfirmDialog(this.t('conversationsDelete') || 'Delete', `Are you sure you want to delete conversation "${conv.title}"?`, () => this.deleteConversation(conv.id)); } /** - * 删除会话 - */ + * Delete session*/ deleteConversation(convId) { const conv = this.data.conversations[convId]; if (!conv) return; @@ -7210,29 +6908,27 @@ delete this.data.conversations[convId]; this.saveData(); this.createUI(); - showToast(this.t('conversationsDeleted') || '已删除'); + showToast(this.t('conversationsDeleted') || 'Removed'); - // 根据设置决定是否同步云端删除 + // Determine whether to synchronize cloud deletions based on settings if (this.settings?.conversations?.syncDeleteToCloud) { this.syncDeleteToCloud(convId); } } /** - * 同步删除到云端(侧边栏) - */ + * Synchronize deletions to the cloud (sidebar)*/ syncDeleteToCloud(convId) { - // 尝试在侧边栏找到对应会话并触发删除 + // Try to find the corresponding session in the sidebar and trigger deletion const sidebarItem = DOMToolkit.query(`.conversation[jslog*="${convId}"]`); if (sidebarItem) { - console.log(`[ConversationManager] 云端同步删除会话:${convId}`); - // TODO: 实现实际的侧边栏删除操作 + console.log(`[ConversationManager] Cloud sync delete conversation: ${convId}`); + // TODO: Implement the actual sidebar deletion operation } } /** - * 更新底部批量操作栏状态 - */ + * Update bottom batch operation bar status*/ updateBatchActionBar() { const batchBar = document.getElementById('conversations-batch-bar'); const batchInfo = document.getElementById('conversations-batch-info'); @@ -7241,35 +6937,33 @@ const count = this.selectedIds.size; if (count > 0) { batchBar.style.display = 'flex'; - batchInfo.textContent = (this.t('batchSelected') || '已选 {n} 个').replace('{n}', count); + batchInfo.textContent = (this.t('batchSelected') || 'Selected {n}').replace('{n}', count); } else { batchBar.style.display = 'none'; } } /** - * 定位当前对话 - * 从 URL 获取 sessionId,在会话列表中找到对应项并高亮 - */ + * Locate the current conversation * Get the sessionId from the URL, find the corresponding item in the conversation list and highlight it*/ async locateCurrentConversation() { - // 1. 获取当前会话 ID + // 1. Get the current session ID const sessionId = this.siteAdapter.getSessionId(); if (!sessionId || sessionId === 'default' || sessionId === 'app') { showToast(this.t('conversationsLocateNewChat')); return; } - // 2. 获取当前 CID(仅 Business) + // 2. Get the current CID (Business only) const currentCid = this.siteAdapter.getCurrentCid?.() || null; - // 3. 在数据中查找 + // 3. Find in the data let conv = this.data.conversations[sessionId]; - // 4. 如果没找到,尝试自动同步 + // 4. If not found, try automatic synchronization if (!conv) { showToast(this.t('conversationsLocateNotFound')); - // 获取定位按钮并显示 loading 状态 + // Get the positioning button and display the loading state const locateBtn = this.container.querySelector('#conversations-locate-btn'); const LOCATE_PATH = 'M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm8.94 3A8.994 8.994 0 0 0 13 3.06V1h-2v2.06A8.994 8.994 0 0 0 3.06 11H1v2h2.06A8.994 8.994 0 0 0 11 20.94V23h2v-2.06A8.994 8.994 0 0 0 20.94 13H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z'; @@ -7289,13 +6983,13 @@ locateBtn.appendChild(svg); } - // 执行同步 + // Perform synchronization const folderSelect = this.container.querySelector('#conversations-folder-select'); const targetFolderId = folderSelect?.value || 'inbox'; await this.siteAdapter.loadAllConversations(); this.syncConversations(targetFolderId, true, false); // silent sync - // 恢复按钮状态 + // Restore button state if (locateBtn) { locateBtn.disabled = false; clearElement(locateBtn); @@ -7310,7 +7004,7 @@ locateBtn.appendChild(svg); } - // 再次查找 + // Find again conv = this.data.conversations[sessionId]; if (!conv) { showToast(this.t('conversationsLocateSyncFailed')); @@ -7318,14 +7012,14 @@ } } - // 5. 检查 CID 是否匹配(Business 多团队场景) - // 注意:如果会话没有 cid(旧数据),或者当前不在团队模式,则跳过检查 + // 5. Check whether the CID matches (Business multi-team scenario) + // Note: If the session has no cid (old data), or is not currently in team mode, the check is skipped if (currentCid && conv.cid && conv.cid !== currentCid) { - showToast(this.t('conversationsLocateWrongTeam') || '该对话属于其他团队'); + showToast(this.t('conversationsLocateWrongTeam') || 'This conversation belongs to another team'); return; } - // 6. 展开对应文件夹(仅在需要时重建 UI,避免抖动) + // 6. Expand the corresponding folder (rebuild the UI only when needed to avoid jitter) const targetFolderId = conv.folderId || 'inbox'; const needsExpand = this.expandedFolderId !== targetFolderId; @@ -7334,41 +7028,41 @@ this.createUI(); } - // 7. 延迟执行滚动和高亮(等待 DOM 渲染完成) + // 7. Delay execution of scrolling and highlighting (waiting for DOM rendering to complete) const doHighlight = () => { const item = this.container.querySelector(`.conversations-item[data-id="${sessionId}"]`); if (item) { - // 1. 找到会话所在的内层滚动容器 (.conversations-list) + // 1. Find the inner scrolling container (.conversations-list) where the conversation is located const conversationsList = item.closest('.conversations-list'); if (conversationsList) { - // 滚动内层容器使会话项居中 + // Scroll the inner container to center the session item const itemRect = item.getBoundingClientRect(); const listRect = conversationsList.getBoundingClientRect(); const scrollOffset = itemRect.top - listRect.top - listRect.height / 2 + itemRect.height / 2; conversationsList.scrollBy({ top: scrollOffset, behavior: 'smooth' }); } - // 2. 同时确保外层文件夹列表也滚动到正确位置 + // 2. Also ensure that the outer folder list is also scrolled to the correct position const folderList = this.container.querySelector('.conversations-folder-list'); const folderItem = item.closest('.conversations-folder-item'); if (folderList && folderItem) { const folderRect = folderItem.getBoundingClientRect(); const outerRect = folderList.getBoundingClientRect(); - // 如果文件夹不在可视区域内,滚动到可见位置 + // If the folder is not within the visible area, scroll to the visible position if (folderRect.top < outerRect.top || folderRect.bottom > outerRect.bottom) { - const scrollOffset = folderRect.top - outerRect.top - 20; // 顶部留20px边距 + const scrollOffset = folderRect.top - outerRect.top - 20; // Leave 20px margin at the top folderList.scrollBy({ top: scrollOffset, behavior: 'smooth' }); } } - // 高亮效果 + // Highlight effect item.classList.add('locate-highlight'); setTimeout(() => item.classList.remove('locate-highlight'), 2000); showToast(this.t('conversationsLocateSuccess')); } }; - // 如果重建了 UI,等待下一帧;否则直接执行 + // If the UI is rebuilt, wait for the next frame; otherwise execute directly if (needsExpand) { requestAnimationFrame(doHighlight); } else { @@ -7377,15 +7071,14 @@ } /** - * 切换批量模式 - */ + * Switch batch mode*/ toggleBatchMode() { this.batchMode = !this.batchMode; if (!this.batchMode) { this.selectedIds.clear(); } this.createUI(); - // 恢复之前展开的文件夹 + // Restore a previously expanded folder if (this.expandedFolderId) { const folderHeader = this.container.querySelector(`.conversations-folder-header[data-folder-id="${this.expandedFolderId}"]`); if (folderHeader) folderHeader.click(); @@ -7393,13 +7086,12 @@ } /** - * 清除选中状态并退出批量模式 - */ + * Clear selection and exit batch mode*/ clearSelection() { this.selectedIds.clear(); this.batchMode = false; this.createUI(); - // 恢复之前展开的文件夹 + // Restore a previously expanded folder if (this.expandedFolderId) { const folderHeader = this.container.querySelector(`.conversations-folder-header[data-folder-id="${this.expandedFolderId}"]`); if (folderHeader) folderHeader.click(); @@ -7407,28 +7099,27 @@ } /** - * 批量移动会话 - */ + * Move sessions in bulk*/ batchMove() { if (this.selectedIds.size === 0) return; const overlay = createElement('div', { className: 'conversations-dialog-overlay' }); const dialog = createElement('div', { className: 'conversations-dialog' }); - dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, `移动 ${this.selectedIds.size} 个会话到...`)); + dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, `Move ${this.selectedIds.size} conversation(s) to...`)); - // 搜索框 + 新建文件夹按钮 + // Search box + New folder button const searchRow = createElement('div', { style: 'display: flex; gap: 8px; margin-bottom: 8px; align-items: center;', }); const searchInput = createElement('input', { type: 'text', className: 'conversations-dialog-search', - placeholder: '搜索文件夹...', + placeholder: 'Search folders...', style: 'flex: 1; padding: 8px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 4px; box-sizing: border-box; font-size: 13px;', }); const addFolderBtn = createElement('button', { className: 'conversations-dialog-add-folder-btn', - title: this.t('conversationsAddFolder') || '新建文件夹', + title: this.t('conversationsAddFolder') || 'New Folder', style: 'width: 36px; height: 36px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 4px; background: var(--gh-bg, white); cursor: pointer; display: flex; align-items: center; justify-content: center;', }); const addSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); @@ -7448,10 +7139,10 @@ searchRow.appendChild(addFolderBtn); dialog.appendChild(searchRow); - // 文件夹列表容器 + // folder list container const list = createElement('div', { className: 'conversations-folder-select-list' }); - // 渲染列表函数 + // render list function const renderList = (filter = '') => { clearElement(list); this.data.folders.forEach((folder) => { @@ -7460,7 +7151,7 @@ const item = createElement('div', { className: 'conversations-folder-select-item' }, `${folder.icon} ${folderName}`); item.addEventListener('click', () => { - // 批量移动 + // Batch move this.selectedIds.forEach((convId) => { if (this.data.conversations[convId]) { this.data.conversations[convId].folderId = folder.id; @@ -7477,19 +7168,19 @@ }); }; - // 初始渲染 + // initial rendering renderList(); - // 搜索事件 + // Search events searchInput.addEventListener('input', (e) => { renderList(e.target.value); }); dialog.appendChild(list); - // 取消按钮 + // Cancel button const btns = createElement('div', { className: 'conversations-dialog-buttons' }); - const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消'); + const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || 'Cancel'); cancelBtn.addEventListener('click', () => overlay.remove()); btns.appendChild(cancelBtn); dialog.appendChild(btns); @@ -7500,12 +7191,11 @@ } /** - * 批量删除会话 - */ + * Delete sessions in batches*/ batchDelete() { if (this.selectedIds.size === 0) return; - this.showConfirmDialog('批量删除', `确定删除选中的 ${this.selectedIds.size} 个会话吗?`, () => { + this.showConfirmDialog('Batch Delete', `Are you sure you want to delete the selected ${this.selectedIds.size} conversation(s)?`, () => { const count = this.selectedIds.size; this.selectedIds.forEach((convId) => { if (this.data.conversations[convId]) { @@ -7520,15 +7210,13 @@ } /** - * 显示导出格式选择菜单 - * @param {HTMLElement} anchorEl 锚点元素 - */ + * Display the export format selection menu * @param {HTMLElement} anchorEl anchor element*/ showExportMenu(anchorEl) { - // 移除已有菜单 + // Remove existing menu document.querySelectorAll('.conversations-export-menu').forEach((m) => m.remove()); const menu = createElement('div', { className: 'conversations-export-menu' }); - // 菜单样式 + // Menu style Object.assign(menu.style, { position: 'absolute', background: 'var(--gh-bg, white)', @@ -7540,7 +7228,7 @@ zIndex: '100', }); - // 按钮通用样式 + // Button common style const btnStyle = { display: 'block', width: '100%', @@ -7554,7 +7242,7 @@ color: 'var(--gh-text, #374151)', }; - // Markdown 选项 + // Markdown options const mdBtn = createElement('button', {}, '📝 ' + (this.t('exportToMarkdown') || 'Markdown')); Object.assign(mdBtn.style, btnStyle); mdBtn.addEventListener('mouseenter', () => (mdBtn.style.background = 'var(--gh-bg-hover, #f3f4f6)')); @@ -7565,7 +7253,7 @@ }); menu.appendChild(mdBtn); - // JSON 选项 + // JSON options const jsonBtn = createElement('button', {}, '📋 ' + (this.t('exportToJSON') || 'JSON')); Object.assign(jsonBtn.style, btnStyle); jsonBtn.addEventListener('mouseenter', () => (jsonBtn.style.background = 'var(--gh-bg-hover, #f3f4f6)')); @@ -7576,7 +7264,7 @@ }); menu.appendChild(jsonBtn); - // TXT 选项 + // TXT option const txtBtn = createElement('button', {}, '📄 ' + (this.t('exportToTXT') || 'TXT')); Object.assign(txtBtn.style, btnStyle); txtBtn.addEventListener('mouseenter', () => (txtBtn.style.background = 'var(--gh-bg-hover, #f3f4f6)')); @@ -7587,7 +7275,7 @@ }); menu.appendChild(txtBtn); - // 定位菜单(相对于按钮向上弹出) + // Positioning menu (pops up relative to button) const parentRect = this.container.getBoundingClientRect(); const btnRect = anchorEl.getBoundingClientRect(); menu.style.bottom = `${parentRect.bottom - btnRect.top + 4}px`; @@ -7595,7 +7283,7 @@ this.container.appendChild(menu); - // 点击外部关闭 + // Click outside to close const closeHandler = (e) => { if (!menu.contains(e.target) && e.target !== anchorEl) { menu.remove(); @@ -7606,31 +7294,29 @@ } /** - * 导出选中的会话 - * @param {'markdown'|'json'} format 导出格式 - */ + * Export selected sessions * @param {'markdown'|'json'} format export format*/ async exportConversations(format) { if (this.selectedIds.size === 0) return; - // 目前只支持单个会话导出 + // Currently only single session export is supported const convId = [...this.selectedIds][0]; const conv = this.data.conversations[convId]; if (!conv) { - showToast(this.t('exportNoContent') || '未找到对话内容'); + showToast(this.t('exportNoContent') || 'No conversation content found'); return; } - // 检查是否为当前会话 + // Check if this is the current session const currentSessionId = this.siteAdapter.getSessionId(); if (currentSessionId !== convId) { - showToast(this.t('exportNeedOpenFirst') || '请先打开要导出的会话'); + showToast(this.t('exportNeedOpenFirst') || 'Please open the conversation first'); return; } try { - showToast(this.t('exportLoading') || '正在加载对话历史...'); + showToast(this.t('exportLoading') || 'Loading conversation history...'); - // 加载完整历史(滚动到顶部触发加载) + // Load full history (scroll to top triggers loading) const scrollContainer = this.siteAdapter.getScrollContainer?.(); if (scrollContainer) { let prevHeight = 0; @@ -7652,38 +7338,38 @@ } } - // 提取对话内容 + // Extract conversation content const messages = this.extractConversationMessages(); if (messages.length === 0) { - showToast(this.t('exportNoContent') || '未找到对话内容'); + showToast(this.t('exportNoContent') || 'No conversation content found'); return; } - // 格式化并下载 + // Format and download let content, filename, mimeType; const safeTitle = (conv.title || 'conversation').replace(/[<>:"/\\|?*]/g, '_').substring(0, 50); if (format === 'clipboard') { content = this.formatToMarkdown(conv, messages); - // 处理 blob 图片 (复制时也需要转换为 Base64) + // Process blob images (also need to be converted to Base64 when copying) if (content.includes('](blob:')) { try { - showToast(this.t('exportProcessingImages') || '正在处理图片...'); + showToast(this.t('exportProcessingImages') || 'Processing images...'); content = await this.processMarkdownImages(content); } catch (e) { console.error('Base64 image processing failed:', e); } } await navigator.clipboard.writeText(content); - showToast(this.t('copySuccess') || '已复制到剪贴板'); + showToast(this.t('copySuccess') || 'Copied to clipboard'); return; } else if (format === 'markdown') { content = this.formatToMarkdown(conv, messages); - // 处理 Base64 图片导出 (如果设置开启,或者检测到 blob 图片) + // Handles Base64 image export (if setting is enabled, or blob images are detected) if (this.settings.conversations?.exportImagesToBase64 || content.includes('](blob:')) { try { - showToast(this.t('exportProcessingImages') || '正在处理图片...'); + showToast(this.t('exportProcessingImages') || 'Processing images...'); content = await this.processMarkdownImages(content); } catch (e) { console.error('Base64 image processing failed:', e); @@ -7704,21 +7390,20 @@ } this.downloadFile(content, filename, mimeType); - showToast(this.t('exportSuccess') || '导出成功'); + showToast(this.t('exportSuccess') || 'Export successful'); } catch (error) { console.error('[ConversationManager] Export failed:', error); - showToast(this.t('exportFailed') || '导出失败'); + showToast(this.t('exportFailed') || 'Export failed'); } } /** - * 提取当前页面的对话消息 - * @returns {Array<{role: 'user'|'assistant', content: string}>} + * Extract conversation messages from the current page * @returns {Array<{role: 'user'|'assistant', content: string}>} */ extractConversationMessages() { const messages = []; - // 从 siteAdapter 获取配置 + // Get configuration from siteAdapter const config = this.siteAdapter.getExportConfig?.(); if (!config) { console.warn('[ConversationManager] Export config not available for this site'); @@ -7728,19 +7413,19 @@ const { userQuerySelector, assistantResponseSelector, useShadowDOM, extractUserText, extractAssistantContent } = config; const queryOpts = { all: true, shadow: useShadowDOM }; - // 方案:分别提取用户和 AI 消息 + // Solution: Extract user and AI messages separately const userMessages = DOMToolkit.query(userQuerySelector, queryOpts) || []; const aiMessages = DOMToolkit.query(assistantResponseSelector, queryOpts) || []; const maxLen = Math.max(userMessages.length, aiMessages.length); for (let i = 0; i < maxLen; i++) { if (userMessages[i]) { - // 使用自定义提取函数(如果有),否则使用 textContent + // Use custom extraction function if available, otherwise use textContent const userContent = extractUserText ? extractUserText(userMessages[i]) : userMessages[i].textContent?.trim() || ''; messages.push({ role: 'user', content: userContent }); } if (aiMessages[i]) { - // 使用自定义提取函数获取目标元素(如果有) + // Get the target element (if any) using a custom extraction function let targetEl = aiMessages[i]; if (extractAssistantContent) { targetEl = extractAssistantContent(aiMessages[i]) || aiMessages[i]; @@ -7756,9 +7441,7 @@ } /** - * HTML 转 Markdown - * @param {HTMLElement} el - * @returns {string} + * HTML to Markdown * @param {HTMLElement} el * @returns {string} */ htmlToMarkdown(el) { if (!el) return ''; @@ -7773,17 +7456,17 @@ } // ============================================================ - // 1. 优先处理特殊标签:这些标签不需要递归处理子节点 - // 或者需要完全自定义子节点的处理方式 + // 1. Prioritize processing of special tags: these tags do not require recursive processing of child nodes + // Or you need to completely customize how child nodes are handled // ============================================================ - // 处理数学公式块(从 data-math 属性提取 LaTeX 源码) + // Process math formula blocks (extract LaTeX source code from data-math attribute) if (node.classList?.contains('math-block')) { const latex = node.getAttribute('data-math'); if (latex) return `\n$$${latex}$$\n`; } - // 处理行内数学公式 + // Handle inline math formulas if (node.classList?.contains('math-inline')) { const latex = node.getAttribute('data-math'); if (latex) return `$${latex}$`; @@ -7791,14 +7474,14 @@ const tag = node.tagName.toLowerCase(); - // 图片:直接生成 Markdown,不需要子节点 + // Picture: Directly generate Markdown, no child nodes are needed if (tag === 'img') { - const alt = node.alt || node.getAttribute('alt') || '图片'; + const alt = node.alt || node.getAttribute('alt') || 'Image'; const src = node.src || node.getAttribute('src') || ''; return `![${alt}](${src})`; } - // 代码块容器 (Gemini 特有):手动提取语言和内容,忽略内部结构(避免输出 "Copy" 按钮文本) + // Code block container (Gemini-specific): extract language and content manually, ignore internal structure (avoid outputting "Copy" button text) if (tag === 'code-block') { const decoration = node.querySelector('.code-block-decoration'); const lang = decoration?.querySelector('span')?.textContent?.trim()?.toLowerCase() || ''; @@ -7807,14 +7490,14 @@ return `\n\`\`\`${lang}\n${text}\n\`\`\`\n`; } - // 预格式化块:手动提取 code 内容,忽略子节点递归结果(code-block内的pre会被上面的逻辑拦截,这里处理独立的pre) + // Preformatted block: Manually extract the code content, ignoring the recursive results of child nodes (the pre in the code-block will be intercepted by the above logic, and independent pre is processed here) if (tag === 'pre') { const code = node.querySelector('code'); - // 尝试多种方式获取语言 + // Try various ways to acquire the language let lang = code?.className.match(/language-(\w+)/)?.[1] || ''; if (!lang) { - // 方式2: 向上遍历兄弟元素查找 .code-block-decoration + // Method 2: Traverse sibling elements upwards to find .code-block-decoration let sibling = node.previousElementSibling; while (sibling && !lang) { if (sibling.classList?.contains('code-block-decoration')) { @@ -7826,7 +7509,7 @@ } if (!lang) { - // 方式3: 在父容器中查找 .code-block-decoration + // Method 3: Find .code-block-decoration in the parent container const parent = node.parentElement; const decoration = parent?.querySelector('.code-block-decoration'); if (decoration) { @@ -7838,37 +7521,37 @@ return `\n\`\`\`${lang}\n${text}\n\`\`\`\n`; } - // 内联代码:简单包裹,忽略子元素(通常没子元素,或者是高亮span) + // Inline code: simple wrapping, ignoring child elements (usually no child elements, or highlighted span) if (tag === 'code') { - // 如果父元素是 pre,返回空字符串(因为内容已被 pre 处理,且我们即将返回 children 拼接结果) - // 但这里我们在计算 children 之前就拦截了。 - // 修正逻辑:如果父元素是 pre,则该 code 节点不需要再输出(因为父 pre 已经提取了它的 textContent) + // If the parent element is pre, return an empty string (because the content has been pre processed and we are about to return the children splicing result) + // But here we intercept children before calculating them. + // Corrected logic: If the parent element is pre, the code node does not need to be output anymore (because the parent pre has already extracted its textContent) if (node.parentElement?.tagName.toLowerCase() === 'pre') return ''; return `\`${node.textContent}\``; } - // 表格:完全自定义子节点处理逻辑 + // Table: Completely customizable child node processing logic if (tag === 'table') { const rows = []; const thead = node.querySelector('thead'); const tbody = node.querySelector('tbody'); - // 辅助函数:从单元格提取内容(处理 Shadow DOM) + // Helper function: Extract content from cells (handling Shadow DOM) const getCellContent = (cell) => { - // 如果单元格有 Shadow DOM,递归处理 + // If the cell has Shadow DOM, process it recursively if (cell.shadowRoot) { return Array.from(cell.shadowRoot.childNodes).map(processNode).join('').replace(/\n/g, ' ').trim(); } - // 尝试用 htmlToMarkdown 处理 + // Try handling it with htmlToMarkdown const md = this.htmlToMarkdown(cell); if (md && md.trim()) { return md.replace(/\n/g, ' ').trim(); } - // 回退:使用 textContent + // Fallback: use textContent return cell.textContent?.trim() || ''; }; - // 处理表头 + // Process header if (thead) { const headerRow = thead.querySelector('tr'); if (headerRow) { @@ -7880,7 +7563,7 @@ } } - // 处理表体 + // Process table body if (tbody) { const bodyRows = tbody.querySelectorAll('tr'); bodyRows.forEach((tr) => { @@ -7891,7 +7574,7 @@ }); } - // 如果没有 thead/tbody,直接遍历所有 tr + // If there is no thead/tbody, directly traverse all tr if (!thead && !tbody) { const allRows = node.querySelectorAll('tr'); let isFirst = true; @@ -7910,35 +7593,35 @@ return rows.length > 0 ? '\n' + rows.join('\n') + '\n' : ''; } - // Gemini 表格容器:直接处理内部表格,忽略其他可能的装饰元素 + // Gemini table container: handles the inner table directly, ignoring other possible decorative elements if (tag === 'table-block') { const innerTable = node.querySelector('table'); if (innerTable) { return processNode(innerTable); } - // 如果没找到 table,则退化为处理所有子节点 + // If the table is not found, it degrades to processing all child nodes. } - // Gemini Business 表格容器 + // Gemini Business table container if (tag === 'ucs-markdown-table') { const innerTable = node.querySelector('table'); if (innerTable) { return processNode(innerTable); } - // 如果没找到 table,则退化为处理所有子节点 + // If the table is not found, it degrades to processing all child nodes. } - // 表格内部标签:由于 table 已经手动处理了 thead/tbody/tr/td, - // 如果递归遍历到了这些标签(例如 table-block 没有拦截住,或者非标准结构的表格), - // 我们应该只返回子节点内容,或者什么都不做以免破坏表格结构。 - // 暂时按返回子节点内容处理。 + // Table internal tags: Since table has manually processed thead/tbody/tr/td, + // If these tags are traversed recursively (for example, table-block is not intercepted, or the table has a non-standard structure), + // We should only return the child node content, or do nothing to avoid destroying the table structure. + // For the time being, return the content of the child node. if (['thead', 'tbody', 'tr', 'td', 'th'].includes(tag)) { - // 这些通常在 table 的处理逻辑中被 htmlToMarkdown(cell) 调用 - // 这里只需要返回 children 拼接结果即可(保留内部格式如 b/i) + // These are usually called by htmlToMarkdown(cell) in the table's processing logic. + // Here you only need to return the children splicing result (retain the internal format such as b/i) } // ============================================================ - // 2. 常规标签:递归处理子节点,然后包裹格式 + // 2. Regular tags: recursively process child nodes and then wrap the format // ============================================================ const children = Array.from(node.childNodes).map(processNode).join(''); @@ -7975,11 +7658,11 @@ case 'ol': return `\n${children}`; default: - // 处理带 Shadow DOM 的自定义元素(如 Gemini Business 的 ucs-* 组件) + // Handle custom elements with Shadow DOM (such as Gemini Business's ucs-* components) if (node.shadowRoot) { return Array.from(node.shadowRoot.childNodes).map(processNode).join(''); } - // 对于不匹配的标签(如 div, span, table-block 等),直接返回内容 + // For unmatched tags (such as div, span, table-block, etc.), the content is returned directly return children; } }; @@ -7988,24 +7671,23 @@ } /** - * 格式化为 Markdown - */ + * Formatted as Markdown*/ formatToMarkdown(conv, messages) { const lines = []; const now = new Date().toLocaleString(); - const userLabel = this.t('exportUserLabel') || '用户'; + const userLabel = this.t('exportUserLabel') || 'User'; - // 元数据头 + // metadata header lines.push('---'); - lines.push(`# 📤 ${this.t('exportMetaTitle') || '导出信息'}`); - lines.push(`- **${this.t('exportMetaConvTitle') || '会话标题'}**: ${conv.title || '未命名'}`); - lines.push(`- **${this.t('exportMetaTime') || '导出时间'}**: ${now}`); - lines.push(`- **${this.t('exportMetaSource') || '来源'}**: ${this.siteAdapter.getName()}`); - lines.push(`- **${this.t('exportMetaUrl') || '链接'}**: ${window.location.href}`); + lines.push(`# 📤 ${this.t('exportMetaTitle') || 'Export Info'}`); + lines.push(`- **${this.t('exportMetaConvTitle') || 'Conversation Title'}**: ${conv.title || 'Untitled'}`); + lines.push(`- **${this.t('exportMetaTime') || 'Export Time'}**: ${now}`); + lines.push(`- **${this.t('exportMetaSource') || 'Source'}**: ${this.siteAdapter.getName()}`); + lines.push(`- **${this.t('exportMetaUrl') || 'URL'}**: ${window.location.href}`); lines.push('---'); lines.push(''); - // 对话内容 + // Conversation content messages.forEach((msg) => { if (msg.role === 'user') { lines.push(`## 🙋 ${userLabel}`); @@ -8028,12 +7710,11 @@ } /** - * 格式化为 JSON - */ + * Format to JSON*/ formatToJSON(conv, messages) { const data = { metadata: { - title: conv.title || '未命名', + title: conv.title || 'Untitled', id: conv.id, url: window.location.href, exportTime: new Date().toISOString(), @@ -8048,23 +7729,22 @@ } /** - * 格式化为 TXT(纯文本) - */ + * Format to TXT (plain text)*/ formatToTXT(conv, messages) { const lines = []; const now = new Date().toLocaleString(); - const userLabel = this.t('exportUserLabel') || '用户'; + const userLabel = this.t('exportUserLabel') || 'User'; - // 元数据 - lines.push(`${this.t('exportMetaConvTitle') || '会话标题'}: ${conv.title || '未命名'}`); - lines.push(`${this.t('exportMetaTime') || '导出时间'}: ${now}`); - lines.push(`${this.t('exportMetaSource') || '来源'}: ${this.siteAdapter.getName()}`); - lines.push(`${this.t('exportMetaUrl') || '链接'}: ${window.location.href}`); + // metadata + lines.push(`${this.t('exportMetaConvTitle') || 'Conversation Title'}: ${conv.title || 'Untitled'}`); + lines.push(`${this.t('exportMetaTime') || 'Export Time'}: ${now}`); + lines.push(`${this.t('exportMetaSource') || 'Source'}: ${this.siteAdapter.getName()}`); + lines.push(`${this.t('exportMetaUrl') || 'URL'}: ${window.location.href}`); lines.push(''); lines.push('='.repeat(50)); lines.push(''); - // 对话内容 + // Conversation content messages.forEach((msg) => { if (msg.role === 'user') { lines.push(`[${userLabel}]`); @@ -8081,9 +7761,7 @@ } /** - * 处理 Markdown 中的图片链接,转换为 Base64 - * @param {string} markdownContent - * @returns {Promise} + * Process image links in Markdown and convert them to Base64 * @param {string} markdownContent * @returns {Promise} */ async processMarkdownImages(markdownContent) { const imgRegex = /!\[(.*?)\]\((.*?)\)/g; @@ -8093,16 +7771,16 @@ let newContent = markdownContent; - // 使用并行处理加快速度 + // Use parallel processing to speed up const processingPromises = matches.map(async (match) => { const [fullMatch, alt, url] = match; - // 跳过已经是 Base64 的图片 + // Skip images that are already Base64 if (url.startsWith('data:image')) return { fullMatch, base64: null }; const isBlob = url.startsWith('blob:'); - // 如果不是 blob 且未开启 Base64 导出,则跳过 + // If it is not a blob and Base64 export is not enabled, skip if (!isBlob && !this.settings.conversations?.exportImagesToBase64) { return { fullMatch, base64: null }; } @@ -8110,7 +7788,7 @@ try { let blob; if (isBlob) { - // Blob URL: 从 DOM 中找到已加载的图片,用 canvas 提取数据 + // Blob URL: Find the loaded image from the DOM and use canvas to extract the data const imgEl = document.querySelector(`img[src="${url}"]`); if (!imgEl || !imgEl.complete || imgEl.naturalWidth === 0) { console.warn(`Image not found or not loaded: ${url}`); @@ -8124,7 +7802,7 @@ const base64 = canvas.toDataURL('image/png'); return { fullMatch, base64, alt }; } else { - // 远程 URL: 使用 GM_xmlhttpRequest 跨域获取图片 + // Remote URL: Use GM_xmlhttpRequest to obtain images across domains const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', @@ -8140,7 +7818,7 @@ blob = response; } - // Blob 转 Base64 + // Blob to Base64 const base64 = await new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); @@ -8156,10 +7834,10 @@ const results = await Promise.all(processingPromises); - // 替换内容 + // Replace content results.forEach(({ fullMatch, base64, alt }) => { if (base64) { - // 使用 split/join 替换所有相同的匹配项(处理同一图片多次引用) + // Use split/join to replace all identical matches (handle multiple references to the same image) newContent = newContent.split(fullMatch).join(`![${alt}](${base64})`); } }); @@ -8168,8 +7846,7 @@ } /** - * 下载文件 - */ + * Download file*/ downloadFile(content, filename, mimeType) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); @@ -8183,17 +7860,14 @@ } /** - * 创建标签 - * @param {string} name 标签名称 - * @param {string} color 标签颜色 - */ + * Create label * @param {string} name tag name * @param {string} color label color*/ createTag(name, color) { if (!this.data.tags) this.data.tags = []; // Check duplicate const exists = this.data.tags.some((t) => t.name.toLowerCase() === name.toLowerCase()); if (exists) { - showToast(this.t('conversationsTagExists') || '标签名称已存在'); + showToast(this.t('conversationsTagExists') || 'Tag name already exists'); return null; } @@ -8208,18 +7882,14 @@ } /** - * 更新标签 - * @param {string} tagId 标签ID - * @param {string} name 标签名称 - * @param {string} color 标签颜色 - */ + * Update label * @param {string} tagId tag ID * @param {string} name tag name * @param {string} color label color*/ updateTag(tagId, name, color) { if (!this.data.tags) return null; // Check duplicate (exclude self) const exists = this.data.tags.some((t) => t.id !== tagId && t.name.toLowerCase() === name.toLowerCase()); if (exists) { - showToast(this.t('conversationsTagExists') || '标签名称已存在'); + showToast(this.t('conversationsTagExists') || 'Tag name already exists'); return null; } @@ -8233,15 +7903,13 @@ } /** - * 删除标签 - * @param {string} tagId 标签ID - */ + * Delete tag * @param {string} tagId tag ID */ deleteTag(tagId) { if (!this.data.tags) return; - // 1. 删除标签定义 + // 1. Delete label definition this.data.tags = this.data.tags.filter((t) => t.id !== tagId); - // 2. 从所有会话中移除该标签引用 + // 2. Remove the tag reference from all sessions Object.values(this.data.conversations).forEach((conv) => { if (conv.tagIds) { conv.tagIds = conv.tagIds.filter((id) => id !== tagId); @@ -8253,10 +7921,7 @@ } /** - * 设置会话标签 - * @param {string} convId 会话ID - * @param {Array} tagIds 标签ID数组 - */ + * Set session tag * @param {string} convId session ID * @param {Array} tagIds tag ID array*/ setConversationTags(convId, tagIds) { if (this.data.conversations[convId]) { if (tagIds && tagIds.length > 0) { @@ -8269,27 +7934,26 @@ } /** - * 显示移动到文件夹对话框 - */ + * Shows the Move to Folder dialog box*/ showMoveToFolderDialog(conv) { const overlay = createElement('div', { className: 'conversations-dialog-overlay' }); const dialog = createElement('div', { className: 'conversations-dialog' }); - dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, this.t('conversationsMoveTo') || '移动到...')); + dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, this.t('conversationsMoveTo') || 'Move to...')); - // 搜索框 + 新建文件夹按钮 + // Search box + New folder button const searchRow = createElement('div', { style: 'display: flex; gap: 8px; margin-bottom: 8px; align-items: center;', }); const searchInput = createElement('input', { type: 'text', className: 'conversations-dialog-search', - placeholder: '搜索文件夹...', + placeholder: 'Search folders...', style: 'flex: 1; padding: 8px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 4px; box-sizing: border-box; font-size: 13px;', }); const addFolderBtn = createElement('button', { className: 'conversations-dialog-add-folder-btn', - title: this.t('conversationsAddFolder') || '新建文件夹', + title: this.t('conversationsAddFolder') || 'New Folder', style: 'width: 36px; height: 36px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 4px; background: var(--gh-bg, white); cursor: pointer; display: flex; align-items: center; justify-content: center;', }); const addSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); @@ -8309,14 +7973,14 @@ searchRow.appendChild(addFolderBtn); dialog.appendChild(searchRow); - // 文件夹列表 + // folder list const list = createElement('div', { className: 'conversations-folder-select-list' }); - // 渲染列表函数 + // render list function const renderList = (filter = '') => { clearElement(list); this.data.folders.forEach((folder) => { - // 排除当前所在文件夹 + // Exclude the current folder if (folder.id === conv.folderId) return; const folderName = folder.name.replace(folder.icon, '').trim(); @@ -8331,31 +7995,31 @@ `${folder.icon} ${folderName}`, ); item.addEventListener('click', () => { - // 移动会话 + // mobile session this.data.conversations[conv.id].folderId = folder.id; this.data.conversations[conv.id].updatedAt = Date.now(); this.saveData(); this.createUI(); overlay.remove(); - showToast((this.t('conversationsMoved') || '已移动到') + ` ${folder.name}`); + showToast((this.t('conversationsMoved') || 'Moved to') + ` ${folder.name}`); }); list.appendChild(item); }); }; - // 初始渲染 + // initial rendering renderList(); - // 搜索事件 + // Search events searchInput.addEventListener('input', (e) => { renderList(e.target.value); }); dialog.appendChild(list); - // 取消按钮 + // Cancel button const btns = createElement('div', { className: 'conversations-dialog-buttons' }); - const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消'); + const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || 'Cancel'); cancelBtn.addEventListener('click', () => overlay.remove()); btns.appendChild(cancelBtn); dialog.appendChild(btns); @@ -8366,39 +8030,37 @@ } /** - * 格式化时间显示 - */ + * Format time display*/ formatTime(timestamp) { if (!timestamp) return ''; const date = new Date(timestamp); const now = new Date(); const diff = now - date; - if (diff < 60000) return this.t('justNow') || '刚刚'; - if (diff < 3600000) return Math.floor(diff / 60000) + (this.t('minutesAgo') || '分钟前'); - if (diff < 86400000) return Math.floor(diff / 3600000) + (this.t('hoursAgo') || '小时前'); - if (diff < 604800000) return Math.floor(diff / 86400000) + (this.t('daysAgo') || '天前'); + if (diff < 60000) return this.t('justNow') || 'Just now'; + if (diff < 3600000) return Math.floor(diff / 60000) + (this.t('minutesAgo') || 'm ago'); + if (diff < 86400000) return Math.floor(diff / 3600000) + (this.t('hoursAgo') || 'h ago'); + if (diff < 604800000) return Math.floor(diff / 86400000) + (this.t('daysAgo') || 'd ago'); return date.toLocaleDateString(); } /** - * 显示文件夹操作菜单 - */ + * Show folder operation menu*/ showFolderMenu(folder, anchorEl) { - // 移除已有菜单 + // Remove existing menu document.querySelectorAll('.conversations-folder-menu').forEach((m) => m.remove()); const menu = createElement('div', { className: 'conversations-folder-menu' }); - const renameBtn = createElement('button', {}, this.t('conversationsRename') || '重命名'); + const renameBtn = createElement('button', {}, this.t('conversationsRename') || 'Rename'); renameBtn.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); this.showRenameFolderDialog(folder); }); - const deleteBtn = createElement('button', { style: 'color: #ef4444;' }, this.t('conversationsDelete') || '删除'); + const deleteBtn = createElement('button', { style: 'color: #ef4444;' }, this.t('conversationsDelete') || 'Delete'); deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); @@ -8408,7 +8070,7 @@ menu.appendChild(renameBtn); menu.appendChild(deleteBtn); - // 定位菜单 + // Locate menu const rect = anchorEl.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.top = `${rect.bottom + 4}px`; @@ -8416,7 +8078,7 @@ document.body.appendChild(menu); - // 点击外部关闭 + // Click outside to close const closeMenu = (e) => { if (!menu.contains(e.target)) { menu.remove(); @@ -8427,87 +8089,83 @@ } /** - * 显示新建文件夹对话框 - */ + * Show new folder dialog*/ showCreateFolderDialog() { this.showFolderDialog({ - title: this.t('conversationsAddFolder') || '新建文件夹', + title: this.t('conversationsAddFolder') || 'New Folder', icon: '📁', name: '', onConfirm: (name, icon) => { if (name.trim()) { this.createFolder(name.trim(), icon); - this.createUI(); // 刷新 UI - showToast(this.t('conversationsFolderCreated') || '文件夹已创建'); + this.createUI(); // Refresh UI + showToast(this.t('conversationsFolderCreated') || 'Folder created'); } }, }); } /** - * 显示重命名文件夹对话框 - */ + * Show the rename folder dialog box */ showRenameFolderDialog(folder) { const currentName = folder.name.replace(folder.icon, '').trim(); this.showFolderDialog({ - title: this.t('conversationsRename') || '重命名文件夹', + title: this.t('conversationsRename') || 'Rename', icon: folder.icon, name: currentName, onConfirm: (name, icon) => { if (name.trim()) { this.renameFolder(folder.id, name.trim(), icon); - this.createUI(); // 刷新 UI - showToast(this.t('conversationsFolderRenamed') || '文件夹已重命名'); + this.createUI(); // Refresh UI + showToast(this.t('conversationsFolderRenamed') || 'Folder renamed'); } }, }); } /** - * 确认删除文件夹 - */ + * Confirm deletion of folder*/ confirmDeleteFolder(folder) { - this.showConfirmDialog(this.t('conversationsDelete') || '删除', this.t('conversationsDeleteConfirm') || `确定删除文件夹 "${folder.name}" 吗?其中的会话将移到收件箱。`, () => { + this.showConfirmDialog(this.t('conversationsDelete') || 'Delete', this.t('conversationsDeleteConfirm') || `Are you sure you want to delete folder "${folder.name}"? Conversations inside will be moved to Inbox.`, () => { if (this.deleteFolder(folder.id)) { - this.createUI(); // 刷新 UI - showToast(this.t('conversationsFolderDeleted') || '文件夹已删除'); + this.createUI(); // Refresh UI + showToast(this.t('conversationsFolderDeleted') || 'Folder deleted'); } }); } /** - * 通用文件夹对话框(新建/重命名复用) - */ + * Common folder dialog (new/rename reuse) */ showFolderDialog({ title, icon, name, onConfirm }) { const overlay = createElement('div', { className: 'conversations-dialog-overlay' }); const dialog = createElement('div', { className: 'conversations-dialog' }); - // 标题 + // Title dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, title)); - // Emoji 选择器 + // Emoji selector const emojiSection = createElement('div', { className: 'conversations-dialog-section' }); - emojiSection.appendChild(createElement('label', {}, this.t('conversationsIcon') || '图标')); + emojiSection.appendChild(createElement('label', {}, this.t('conversationsIcon') || 'Icon')); const emojiPicker = this.createEmojiPicker(icon); emojiSection.appendChild(emojiPicker); dialog.appendChild(emojiSection); - // 名称输入 + // Name input const nameSection = createElement('div', { className: 'conversations-dialog-section' }); - nameSection.appendChild(createElement('label', {}, this.t('conversationsFolderName') || '名称')); + nameSection.appendChild(createElement('label', {}, this.t('conversationsFolderName') || 'Name')); const nameInput = createElement('input', { type: 'text', className: 'conversations-dialog-input', value: name, - placeholder: this.t('conversationsFolderNamePlaceholder') || '输入文件夹名称', + placeholder: this.t('conversationsFolderNamePlaceholder') || 'Enter folder name', }); nameSection.appendChild(nameInput); dialog.appendChild(nameSection); - // 按钮 + // button const buttons = createElement('div', { className: 'conversations-dialog-buttons' }); - const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消'); - const confirmBtn = createElement('button', { className: 'conversations-dialog-btn confirm' }, this.t('confirm') || '确定'); + const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || 'Cancel'); + const confirmBtn = createElement('button', { className: 'conversations-dialog-btn confirm' }, this.t('confirm') || 'Confirm'); cancelBtn.addEventListener('click', () => overlay.remove()); confirmBtn.addEventListener('click', () => { @@ -8524,16 +8182,16 @@ overlay.appendChild(dialog); document.body.appendChild(overlay); - // 聚焦输入框 + // Focus input box nameInput.focus(); - // 点击遮罩关闭 (智能行为:有输入则保存,无输入则关闭) + // Click the mask to close (intelligent behavior: save if there is input, close if there is no input) overlay.addEventListener('click', (e) => { if (e.target === overlay) { const name = nameInput.value.trim(); - // 这里我们复用 confirmBtn 的逻辑,因为 confirmBtn 里也只是调用 onConfirm - // 但我们需要判断是否有效。 - // 用户的要求:输入了->新建/编辑;没有输入->关闭 + // Here we reuse the logic of confirmBtn, because confirmBtn only calls onConfirm + // But we need to judge whether it is effective. + // User's request: input->New/Edit; no input->Close if (name) { confirmBtn.click(); } else { @@ -8542,7 +8200,7 @@ } }); - // ESC 关闭,Enter 确定 + // ESC to close, Enter to confirm overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') overlay.remove(); if (e.key === 'Enter') confirmBtn.click(); @@ -8550,27 +8208,26 @@ } /** - * 创建 Emoji 选择器 (增强版) - */ + * Create an Emoji picker (enhanced version)*/ createEmojiPicker(selectedEmoji = '📁') { const container = createElement('div', { className: 'conversations-emoji-picker', style: 'display: flex; flex-direction: column; gap: 8px;', }); - // 1. 自定义输入区域 + // 1. Custom input area const customRow = createElement('div', { className: 'conversations-emoji-custom-row', style: 'display: flex; align-items: center; gap: 8px; padding: 4px; background: var(--gh-bg-secondary, #f9fafb); border-radius: 4px; border: 1px solid var(--gh-border, #e5e7eb);', }); - const customLabel = createElement('span', { style: 'font-size: 12px; color: var(--gh-text-secondary, #6b7280); flex-shrink: 0;' }, this.t('conversationsCustomIcon') || '自定义:'); + const customLabel = createElement('span', { style: 'font-size: 12px; color: var(--gh-text-secondary, #6b7280); flex-shrink: 0;' }, this.t('conversationsCustomIcon') || 'Custom Icon'); const customInput = createElement('input', { type: 'text', className: 'conversations-emoji-custom-input', value: selectedEmoji, - maxLength: 4, // 稍微放宽长度 + maxLength: 4, // slightly wider length placeholder: '☺', style: 'width: 60px; text-align: center; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 4px; padding: 2px; font-size: 16px; background: var(--gh-input-bg, #ffffff); color: var(--gh-text, #1f2937);', }); @@ -8579,7 +8236,7 @@ customRow.appendChild(customInput); container.appendChild(customRow); - // 2. 预设列表区域 + // 2. Default list area const listContainer = createElement('div', { className: 'conversations-emoji-list', style: 'display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px; max-height: 120px; overflow-y: auto; padding: 2px; scrollbar-width: none; -ms-overflow-style: none;', @@ -8589,9 +8246,9 @@ hideScrollStyle.textContent = `.conversations-emoji-list::-webkit-scrollbar { display: none; }`; listContainer.appendChild(hideScrollStyle); - // 扩充的预设 Emoji 库 (64个) + // Expanded library of preset Emojis (64) const presetEmojis = [ - // 📂 基础文件夹 + // 📂 Basic folder '📁', '📂', '📥', @@ -8600,7 +8257,7 @@ '📈', '📉', '📋', - // 💼 办公/工作 + // 💼 Office/Work '💼', '📅', '📌', @@ -8609,7 +8266,7 @@ '✒️', '🔍', '💡', - // 💻 编程/技术 + // 💻 Programming/Technology '💻', '⌨️', '🖥️', @@ -8618,7 +8275,7 @@ '🔧', '🔨', '⚙️', - // 🤖 AI/机器人 + // 🤖 AI/Robot '🤖', '👾', '🧠', @@ -8627,7 +8284,7 @@ '✨', '🎓', '📚', - // 🎨 创意/艺术 + // 🎨Creativity/Art '🎨', '🎭', '🎬', @@ -8636,7 +8293,7 @@ '📷', '🖌️', '🖍️', - // 🏠 生活/日常 + // 🏠 Life/daily life '🏠', '🛒', '✈️', @@ -8645,7 +8302,7 @@ '🍔', '☕', '❤️', - // 🌈 颜色/标记 + // 🌈 Color/Marking '🔴', '🟠', '🟡', @@ -8654,7 +8311,7 @@ '🟣', '⚫', '⚪', - // ⭐ 其他 + // ⭐ Others '⭐', '🌟', '🎉', @@ -8665,7 +8322,7 @@ '❓', ]; - // 选中状态管理 + // Check status management let currentSelectedBtn = null; presetEmojis.forEach((emoji) => { @@ -8681,24 +8338,24 @@ if (emoji === selectedEmoji) currentSelectedBtn = btn; btn.addEventListener('click', (e) => { - e.preventDefault(); // 防止触发表单提交等意外行为 + e.preventDefault(); // Prevent unexpected behavior such as triggering form submissions - // 更新按钮选中状态 + // Update button selected state if (currentSelectedBtn) { currentSelectedBtn.classList.remove('selected'); currentSelectedBtn.style.backgroundColor = 'transparent'; } btn.classList.add('selected'); - btn.style.backgroundColor = '#dbeafe'; // 浅蓝背景表示选中 + btn.style.backgroundColor = '#dbeafe'; // Light blue background indicates selection currentSelectedBtn = btn; - // 同步到自定义输入框 + // Synchronize to custom input box customInput.value = emoji; - // 标记为用户手动选择的 (通过 class,供外部获取值时优先使用输入框的值) + // Marked as manually selected by the user (through class, the value of the input box will be used first when obtaining the value externally) customInput.classList.add('selected'); }); - // Hover 效果 + // Hover effect btn.onmouseenter = () => { if (!btn.classList.contains('selected')) btn.style.backgroundColor = 'var(--gh-hover, #f3f4f6)'; }; @@ -8711,25 +8368,25 @@ container.appendChild(listContainer); - // 自定义输入监听 + // Custom input monitoring customInput.addEventListener('input', (e) => { let val = e.target.value; - // 简单的 Emoji 校验:利用 Unicode 属性 \p{Extended_Pictographic} + // Simple Emoji verification: using the Unicode attribute \p{Extended_Pictographic} const emojiRegex = /[^\p{Extended_Pictographic}\u200d\ufe0f]/gu; if (val && emojiRegex.test(val)) { val = val.replace(emojiRegex, ''); e.target.value = val; } - // 清除按钮选中状态,因为现在是自定义的 + // Clear button selected state since it is now custom if (currentSelectedBtn) { currentSelectedBtn.classList.remove('selected'); currentSelectedBtn.style.backgroundColor = 'transparent'; currentSelectedBtn = null; } - // 尝试反向匹配:如果输入的内容刚好在预设里,把那个按钮高亮 + // Try reverse matching: if the input content happens to be in the preset, highlight that button const matchBtn = Array.from(listContainer.children).find((b) => b.textContent === val); if (matchBtn) { matchBtn.classList.add('selected'); @@ -8737,11 +8394,11 @@ currentSelectedBtn = matchBtn; } - // 给 input 加个标记类 + // Add a tag class to input customInput.classList.add('selected'); }); - // 初始高亮颜色 + // Initial highlight color if (currentSelectedBtn) { currentSelectedBtn.style.backgroundColor = '#dbeafe'; } @@ -8750,22 +8407,19 @@ } /** - * 设置激活状态 - * 激活时刷新所有文件夹计数和展开的文件夹 - */ + * Set activation status * Refresh all folder counts and expanded folders on activation*/ setActive(active) { const wasActive = this.isActive; this.isActive = active; - // 从非激活变为激活时,刷新所有文件夹计数和展开的文件夹 + // Refresh all folder counts and expanded folders when changing from inactive to active if (!wasActive && active) { this.refreshAllFolderCounts(); } } /** - * 刷新所有文件夹的计数和展开的文件夹会话列表 - */ + * Refresh all folder counts and expanded folder session list */ refreshAllFolderCounts() { if (!this.data || !this.data.folders) return; @@ -8775,46 +8429,40 @@ } /** - * 刷新会话列表 - */ + * Refresh session list */ refresh() { this.loadData(); this.createUI(); } /** - * 处理搜索输入 - * @param {string} query 搜索关键词 - */ + * Process search input * @param {string} query search keyword*/ handleSearch(query) { this.searchQuery = query; if (!query && (!this.filterTagIds || this.filterTagIds.size === 0) && !this.filterPinned) { - // 清空搜索时重置(无关键词、无标签筛选、无置顶筛选) + // Reset when clearing search (no keywords, no tag filter, no top filter) this.searchResult = null; this.refreshAfterSearch(); return; } - // 执行搜索 + // Perform a search this.searchResult = this.performSearch(query); this.refreshAfterSearch(); } /** - * 执行搜索 - * @param {string} query 搜索关键词 - * @returns {{ folderMatches: Set, conversationMatches: Set, conversationFolderMap: Map, totalCount: number }} - */ + * Execute search * @param {string} query search keyword * @returns {{ folderMatches: Set, conversationMatches: Set, conversationFolderMap: Map, totalCount: number }} */ performSearch(query) { const lowerQuery = query.toLowerCase(); - const folderMatches = new Set(); // 直接匹配的文件夹 ID - const conversationMatches = new Set(); // 匹配的会话 ID - const conversationFolderMap = new Map(); // 会话 ID -> 所属文件夹 ID(用于展开父级) + const folderMatches = new Set(); // Direct matching folder ID + const conversationMatches = new Set(); // Matching session ID + const conversationFolderMap = new Map(); // Session ID -> Owning folder ID (used to expand the parent) - // 获取当前 CID(仅 Gemini Business 有效) + // Get the current CID (valid only for Gemini Business) const currentCid = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null; - // 1. 遍历文件夹,匹配名称 + // 1. Traverse folders and match names if (this.data && this.data.folders && lowerQuery) { this.data.folders.forEach((folder) => { if (folder.name.toLowerCase().includes(lowerQuery)) { @@ -8823,13 +8471,13 @@ }); } - // 2. 遍历会话,匹配标题(按 CID 过滤) + // 2. Traverse conversations and match titles (filtered by CID) if (this.data && this.data.conversations) { Object.values(this.data.conversations).forEach((conv) => { - // 先按 CID 过滤 + // Filter by CID first if (!this.matchesCid(conv, currentCid)) return; - // 逻辑整合:关键词 AND 标签 AND 置顶 + // Logical integration: keyword AND label AND pin const matchQuery = !lowerQuery || (conv.title && conv.title.toLowerCase().includes(lowerQuery)); const matchTags = !this.filterTagIds || this.filterTagIds.size === 0 || (conv.tagIds && conv.tagIds.some((id) => this.filterTagIds.has(id))); const matchPinned = !this.filterPinned || conv.pinned; @@ -8850,14 +8498,13 @@ } /** - * 搜索后刷新 UI(不重建整个面板,只更新列表和结果条) - */ + * Refresh UI after search (does not rebuild the entire panel, only updates the list and results bar)*/ refreshAfterSearch() { - // 更新结果条 + // Update results bar const resultBar = document.getElementById('conversations-result-bar'); if (resultBar) { if (this.searchResult) { - resultBar.textContent = `${this.searchResult.totalCount} ${this.t('conversationsSearchResult') || '个结果'}`; + resultBar.textContent = `${this.searchResult.totalCount} ${this.t('conversationsSearchResult') || 'result(s)'}`; resultBar.classList.add('visible'); } else { resultBar.textContent = ''; @@ -8865,7 +8512,7 @@ } } - // 重建文件夹列表(带搜索过滤) + // Rebuild folder list (with search filter) const container = this.container?.querySelector('.conversations-content'); const oldFolderList = container?.querySelector('.conversations-folder-list'); if (container && oldFolderList) { @@ -8875,11 +8522,7 @@ } /** - * 高亮文本中的关键词 - * @param {string} text 原始文本 - * @param {string} query 搜索关键词 - * @returns {DocumentFragment} 带高亮的文档片段 - */ + * Keywords in highlighted text * @param {string} text original text * @param {string} query Search keywords * @returns {DocumentFragment} Highlighted document fragment*/ highlightText(text, query) { const fragment = document.createDocumentFragment(); if (!query) { @@ -8912,26 +8555,25 @@ } /** - * 显示标签管理对话框 - */ + * Display the tag management dialog box*/ showTagManagerDialog(conv = null) { const overlay = createElement('div', { className: 'conversations-dialog-overlay' }); const dialog = createElement('div', { className: 'conversations-dialog conversations-dialog-tag-manager' }); - // 标题 - // 标题栏 (含关闭按钮) + // Title + // Title bar (including close button) const titleRow = createElement('div', { className: 'conversations-dialog-title', style: 'display:flex; justify-content:space-between; align-items:center;', }); - titleRow.textContent = this.t('conversationsManageTags') || '管理标签'; + titleRow.textContent = this.t('conversationsManageTags') || 'Manage Tags'; const closeIcon = createElement( 'span', { className: 'conversations-close-icon', style: 'cursor:pointer; padding:4px; font-size:20px; color:#9ca3af; line-height:1; width:24px; height:24px; display:flex; align-items:center; justify-content:center; border-radius:4px;', - title: this.t('close') || '关闭', + title: this.t('close') || 'Close', }, '×', ); @@ -8944,12 +8586,12 @@ const content = createElement('div', { className: 'conversations-dialog-content' }); - // 标签列表容器 (隐藏滚动条) + // Tag list container (hide scrollbar) const listContainer = createElement('div', { className: 'conversations-tag-manager-list', style: 'scrollbar-width: none; -ms-overflow-style: none;', // Firefox, IE }); - // 注入隐藏 scrollbar 的样式 (Chrome/Safari) + // Inject styles that hide scrollbar (Chrome/Safari) const hideScrollStyle = document.createElement('style'); hideScrollStyle.textContent = `.conversations-tag-manager-list::-webkit-scrollbar { display: none; }`; listContainer.appendChild(hideScrollStyle); @@ -8957,14 +8599,14 @@ const renderList = () => { clearElement(listContainer); if (!this.data.tags || this.data.tags.length === 0) { - listContainer.appendChild(createElement('div', { className: 'conversations-empty' }, this.t('conversationsNoTags') || '暂无标签')); + listContainer.appendChild(createElement('div', { className: 'conversations-empty' }, this.t('conversationsNoTags') || 'No Tags')); return; } this.data.tags.forEach((tag) => { const item = createElement('div', { className: 'conversations-tag-manager-item' }); - // 左侧:勾选框(如果有会话上下文)+ 预览 + // Left: checkbox (if there is session context) + preview const left = createElement('div', { style: 'display:flex; align-items:center; gap:8px;' }); let checkbox = null; @@ -8983,7 +8625,7 @@ const list = this.container.querySelector(`.conversations-list[data-folder-id="${conv.folderId}"]`); if (list) this.renderConversationList(conv.folderId, list); }); - checkbox.addEventListener('click', (e) => e.stopPropagation()); // 防止点击 checkbox 触发行点击 + checkbox.addEventListener('click', (e) => e.stopPropagation()); // Prevent clicks on checkboxes from triggering row clicks left.appendChild(checkbox); } @@ -8998,10 +8640,10 @@ left.appendChild(preview); item.appendChild(left); - // 右侧:编辑/删除按钮 + // Right: Edit/Delete button const actions = createElement('div', { className: 'conversations-tag-actions' }); - // 编辑逻辑简化:点击填充到底部输入框,暂不实现行内编辑 + // Simplified editing logic: click to fill in the bottom input box, inline editing is not implemented yet const editBtn = createElement( 'button', { @@ -9011,11 +8653,11 @@ '✎', ); editBtn.addEventListener('click', (e) => { - e.stopPropagation(); // 防止触发行点击 + e.stopPropagation(); // Prevent trigger row clicks nameInput.value = tag.name; updateColorSelection(tag.color); editingId = tag.id; - addBtn.textContent = this.t('conversationsUpdateTag') || '更新标签'; + addBtn.textContent = this.t('conversationsUpdateTag') || 'Update Tag'; }); actions.appendChild(editBtn); @@ -9028,11 +8670,11 @@ '×', ); delBtn.addEventListener('click', (e) => { - e.stopPropagation(); // 防止触发行点击 - if (confirm(this.t('confirmDelete') || '确定删除?')) { + e.stopPropagation(); // Prevent trigger row clicks + if (confirm(this.t('confirmDelete') || 'Delete this prompt?')) { this.deleteTag(tag.id); renderList(); - // 刷新所有可见的会话列表 + // Refresh the list of all visible sessions this.container.querySelectorAll('.conversations-list').forEach((list) => { const fid = list.dataset.folderId; if (fid) this.renderConversationList(fid, list); @@ -9043,12 +8685,12 @@ item.appendChild(actions); - // 整行点击切换 checkbox(仅在有会话上下文时) + // Click to toggle checkbox for entire row (only when there is session context) if (conv && checkbox) { item.style.cursor = 'pointer'; item.addEventListener('click', () => { checkbox.checked = !checkbox.checked; - checkbox.dispatchEvent(new Event('change')); // 触发 change 事件更新数据 + checkbox.dispatchEvent(new Event('change')); // Trigger change event to update data }); } @@ -9058,7 +8700,7 @@ content.appendChild(listContainer); - // 新建/编辑区域 + // Create new/edit area const formSection = createElement('div', { className: 'conversations-dialog-section', style: 'border-top:1px solid #eee; padding-top:10px;', @@ -9069,10 +8711,10 @@ const nameInput = createElement('input', { type: 'text', className: 'conversations-dialog-input', - placeholder: this.t('conversationsTagName') || '标签名称', + placeholder: this.t('conversationsTagName') || 'Tag Name', style: 'flex:1; margin-bottom: 8px;', }); - // Enter 提交 + // Enter Submit nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addBtn.click(); }); @@ -9081,30 +8723,30 @@ const colorPicker = createElement('div', { className: 'conversations-color-picker' }); let selectedColor = TAG_COLORS[0]; - // 1. 渲染 30 色预设网格 + // 1. Render 30-color preset mesh const updateColorSelection = (color, source = 'click') => { if (!color.startsWith('#')) color = '#' + color; selectedColor = color; - // 更新 Hex 输入框 + // Update Hex input box if (source !== 'input') { hexInput.value = color; hexInput.style.borderColor = '#ddd'; // Reset error state } - // 更新选中状态 - // 检查是否在预设中 + // Update selected status + // Check if it is in the preset const presetMatch = Array.from(colorPicker.children).find((c) => c.dataset.color && c.dataset.color.toLowerCase() === color.toLowerCase()); Array.from(colorPicker.children).forEach((c) => c.classList.remove('selected')); if (presetMatch) { presetMatch.classList.add('selected'); - // 重置自定义按钮 + // Reset custom button customBtnInner.style.background = 'conic-gradient(from 180deg, red, yellow, lime, aqua, blue, magenta, red)'; customBtn.classList.remove('active-custom'); } else { - // 自定义颜色选中 + // Custom color selected customBtnInner.style.background = color; customBtn.classList.add('active-custom'); } @@ -9122,18 +8764,18 @@ }); formSection.appendChild(colorPicker); - // 2. 自定义颜色行 (彩虹按钮 + Hex 输入框) + // 2. Custom color row (rainbow button + Hex input box) const customRow = createElement('div', { style: 'display: flex; align-items: center; gap: 12px; margin-top: 12px; padding: 0 4px;', }); - // 彩虹按钮容器 + // rainbow button container const customBtn = createElement('div', { className: 'conversations-color-item custom-btn-wrapper', - title: '自定义颜色', + title: 'Custom Color', style: 'position: relative; cursor: pointer; border: 2px solid transparent; width: 32px; height: 32px; border-radius: 50%; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);', }); - // 选中样式 CSS (通过 class 控制 border) + // Select style CSS (control border through class) const customBtnStyle = document.createElement('style'); customBtnStyle.textContent = ` .active-custom { border-color: #666 !important; transform: scale(1.1); } @@ -9154,7 +8796,7 @@ customBtn.appendChild(nativePicker); customRow.appendChild(customBtn); - // Hex 输入区域 + // Hex input area const hexWrapper = createElement('div', { style: 'display: flex; align-items: center; gap: 8px; flex: 1;', }); @@ -9170,11 +8812,11 @@ hexInput.addEventListener('input', (e) => { const val = e.target.value; - // 正则校验: #后面跟3或6位16进制字符 + // Regular check: # followed by 3 or 6 hexadecimal characters const hexRegex = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/; if (hexRegex.test(val)) { hexInput.style.borderColor = '#ddd'; // Valid - // 补全3位到6位 + // Complete digits 3 to 6 let expandVal = val; if (val.length === 4) { expandVal = '#' + val[1] + val[1] + val[2] + val[2] + val[3] + val[3]; @@ -9185,7 +8827,7 @@ } }); - // 失去焦点时如果无效则恢复 + // Restore if invalid when losing focus hexInput.addEventListener('blur', () => { if (hexInput.style.borderColor === 'rgb(239, 68, 68)' || hexInput.style.borderColor === '#ef4444') { hexInput.value = selectedColor; @@ -9198,7 +8840,7 @@ formSection.appendChild(customRow); - // 初始化颜色选择状态 + // Initialize color selection state updateColorSelection(selectedColor, 'init'); const addBtn = createElement( @@ -9207,7 +8849,7 @@ className: 'conversations-dialog-btn confirm', style: 'width:100%; margin-top:8px;', }, - this.t('conversationsNewTag') || '新建标签', + this.t('conversationsNewTag') || 'New Tag', ); addBtn.addEventListener('click', () => { @@ -9225,13 +8867,13 @@ // Success if (editingId) { editingId = null; - addBtn.textContent = this.t('conversationsNewTag') || '新建标签'; + addBtn.textContent = this.t('conversationsNewTag') || 'New Tag'; } nameInput.value = ''; // Reset color selection? Maybe keep it. renderList(); - // 刷新所有可见的会话列表 (因为标签修改会影响所有使用了该标签的会话) + // Refresh the list of all visible sessions (because tag modifications will affect all sessions that use the tag) this.container.querySelectorAll('.conversations-list').forEach((list) => { const fid = list.dataset.folderId; if (fid) this.renderConversationList(fid, list); @@ -9247,15 +8889,15 @@ overlay.appendChild(dialog); document.body.appendChild(overlay); - // 渲染列表 + // render list renderList(); - // 点击遮罩关闭 + // Click on the mask to close overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); - // ESC 关闭 + // ESC close overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') overlay.remove(); }); @@ -9266,17 +8908,14 @@ } /** - * 通用大纲管理器 - * 负责大纲的 UI 渲染、交互和状态管理 - * 数据源由外部适配器提供 - */ + * Universal Outline Manager * Responsible for UI rendering, interaction and state management of the outline * Data sources are provided by external adapters*/ class OutlineManager { constructor(config) { this.container = config.container; this.settings = config.settings; - this.siteAdapter = config.siteAdapter; // 用于获取滚动容器等 + this.siteAdapter = config.siteAdapter; // Used to get scrolling containers, etc. this.onSettingsChange = config.onSettingsChange; - this.onJumpBefore = config.onJumpBefore; // 跳转前回调,用于保存锚点 + this.onJumpBefore = config.onJumpBefore; // Callback before jump, used to save the anchor point this.t = config.i18n || ((k) => k); this.state = { @@ -9284,23 +8923,23 @@ treeKey: '', minLevel: 1, expandLevel: this.settings.outline?.maxLevel ?? 6, - includeUserQueries: this.settings.outline?.showUserQueries ?? false, // 是否展示用户提问 + includeUserQueries: this.settings.outline?.showUserQueries ?? false, // Whether to display user questions levelCounts: {}, isAllExpanded: false, rawOutline: [], - // 搜索相关状态 + // Search related status searchQuery: '', - searchLevelManual: false, // 标记用户是否在搜索时手动调整了层级 - searchResults: null, // 存储搜索匹配信息 { matchedIds: Set, relevantIds: Set } - preSearchState: null, // 搜索前的状态快照 + searchLevelManual: false, // Flag if the user manually adjusted the hierarchy while searching + searchResults: null, // Store search matching information { matchedIds: Set, relevantIds: Set } + preSearchState: null, // State snapshot before search }; - // 自动更新相关 + // Automatic updates related this.observer = null; this.updateDebounceTimer = null; - this.isActive = false; // 标记 Tab 是否激活 + this.isActive = false; // Mark whether Tab is active - // 同步滚动相关 + // Synchronous scrolling related this.syncScrollHandler = null; this.syncScrollThrottleTimer = null; this.currentHighlightedItem = null; @@ -9320,7 +8959,7 @@ } updateAutoUpdateState() { - // 只有当:大纲功能开启 AND 自动更新开启 AND Tab处于激活状态 时才启用 Observer + // Observer is only enabled if: Outlining is on AND Auto-update is on AND Tab is active const shouldEnable = this.settings.outline?.enabled && this.settings.outline?.autoUpdate && this.isActive; if (shouldEnable) { @@ -9330,7 +8969,7 @@ } } - // ========== 同步滚动功能 ========== + // ========== Synchronous scrolling function ========== updateSyncScrollState() { const shouldEnable = this.settings.outline?.enabled && this.settings.outline?.syncScroll && this.isActive; if (shouldEnable) { @@ -9346,7 +8985,7 @@ const scrollContainer = this.siteAdapter.getScrollContainer(); if (!scrollContainer) { - // 滚动容器可能还没准备好,最多重试 10 次,每次间隔 300ms(共 3 秒) + // The scrolling container may not be ready yet and will be retried up to 10 times with 300ms intervals (3 seconds total) if (retryCount < 10) { setTimeout(() => { if (this.settings.outline?.syncScroll && this.isActive && !this.syncScrollHandler) { @@ -9358,10 +8997,10 @@ } this.syncScrollHandler = () => { - // 搜索模式下暂停同步 + // Pause sync in search mode if (this.state.searchQuery) return; - // 节流:200ms + // Throttle: 200ms if (this.syncScrollThrottleTimer) return; this.syncScrollThrottleTimer = setTimeout(() => { this.syncScrollThrottleTimer = null; @@ -9382,13 +9021,13 @@ } this.syncScrollHandler = null; - // 清除节流计时器 + // Clear throttling timer if (this.syncScrollThrottleTimer) { clearTimeout(this.syncScrollThrottleTimer); this.syncScrollThrottleTimer = null; } - // 移除当前高亮 + // Remove current highlight if (this.currentHighlightedItem) { this.currentHighlightedItem.classList.remove('sync-highlight'); this.currentHighlightedItem = null; @@ -9402,7 +9041,7 @@ const scrollContainer = this.siteAdapter.getScrollContainer(); if (!scrollContainer) return; - // 展平树结构 + // flatten tree structure const flattenTree = (items) => { const result = []; items.forEach((item) => { @@ -9415,7 +9054,7 @@ }; const allItems = flattenTree(this.state.tree); - // 找到当前可视区域的第一个大纲元素 + // Find the first outline element in the current viewable area const containerRect = scrollContainer.getBoundingClientRect(); const viewportTop = containerRect.top; const viewportBottom = containerRect.bottom; @@ -9436,24 +9075,24 @@ if (!currentItem) return; - // 移除旧高亮 + // Remove old highlighting if (this.currentHighlightedItem) { this.currentHighlightedItem.classList.remove('sync-highlight'); } - // 找到大纲面板中对应的 DOM 元素 + // Find the corresponding DOM element in the outline panel const outlineList = document.getElementById('outline-list'); if (!outlineList) return; let outlineItem = outlineList.querySelector(`.outline-item[data-index="${currentItem.index}"]`); if (!outlineItem) return; - // 如果目标项被隐藏(折叠),向上找可见的父级 + // If the target item is hidden (collapsed), look up for the visible parent if (outlineItem.classList.contains('outline-hidden')) { let parent = outlineItem.previousElementSibling; while (parent) { if (parent.classList.contains('outline-item') && !parent.classList.contains('outline-hidden')) { - // 找到可见的父级,检查它的 data-level 是否比当前项小(确保是父级而非同级) + // Find the visible parent and check if its data-level is smaller than the current item (make sure it is the parent and not the sibling) const parentLevel = parseInt(parent.dataset.level, 10); const currentLevel = parseInt(outlineItem.dataset.level, 10); if (parentLevel < currentLevel) { @@ -9463,20 +9102,20 @@ } parent = parent.previousElementSibling; } - // 如果还是隐藏的,放弃高亮 + // If it is still hidden, give up highlighting. if (outlineItem.classList.contains('outline-hidden')) return; } - // 添加高亮 + // Add highlight outlineItem.classList.add('sync-highlight'); this.currentHighlightedItem = outlineItem; - // 轻微滚动大纲面板使高亮项可见(如果超出视口) + // Scroll the outline panel slightly to make highlighted items visible (if outside the viewport) const wrapper = document.getElementById('outline-list-wrapper'); if (wrapper) { const wrapperRect = wrapper.getBoundingClientRect(); const itemRect = outlineItem.getBoundingClientRect(); - // 如果高亮项在可视区域外,滚动使其可见 + // If the highlighted item is outside the visible area, scroll to make it visible if (itemRect.top < wrapperRect.top || itemRect.bottom > wrapperRect.bottom) { const scrollOffset = itemRect.top - wrapperRect.top - wrapperRect.height / 2 + itemRect.height / 2; wrapper.scrollBy({ top: scrollOffset, behavior: 'smooth' }); @@ -9487,13 +9126,13 @@ startObserver() { if (this.observer) return; - // 找到聊天记录容器作为观察目标 - // 既然我们增加了 getChatContentSelectors,也许可以用那个? - // 但对于大纲来说,只要 DOM 变了就可能产生新标题。观察 body 可能最稳妥但性能最差。 - // 观察聊天容器是折中方案。 - // 复用 SiteAdapter 的 getScrollContainer 得到的通常是主滚动容器, - // 或者用 getResponseContainerSelector - // 鉴于 Gemini Business 返回空,我们尝试观察 document.body,加上防抖,性能应该可控。 + // Find the chat record container as the observation target + // Now that we've added getChatContentSelectors, maybe we can use that? + // But for outlines, new titles may be generated as long as the DOM changes. Observing body is probably the most reliable but has the worst performance. + // Watching the chat container is a compromise. + // Reusing SiteAdapter's getScrollContainer usually results in the main scroll container. + // Or use getResponseContainerSelector + // Since Gemini Business returns empty, we try to observe document.body, and with anti-shake, the performance should be controllable. this.observer = new MutationObserver(() => { this.triggerAutoUpdate(); @@ -9502,7 +9141,7 @@ this.observer.observe(document.body, { childList: true, subtree: true, - characterData: true, // 标题文字变化也要检测 + characterData: true, // Title text changes must also be detected }); console.log('Gemini Helper: Outline Auto-Update Started'); } @@ -9522,10 +9161,10 @@ triggerAutoUpdate() { const interval = (this.settings.outline?.updateInterval || 5) * 1000; - // 如果已经在等待更新,不需要重置定时器(这是 throttle/debounce 的关键区别) - // 我们希望:只要有请求,就确保在未来某个时刻执行,但不要频繁执行 - // 策略:如果 timer 存在,说明已经安排了更新,什么都不做(让它在原定时间触发) - // 只有 timer 不存在时,才设置一个新的 + // If you are already waiting for an update, there is no need to reset the timer (this is the key difference between throttle/debounce) + // We hope: As long as there is a request, it will be executed at some point in the future, but not frequently. + // Strategy: If timer exists, it means that the update has been scheduled, do nothing (let it trigger at the original time) + // Only if timer does not exist, set a new one if (!this.updateDebounceTimer) { this.updateDebounceTimer = setTimeout(() => { this.executeAutoUpdate(); @@ -9539,12 +9178,12 @@ this.updateDebounceTimer = null; } - // 触发更新回调(在 GeminiHelper 中定义,实际调用 refreshOutline) + // Trigger update callback (defined in GeminiHelper, actually call refreshOutline) if (this.config && this.config.onAutoUpdate) { this.config.onAutoUpdate(); } - // 发送自定义事件通知外部刷新 + // Send custom events to notify external refreshes window.dispatchEvent(new CustomEvent('gemini-helper-outline-auto-refresh')); } @@ -9554,13 +9193,13 @@ const content = createElement('div', { className: 'outline-content' }); - // 固定工具栏 + // Fixed toolbar const toolbar = createElement('div', { className: 'outline-fixed-toolbar' }); - // 第一行:按钮和搜索占位 + // First row: button and search placeholder const row1 = createElement('div', { className: 'outline-toolbar-row' }); - // 用户提问分组按钮 + // User question group button const groupBtn = createElement( 'button', { @@ -9573,7 +9212,7 @@ groupBtn.addEventListener('click', () => this.toggleGroupMode()); row1.appendChild(groupBtn); - // 创建展开/折叠 SVG 图标的辅助函数 + // Helper function for creating expand/collapse SVG icons const createExpandIcon = () => { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 16 16'); @@ -9582,20 +9221,20 @@ svg.setAttribute('stroke-width', '2'); svg.style.width = '14px'; svg.style.height = '14px'; - // 圆圈 + // circle const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', '8'); circle.setAttribute('cy', '8'); circle.setAttribute('r', '6.5'); svg.appendChild(circle); - // 横线 + // horizontal line const h = document.createElementNS('http://www.w3.org/2000/svg', 'line'); h.setAttribute('x1', '4'); h.setAttribute('y1', '8'); h.setAttribute('x2', '12'); h.setAttribute('y2', '8'); svg.appendChild(h); - // 竖线 (⊕ 独有) + // vertical line (⊕ unique) const v = document.createElementNS('http://www.w3.org/2000/svg', 'line'); v.setAttribute('x1', '8'); v.setAttribute('y1', '4'); @@ -9612,13 +9251,13 @@ svg.setAttribute('stroke-width', '2'); svg.style.width = '14px'; svg.style.height = '14px'; - // 圆圈 + // circle const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', '8'); circle.setAttribute('cy', '8'); circle.setAttribute('r', '6.5'); svg.appendChild(circle); - // 横线 + // horizontal line const h = document.createElementNS('http://www.w3.org/2000/svg', 'line'); h.setAttribute('x1', '4'); h.setAttribute('y1', '8'); @@ -9627,11 +9266,11 @@ svg.appendChild(h); return svg; }; - // 保存到类实例以便后续切换使用 + // Save to class instance for subsequent switching use this._createExpandIcon = createExpandIcon; this._createCollapseIcon = createCollapseIcon; - // 展开/折叠按钮 (使用 SVG 图标确保跨平台一致性) + // Expand/collapse buttons (use SVG icons for cross-platform consistency) const expandBtn = createElement('button', { className: 'outline-toolbar-btn', id: 'outline-expand-btn', @@ -9641,15 +9280,15 @@ expandBtn.addEventListener('click', () => this.toggleExpandAll()); row1.appendChild(expandBtn); - // 定位当前位置按钮 (使用 SVG 图标确保跨平台一致性) + // Position the current location button (uses SVG icons to ensure cross-platform consistency) const locateBtn = createElement('button', { className: 'outline-toolbar-btn', id: 'outline-locate-btn', title: this.t('outlineLocateCurrent'), }); - // 创建定位图标 SVG (crosshair/target 风格) + // Create target icon SVG (crosshair/target style) const locateSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - // 使用较小的 viewBox 让图形内容占据更大比例 + // Use a smaller viewBox to let graphic content take up a larger proportion locateSvg.setAttribute('viewBox', '0 0 18 18'); locateSvg.setAttribute('fill', 'none'); locateSvg.setAttribute('stroke', 'currentColor'); @@ -9658,13 +9297,13 @@ locateSvg.setAttribute('stroke-linejoin', 'round'); locateSvg.style.width = '18px'; locateSvg.style.height = '18px'; - // 圆圈 (中心9,9 半径4.5) + // Circle (center 9,9 radius 4.5) const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', '9'); circle.setAttribute('cy', '9'); circle.setAttribute('r', '4.5'); locateSvg.appendChild(circle); - // 十字准线 (从边缘到圆圈) + // Crosshairs (from edge to circle) const line1 = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line1.setAttribute('x1', '9'); line1.setAttribute('y1', '1'); @@ -9693,7 +9332,7 @@ locateBtn.addEventListener('click', () => this.locateCurrentPosition()); row1.appendChild(locateBtn); - // 滚动按钮 + // scroll button const scrollBtn = createElement( 'button', { @@ -9706,7 +9345,7 @@ scrollBtn.addEventListener('click', () => this.scrollList()); row1.appendChild(scrollBtn); - // 搜索框区域 + // search box area const searchWrapper = createElement('div', { className: 'outline-search-wrapper' }); const searchInput = createElement('input', { @@ -9725,7 +9364,7 @@ '×', ); - // 搜索事件处理 + // Search event handling let debounceTimer; searchInput.addEventListener('input', (e) => { const val = e.target.value; @@ -9759,11 +9398,11 @@ toolbar.appendChild(row1); - // 第二行:层级滑块 + // Second row: level slider const row2 = createElement('div', { className: 'outline-toolbar-row' }); const sliderContainer = createElement('div', { className: 'outline-level-slider-container' }); - // 层级节点 + // Hierarchy node const dotsContainer = createElement('div', { className: 'outline-level-dots', id: 'outline-level-dots' }); const levelLine = createElement('div', { className: 'outline-level-line' }); const levelProgress = createElement('div', { @@ -9773,7 +9412,7 @@ levelLine.appendChild(levelProgress); dotsContainer.appendChild(levelLine); - // 创建 6 个层级节点(0 表示不展开,1-6 表示层级) + // Create 6 hierarchical nodes (0 means no expansion, 1-6 means hierarchical) for (let i = 0; i <= 6; i++) { const dot = createElement('div', { className: `outline-level-dot ${i <= this.state.expandLevel ? 'active' : ''}`, @@ -9781,7 +9420,7 @@ }); const tooltip = createElement('div', { className: 'outline-level-dot-tooltip' }); if (i === 0) { - tooltip.textContent = '⊖'; // 不展开 + tooltip.textContent = '⊖'; // Do not expand } else { tooltip.textContent = `H${i}: 0`; } @@ -9795,14 +9434,14 @@ toolbar.appendChild(row2); content.appendChild(toolbar); - // 搜索结果统计条 (插入在工具栏和列表之间) + // Search results statistics bar (inserted between toolbar and list) const resultBar = createElement('div', { className: 'outline-result-bar hidden', id: 'outline-result-bar', }); content.appendChild(resultBar); - // 大纲列表包装器(可滚动) + // Outline list wrapper (scrollable) const listWrapper = createElement('div', { className: 'outline-list-wrapper', id: 'outline-list-wrapper' }); const list = createElement('div', { className: 'outline-list', id: 'outline-list' }); listWrapper.appendChild(list); @@ -9811,7 +9450,7 @@ container.appendChild(content); } - // 刷新数据 + // Refresh data update(outlineData) { const listContainer = document.getElementById('outline-list'); if (!listContainer) return; @@ -9823,85 +9462,85 @@ return; } - // 保存原始大纲 + // Save original outline this.state.rawOutline = outlineData; - // 统计各层级数量 + // Count the number of levels this.state.levelCounts = {}; outlineData.forEach((item) => { this.state.levelCounts[item.level] = (this.state.levelCounts[item.level] || 0) + 1; }); this.updateTooltips(); - // 智能缩进:检测最高层级(排除用户提问节点,只考虑 AI 回复的标题) + // Smart indentation: detect the highest level (exclude user question nodes and only consider the title of the AI reply) const headingLevels = outlineData.filter((item) => !item.isUserQuery).map((item) => item.level); const minLevel = headingLevels.length > 0 ? Math.min(...headingLevels) : 1; this.state.minLevel = minLevel; - // 在重构树之前,捕获当前的折叠状态 + // Before reconstructing the tree, capture the current folding state const currentStateMap = {}; if (this.state.tree) { this.captureTreeState(this.state.tree, currentStateMap); } - // 构建树形结构 + // Build a tree structure const outlineKey = outlineData.map((i) => i.text).join('|'); - // 只要 key 变了,或者是首次构建,都重新构建树 - // 注意:实时更新时 key 会不断变化,所以必须每次都重建树以包含新节点 - // 但我们需要保持用户的折叠状态 + // Whenever the key changes or it is built for the first time, the tree is rebuilt. + // Note: The key will continue to change during real-time updates, so the tree must be rebuilt each time to include the new nodes. + // But we need to keep the user in the collapsed state if (this.state.treeKey !== outlineKey || !this.state.tree) { this.state.tree = this.buildTree(outlineData, minLevel); this.state.treeKey = outlineKey; } const tree = this.state.tree; - // 恢复折叠状态 - // 策略:先根据 displayLevel 初始化所有节点的折叠状态,再恢复用户手动操作的状态 + // Restore folded state + // Strategy: First initialize the folded state of all nodes according to displayLevel, and then restore the state of manual operation by the user. const displayLevel = this.state.expandLevel ?? 6; - // 根据是否开启用户提问动态调整最小有效层级 + // Dynamically adjust the minimum effective level according to whether user questions are enabled or not. const minDisplayLevel = this.state.includeUserQueries ? 0 : 1; const effectiveDisplayLevel = displayLevel < minDisplayLevel ? minDisplayLevel : displayLevel; - // 1. 先按默认规则初始化所有节点(包括新节点) + // 1. First initialize all nodes (including new nodes) according to default rules this.initializeCollapsedState(tree, effectiveDisplayLevel); - // 2. 再恢复用户之前的手动操作(只影响旧节点,新节点保持初始化状态) + // 2. Restore the user’s previous manual operation (only affects old nodes, new nodes remain initialized) if (Object.keys(currentStateMap).length > 0) { this.restoreTreeState(tree, currentStateMap); } - // 如果在搜索模式,需要重新应用搜索标记 + // If in search mode, search tags need to be reapplied if (this.state.searchQuery) { - this.performSearch(this.state.searchQuery, false); // false = 不触发额外刷新 + this.performSearch(this.state.searchQuery, false); // false = do not trigger additional refreshes } - // 渲染 + // rendering this.refreshCurrent(); } - // 处理搜索输入 + // Handle search input handleSearch(query) { if (!query) { - // === 结束搜索 === - // 1. 清理搜索状态 + // === End search === + // 1. Clear search status this.state.searchQuery = ''; this.state.searchResults = null; this.state.searchLevelManual = false; - // 2. 隐藏结果条 + // 2. Hide the results bar const resultBar = document.getElementById('outline-result-bar'); if (resultBar) resultBar.classList.add('hidden'); - // 3. 恢复折叠状态 + // 3. Restore folded state if (this.state.tree) { - // 3.1 先重置为全局设定的层级状态(兜底) + // 3.1 First reset to the globally set hierarchical state (cover the bottom) const displayLevel = this.state.expandLevel ?? 6; this.clearForceExpandedState(this.state.tree, displayLevel); - // 3.2 如果有搜索前的状态快照,则恢复它(覆盖默认状态) + // 3.2 If there is a state snapshot before the search, restore it (overwrite the default state) if (this.state.preSearchState) { this.restoreTreeState(this.state.tree, this.state.preSearchState); - this.state.preSearchState = null; // 恢复后清除快照 + this.state.preSearchState = null; // Clear snapshot after recovery } } @@ -9909,25 +9548,25 @@ return; } - // === 开始或更新搜索 === + // === Start or update search === - // 如果是从无搜索状态进入搜索状态,保存当前快照 + // If you enter the search state from no search state, save the current snapshot if (!this.state.searchQuery && this.state.tree) { this.state.preSearchState = {}; this.captureTreeState(this.state.tree, this.state.preSearchState); - // Fix Issue 2: 搜索前重置所有状态(折叠所有 + 清除手动展开标记) - // 这样搜索结果就只展示匹配的路径,不会受之前手动展开的干扰 + // Fix Issue 2: Reset all states before searching (collapse all + clear manual expansion flag) + // In this way, the search results will only display matching paths and will not be interfered by previous manual expansion. this.clearForceExpandedState(this.state.tree, 0); } this.state.searchQuery = query; - this.state.searchLevelManual = false; // 重置手动层级标记 + this.state.searchLevelManual = false; // Reset manual level markers this.performSearch(query); this.refreshCurrent(); } - // 执行搜索计算 + // Perform search calculations performSearch(query, updateUI = true) { if (!this.state.tree) return; @@ -9935,8 +9574,8 @@ const normalizedQuery = normalize(query); let matchCount = 0; - // 递归标记树 - // 返回值: { isMatch: boolean, hasMatchedDescendant: boolean } + // Recursive tag tree + // Return value: { isMatch: boolean, hasMatchedDescendant: boolean } const traverse = (nodes) => { let hasAnyMatch = false; nodes.forEach((node) => { @@ -9952,10 +9591,10 @@ node.hasMatchedDescendant = false; } - // 如果有匹配子项,自动展开 + // If there are matching subkeys, automatically expand if (node.hasMatchedDescendant) { node.collapsed = false; - // node.forceExpanded = true; // 可选:是否强制标记为展开? 暂时不需要,只要 collapsed=false 即可 + // node.forceExpanded = true; // Optional: Do you want to force the mark to be expanded? Not needed at the moment, just collapse=false } if (isMatch || node.hasMatchedDescendant) { @@ -9967,7 +9606,7 @@ traverse(this.state.tree); - // 更新结果条 + // Update results bar if (updateUI) { const resultBar = document.getElementById('outline-result-bar'); if (resultBar) { @@ -9977,7 +9616,7 @@ } } - // 获取用户问题节点在所有用户问题中的序号(从1开始) + // Get the serial number of the user question node among all user questions (starting from 1) getUserQueryIndex(targetIndex) { if (!this.state.tree) return 0; let count = 0; @@ -9997,25 +9636,25 @@ return countInTree(this.state.tree); } - // 内部刷新(用于交互更新) + // Internal refresh (for interactive updates) refreshCurrent() { const listContainer = document.getElementById('outline-list'); if (this.state.tree && listContainer) { clearElement(listContainer); - // 确定当前的显示层级上限 - // 如果在搜索模式且未手动调整,显示所有层级 (Infinity) - // 否则使用设定的 expandLevel + // Determine the current display level limit + // If in search mode and not manually adjusted, show all levels (Infinity) + // Otherwise use the set expandLevel let displayLevel; if (this.state.searchQuery && !this.state.searchLevelManual) { - displayLevel = 100; // 足够大以显示所有 + displayLevel = 100; // large enough to show all } else { displayLevel = this.state.expandLevel ?? 6; } - // 根据是否开启用户提问动态调整最小有效层级 - // - 开启用户提问时:displayLevel = 0 有意义(只显示用户提问) - // - 未开启用户提问时:displayLevel 最小为 1(因为 AI 标题最低为 H1) + // Dynamically adjust the minimum effective level according to whether user questions are enabled or not. + // - When user questions are enabled: displayLevel = 0 is meaningful (only user questions are displayed) + // - When user questions are not enabled: the minimum displayLevel is 1 (because the minimum AI title is H1) const minDisplayLevel = this.state.includeUserQueries ? 0 : 1; if (displayLevel < minDisplayLevel) { displayLevel = minDisplayLevel; @@ -10025,14 +9664,14 @@ } } - // 构建树形结构 + // Build a tree structure buildTree(outline, minLevel) { const tree = []; const stack = []; outline.forEach((item, index) => { - // 用户提问节点固定 relativeLevel = 0 - // AI 标题节点使用 level - minLevel + 1(实现层级提升) + // User question node fixed relativeLevel = 0 + // AI title nodes use level - minLevel + 1 (to achieve level improvement) const relativeLevel = item.isUserQuery ? 0 : item.level - minLevel + 1; const node = { ...item, @@ -10042,7 +9681,7 @@ collapsed: false, }; - // 找到父节点 + // Find parent node while (stack.length > 0 && stack[stack.length - 1].relativeLevel >= relativeLevel) { stack.pop(); } @@ -10059,62 +9698,62 @@ return tree; } - // 渲染大纲项 - // 注意:使用 relativeLevel 判断层级,与视觉层级保持一致 - // - 用户提问节点 relativeLevel = 0 - // - AI 标题节点 relativeLevel = 1, 2, 3...(已经过智能提升) + // Render outline items + // Note: Use relativeLevel to determine the level, consistent with the visual level + // - User question node relativeLevel = 0 + // - AI title node relativeLevel = 1, 2, 3... (has been intelligently improved) renderItems(container, items, minLevel, displayLevel, parentCollapsed = false, parentForceExpanded = false) { - // 根据是否开启用户提问,确定根节点的 relativeLevel - // - 开启用户提问:根节点是用户提问节点,relativeLevel = 0 - // - 不开启用户提问:根节点是最高级 AI 标题,relativeLevel = 1 + // Determine the relativeLevel of the root node according to whether user questions are enabled. + // - Enable user questions: the root node is the user question node, relativeLevel = 0 + // - Do not enable user questions: the root node is the highest level AI title, relativeLevel = 1 const minRelativeLevel = this.state.includeUserQueries ? 0 : 1; items.forEach((item) => { const hasChildren = item.children && item.children.length > 0; - // 使用 relativeLevel 判断是否为根节点(用户提问或顶层标题) + // Use relativeLevel to determine whether it is the root node (user question or top-level title) const isRootNode = item.relativeLevel === minRelativeLevel; let shouldShow; - // 计算可见性:使用 relativeLevel 与 displayLevel 比较 + // Calculate visibility: use relativeLevel compared with displayLevel const isLevelAllowed = item.relativeLevel <= displayLevel || parentForceExpanded; if (isRootNode) { - // 顶层节点逻辑 + // Top-level node logic if (this.state.searchQuery) { - // Fix: 搜索模式下严控顶层显示,无论是否有手动层级操作 - // 确保 Expand All 不会将不相关的顶层节点展示出来 + // Fix: Strictly control top-level display in search mode, regardless of whether there is manual hierarchical operation + // Ensure that Expand All does not display irrelevant top-level nodes shouldShow = item.isMatch || item.hasMatchedDescendant; } else { - // 普通模式:只需存在即可 + // Normal mode: Just exist shouldShow = true; } } else { - // 非顶层节点 + // non-top node const isRelevant = !this.state.searchQuery || item.isMatch || item.hasMatchedDescendant || parentForceExpanded; - // 注意:parentForceExpanded 意味着父级被手动点开了,此时应该显示子级(即使不匹配) + // Note: parentForceExpanded means that the parent has been manually clicked, and the children should be displayed at this time (even if they do not match) - // 综合判断 + // Comprehensive judgment if (this.state.searchQuery && !this.state.searchLevelManual) { - // 纯搜索模式:相关即显示,忽略层级 - // 但如果 parentForceExpanded,也显示 + // Pure search mode: display when relevant, ignore hierarchy + // But if parentForceExpanded, it also displays shouldShow = isRelevant && !parentCollapsed; } else if (this.state.searchQuery && this.state.searchLevelManual) { - // 搜索且有层级限制 - // 必须相关 AND 层级允许 + // Search with level restrictions + // Must be related AND level allowed shouldShow = isRelevant && isLevelAllowed && !parentCollapsed; } else { - // 普通模式 + // Normal mode shouldShow = isLevelAllowed && !parentCollapsed; } } - // 如果父级折叠了,那肯定看不到 + // If the parent is collapsed, you will definitely not be able to see it. if (parentCollapsed) shouldShow = false; - // 构建 CSS 类名 - // 用户提问节点用 relativeLevel (0) - // 标题节点统一用 relativeLevel,这样层级会自动提升(如 H2 变成 level 1) + // Build CSS class names + // User question nodes use relativeLevel (0) + // Title nodes use relativeLevel, so that the level will automatically increase (for example, H2 becomes level 1) let cssLevel = item.relativeLevel; let itemClassName = `outline-item outline-level-${cssLevel}`; @@ -10150,7 +9789,7 @@ } itemEl.appendChild(toggle); - // 用户提问节点添加序号徽章(图标+角标数字) + // Add a serial number badge (icon + subscript number) to the user question node if (item.isUserQuery) { const queryNumber = this.getUserQueryIndex(item.index); const badge = createElement('span', { className: 'user-query-badge' }); @@ -10163,7 +9802,7 @@ const textEl = createElement('span', { className: 'outline-item-text' }); - // 高亮处理 + // Highlighting if (this.state.searchQuery && item.isMatch) { try { const query = this.state.searchQuery; @@ -10195,12 +9834,12 @@ } itemEl.appendChild(textEl); - // 用户提问添加复制按钮 + // User asked to add a copy button if (item.isUserQuery) { const copyBtn = createElement('span', { className: 'outline-item-copy-btn' }); copyBtn.title = 'Copy'; - // 使用 DOM API 创建 SVG(避免 innerHTML 的 CSP 问题) + // Create SVG using DOM API (avoiding innerHTML's CSP issues) const createCopyIcon = () => { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); @@ -10245,16 +9884,16 @@ copyBtn.appendChild(createCopyIcon()); copyBtn.addEventListener('click', async (e) => { - e.stopPropagation(); // 阻止跳转 + e.stopPropagation(); // Prevent jump try { - // 智能获取文本:短文本直接用缓存,长文本(被截断)从 DOM 重新提取 + // Intelligent text acquisition: short text is cached directly, long text (truncated) is re-extracted from the DOM let textToCopy = item.text; if (item.isTruncated && item.element && item.element.isConnected) { - // 文本被截断,从 DOM 提取完整文本 + // Text is truncated, extract full text from DOM textToCopy = this.siteAdapter.extractUserQueryText(item.element) || item.text; } await navigator.clipboard.writeText(textToCopy); - // 临时变成对号反馈 + // Temporarily become a check mark feedback copyBtn.replaceChildren(createCheckIcon()); setTimeout(() => { copyBtn.replaceChildren(createCopyIcon()); @@ -10269,11 +9908,11 @@ itemEl.addEventListener('click', () => { let targetElement = item.element; - // 1. 检查元素是否有效 + // 1. Check if the element is valid if (!targetElement || !targetElement.isConnected) { - // 尝试重新查找 - // 简单的重新查找策略:在文档中根据文本内容找一个最相似的 H? 标签 - // 这是一个兜底,Gemini 动态渲染可能会导致元素重建 + // Try searching again + // Simple re-search strategy: find the most similar H? tag in the document based on the text content + // This is a caveat, Gemini dynamic rendering may cause elements to be rebuilt const headings = document.querySelectorAll(`h${item.level}`); for (const h of headings) { if (h.textContent.trim() === item.text) { @@ -10284,12 +9923,12 @@ } if (targetElement && targetElement.isConnected) { - // 跳转前回调(用于保存当前位置为锚点) + // Callback before jump (used to save the current position as the anchor point) if (this.onJumpBefore) { this.onJumpBefore(); } - // 传入 __bypassLock: true 以绕过 ScrollLockManager 的拦截 - // 恢复 behavior: 'smooth',因为我们已经处理了元素重新查找,应该可以兼容 + // Pass in __bypassLock: true to bypass the interception of ScrollLockManager + // Restore behavior: 'smooth', since we have already handled element re-finding, it should be compatible targetElement.scrollIntoView({ behavior: 'smooth', block: 'start', __bypassLock: true }); targetElement.classList.add('outline-highlight'); setTimeout(() => targetElement.classList.remove('outline-highlight'), 2000); @@ -10311,12 +9950,12 @@ }); } - // 初始化树的折叠状态 - // 使用 relativeLevel 判断,与视觉层级保持一致 + // Initialize the collapsed state of the tree + // Use relativeLevel to judge, consistent with the visual hierarchy initializeCollapsedState(items, displayLevel) { items.forEach((item) => { if (item.children && item.children.length > 0) { - // 使用 relativeLevel 判断所有子节点是否都超过显示层级 + // Use relativeLevel to determine whether all child nodes exceed the display level const allChildrenHidden = item.children.every((child) => child.relativeLevel > displayLevel); item.collapsed = allChildrenHidden; this.initializeCollapsedState(item.children, displayLevel); @@ -10326,7 +9965,7 @@ }); } - // 滚动列表 + // scroll list scrollList() { const wrapper = document.getElementById('outline-list-wrapper'); const btn = document.getElementById('outline-scroll-btn'); @@ -10344,26 +9983,26 @@ } } - // 定位到当前页面位置对应的大纲项 + // Locate the outline item corresponding to the current page position locateCurrentPosition() { if (!this.state.tree || this.state.tree.length === 0) return; if (!this.siteAdapter) return; - // 0. 如果在搜索模式,先清除搜索(确保目标项能显示) + // 0. If in search mode, clear the search first (make sure the target item can be displayed) if (this.state.searchQuery) { this.handleSearch(''); - // 清除搜索框内容 + // Clear search box contents const searchInput = document.querySelector('.outline-search-input'); const clearBtn = document.querySelector('.outline-search-clear'); if (searchInput) searchInput.value = ''; if (clearBtn) clearBtn.classList.add('hidden'); } - // 1. 获取页面滚动容器 + // 1. Get the page scroll container const scrollContainer = this.siteAdapter.getScrollContainer(); if (!scrollContainer) return; - // 2. 收集所有大纲项的 element(展平树结构) + // 2. Collect elements of all outline items (flatten the tree structure) const flattenTree = (items) => { const result = []; items.forEach((item) => { @@ -10376,7 +10015,7 @@ }; const allItems = flattenTree(this.state.tree); - // 3. 找到当前可视区域中的第一个大纲元素 + // 3. Find the first outline element in the current visible area const containerRect = scrollContainer.getBoundingClientRect(); const viewportTop = containerRect.top; const viewportBottom = containerRect.bottom; @@ -10386,12 +10025,12 @@ if (!item.element || !item.element.isConnected) continue; const rect = item.element.getBoundingClientRect(); - // 判断元素是否在可视区域内(上边缘在视口内或元素跨越视口顶部) + // Determine whether the element is within the visible area (the top edge is within the viewport or the element spans the top of the viewport) if (rect.top >= viewportTop && rect.top < viewportBottom) { currentItem = item; break; } - // 如果元素跨越视口顶部(元素底部在视口内,顶部在视口上方) + // If the element spans the top of the viewport (the bottom of the element is inside the viewport and the top is above the viewport) if (rect.top < viewportTop && rect.bottom > viewportTop) { currentItem = item; break; @@ -10399,7 +10038,7 @@ } if (!currentItem) { - // 如果没找到,尝试找最接近视口顶部的元素 + // If not found, try to find the element closest to the top of the viewport let minDistance = Infinity; for (const item of allItems) { if (!item.element || !item.element.isConnected) continue; @@ -10414,11 +10053,11 @@ if (!currentItem) return; - // 4. 展开目标项的所有父级节点(确保目标可见) + // 4. Expand all parent nodes of the target item (make sure the target is visible) const expandParents = (items, targetIndex, parents = []) => { for (const item of items) { if (item.index === targetIndex) { - // 找到目标,展开所有父级 + // Find target, expand all parents parents.forEach((p) => { p.collapsed = false; p.forceExpanded = true; @@ -10435,43 +10074,43 @@ }; expandParents(this.state.tree, currentItem.index); - // 5. 刷新显示(展开父级后需要重新渲染) + // 5. Refresh the display (re-rendering is required after expanding the parent) this.refreshCurrent(); - // 6. 延迟执行滚动和高亮(等待 DOM 更新) + // 6. Delay scrolling and highlighting (waiting for DOM updates) setTimeout(() => { const outlineList = document.getElementById('outline-list'); if (!outlineList) return; - // 通过 data-index 找到对应的大纲项 + // Find the corresponding outline item through data-index const outlineItem = outlineList.querySelector(`.outline-item[data-index="${currentItem.index}"]`); if (!outlineItem) return; - // 滚动大纲面板到该项 + // Scroll the outline panel to the item const wrapper = document.getElementById('outline-list-wrapper'); if (wrapper) { const wrapperRect = wrapper.getBoundingClientRect(); const itemRect = outlineItem.getBoundingClientRect(); - // 计算需要滚动的距离,使目标元素居中 + // Calculate the distance required to scroll so that the target element is centered const scrollOffset = itemRect.top - wrapperRect.top - wrapperRect.height / 2 + itemRect.height / 2; wrapper.scrollBy({ top: scrollOffset, behavior: 'smooth' }); } - // 高亮该大纲项 + // Highlight this outline item outlineItem.classList.add('highlight'); setTimeout(() => outlineItem.classList.remove('highlight'), 2000); }, 50); } - // 展开/折叠全部 + // Expand/collapse all toggleExpandAll() { const btn = document.getElementById('outline-expand-btn'); if (!btn) return; if (this.state.isAllExpanded) { - // 如果开启了"只显示用户提问",收起时应折叠到 Level 0 (只显示提问) - // 否则折叠到最小标题层级 (通常是 1) + // If "Only display user questions" is turned on, it should collapse to Level 0 (only display questions) when collapsed. + // Otherwise collapse to the smallest heading level (usually 1) const targetLevel = this.settings.outline?.showUserQueries ? 0 : this.state.minLevel || 1; this.setLevel(targetLevel); } else { @@ -10480,43 +10119,43 @@ } } - // 切换用户提问分组模式 + // Switch user question grouping mode toggleGroupMode() { const btn = document.getElementById('outline-group-btn'); if (!this.settings.outline) return; - // 切换设置 + // Switch settings this.settings.outline.showUserQueries = !this.settings.outline.showUserQueries; - // 同步到 state(用于 minDisplayLevel 计算) + // Sync to state (for minDisplayLevel calculation) this.state.includeUserQueries = this.settings.outline.showUserQueries; - // 更新按钮状态 + // Update button state if (btn) { btn.classList.toggle('active', this.settings.outline.showUserQueries); } - // 保存设置 + // Save settings if (this.onSettingsChange) this.onSettingsChange(); - // 触发大纲刷新 + // Trigger outline refresh window.dispatchEvent(new CustomEvent('gemini-helper-outline-auto-refresh')); } - // 设置层级 + // Set level setLevel(level) { this.state.expandLevel = level; - // 更新外部设置 + // Update external settings if (this.settings.outline) { this.settings.outline.maxLevel = level; if (this.onSettingsChange) this.onSettingsChange(); } - // 清除强制展开状态 + // Clear force expansion status if (this.state.tree) { this.clearForceExpandedState(this.state.tree, level); } - // 更新 UI + // Update UI const dots = document.querySelectorAll('.outline-level-dot'); dots.forEach((dot) => { const dotLevel = parseInt(dot.dataset.level, 10); @@ -10528,14 +10167,14 @@ progress.style.width = `${(level / 6) * 100}%`; } - // 如果在搜索状态下调整了 Slider,标记为手动 + // If the Slider was adjusted while in search mode, mark it as Manual if (this.state.searchQuery) { this.state.searchLevelManual = true; this.refreshCurrent(); } else { - // 非搜索状态,这里可能不需要 refreshCurrent,因为 updateTooltips 或其他地方可能触发? - // 原有逻辑似乎没有显式调用 refreshCurrent,可能是 toggleExpnadAll 调用的? - // 不,setLevel 是被点击调用的。所以必须刷新。 + // Non-search state, refreshCurrent may not be needed here as updateTooltips or elsewhere may trigger? + // The original logic does not seem to explicitly call refreshCurrent, maybe it is called by toggleExpnadAll? + // No, setLevel is called on click. So it must be refreshed. this.refreshCurrent(); } @@ -10556,7 +10195,7 @@ this.refreshCurrent(); } - // 清除强制展开状态 + // Clear force expansion status clearForceExpandedState(items, displayLevel) { items.forEach((item) => { item.forceExpanded = false; @@ -10570,7 +10209,7 @@ }); } - // 更新提示 + // Update tips updateTooltips() { const dots = document.querySelectorAll('.outline-level-dot'); const showUserQueries = this.settings.outline?.showUserQueries || false; @@ -10581,7 +10220,7 @@ if (!tooltip) return; if (level === 0) { - // Level 0: 分组模式下显示"只显示用户提问",否则显示折叠符号 + // Level 0: In group mode, "Only display user questions" is displayed, otherwise a folding symbol is displayed. tooltip.textContent = showUserQueries ? this.t('outlineOnlyUserQueries') : '⊖'; } else { const count = this.state.levelCounts[level] || 0; @@ -10590,17 +10229,17 @@ }); } - // 捕获树的状态(expanded/collapsed) + // Capture the state of the tree (expanded/collapsed) captureTreeState(nodes, stateMap) { nodes.forEach((node) => { - // 使用 level + text 作为 key - // 注意:如果有完全相同的标题在同一级,可能会冲突,但在当前场景下可以接受 + // Use level + text as key + // Note: If there are exactly the same titles at the same level, it may conflict, but it is acceptable in the current scenario const key = `${node.level}_${node.text}`; const hasChildren = node.children && node.children.length > 0; stateMap[key] = { collapsed: node.collapsed, forceExpanded: node.forceExpanded, - hadChildren: hasChildren, // 记录当时是否有子节点,用于判断结构变化 + hadChildren: hasChildren, // Record whether there are child nodes at that time, used to determine structural changes }; if (hasChildren) { @@ -10609,10 +10248,10 @@ }); } - // 恢复树的状态 - // 策略:只有当节点结构未发生「无子节点→有子节点」变化时才恢复折叠状态 - // 这是为了避免:用户提问刚发出时无子节点(collapsed=false),AI回复后有子节点 - // 此时应该尊重 initializeCollapsedState 基于 displayLevel 计算的新值 + // Restoring the state of the tree + // Strategy: Only restore the folded state when the node structure does not change from "no child node to child node" + // This is to avoid: there are no child nodes when the user asks the question (collapsed=false), but there are child nodes after the AI replies. + // At this time the new value calculated by initializeCollapsedState based on displayLevel should be respected. restoreTreeState(nodes, stateMap) { nodes.forEach((node) => { const key = `${node.level}_${node.text}`; @@ -10621,13 +10260,13 @@ const hasChildrenNow = node.children && node.children.length > 0; const hadChildrenBefore = state.hadChildren; - // 只有当「之前有子节点 或 现在没有子节点」时才恢复 collapsed 状态 - // 即:如果从「无子节点」变为「有子节点」,不恢复(保持 initializeCollapsedState 的结果) + // The collapsed state is restored only when "there were child nodes before or there are no child nodes now" + // That is: if it changes from "no child node" to "with child node", it will not be restored (the result of initializeCollapsedState will be retained) if (hadChildrenBefore || !hasChildrenNow) { node.collapsed = state.collapsed; } - // forceExpanded 可以无条件恢复(这是用户手动操作的标记) + // forceExpanded can be restored unconditionally (this is a mark of manual operation by the user) if (state.forceExpanded !== undefined) { node.forceExpanded = state.forceExpanded; } @@ -10641,16 +10280,10 @@ } /** - * 设置管理器 - * 负责所有设置的加载、保存和默认值合并 - */ + * settings manager * Responsible for loading, saving and merging default values of all settings*/ class SettingsManager { /** - * 兼容性处理:确保 collapsedButtonsOrder 包含所有默认按钮 - * 新增的按钮会自动插入到 scrollBottom 之前 - * @param {Array} savedOrder 保存的按钮顺序 - * @returns {Array} 处理后的按钮顺序 - */ + * Compatibility processing: ensure collapsedButtonsOrder contains all default buttons * New buttons will be automatically inserted before scrollBottom * @param {Array} savedOrder saved button order * @returns {Array} processed button order*/ _migrateCollapsedButtonsOrder(savedOrder) { if (!savedOrder || savedOrder.length === 0) { return DEFAULT_COLLAPSED_BUTTONS_ORDER; @@ -10659,17 +10292,17 @@ const savedIds = savedOrder.map((b) => b.id); const defaultIds = DEFAULT_COLLAPSED_BUTTONS_ORDER.map((b) => b.id); - // 找出缺失的按钮 + // Find the missing button const missingIds = defaultIds.filter((id) => !savedIds.includes(id)); if (missingIds.length === 0) { return savedOrder; } - // 复制一份,避免修改原数组 + // Make a copy to avoid modifying the original array let result = [...savedOrder]; - // 在 scrollBottom 之前插入缺失的按钮 + // Insert missing button before scrollBottom const scrollBottomIndex = result.findIndex((b) => b.id === 'scrollBottom'); const insertIndex = scrollBottomIndex !== -1 ? scrollBottomIndex : result.length; @@ -10680,48 +10313,44 @@ } }); - // 保存更新后的配置 + // Save updated configuration GM_setValue(SETTING_KEYS.COLLAPSED_BUTTONS_ORDER, result); return result; } /** - * 加载设置 - * @param {SiteRegistry} registry 站点注册表 - * @param {SiteAdapter} currentAdapter 当前适配器 - * @returns {Object} 完整的设置对象 - */ + * Load settings * @param {SiteRegistry} registry site registry * @param {SiteAdapter} currentAdapter current adapter * @returns {Object} complete settings object*/ load(registry, currentAdapter) { const widthSettings = GM_getValue(SETTING_KEYS.PAGE_WIDTH, DEFAULT_WIDTH_SETTINGS); const outlineSettings = GM_getValue(SETTING_KEYS.OUTLINE, DEFAULT_OUTLINE_SETTINGS); const promptsSettings = GM_getValue(SETTING_KEYS.PROMPTS_SETTINGS, DEFAULT_PROMPTS_SETTINGS); let tabOrder = GM_getValue(SETTING_KEYS.TAB_ORDER, DEFAULT_TAB_ORDER); - // 兼容老用户:确保所有默认 Tab 都在 tabOrder 中 - // 如果有新增的 Tab(如 conversations),自动添加到 settings 之前 + // Compatible with old users: ensure that all default Tabs are in tabOrder + // If there are new Tabs (such as conversations), they will be automatically added before settings const missingTabs = DEFAULT_TAB_ORDER.filter((tab) => !tabOrder.includes(tab)); if (missingTabs.length > 0) { const settingsIndex = tabOrder.indexOf('settings'); if (settingsIndex !== -1) { - // 在 settings 之前插入缺失的 Tab + // Insert missing Tab before settings tabOrder = [...tabOrder.slice(0, settingsIndex), ...missingTabs, ...tabOrder.slice(settingsIndex)]; } else { - // 如果没有 settings,直接追加 + // If there are no settings, add them directly. tabOrder = [...tabOrder, ...missingTabs]; } - // 保存更新后的 tabOrder + // Save the updated tabOrder GM_setValue(SETTING_KEYS.TAB_ORDER, tabOrder); } - // 加载模型锁定设置(按站点隔离,但一次性加载所有站点的配置) + // Load model lock settings (isolate by site, but load configuration for all sites at once) const savedModelLockSettings = GM_getValue(SETTING_KEYS.MODEL_LOCK, {}); const mergedModelLockConfig = {}; - // 兼容旧的单一适配器模式(防御性代码) + // Compatible with old single adapter pattern (defensive code) const currentSiteId = currentAdapter ? currentAdapter.getSiteId() : 'unknown'; - // 遍历所有注册的适配器,合并默认配置和保存的配置 + // Iterate through all registered adapters, merging default and saved configurations if (registry && registry.adapters) { registry.adapters.forEach((adapter) => { const siteId = adapter.getSiteId(); @@ -10733,11 +10362,11 @@ mergedModelLockConfig[currentSiteId] = { ...defaults, ...(savedModelLockSettings[currentSiteId] || {}) }; } - // 确保大纲设置有默认值 (合并默认配置与保存的配置) + // Make sure the outline settings have default values (merge default configuration with saved configuration) const mergedOutlineSettings = { ...DEFAULT_OUTLINE_SETTINGS, ...outlineSettings }; return { - clearTextareaOnSend: GM_getValue(SETTING_KEYS.CLEAR_TEXTAREA_ON_SEND, false), // 默认关闭 + clearTextareaOnSend: GM_getValue(SETTING_KEYS.CLEAR_TEXTAREA_ON_SEND, false), // Off by default modelLockConfig: mergedModelLockConfig, pageWidth: widthSettings[currentSiteId] || DEFAULT_WIDTH_SETTINGS[currentSiteId], outline: mergedOutlineSettings, @@ -10752,80 +10381,75 @@ syncUnpin: false, ...GM_getValue(SETTING_KEYS.CONVERSATIONS_SETTINGS, {}), }, - // 默认面板状态 + // Default panel state defaultPanelState: GM_getValue(SETTING_KEYS.DEFAULT_PANEL_STATE, true), - // 自动隐藏面板 + // Autohide panel autoHidePanel: GM_getValue(SETTING_KEYS.AUTO_HIDE_PANEL, false), - // 主题模式 (null=跟随系统/默认, 'light', 'dark') + // Theme mode (null=follow system/default, 'light', 'dark') themeMode: GM_getValue(`gemini_theme_mode_${currentAdapter ? currentAdapter.getSiteId() : 'default'}`, null), - // 边缘吸附隐藏功能 + // Edge adsorption hiding function edgeSnapHide: GM_getValue('gemini_edge_snap_hide', false), - // 水印移除功能 + // Watermark removal function watermarkRemoval: GM_getValue('gemini_watermark_removal', false), }; } /** - * 保存设置 - * @param {Object} settings 当前设置对象 - * @param {SiteAdapter} currentAdapter 当前适配器 - */ + * Save settings * @param {Object} settings current settings object * @param {SiteAdapter} currentAdapter current adapter*/ save(settings, currentAdapter) { GM_setValue(SETTING_KEYS.CLEAR_TEXTAREA_ON_SEND, settings.clearTextareaOnSend); - // 保存模型锁定设置(保存整个字典) + // Save model lock settings (save the entire dictionary) GM_setValue(SETTING_KEYS.MODEL_LOCK, settings.modelLockConfig); - // 保存标签页设置 + // Save tab settings GM_setValue(SETTING_KEYS.TAB_SETTINGS, settings.tabSettings); - // 保存页面宽度设置 + // Save page width settings const allWidthSettings = GM_getValue(SETTING_KEYS.PAGE_WIDTH, DEFAULT_WIDTH_SETTINGS); if (currentAdapter) { allWidthSettings[currentAdapter.getSiteId()] = settings.pageWidth; } GM_setValue(SETTING_KEYS.PAGE_WIDTH, allWidthSettings); - // 保存大纲设置 + // Save outline settings GM_setValue(SETTING_KEYS.OUTLINE, settings.outline); - // 保存提示词设置 + // Save prompt word settings GM_setValue(SETTING_KEYS.PROMPTS_SETTINGS, settings.prompts); - // 保存 Tab 顺序 + // Save tab order GM_setValue(SETTING_KEYS.TAB_ORDER, settings.tabOrder); - // 保存防滚动设置 + // Save anti-roll settings GM_setValue('gemini_prevent_auto_scroll', settings.preventAutoScroll); - // 保存阅读历史设置 + // Save reading history settings GM_setValue(SETTING_KEYS.READING_HISTORY, settings.readingHistory); - // 保存会话设置 + // Save session settings if (settings.conversations) { GM_setValue(SETTING_KEYS.CONVERSATIONS_SETTINGS, settings.conversations); } GM_setValue('gemini_default_panel_state', settings.defaultPanelState); GM_setValue('gemini_default_auto_hide', settings.autoHidePanel); - // 保存主题模式 (使用站点特有的 Key) + // Save theme (using site-specific Key) if (currentAdapter) { GM_setValue(`gemini_theme_mode_${currentAdapter.getSiteId()}`, settings.themeMode); } else { GM_setValue('gemini_theme_mode_default', settings.themeMode); } - // 保存折叠面板按钮顺序 + // Save accordion button order GM_setValue(SETTING_KEYS.COLLAPSED_BUTTONS_ORDER, settings.collapsedButtonsOrder); - // 保存边缘吸附隐藏设置 + // Save edge snapping hide settings GM_setValue('gemini_edge_snap_hide', settings.edgeSnapHide); - // 保存水印移除设置 + // Save watermark removal settings GM_setValue('gemini_watermark_removal', settings.watermarkRemoval); } } /** - * 复制管理器 - * 负责公式双击复制、表格 Markdown 复制等功能 - */ + * Copy Manager * Responsible for functions such as double-click copying of formulas and Markdown copying of tables.*/ class CopyManager { #settings; #formulaCopyInitialized = false; #tableCopyInitialized = false; #formulaDblClickHandler = null; - #stopTableWatch = null; // DOMToolkit.each 返回的停止函数 + #stopTableWatch = null; // Stop function returned by DOMToolkit.each #injectTableButton = null; constructor(settings) { @@ -10835,14 +10459,12 @@ // ==================== Formula Copy ==================== /** - * 初始化公式双击复制功能 - * 禁用公式文字选择,双击复制 LaTeX 源码 - */ + * Initialize formula double-click copy function * Disable formula text selection, double-click to copy LaTeX source code*/ initFormulaCopy() { if (this.#formulaCopyInitialized) return; this.#formulaCopyInitialized = true; - // 注入 CSS + // Inject CSS const styleId = 'gh-formula-copy-style'; if (!document.getElementById(styleId)) { const style = document.createElement('style'); @@ -10861,7 +10483,7 @@ document.head.appendChild(style); } - // 双击事件委托处理 + // Double-click event delegate processing this.#formulaDblClickHandler = (e) => { const mathEl = e.target.closest('.math-block, .math-inline'); if (!mathEl) return; @@ -10880,7 +10502,7 @@ navigator.clipboard .writeText(copyText) - .then(() => showToast(t('formulaCopied') || '公式已复制')) + .then(() => showToast(t('formulaCopied') || 'Formula copied')) .catch((err) => { console.error('[FormulaCopy] Copy failed:', err); showToast(this.t('copyFailed')); @@ -10894,8 +10516,7 @@ } /** - * 销毁公式双击复制功能 - */ + * Destroy formula double-click copy function*/ destroyFormulaCopy() { this.#formulaCopyInitialized = false; @@ -10911,13 +10532,12 @@ // ==================== Table Copy ==================== /** - * 初始化表格 Markdown 复制功能 - */ + * Initialize table Markdown copy function*/ initTableCopy() { if (this.#tableCopyInitialized) return; this.#tableCopyInitialized = true; - // 注入 CSS + // Inject CSS const styleId = 'gh-table-copy-style'; if (!document.getElementById(styleId)) { const style = document.createElement('style'); @@ -10955,21 +10575,21 @@ document.head.appendChild(style); } - // 按钮注入函数 + // Button injection function this.#injectTableButton = (table) => { if (table.dataset.ghTableCopy) return; table.dataset.ghTableCopy = 'true'; try { - // 尝试找到原生表格容器: - // - Gemini 普通版: table-block - // - Gemini Business: ucs-markdown-table (在 Shadow DOM 内部,closest 可能找不到) - // 如果都没有,使用 table 的父元素(不创建 wrapper 以避免破坏流式渲染) + // Try to find the native table container: + // - Gemini regular version: table-block + // - Gemini Business: ucs-markdown-table (inside Shadow DOM, closest may not be found) + // If neither exists, use the table's parent element (without creating a wrapper to avoid breaking streaming rendering) let container = table.closest('table-block, ucs-markdown-table'); if (!container) { container = table.parentNode; if (!container) return; - // 添加标记类以便 CSS 选择器可以匹配 + // Add markup classes so CSS selectors can match container.classList.add('gh-table-container'); } container.style.position = 'relative'; @@ -10978,11 +10598,11 @@ btn.className = 'gh-table-copy-btn'; btn.textContent = '📋'; btn.title = t('tableCopyLabel') || 'Copy Markdown'; - // 检测是否在 Gemini Business 容器中(有原生按钮),调整位置避免遮挡 + // Detect whether it is in the Gemini Business container (with native buttons) and adjust the position to avoid obstruction const isGeminiBusiness = container.tagName?.toLowerCase() === 'ucs-markdown-table' || container.closest?.('ucs-markdown-table') || container.classList?.contains('gh-table-container'); const rightOffset = isGeminiBusiness ? '80px' : '4px'; - // 使用内联样式确保定位正确(CSS 可能无法穿透 Shadow DOM) + // Use inline styles to ensure correct positioning (CSS may not penetrate Shadow DOM) Object.assign(btn.style, { position: 'absolute', top: '4px', @@ -11001,7 +10621,7 @@ transition: 'opacity 0.2s, transform 0.2s', zIndex: '10', }); - // hover 效果 + // hover effect btn.addEventListener('mouseenter', () => { btn.style.opacity = '1'; btn.style.transform = 'scale(1.1)'; @@ -11019,7 +10639,7 @@ navigator.clipboard .writeText(markdown) .then(() => { - showToast(t('tableCopied') || '表格已复制'); + showToast(t('tableCopied') || 'Table copied'); btn.textContent = '✓'; setTimeout(() => { btn.textContent = '📋'; @@ -11037,8 +10657,8 @@ } }; - // 使用 DOMToolkit.each 持续监听表格(支持 Shadow DOM 穿透) - // 这比 MutationObserver 更适合 Gemini Business 的深层 Shadow DOM 结构 + // Use DOMToolkit.each to continuously monitor the table (supports Shadow DOM penetration) + // This is better suited to Gemini Business's deep Shadow DOM structure than MutationObserver this.#stopTableWatch = DOMToolkit.each( 'table', (table) => { @@ -11049,8 +10669,7 @@ } /** - * 表格转 Markdown - */ + * Convert table to Markdown */ tableToMarkdown(table) { const rows = table.querySelectorAll('tr'); if (rows.length === 0) return ''; @@ -11097,12 +10716,11 @@ } /** - * 销毁表格复制功能 - */ + * Destroy table copy function*/ destroyTableCopy() { this.#tableCopyInitialized = false; - // 停止 DOMToolkit.each 的监听 + // Stop listening for DOMToolkit.each if (this.#stopTableWatch) { this.#stopTableWatch(); this.#stopTableWatch = null; @@ -11111,12 +10729,12 @@ const style = document.getElementById('gh-table-copy-style'); if (style) style.remove(); - // 使用 DOMToolkit 清理 Shadow DOM 中的元素 + // Use DOMToolkit to clean elements in Shadow DOM DOMToolkit.query('.gh-table-copy-btn', { all: true, shadow: true })?.forEach((btn) => btn.remove()); DOMToolkit.query('[data-gh-table-copy]', { all: true, shadow: true })?.forEach((el) => { delete el.dataset.ghTableCopy; }); - // 清理添加的容器类名 + // Clean up added container class names DOMToolkit.query('.gh-table-container', { all: true, shadow: true })?.forEach((el) => { el.classList.remove('gh-table-container'); }); @@ -11124,40 +10742,277 @@ } /** - * Gemini 助手核心类 - * 管理提示词、设置和 UI 界面 + * Smart Enter Manager + * Queue Enter key and auto-submit once image/attachment upload completes + * Also manages floating scroll button and disclaimer hiding */ + class SmartEnterManager { + constructor(helper) { + this.helper = helper; + this.siteAdapter = helper.siteAdapter; + this.settings = helper.settings; + this.enterQueued = false; + this._pollInterval = null; + this._retryInterval = null; + this._observer = null; + } + + init() { + // Register keydown interceptor in capture phase + document.addEventListener('keydown', (e) => this.handleKeyDown(e), true); + // Register paste interceptor in capture phase + document.addEventListener('paste', (e) => this.handlePaste(e), true); + } + + start() { + this.toggleDisclaimer(); + this.toggleScrollButton(); + + // Observe DOM changes to dynamically re-apply features + if (this._observer) this._observer.disconnect(); + this._observer = new MutationObserver(() => { + this.toggleDisclaimer(); + this.toggleScrollButton(); + }); + this._observer.observe(document.documentElement, { childList: true, subtree: true }); + } + + stop() { + if (this._observer) { + this._observer.disconnect(); + this._observer = null; + } + if (this._pollInterval) clearInterval(this._pollInterval); + if (this._retryInterval) clearInterval(this._retryInterval); + const btn = document.getElementById('gem-scroll-btn'); + if (btn) btn.remove(); + const style = document.getElementById('gem-hide-style'); + if (style) style.remove(); + } + + isUploading() { + // Standard uploader progress + const area = document.querySelector('.xap-uploader-dropzone'); + if (area?.querySelector('.mdc-circular-progress--indeterminate, mat-progress-spinner, mat-spinner, [role="progressbar"]')) { + return true; + } + + // Image attachment not fully loaded + const img = document.querySelector('img.gem-attachment-style-img, img[src^="blob:"]'); + if (img && img.naturalWidth === 0) { + return true; + } + + // Other attachment progress + const progress = document.querySelector('gem-media-attachment [role="progressbar"], .xap-uploader-dropzone [role="progressbar"]'); + if (progress) { + return true; + } + + return false; + } + + hasAttachment() { + return !!document.querySelector('gem-media-attachment, .xap-uploader-dropzone img, .gem-attachment-style-img, [class*="attachment-"]'); + } + + findSubmitButton() { + const selectors = this.siteAdapter.getSubmitButtonSelectors?.() || ['button[aria-label*="Send"]', 'button[aria-label*="Submit"]', '.send-button']; + for (const sel of selectors) { + const btn = document.querySelector(sel); + if (btn) return btn; + } + return null; + } + + findActualInputBox() { + if (this.siteAdapter.textarea && this.siteAdapter.textarea.isConnected) { + return this.siteAdapter.textarea; + } + return document.querySelector('div[contenteditable="true"]') || + document.querySelector('.text-input-field_textarea-wrapper [contenteditable="true"]') || + document.querySelector('textarea, .ProseMirror'); + } + + handlePaste(e) { + if (!this.settings.tabSettings?.pasteFocusFix) return; + + const hasFiles = e.clipboardData && e.clipboardData.files && e.clipboardData.files.length > 0; + if (hasFiles) { + let attempts = 0; + const focusInterval = setInterval(() => { + const chatBox = this.findActualInputBox(); + if (chatBox) { + chatBox.focus(); + } + attempts++; + if (attempts > 20) { + clearInterval(focusInterval); + } + }, 50); + } else { + setTimeout(() => { + const chatBox = this.findActualInputBox(); + if (chatBox) chatBox.focus(); + }, 50); + } + } + + handleKeyDown(e) { + if (!this.settings.tabSettings?.smartEnter) return; + if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.altKey) return; + + const active = document.activeElement; + const inInput = active && ( + active.tagName === 'TEXTAREA' || + active.getAttribute('contenteditable') === 'true' || + active.closest('[contenteditable="true"]') || + active.closest('.xap-uploader-dropzone') + ); + + if (!inInput) return; + + if (this.hasAttachment() && this.isUploading()) { + e.preventDefault(); + e.stopPropagation(); + + if (!this.enterQueued) { + this.enterQueued = true; + showToast('Queued submit (waiting for upload)...'); + this.pollForUploadDone(); + } + } + } + + pollForUploadDone() { + if (this._pollInterval) clearInterval(this._pollInterval); + + let elapsed = 0; + this._pollInterval = setInterval(() => { + elapsed += 100; + if (!this.enterQueued) { + clearInterval(this._pollInterval); + return; + } + + if (!this.isUploading()) { + clearInterval(this._pollInterval); + this.retrySubmit(); + return; + } + + if (elapsed > 30000) { + this.enterQueued = false; + clearInterval(this._pollInterval); + showToast('Upload timeout (queue canceled).'); + } + }, 100); + } + + retrySubmit() { + if (this._retryInterval) clearInterval(this._retryInterval); + + let attempts = 0; + this._retryInterval = setInterval(() => { + attempts++; + if (!this.enterQueued) { + clearInterval(this._retryInterval); + return; + } + + const btn = this.findSubmitButton(); + if (btn && !btn.disabled && btn.offsetParent !== null) { + btn.click(); + this.enterQueued = false; + clearInterval(this._retryInterval); + return; + } + + if (attempts > 20) { + this.enterQueued = false; + clearInterval(this._retryInterval); + showToast('Could not auto-submit (submit button disabled).'); + } + }, 150); + } + + toggleDisclaimer() { + const hide = this.settings.tabSettings?.hideDisclaimer; + let style = document.getElementById('gem-hide-style'); + if (hide) { + if (!style) { + style = document.createElement('style'); + style.id = 'gem-hide-style'; + style.textContent = `p[data-test-id="disclaimer"] { display: none !important; }`; + document.head.appendChild(style); + } + } else { + if (style) style.remove(); + } + } + + toggleScrollButton() { + const show = this.settings.tabSettings?.showScrollBtn; + const btn = document.getElementById('gem-scroll-btn'); + if (show) { + if (!btn) this.addScrollButton(); + } else { + if (btn) btn.remove(); + } + } + + addScrollButton() { + if (document.getElementById('gem-scroll-btn')) return; + const btn = document.createElement('button'); + btn.id = 'gem-scroll-btn'; + btn.textContent = '▼'; + btn.title = 'Scroll to bottom'; + btn.addEventListener('click', () => { + const containers = document.querySelectorAll('*'); + for (const el of containers) { + if (el.scrollHeight > el.clientHeight + 10) { + el.scrollTop = el.scrollHeight; + } + } + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }); + document.body.appendChild(btn); + } + } + + /** + * Gemini assistant core class * Manage prompts, settings and UI interface */ class GeminiHelper { constructor(siteRegistry) { this.prompts = this.loadPrompts(); this.registry = siteRegistry; - // 保持 siteAdapter 引用以便兼容旧代码,指向当前匹配的站点 + // Keep a siteAdapter reference for compatibility with older code, pointing to the current matching site this.siteAdapter = siteRegistry.getCurrent(); this.selectedPrompt = null; - this.isScrolling = false; // 滚动状态锁 - this.lang = detectLanguage(); // 当前语言 - this.i18n = I18N[this.lang]; // 当前语言文本 + this.isScrolling = false; // scroll state lock + this.lang = detectLanguage(); // Current language + this.i18n = I18N[this.lang]; // Current language text this.settingsManager = new SettingsManager(); - this.settings = this.loadSettings(); // 加载设置 - this.copyManager = new CopyManager(this.settings); // 复制管理器 + this.settings = this.loadSettings(); // Load settings + this.copyManager = new CopyManager(this.settings); // Replication Manager // Restore saved theme preference if exists if (this.settings.themeMode) { this.applyTheme(this.settings.themeMode); } - // 根据设置初始化面板折叠状态 (默认显示面板 -> !collapsed) + // Initialize panel collapse state according to settings (default display panel -> !collapsed) this.isCollapsed = !this.settings.defaultPanelState; - // 初始化当前 Tab:优先使用设置的第一个 Tab + // Initialize the current Tab: give priority to the first Tab set this.currentTab = this.settings.tabOrder && this.settings.tabOrder.length > 0 ? this.settings.tabOrder[0] : 'prompts'; - // 兜底:如果首个 Tab 被禁用,则回退到 safe tab + // Bottom line: If the first Tab is disabled, fall back to the safe tab const isOutlineDisabled = this.currentTab === 'outline' && !this.settings.outline?.enabled; const isPromptsDisabled = this.currentTab === 'prompts' && !this.settings.prompts?.enabled; if (isOutlineDisabled || isPromptsDisabled) { - // 尝试找一个可用的 tab + // Try to find an available tab const availableTab = this.settings.tabOrder.find((t) => { if (t === 'outline') return this.settings.outline?.enabled; if (t === 'prompts') return this.settings.prompts?.enabled; @@ -11166,37 +11021,40 @@ this.currentTab = availableTab || 'settings'; } - // 初始化核心功能管理器 + // Initialize core functionality manager this.scrollManager = new ScrollManager(this.siteAdapter); this.readingProgressManager = new ReadingProgressManager(this.settings, this.scrollManager, (k) => this.t(k)); this.anchorManager = new AnchorManager(this.scrollManager, (k) => this.t(k)); this.historyLoader = new HistoryLoader(this.scrollManager, (k) => this.t(k)); - // 绑定锚点状态变化更新 UI + // Bind anchor point state changes update UI this.anchorManager.bindUI((hasAnchor) => this.updateAnchorButtonState(hasAnchor)); - // 初始化滚动锁定管理器 + // Initialize the scroll lock manager this.scrollLockManager = new ScrollLockManager(this.siteAdapter); - // 根据设置初始化状态,前提是当前站点支持 + // Initialize the state according to the setting, provided that the current site supports if (this.settings.preventAutoScroll && this.siteAdapter.supportsScrollLock()) { this.scrollLockManager.setEnabled(true); } this.outlineManager = null; - this.markdownFixer = null; // Markdown 加粗修复器 - // 边缘吸附状态 + this.markdownFixer = null; // Markdown bold fixer + // edge adsorption state this.edgeSnapState = null; // null | 'left' | 'right' - // 手动锚点位置 + // Manual anchor position this.savedAnchorTop = null; - // 水印移除器 + // watermark remover this.watermarkRemover = new WatermarkRemover(); if (this.settings.watermarkRemoval && this.siteAdapter instanceof GeminiAdapter) { this.watermarkRemover.start(); } + this.smartEnterManager = new SmartEnterManager(this); + this.smartEnterManager.init(); + this.smartEnterManager.start(); this.init(); } - // 获取翻译文本 + // Get translated text t(key) { return this.i18n[key] || key; } @@ -11214,12 +11072,12 @@ GM_setValue('universal_prompts', this.prompts); } - // 加载设置 + // Load settings loadSettings() { return this.settingsManager.load(this.registry, this.siteAdapter); } - // 保存设置 + // Save settings saveSettings() { this.settingsManager.save(this.settings, this.siteAdapter); } @@ -11262,58 +11120,58 @@ this.createUI(); this.monitorTheme(); this.bindEvents(); - // 初始化锚点按钮状态(初始时没有锚点,应置灰) + // Initialize the anchor point button state (there is no anchor point initially and should be grayed out) this.updateAnchorButtonState(false); this.siteAdapter.findTextarea(); - // 对于 Gemini Business,根据设置决定是否在初始化时插入零宽字符 + // For Gemini Business, depending on the setting determines whether to insert zero-width characters on initialization const currentSiteId = this.siteAdapter.getSiteId(); const adapterOptions = { clearOnInit: this.siteAdapter instanceof GeminiBusinessAdapter ? this.settings.clearTextareaOnSend : false, - modelLockConfig: this.settings.modelLockConfig[currentSiteId], // 传递当前站点的配置 + modelLockConfig: this.settings.modelLockConfig[currentSiteId], // Pass the configuration of the current site }; - // 绑定新对话监听 (点击按钮或快捷键) + // Bind new conversation listener (click button or shortcut key) this.siteAdapter.bindNewChatListeners(() => { console.log('Gemini Helper: New chat detected, re-initializing...'); - // 使用当前内存中的设置重新应用配置(无需重新加载) + // Reapply the configuration using the settings currently in memory (no reloading required) const currentSiteId = this.siteAdapter.getSiteId(); const adapterOptions = { clearOnInit: this.siteAdapter instanceof GeminiBusinessAdapter ? this.settings.clearTextareaOnSend : false, modelLockConfig: this.settings.modelLockConfig[currentSiteId], }; this.siteAdapter.afterPropertiesSet(adapterOptions); - // 重新应用滚动锁定状态 + // Reapply scroll lock state if (this.scrollLockManager) { - this.scrollLockManager.siteAdapter = this.siteAdapter; // 确保适配器更新 + this.scrollLockManager.siteAdapter = this.siteAdapter; // Make sure the adapter is updated this.scrollLockManager.setEnabled(this.settings.preventAutoScroll); } - // 重新应用宽度样式 (防止页面重置) + // Reapply width styles (prevent page reset) if (this.widthStyleManager) { this.widthStyleManager.apply(); } }); this.siteAdapter.afterPropertiesSet(adapterOptions); - // 初始化时执行锚点恢复和清理 + // Perform anchor recovery and cleanup on initialization if (this.settings.readingHistory.persistence) { - // 延迟触发以确保页面加载完成 + // Delay triggering to ensure page load is complete setTimeout(() => { this.restoreReadingProgress(); this.cleanupReadingHistory(); }, 2000); } - // 创建并应用页面宽度样式 + // Create and apply page width styles this.widthStyleManager = new WidthStyleManager(this.siteAdapter, this.settings.pageWidth); this.widthStyleManager.apply(); - // 初始化标签页重命名管理器 + // Initialize the tab rename manager this.tabRenameManager = new TabRenameManager(this.siteAdapter, this.settings, (key) => this.t(key)); if (this.settings.tabSettings?.autoRenameTab) { this.tabRenameManager.start(); } - // 初始化 Markdown 加粗修复(仅 Gemini 普通版需要) + // Initialization Markdown bold fix (only required for Gemini regular version) const isStandardGemini = this.siteAdapter instanceof GeminiAdapter; const mdFixSettings = GM_getValue(SETTING_KEYS.MARKDOWN_FIX, DEFAULT_MARKDOWN_FIX_SETTINGS); if (isStandardGemini && mdFixSettings.enabled) { @@ -11321,8 +11179,8 @@ this.markdownFixer.start(); } - // 初始化公式双击复制功能(仅 Gemini 普通版,且设置已开启) - // 默认开启(首次使用时) + // Initialization formula double-click copy function (only Gemini regular version, and the setting is turned on) + // Enabled by default (when used for the first time) if (isStandardGemini) { if (this.settings.formulaCopyEnabled === undefined) { this.settings.formulaCopyEnabled = true; @@ -11337,8 +11195,8 @@ } } - // 初始化表格复制功能(通用功能,两个版本都支持) - // 默认开启(首次使用时) + // Initialize table copy function (common function, supported by both versions) + // Enabled by default (when used for the first time) if (this.settings.tableCopyEnabled === undefined) { this.settings.tableCopyEnabled = true; this.saveSettings(); @@ -11347,18 +11205,18 @@ this.copyManager.initTableCopy(); } - // 监听自定义大纲自动刷新事件 + // Listen to custom outline automatic refresh events window.addEventListener('gemini-helper-outline-auto-refresh', () => { this.refreshOutline(); }); - // 如果初始 Tab 是大纲,尽快刷新内容(用户体验) + // If the initial Tab is an outline, refresh the content as soon as possible (user experience) if (this.currentTab === 'outline') { setTimeout(() => this.refreshOutline(), 500); } - // 延迟重新初始化当前 Tab 的功能,确保页面完全就绪后绑定到正确的滚动容器 - // 注意:必须先 stopSyncScroll 清除旧 handler,否则 startSyncScroll 会短路返回 + // Delayed reinitialization of the current Tab function to ensure that the page is bound to the correct scroll container when it is fully ready + // Note: StopSyncScroll must first clear the old handler, otherwise startSyncScroll will short-circuit and return setTimeout(() => { if (this.currentTab === 'outline' && this.outlineManager) { this.outlineManager.stopSyncScroll(); @@ -11366,15 +11224,15 @@ this.switchTab(this.currentTab); }, 1500); - // SPA 导航监听:切换会话后重新初始化大纲和同步滚动 + // SPA navigation listener: reinitialize outline and synchronized scrolling after switching sessions if (window.onurlchange === null) { window.addEventListener('urlchange', (e) => { - // 延迟执行,等待新页面 DOM 渲染完成 + // Delay execution and wait for the new page DOM rendering to complete setTimeout(() => { if (this.currentTab === 'outline' && this.outlineManager) { this.outlineManager.stopSyncScroll(); this.refreshOutline(); - // 再次延迟启动同步滚动,确保大纲刷新完成 + // Delay the start of synchronous scrolling again to ensure that the outline refresh is completed setTimeout(() => { if (this.outlineManager && this.settings.outline?.syncScroll) { this.outlineManager.startSyncScroll(); @@ -11495,7 +11353,7 @@ background: var(--gh-bg); } - /* 迁移通知样式 */ + /* Migration notification style */ .gh-migration-notice { background: #fff7ed; border-bottom: 1px solid #fed7aa; color: #9a3412; padding: 10px 14px; font-size: 13px; line-height: 1.5; @@ -11516,7 +11374,7 @@ } .gh-notice-close:hover { opacity: 1; background: rgba(0,0,0,0.05); border-radius: 4px; } - /* 主面板样式 */ + /* Main panel style */ #gemini-helper-panel { position: fixed; top: 50%; @@ -11609,7 +11467,7 @@ transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 6px; } .add-prompt-btn:hover { transform: translateY(-2px); } - /* 模态框 */ + /* modal box */ .prompt-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000000; animation: fadeIn 0.2s; @@ -11634,9 +11492,9 @@ .prompt-modal-btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; border: none; } .prompt-modal-btn.primary { background: var(--gh-header-bg); color: white; border: 1px solid rgba(255,255,255,0.2); } .prompt-modal-btn.secondary { background: var(--gh-hover, #f3f4f6); color: #4b5563; } - /* 选中的提示词显示栏 */ + /* Selected prompt word display column */ .selected-prompt-bar { - position: fixed; bottom: 120px; left: 50%; transform: translateX(-50%); /* bottom 由 JS 动态控制 */ + position: fixed; bottom: 120px; left: 50%; transform: translateX(-50%); /* The bottom is dynamically controlled by JS */ background: var(--gh-header-bg); color: white; padding: 8px 16px; border-radius: 20px; font-size: 13px; display: none; align-items: center; gap: 8px; box-shadow: 0 4px 12px rgba(66,133,244,0.3); @@ -11657,7 +11515,7 @@ border: none; transition: transform 0.3s; } .quick-prompt-btn:hover { transform: scale(1.1); } - /* 快捷按钮组(统一侧边按钮) */ + /* Shortcut button group (unified side buttons) */ .quick-btn-group { position: fixed; right: 16px; top: 50%; transform: translateY(-50%); @@ -11703,7 +11561,7 @@ display: block; } - /* ========== 边缘吸附隐藏功能样式 ========== */ + /* ========== Edge adsorption hidden function style ========== */ #gemini-helper-panel.edge-snapped-left { left: -310px !important; right: auto !important; @@ -11762,7 +11620,7 @@ } - /* 锚点标记 - 侧边小标记 */ + /* Anchor mark - small mark on the side */ .manual-anchor-marker { position: absolute; left: 0; @@ -11786,12 +11644,12 @@ .outline-hidden { display: none !important; } .gemini-toast { position: fixed !important; top: 32px !important; left: 50% !important; transform: translateX(-50%) !important; - background: var(--gh-header-bg); /* 品牌渐变色 -> 动态主题色 */ - color: white; /* 渐变色背景通常较深,配白字 */ + background: var(--gh-header-bg); /* Brand gradient color -> Dynamic theme color */ + color: white; /* Gradient background is usually darker with white text */ padding: 10px 24px; border-radius: 9999px; font-size: 14px; font-weight: 500; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.2); z-index: 1000001 !important; animation: toastSlideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1); - border: 1px solid rgba(255, 255, 255, 0.15); /* 增加一点白色内描边提升精致感 */ + border: 1px solid rgba(255, 255, 255, 0.15); /* Add a little white inner stroke to enhance the sense of sophistication */ display: flex; align-items: center; justify-content: center; gap: 8px; pointer-events: none; } @@ -11825,7 +11683,7 @@ from { clip-path: circle(150% at var(--theme-x, 95%) var(--theme-y, 5%)); } to { clip-path: circle(0% at var(--theme-x, 95%) var(--theme-y, 5%)); } } - /* 快捷跳转按钮组(面板内) */ + /* Quick jump button group (in panel) */ .scroll-nav-container { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--gh-border, #e5e7eb); background: var(--gh-bg-secondary); @@ -11848,10 +11706,10 @@ transform: rotate(360deg) scale(1.2); } - /* ========== 会话面板样式 ========== */ + /* ========== Session panel style ========== */ .conversations-content { display: flex; flex-direction: column; flex: 1; min-height: 200px; - overflow-x: hidden; /* 隐藏横向滚动条 */ + overflow-x: hidden; /* Hide horizontal scroll bar */ } .conversations-toolbar { display: flex; gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--gh-border, #e5e7eb); flex-shrink: 0; @@ -11882,19 +11740,19 @@ display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; margin-bottom: 4px; border-radius: 8px; background: var(--gh-bg-secondary, #f9fafb); cursor: pointer; transition: all 0.2s; - flex-wrap: wrap; /* 允许换行,会话列表在下方 */ + flex-wrap: wrap; /* Line breaks allowed, session list below */ } .conversations-folder-item:hover { background: var(--gh-hover, #f3f4f6); } .conversations-folder-item.default { background: var(--gh-folder-bg-default); } .conversations-folder-item.expanded { - background: var(--gh-folder-bg-expanded) !important; /* 更深的紫蓝色 */ - border: 2px solid var(--gh-border-active); /* 明显的边框 */ + background: var(--gh-folder-bg-expanded) !important; /* darker purple blue */ + border: 2px solid var(--gh-border-active); /* Obvious borders */ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25); - border-radius: 8px 8px 0 0; /* 展开时上方圆角 */ + border-radius: 8px 8px 0 0; /* Rounded corners at the top when expanded */ } .conversations-folder-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; - position: relative; /* 为绝对定位的箭头提供参考 */ + position: relative; /* Provides a reference for absolutely positioned arrows */ } .conversations-folder-icon { font-size: 18px; width: 24px; height: 24px; @@ -11904,7 +11762,7 @@ .conversations-folder-name { font-size: 14px; font-weight: 500; color: var(--gh-text, #1f2937); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - user-select: none; /* 禁止选中 */ + user-select: none; /* Disable selection */ } .conversations-folder-count { font-size: 12px; color: var(--gh-text-secondary, #6b7280); flex-shrink: 0; user-select: none; } .conversations-folder-menu-btn { @@ -11919,7 +11777,7 @@ position: absolute; right: 0; top: 50%; transform: translateY(-50%); display: flex; align-items: center; gap: 2px; opacity: 0; transition: opacity 0.2s; - background: linear-gradient(to right, transparent, currentColor 8px); /* 渐变遮罩 */ + background: linear-gradient(to right, transparent, currentColor 8px); /* gradient mask */ background: inherit; padding-left: 8px; } .conversations-folder-item:hover .conversations-folder-order-btns { @@ -11951,7 +11809,7 @@ text-align: center; padding: 40px 20px; color: #9ca3af; font-size: 14px; } - /* 搜索栏样式 */ + /* Search bar style */ .conversations-search-bar { padding: 8px 12px; border-bottom: 1px solid var(--gh-border, #e5e7eb); @@ -12016,7 +11874,7 @@ } .conversations-result-bar.visible { display: block; } - /* 标签样式 */ + /* label style */ .conversations-tag { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; margin-right: 4px; margin-top: 4px; @@ -12029,7 +11887,7 @@ } .conversations-tag-list:empty { display: none; } - /* 标签筛选按钮 */ + /* Tag filter button */ .conversations-tag-search-btn { cursor: pointer; width: 36px; height: 36px; color: #9ca3af; font-size: 14px; display: flex; align-items: center; justify-content: center; @@ -12058,7 +11916,7 @@ /* Removed old inner clear button styles */ - /* 标签筛选菜单 */ + /* Tag filter menu */ .conversations-tag-filter-menu { position: absolute; top: calc(100% + 4px); @@ -12106,7 +11964,7 @@ .conversations-tag-filter-divider { height: 1px; background: #eee; margin: 4px 0; flex-shrink: 0; } .conversations-tag-filter-action { color: var(--gh-border-active); font-weight: 500; justify-content: center; } - /* 标签管理弹窗 */ + /* Tag management pop-up window */ .conversations-tag-manager-list { max-height: 250px; overflow-y: auto; border: 1px solid var(--gh-border, #e5e7eb); border-radius: 4px; margin-bottom: 12px; padding: 4px; } @@ -12130,7 +11988,7 @@ .conversations-tag-btn:hover { background: #fee2e2; color: #ef4444; } .conversations-tag-btn.edit:hover { background: #e0f2fe; color: #3b82f6; } - /* 颜色选择器 */ + /* color picker */ .conversations-color-picker { display: grid; grid-template-columns: repeat(10, 1fr); gap: 6px; margin: 12px 0; } @@ -12152,9 +12010,9 @@ } .conversations-result-bar.visible { display: block; } - /* 会话列表样式 */ + /* Session list style */ .conversations-list { - width: calc(100% - 8px); /* 留出边距给高亮效果 */ + width: calc(100% - 8px); /* Leave margins for highlighting */ margin-left: 4px; margin-right: 4px; padding: 8px; @@ -12162,7 +12020,7 @@ border: 2px solid var(--gh-border-active); border-top: none; border-radius: 0 0 8px 8px; - margin-top: -4px; /* 与文件夹项视觉连接 */ + margin-top: -4px; /* Visual connection with folder items */ margin-bottom: 4px; max-height: 300px; overflow-y: auto; @@ -12199,7 +12057,7 @@ width: 4px; height: 80%; background-color: #428cf1; - border-radius: 0 4px 4px 0; /* 右侧圆角,左侧贴边 */ + border-radius: 0 4px 4px 0; /* Rounded corners on the right side, welted on the left side */ transition: transform 0.2s; } .conversations-item:hover::before { @@ -12246,7 +12104,7 @@ .conversations-item-menu button.danger { color: #dc2626; } .conversations-item-menu button.danger:hover { background: #fef2f2; } - /* 定位高亮动画 */ + /* Positioning highlight animation */ .conversations-item.locate-highlight { background: var(--gh-outline-locate-bg) !important; border: 2px solid var(--gh-outline-locate-border) !important; @@ -12259,7 +12117,7 @@ 50% { transform: scale(1.01); } } - /* 复选框样式 */ + /* Checkbox style */ .conversations-folder-checkbox { margin-right: 8px; width: 16px; height: 16px; cursor: pointer; accent-color: var(--gh-checkbox-bg, #4f46e5); flex-shrink: 0; @@ -12269,7 +12127,7 @@ accent-color: var(--gh-checkbox-bg, #4f46e5); flex-shrink: 0; } - /* 底部批量操作栏 */ + /* Bottom batch operation bar */ .conversations-batch-bar { position: sticky; bottom: 0; left: 0; right: 0; background: var(--gh-bg, white); @@ -12295,7 +12153,7 @@ .conversations-batch-btn.cancel { background: transparent; border: none; color: var(--gh-text-secondary, #6b7280); } .conversations-batch-btn.cancel:hover { background: var(--gh-hover, #f3f4f6); color: var(--gh-text, #374151); border: none; } - /* 会话对话框样式 */ + /* Session dialog style */ .conversations-dialog-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000003; @@ -12341,7 +12199,7 @@ } .conversations-dialog-btn.confirm:hover { opacity: 0.9; } - /* 文件夹选择列表 */ + /* Folder selection list */ .conversations-folder-select-list { max-height: 250px; overflow-y: auto; margin: 12px 0; } @@ -12353,7 +12211,7 @@ background: var(--gh-hover, #f3f4f6); } - /* Emoji 选择器 */ + /* Emoji selector */ .conversations-emoji-picker { display: flex; flex-wrap: wrap; gap: 4px; } @@ -12366,13 +12224,13 @@ background: #e0e7ff; border-color: #4285f4; box-shadow: 0 0 0 2px rgba(66,133,244,0.2); } - /* 分类管理按钮 */ + /* Category management button */ .category-manage-btn { padding: 4px 8px; background: transparent; border: 1px dashed #9ca3af; border-radius: 12px; font-size: 12px; color: var(--gh-text-secondary, #6b7280); cursor: pointer; transition: all 0.2s; margin-left: 4px; } .category-manage-btn:hover { background: var(--gh-hover, #f3f4f6); border-color: var(--gh-text-secondary, #6b7280); color: var(--gh-text, #374151); } - /* 分类管理弹窗 */ + /* Category management pop-up window */ .category-modal-content { max-height: 400px; } .category-list { max-height: 280px; overflow-y: auto; margin: 16px 0; } .category-item { @@ -12392,7 +12250,7 @@ .category-action-btn.delete { background: #fee2e2; color: #dc2626; } .category-action-btn.delete:hover { background: #fecaca; } .category-empty { text-align: center; color: #9ca3af; padding: 40px 0; font-size: 14px; } - /* Tab 切换栏 */ + /* Tab switching bar */ .prompt-panel-tabs { display: flex; background: var(--gh-bg-secondary, #f9fafb); border-bottom: 1px solid var(--gh-border, #e5e7eb); } @@ -12405,10 +12263,10 @@ .prompt-panel-tab.active { color: ${colors.primary}; border-bottom-color: ${colors.primary}; background: var(--gh-bg, white); } - /* 面板内容区 */ + /* Panel content area */ .prompt-panel-content { display: flex; flex-direction: column; flex: 1; overflow: hidden; min-height: 280px; } .prompt-panel-content.hidden { display: none; } - /* 设置面板样式 - 合并优化 */ + /* Set panel style - merge optimization */ .settings-content { padding: 16px; overflow-y: auto; flex: 1; scrollbar-width: none; -ms-overflow-style: none; } .settings-content::-webkit-scrollbar { display: none; } .settings-section { margin-bottom: 24px; } @@ -12440,14 +12298,14 @@ content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background: var(--gh-bg, white); border-radius: 50%; transition: all 0.3s; box-shadow: 0 1px 2px rgba(0,0,0,0.1); } - .setting-toggle.active { background: #4285f4; } /* 默认蓝色,会被JS覆盖 */ + .setting-toggle.active { background: #4285f4; } /* Default blue, will be overwritten by JS */ .setting-toggle.active::after { left: 22px; } - /* 大纲面板样式 */ + /* Outline panel style */ .outline-content { display: flex; flex-direction: column; flex: 1; min-height: 200px; user-select: none; overflow: hidden; } - /* 大纲固定工具栏 */ + /* Outline pinned toolbar */ .outline-fixed-toolbar { padding: 10px 12px; background: var(--gh-bg-secondary, #f9fafb); border-bottom: 1px solid var(--gh-border, #e5e7eb); flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; @@ -12478,7 +12336,7 @@ .outline-search-clear:hover { background: #9ca3af; } .outline-search-wrapper { position: relative; flex: 1; display: flex; align-items: center; } - /* 隐身模式:隐藏 Gemini Business 设置菜单 (防止切换主题时闪烁) */ + /* Stealth mode: Hide Gemini Business settings menu (prevent flickering when switching themes) */ body.gh-stealth-mode md-menu, body.gh-stealth-mode md-menu-surface, body.gh-stealth-mode .mat-menu-panel, @@ -12492,7 +12350,7 @@ border-bottom: 1px solid #dbeafe; text-align: center; flex-shrink: 0; transition: all 0.3s; } - /* 层级滑块 */ + /* Level slider */ .outline-level-slider-container { display: flex; align-items: center; gap: 6px; width: 100%; } @@ -12527,7 +12385,7 @@ font-size: 11px; white-space: nowrap; opacity: 0; visibility: hidden; transition: all 0.2s; pointer-events: none; margin-bottom: 4px; } - /* 第一个 dot 的 tooltip 向右对齐,防止溢出 */ + /* The tooltip of the first dot is aligned to the right to prevent overflow */ .outline-level-dot:first-child .outline-level-dot-tooltip { left: 0; transform: none; } .outline-level-dot:hover .outline-level-dot-tooltip { opacity: 1; visibility: visible; } .outline-level-line { @@ -12538,7 +12396,7 @@ position: absolute; left: 0; top: 0; height: 100%; background: var(--gh-tag-active-bg); border-radius: 2px; transition: width 0.2s; } - /* 大纲列表区 */ + /* Outline list area */ .outline-list-wrapper { flex: 1; overflow-y: auto; padding: 8px 12px; } .outline-list { display: flex; flex-direction: column; gap: 2px; } .outline-item { @@ -12558,7 +12416,7 @@ 0%, 100% { transform: scale(1); } 50% { transform: scale(1.02); } } - /* 同步滚动高亮(使用右边框,与用户问题左边框区分) */ + /* Synchronized scroll highlighting (uses right border to distinguish from left border of user question) */ .outline-item.sync-highlight { background: var(--gh-outline-sync-bg) !important; border-right: 3px solid var(--gh-outline-sync-border) !important; @@ -12574,26 +12432,26 @@ .outline-item-toggle.invisible { opacity: 0; cursor: default; pointer-events: none; visibility: visible !important; display: inline-flex !important; } .outline-item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 24px; } .outline-item.collapsed-children { display: none; } - /* 大纲层级缩进 - 箭头跟随缩进,文字保持左对齐 */ - .outline-level-0 { padding-left: 2px; font-weight: 500; } /* 用户提问节点向左突出 */ + /* Outline level indentation - arrows follow the indentation, text remains aligned left */ + .outline-level-0 { padding-left: 2px; font-weight: 500; } /* User question node protrudes to the left */ .outline-level-1 { padding-left: 10px; font-weight: 600; font-size: 14px; } .outline-level-2 { padding-left: 28px; font-weight: 500; } .outline-level-3 { padding-left: 46px; } .outline-level-4 { padding-left: 64px; font-size: 12px; } .outline-level-5 { padding-left: 82px; font-size: 12px; color: var(--gh-text-secondary, #6b7280); } .outline-level-6 { padding-left: 100px; font-size: 12px; color: #9ca3af; } - /* 用户提问节点(Level 0) */ + /* User question node (Level 0) */ .outline-item.user-query-node { background: var(--user-query-bg, rgba(66, 133, 244, 0.08)); border-left: 3px solid var(--gh-border-active); font-weight: 500; padding-left: 8px !important; - /* 复制按钮使用绝对定位悬浮在文字上方,不需要预留空间 */ + /* The copy button uses absolute positioning to float above the text and does not require space to be reserved. */ margin-top: 8px; border-radius: 4px; } .outline-item.user-query-node:first-child { margin-top: 0; } - /* 用户问题徽章:图标+角标数字 */ + /* User issue badge: icon+subscript number */ .outline-item.user-query-node .user-query-badge { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; margin-right: 4px; flex-shrink: 0; @@ -12612,7 +12470,7 @@ box-shadow: 0 0 0 1.5px #ffffff; z-index: 10; } - /* Dark Mode 适配 */ + /* Dark Mode Adaptation */ body[data-gh-mode="dark"] .outline-item.user-query-node .user-query-badge-icon { color: #6b7280; /* Gray 500 */ } @@ -12620,14 +12478,14 @@ color: #e5e7eb; background: #374151; border-color: #4b5563; box-shadow: 0 0 0 1.5px #1f2937; } - /* 用户提问复制按钮 - 悬浮在文字上方 */ + /* User question copy button - hover above the text */ .outline-item-copy-btn { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; color: #6b7280; cursor: pointer; border-radius: 4px; opacity: 0; transition: all 0.2s ease; - background: var(--gh-bg, white); /* 不透明背景遮住文字 */ + background: var(--gh-bg, white); /* Opaque background covers text */ } .outline-item:hover .outline-item-copy-btn { opacity: 1; } .outline-item-copy-btn:hover { color: var(--gh-border-active); background: var(--gh-hover, #f3f4f6); } @@ -12641,14 +12499,14 @@ .outline-item.user-query-node:hover { background: var(--user-query-hover-bg, rgba(66, 133, 244, 0.15)); } .outline-empty { text-align: center; color: #9ca3af; padding: 40px 20px; font-size: 14px; } - /* 大纲高亮效果 */ + /* Outline highlight effect */ .outline-highlight { animation: outlineHighlight 2s ease-out; } @keyframes outlineHighlight { 0% { background: rgba(66, 133, 244, 0.3); } 100% { background: transparent; } } - /* 历史加载遮罩 */ + /* History loading mask */ #gemini-helper-loading-overlay { position: fixed; inset: 0; @@ -12699,13 +12557,39 @@ #gemini-helper-loading-overlay .loading-stop-btn:hover { background: rgba(128,128,128,0.1); } + #gem-scroll-btn { + position: fixed; + bottom: 90px; + right: 20px; + z-index: 999999; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--gh-gradient); + color: #ffffff; + border: none; + font-size: 18px; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0,0,0,0.25); + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; + } + #gem-scroll-btn:hover { + transform: scale(1.1); + box-shadow: 0 6px 16px rgba(0,0,0,0.35); + } + #gem-scroll-btn:active { + transform: scale(0.95); + } `; document.head.appendChild(style); } /** - * 启动主题监听器 (Auto Dark Mode) - */ + * Enable topic listener (Auto Dark Mode)*/ monitorTheme() { const panel = document.getElementById('gemini-helper-panel'); if (!panel) return; @@ -12738,11 +12622,11 @@ // 2. Sync to Plugin UI (ghMode) and color-scheme if (isDark) { document.body.dataset.ghMode = 'dark'; - // 同步 color-scheme,确保原生控件(如 checkbox)颜色一致 + // Synchronize color-scheme to ensure that native controls (such as checkbox) have consistent colors document.body.style.colorScheme = 'dark'; } else { delete document.body.dataset.ghMode; - // 同步 color-scheme,确保原生控件(如 checkbox)颜色一致 + // Synchronize color-scheme to ensure that native controls (such as checkbox) have consistent colors document.body.style.colorScheme = 'light'; } @@ -12805,7 +12689,7 @@ } } - // 应用主题 (Web -> DOM) + // Apply themes (Web -> DOM) applyTheme(targetMode) { const mode = targetMode || this.settings.themeMode; if (!mode) return; @@ -12825,21 +12709,21 @@ } } - // 切换主题 (User Action) - 带圆形扩散动画 + // Switch theme (User Action) - with circular diffusion animation toggleTheme(event) { const bodyClass = document.body.className; // Also check style for robustness const isDark = /\bdark-theme\b/i.test(bodyClass) || document.body.style.colorScheme === 'dark'; const nextMode = isDark ? 'light' : 'dark'; - // 计算动画起点坐标(从点击位置或默认右上角) + // Calculate animation starting point coordinates (from click position or default upper right corner) let x = 95, y = 5; if (event && event.clientX !== undefined) { x = (event.clientX / window.innerWidth) * 100; y = (event.clientY / window.innerHeight) * 100; } else { - // 尝试从主题按钮位置获取 + // Try to get from theme button location const themeBtn = document.getElementById('theme-toggle-btn') || document.getElementById('quick-theme-btn'); if (themeBtn) { const rect = themeBtn.getBoundingClientRect(); @@ -12848,13 +12732,13 @@ } } - // 设置 CSS 变量 + // Set CSS variables document.documentElement.style.setProperty('--theme-x', `${x}%`); document.documentElement.style.setProperty('--theme-y', `${y}%`); - // 执行主题切换的核心逻辑 + // Execute the core logic of theme switching const doToggle = () => { - // 优先使用适配器的原生切换逻辑 (针对 Gemini Business) + // Prefer using the adapter's native switching logic (for Gemini Business) if (typeof this.siteAdapter.toggleTheme === 'function') { return this.siteAdapter.toggleTheme(nextMode).then((success) => { if (!success) { @@ -12865,13 +12749,13 @@ this.applyTheme(nextMode); }; - // 使用 View Transitions API(如果浏览器支持) + // Use the View Transitions API (if your browser supports it) if (document.startViewTransition) { const transition = document.startViewTransition(() => { doToggle(); }); - // 应用自定义动画 + // Apply custom animation transition.ready.then(() => { const animation = isDark ? 'themeReveal' : 'themeShrink'; document.documentElement.animate( @@ -12888,7 +12772,7 @@ ); }); } else { - // 降级:直接切换 + // Downgrade: switch directly doToggle(); } } @@ -12915,7 +12799,7 @@ const controls = createElement('div', { className: 'prompt-panel-controls' }); - // 主题切换按钮 (SVG Icon) - Moved to Controls + // Theme Switch Button (SVG Icon) - Moved to Controls const themeBtn = createElement('button', { id: 'theme-toggle-btn', className: 'prompt-panel-btn', @@ -12940,10 +12824,10 @@ themeBtn.appendChild(svg); themeBtn.addEventListener('click', (e) => { - e.stopPropagation(); // 阻止冒泡,防止触发 Header 双击(隐私模式) + e.stopPropagation(); // Prevent bubbling and trigger Header double-click (privacy mode) this.toggleTheme(); }); - themeBtn.addEventListener('dblclick', (e) => e.stopPropagation()); // 阻止双击冒泡 + themeBtn.addEventListener('dblclick', (e) => e.stopPropagation()); // Prevent double-click bubbling controls.appendChild(themeBtn); const refreshBtn = createElement( @@ -12957,7 +12841,7 @@ ); refreshBtn.addEventListener('click', () => { refreshBtn.classList.add('loading'); - // 根据当前 Tab 智能刷新 + // Smart refresh based on current Tab if (this.currentTab === 'outline') { this.refreshOutline(); showToast(this.t('refreshed')); @@ -12965,7 +12849,7 @@ this.refreshPromptList(); showToast(this.t('refreshed')); } else if (this.currentTab === 'conversations') { - // 只刷新 UI 显示,不执行侧边栏同步 + // Only refresh the UI display and do not perform sidebar synchronization this.conversationManager?.createUI(); showToast(this.t('refreshed')); } else { @@ -12981,11 +12865,11 @@ title: this.t('collapse'), }, - this.isCollapsed ? '+' : '−', // 根据初始状态设置图标 + this.isCollapsed ? '+' : '−', // Set the icon according to the initial state ); - // 注意:toggleBtn 的事件监听在 bindEvents 中统一绑定,避免重复绑定 - // 新建标签页按钮 - // 新标签页按钮 (只有在设置开启且站点支持时显示) + // Note: The event monitoring of toggleBtn is bound uniformly in bindEvents to avoid repeated binding. + // New tab button + // New tab button (only shown if setting is turned on and site supports it) if (this.settings.tabSettings?.openInNewTab && this.siteAdapter.supportsNewTab()) { const newTabBtn = createElement( 'button', @@ -13006,7 +12890,7 @@ controls.appendChild(newTabBtn); } - // 设置按钮(固定在header,不占用Tab位置) + // Setting button (fixed in the header, does not occupy the Tab position) const settingsBtn = createElement( 'button', { @@ -13018,10 +12902,10 @@ ); settingsBtn.addEventListener('click', () => { if (this.currentTab === 'settings') { - // 已在设置页,返回上一个 Tab(默认提示词) + // Already on the settings page, return to the previous Tab (default prompt word) this.switchTab(this.previousTab || 'prompts'); } else { - // 记住当前 Tab,进入设置 + // Remember the current Tab and enter settings this.previousTab = this.currentTab; this.switchTab('settings'); } @@ -13034,18 +12918,18 @@ header.appendChild(title); header.appendChild(controls); - // 双击面板标题切换隐私模式 (Boss Key) + // Double-click the panel title to switch to privacy mode (Boss Key) title.style.cursor = 'pointer'; title.addEventListener('dblclick', () => { if (this.tabRenameManager) { const isPrivate = this.tabRenameManager.togglePrivacyMode(); this.saveSettings(); - // 同步设置面板中的隐私模式开关状态 + // Synchronize the privacy mode switch status in the settings panel const privacyToggle = document.getElementById('toggle-privacy-mode'); if (privacyToggle) { privacyToggle.classList.toggle('active', isPrivate); } - // 同步伪装标题输入框的禁用状态 + // Synchronize the disabled state of the disguised title input box const privacyTitleItem = privacyToggle?.closest('.setting-item')?.nextElementSibling; if (privacyTitleItem && privacyTitleItem.classList.contains('setting-item')) { const privacyTitleInput = privacyTitleItem.querySelector('input'); @@ -13055,43 +12939,43 @@ privacyTitleItem.style.pointerEvents = isPrivate ? 'auto' : 'none'; } } - showToast(isPrivate ? '🔒 隐私模式已开启' : '🔓 隐私模式已关闭'); + showToast(isPrivate ? '🔒 Private mode enabled' : '🔓 Private mode disabled'); } }); - // Tab 栏 + // Tab bar const tabs = createElement('div', { className: 'prompt-panel-tabs' }); - // 根据设置的顺序渲染 Tab + // Render Tabs in the order they are set const tabOrder = this.settings.tabOrder || DEFAULT_TAB_ORDER; - // 确保所有 Tab 都存在(防止新版本新增 Tab 或配置丢失) + // Ensure that all Tabs exist (to prevent new tabs from being added to the new version or configuration being lost) const allTabs = new Set([...tabOrder, ...DEFAULT_TAB_ORDER]); - // 过滤掉未定义的 Tab ID + // Filter out undefined Tab IDs const validTabs = Array.from(allTabs).filter((id) => TAB_DEFINITIONS[id]); validTabs.forEach((tabId) => { const def = TAB_DEFINITIONS[tabId]; - // 特殊处理:如果大纲被禁用,添加 hidden 类,但仍然渲染(为了保持 DOM 结构一致性,或者稍后在 switchTab 处理可见性) - // 这里稍微调整逻辑:创建 button,初始 class 根据状态决定 + // Special handling: if the outline is disabled, add hidden class but still render (to maintain DOM structure consistency, or to handle visibility later in switchTab) + // Adjust the logic slightly here: create a button, and the initial class is determined based on the state let className = 'prompt-panel-tab'; if (this.currentTab === tabId) className += ' active'; - // 大纲特殊显隐逻辑 + // Outline special explicit and implicit logic if (tabId === 'outline' && !this.settings.outline?.enabled) { className += ' hidden'; } - // 提示词特殊显隐逻辑 + // Special explicit and implicit logic of prompt words if (tabId === 'prompts' && !this.settings.prompts?.enabled) { className += ' hidden'; } - // 会话特殊显隐逻辑 + // Session special explicit and implicit logic if (tabId === 'conversations' && this.settings.conversations?.enabled === false) { className += ' hidden'; } - // 设置 Tab 不在这里渲染(已移动到 header 按钮) + // Set Tab not to render here (moved to header button) if (tabId === 'settings') return; const btn = createElement('button', { @@ -13100,7 +12984,7 @@ id: `${tabId}-tab`, }); - // 图标 + 文字 + // icon + text btn.appendChild(createElement('span', { style: 'margin-right: 4px;' }, def.icon)); btn.appendChild(document.createTextNode(this.t(def.labelKey))); // btn.appendChild(document.createTextNode(this.t(def.labelKey))); @@ -13121,9 +13005,9 @@ const contentDiv = createElement('div', { className: 'gh-notice-content' }); contentDiv.appendChild(document.createTextNode('⚠️ ')); - const boldText = createElement('b', {}, isZh ? 'Gemini Helper 已停止更新' : 'Gemini Helper is discontinued'); + const boldText = createElement('b', {}, 'Gemini Helper is discontinued'); contentDiv.appendChild(boldText); - contentDiv.appendChild(document.createTextNode(isZh ? ',建议迁移至新版 ' : '. Please migrate to ')); + contentDiv.appendChild(document.createTextNode('. Please migrate to ')); // Ophel Link const ophelLink = createElement('a', { @@ -13135,7 +13019,7 @@ ophelLink.appendChild(boldOphel); contentDiv.appendChild(ophelLink); - contentDiv.appendChild(document.createTextNode(isZh ? ' 以获得最佳体验。' : ' for the best experience.')); + contentDiv.appendChild(document.createTextNode(' for the best experience.')); const actionLink = createElement( 'a', @@ -13144,7 +13028,7 @@ target: '_blank', className: 'gh-notice-link', }, - isZh ? '去看看 →' : 'Check it out →', + 'Check it out →', ); contentDiv.appendChild(actionLink); @@ -13153,7 +13037,7 @@ 'button', { className: 'gh-notice-close', - title: isZh ? '不再提醒' : 'Dismiss', + title: 'Dismiss', }, '×', ); @@ -13170,8 +13054,8 @@ // ============================================================ panel.appendChild(tabs); - // 内容容器需按固定顺序创建(DOM 结构不受 Tab 顺序影响,只影响 Tab 按钮顺序) - // 1. 提示词面板内容区 + // Content containers need to be created in a fixed order (the DOM structure is not affected by the Tab order, only the Tab button order) + // 1. Prompt word panel content area const promptsContent = createElement('div', { className: `prompt-panel-content${this.currentTab === 'prompts' ? '' : ' hidden'}`, id: 'prompts-content', @@ -13198,43 +13082,43 @@ promptsContent.appendChild(list); promptsContent.appendChild(addBtn); - // 2. 大纲面板内容区 + // 2. Outline panel content area const outlineContent = createElement('div', { className: `prompt-panel-content${this.currentTab === 'outline' ? '' : ' hidden'}`, id: 'outline-content', }); - // 初始化大纲管理器 + // Initialize the outline manager this.outlineManager = new OutlineManager({ container: outlineContent, settings: this.settings, - siteAdapter: this.siteAdapter, // 传入 siteAdapter 用于定位功能 + siteAdapter: this.siteAdapter, // Pass in siteAdapter for positioning function onSettingsChange: () => this.saveSettings(), onJumpBefore: () => this.anchorManager.setAnchor(this.scrollManager.scrollTop), i18n: (k) => this.t(k), }); - // 如果大纲是当前激活的 tab,立即启用 Observer + // If the outline is the currently active tab, immediately enable the Observer if (this.currentTab === 'outline') { this.outlineManager.setActive(true); } - // 3. 会话面板内容区 + // 3. Session panel content area const conversationsContent = createElement('div', { className: `prompt-panel-content${this.currentTab === 'conversations' ? '' : ' hidden'}`, id: 'conversations-content', }); - // 初始化会话管理器 + // Initialize session manager this.conversationManager = new ConversationManager({ container: conversationsContent, settings: this.settings, siteAdapter: this.siteAdapter, i18n: (k) => this.t(k), }); - // 如果会话是当前激活的 tab,立即启用 + // If the session is the currently active tab, enable it immediately if (this.currentTab === 'conversations') { this.conversationManager.setActive(true); } - // 4. 设置面板内容区 + // 4. Set the panel content area const settingsContent = createElement('div', { className: `prompt-panel-content${this.currentTab === 'settings' ? '' : ' hidden'}`, id: 'settings-content', @@ -13248,7 +13132,7 @@ document.body.appendChild(panel); - // 选中提示词悬浮条 + // Select the prompt word floating bar const selectedBar = createElement('div', { className: 'selected-prompt-bar', style: 'user-select: none;' }); selectedBar.appendChild(createElement('span', { style: 'user-select: none;' }, this.t('currentPrompt'))); selectedBar.appendChild( @@ -13262,15 +13146,15 @@ selectedBar.appendChild(clearBtn); document.body.appendChild(selectedBar); - // 统一侧边按钮组 + // Unify side button group const quickBtnGroup = createElement('div', { className: 'quick-btn-group' + (this.isCollapsed ? ' collapsed' : ''), id: 'quick-btn-group', }); - // 按钮工厂函数 + // button factory function const createQuickButton = (id, def, enabled, extraClass = '') => { - // 禁用的按钮添加 btn-disabled 类(CSS 中设置 display: none !important) + // Add the btn-disabled class to the disabled button (set display: none !important in CSS) const disabledClass = enabled ? '' : ' btn-disabled'; const btn = createElement( 'button', @@ -13282,17 +13166,17 @@ def.icon, ); - // 锚点按钮初始状态置灰 + // The initial state of the anchor button is grayed out if (id === 'anchor') { btn.style.opacity = '0.4'; btn.style.cursor = 'default'; - btn.title = '暂无锚点'; + btn.title = 'No anchor point'; } return btn; }; - // 创建手动锚点按钮组 + // Create a manual anchor button group const createManualAnchorGroup = (enabled) => { const fragment = document.createDocumentFragment(); const disabledClass = enabled ? '' : ' btn-disabled'; @@ -13344,7 +13228,7 @@ return fragment; }; - // 事件处理器 + // event handler const buttonActions = { scrollTop: () => this.scrollToTop(), scrollBottom: () => this.scrollToBottom(), @@ -13356,14 +13240,14 @@ }, }; - // 保存按钮引用 + // Save button reference const quickButtons = {}; - // 根据配置动态创建按钮 + // Dynamically create buttons based on configuration const btnOrder = this.settings.collapsedButtonsOrder || DEFAULT_COLLAPSED_BUTTONS_ORDER; - // 智能分隔线逻辑 - // 跟踪上一个实际渲染的按钮(禁用的按钮不计入) + // Smart divider logic + // Keep track of the last actually rendered button (disabled buttons don't count) let prevRenderedType = null; // 'panelOnly' | 'always' | null let prevRenderedId = null; let isFirstRendered = true; @@ -13375,53 +13259,53 @@ const isEnabled = def.canToggle ? btnConfig.enabled : true; const currentType = def.isPanelOnly ? 'panelOnly' : 'always'; - // 如果按钮被禁用,跳过(不渲染,不更新状态) + // If button is disabled, skip (does not render, does not update state) if (!isEnabled) { return; } - // === 智能分隔线插入 === - // 规则1: 当类型从 always 切换到 panelOnly 时,插入 panel-only 分隔线 - // 规则2: 当类型从 panelOnly 切换到 always 时,插入常显分隔线 - // 规则3: manualAnchor 特殊处理 - 上面需要分隔线(除非是第一个渲染的按钮) + // === Smart divider insertion === + // Rule 1: Insert panel-only divider when type switches from always to panelOnly + // Rule 2: When the type is switched from panelOnly to always, insert a constant display line + // Rule 3: Special treatment for manualAnchor - a separator line is required above (unless it is the first button to be rendered) if (!isFirstRendered && prevRenderedType !== null) { - // manualAnchor 上方需要分隔线(始终是常显的,因为 manualAnchor 本身是常显按钮) + // A divider line is required above manualAnchor (it is always displayed because manualAnchor itself is a constantly displayed button) if (btnConfig.id === 'manualAnchor') { quickBtnGroup.appendChild(createElement('div', { className: 'divider' })); } - // 上一个是 manualAnchor,需要分隔线 + // The previous one is manualAnchor, which requires a separator line else if (prevRenderedId === 'manualAnchor') { - // 分隔线类型取决于当前按钮 + // The divider type depends on the current button const dividerClass = currentType === 'panelOnly' ? 'divider panel-only' : 'divider'; quickBtnGroup.appendChild(createElement('div', { className: dividerClass })); } - // 类型切换时插入分隔线 + // Insert separator line when switching types else if (prevRenderedType !== currentType) { - // 分隔线类型:如果下一个是 panelOnly 区域,分隔线也是 panel-only + // Divider type: If the next is a panelOnly area, the divider is also panel-only const dividerClass = currentType === 'panelOnly' ? 'divider panel-only' : 'divider'; quickBtnGroup.appendChild(createElement('div', { className: dividerClass })); } } - // === 创建按钮 === + // === Create Button === if (btnConfig.id === 'manualAnchor') { - // 手动锚点是一组按钮 + // Manual anchors are a set of buttons quickBtnGroup.appendChild(createManualAnchorGroup(isEnabled)); } else { - // 普通按钮 + // Normal button const extraClass = def.isPanelOnly ? 'panel-only' : ''; const btn = createQuickButton(btnConfig.id, def, isEnabled, extraClass); quickButtons[btnConfig.id] = btn; quickBtnGroup.appendChild(btn); } - // 更新状态(仅对实际渲染的按钮) + // Update state (only for actual rendered buttons) prevRenderedType = currentType; prevRenderedId = btnConfig.id; isFirstRendered = false; }); - // 绑定事件 + // Binding events Object.keys(quickButtons).forEach((id) => { const btn = quickButtons[id]; const action = buttonActions[id]; @@ -13432,7 +13316,7 @@ document.body.appendChild(quickBtnGroup); - // 快捷跳转按钮组 - 放在面板底部 + // Quick jump button group - placed at the bottom of the panel const scrollNavContainer = createElement('div', { className: 'scroll-nav-container', id: 'scroll-nav-container', @@ -13448,7 +13332,7 @@ const navAnchorBtn = createElement('button', { className: 'scroll-nav-btn icon-only', id: 'scroll-anchor-btn', - title: '暂无锚点', + title: 'No anchor point', style: 'opacity: 0.4; cursor: default;', }); navAnchorBtn.appendChild(createElement('span', {}, '⚓')); @@ -13473,45 +13357,45 @@ this.refreshCategories(); this.refreshPromptList(); - // 初始化锚点按钮状态 + // Initialize anchor button state setTimeout(() => { this.updateAnchorButtonState(this.anchorManager.hasAnchor()); this.updateManualAnchorButtonState(this.savedAnchorTop !== null); }, 0); } - // Tab 切换 + // Tab switch switchTab(tabName) { this.currentTab = tabName; - // 更新 Tab 激活状态 + // Update Tab activation status document.querySelectorAll('.prompt-panel-tab').forEach((tab) => { tab.classList.toggle('active', tab.dataset.tab === tabName); }); - // 更新设置按钮激活状态 + // Update settings button activation status const settingsBtn = document.getElementById('settings-btn'); if (settingsBtn) { settingsBtn.classList.toggle('active', tabName === 'settings'); } - // 切换内容区 + // Switch content area document.getElementById('prompts-content')?.classList.toggle('hidden', tabName !== 'prompts'); document.getElementById('outline-content')?.classList.toggle('hidden', tabName !== 'outline'); document.getElementById('conversations-content')?.classList.toggle('hidden', tabName !== 'conversations'); document.getElementById('settings-content')?.classList.toggle('hidden', tabName !== 'settings'); - // 通知 OutlineManager 激活状态(用于控制自动更新显隐) + // Notify OutlineManager of activation status (used to control automatic update visibility) if (this.outlineManager) { this.outlineManager.setActive(tabName === 'outline'); } - // 通知 ConversationManager 激活状态 + // Notify ConversationManager of activation status if (this.conversationManager) { this.conversationManager.setActive(tabName === 'conversations'); } - // 更新刷新按钮的提示 + // Update tooltip for refresh button const refreshBtn = document.getElementById('refresh-prompts'); if (refreshBtn) { const titleMap = { @@ -13523,13 +13407,13 @@ refreshBtn.title = titleMap[tabName] || this.t('refresh'); } - // 切换到大纲时自动刷新 + // Automatically refresh when switching to outline if (tabName === 'outline') { this.refreshOutline(); } } - // 刷新大纲 + // Refresh outline refreshOutline() { if (!this.settings.outline?.enabled) return; const showUserQueries = this.settings.outline?.showUserQueries || false; @@ -13539,19 +13423,19 @@ } } - // 创建可折叠区域辅助方法 + // Create a collapsible area helper method createCollapsibleSection(title, content, options = {}) { const { defaultExpanded = false } = options; const section = createElement('div', { className: 'settings-section' }); - // 标题栏(可点击折叠/展开) + // Title bar (can be clicked to collapse/expand) const header = createElement('div', { className: 'settings-section-title', style: 'cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;', }); const headerLeft = createElement('div', { style: 'display: flex; align-items: center; gap: 6px;' }); - // 箭头 + // arrow const arrow = createElement( 'span', { @@ -13566,24 +13450,24 @@ headerLeft.appendChild(headerTitle); header.appendChild(headerLeft); - // 如果有右侧元素(如开关状态提示等),可以扩展 options 传入,这里暂时留空 + // If there are elements on the right (such as switch status prompts, etc.), you can extend options and pass it in, leaving it blank for now. section.appendChild(header); - // 内容容器 + // content container const contentContainer = createElement('div', { className: 'settings-accordion-content', style: `display: ${defaultExpanded ? 'block' : 'none'}; padding-top: 8px; animation: slideDown 0.2s;`, }); contentContainer.appendChild(content); - // 切换折叠状态 + // Toggle folded state let isExpanded = defaultExpanded; const updateState = () => { contentContainer.style.display = isExpanded ? 'block' : 'none'; arrow.style.transform = isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'; }; - // 初始化状态 + // initialization state if (defaultExpanded) arrow.style.transform = 'rotate(90deg)'; header.addEventListener('click', () => { @@ -13595,11 +13479,11 @@ return section; } - // 创建设置面板内容 + // Create settings panel content createSettingsContent(container) { const content = createElement('div', { className: 'settings-content' }); - // 1. 语言设置 (保持在顶部) + // 1. Language settings (keep on top) const langSection = createElement('div', { className: 'settings-section' }); langSection.appendChild(createElement('div', { className: 'settings-section-title' }, this.t('settingsTitle'))); @@ -13609,7 +13493,7 @@ langInfo.appendChild(createElement('div', { className: 'setting-item-desc' }, this.t('languageDesc'))); const langSelect = createElement('select', { className: 'setting-select', id: 'select-language' }); - const currentLang = GM_getValue(SETTING_KEYS.LANGUAGE, 'auto'); + const currentLang = GM_getValue(SETTING_KEYS.LANGUAGE, 'en'); [ { value: 'auto', label: this.t('languageAuto') }, { value: 'zh-CN', label: this.t('languageZhCN') }, @@ -13622,7 +13506,7 @@ }); langSelect.addEventListener('change', () => { GM_setValue(SETTING_KEYS.LANGUAGE, langSelect.value); - resetLanguageCache(); // 清除全局 t() 的语言缓存 + resetLanguageCache(); // Clear the language cache for global t() this.lang = detectLanguage(); this.i18n = I18N[this.lang]; this.createStyles(); @@ -13638,13 +13522,13 @@ content.appendChild(langSection); - // 2. 模型锁定设置 (可折叠) + // 2. Model lock setting (foldable) let lockSection = null; if (this.registry && this.registry.adapters) { const adaptersWithLock = this.registry.adapters; if (adaptersWithLock.length > 0) { const lockContainer = createElement('div', {}); - // 为每个站点生成配置行 + // Generate configuration lines for each site adaptersWithLock.forEach((adapter) => { const siteId = adapter.getSiteId(); const siteConfig = this.settings.modelLockConfig[siteId] || adapter.getDefaultLockSettings(); @@ -13708,10 +13592,10 @@ } } - // 3. 页面宽度设置 (可折叠) + // 3. Page width setting (foldable) const widthContainer = createElement('div', {}); - // 启用开关 + // enable switch const enableWidthItem = createElement('div', { className: 'setting-item' }); const enableWidthInfo = createElement('div', { className: 'setting-item-info' }); enableWidthInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('enablePageWidth'))); @@ -13733,7 +13617,7 @@ enableWidthItem.appendChild(enableToggle); widthContainer.appendChild(enableWidthItem); - // 值设置 + // value setting const widthValueItem = createElement('div', { className: 'setting-item' }); const widthValueInfo = createElement('div', { className: 'setting-item-info' }); widthValueInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('widthValue'))); @@ -13795,7 +13679,7 @@ widthValueItem.appendChild(widthControls); widthContainer.appendChild(widthValueItem); - // 防止自动滚动(从其他设置移入) + // Prevent autoscrolling (moved in from other settings) const scrollLockItem = createElement('div', { className: 'setting-item' }); const scrollLockInfo = createElement('div', { className: 'setting-item-info' }); scrollLockInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('preventAutoScrollLabel'))); @@ -13820,7 +13704,7 @@ const widthSection = this.createCollapsibleSection(this.t('pageDisplaySettings'), widthContainer); - // 4. 界面排版 (可折叠) + // 4. Interface layout (foldable) const layoutContainer = createElement('div', {}); const tabDesc = createElement( 'div', @@ -13833,7 +13717,7 @@ layoutContainer.appendChild(tabDesc); const currentOrder = this.settings.tabOrder || DEFAULT_TAB_ORDER; - // 过滤掉 settings(已移到 header 按钮,不参与排序) + // Filter out settings (moved to header button, not involved in sorting) const validOrder = currentOrder.filter((id) => TAB_DEFINITIONS[id] && id !== 'settings'); validOrder.forEach((tabId, index) => { @@ -13844,13 +13728,13 @@ const controls = createElement('div', { className: 'setting-controls' }); - // 特殊处理:如果是大纲 Tab,在排序按钮旁边添加开关 + // Special treatment: If it is an outline Tab, add a switch next to the sort button if (tabId === 'outline') { const outlineToggle = createElement('div', { className: 'setting-toggle' + (this.settings.outline?.enabled ? ' active' : ''), id: 'toggle-outline-inline', style: 'transform: scale(0.8); margin-right: 12px;', - title: this.t('enableOutline'), // 添加提示 + title: this.t('enableOutline'), // add hint }); outlineToggle.addEventListener('click', (e) => { e.stopPropagation(); @@ -13864,7 +13748,7 @@ if (!this.settings.outline.enabled && this.currentTab === 'outline') this.switchTab('settings'); - // 更新自动更新状态 + // Update auto-update status if (this.outlineManager) { this.outlineManager.updateAutoUpdateState(); } @@ -13874,7 +13758,7 @@ controls.appendChild(outlineToggle); } - // 特殊处理:如果是提示词 Tab,在排序按钮旁边添加开关 + // Special processing: If it is the prompt word Tab, add a switch next to the sort button if (tabId === 'prompts') { const promptsToggle = createElement('div', { className: 'setting-toggle' + (this.settings.prompts?.enabled ? ' active' : ''), @@ -13898,9 +13782,9 @@ controls.appendChild(promptsToggle); } - // 特殊处理:如果是会话 Tab,在排序按钮旁边添加开关 + // Special treatment: If it is a session Tab, add a switch next to the sort button if (tabId === 'conversations') { - // 确保 conversations 设置对象存在 + // Make sure the conversations settings object exists if (!this.settings.conversations) { this.settings.conversations = { enabled: true }; } @@ -13995,10 +13879,10 @@ const layoutSection = this.createCollapsibleSection(this.t('tabOrderSettings'), layoutContainer); - // 4.2 会话设置 + // 4.2 Session settings const convSettingsContainer = createElement('div', {}); - // 同步时更新取消置顶开关 + // Update cancel pin switch during synchronization const syncUnpinItem = createElement('div', { className: 'setting-item' }); const syncUnpinInfo = createElement('div', { className: 'setting-item-info' }); syncUnpinInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('conversationsSyncUnpinLabel'))); @@ -14019,7 +13903,7 @@ syncUnpinItem.appendChild(syncUnpinToggle); convSettingsContainer.appendChild(syncUnpinItem); - // 文件夹彩虹色开关 + // Folder rainbow color switch const folderRainbowItem = createElement('div', { className: 'setting-item' }); const folderRainbowInfo = createElement('div', { className: 'setting-item-info' }); folderRainbowInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('folderRainbowLabel'))); @@ -14032,13 +13916,13 @@ folderRainbowToggle.addEventListener('click', () => { if (!this.settings.conversations) this.settings.conversations = {}; this.settings.conversations.folderRainbow = !this.settings.conversations.folderRainbow; - // 处理 undefined -> false 的情况(默认是 true) + // Handle the case of undefined -> false (default is true) if (this.settings.conversations.folderRainbow === undefined) { this.settings.conversations.folderRainbow = false; } folderRainbowToggle.classList.toggle('active', this.settings.conversations.folderRainbow !== false); this.saveSettings(); - // 刷新会话 UI + // Refresh session UI if (this.conversationManager) this.conversationManager.createUI(); showToast(this.settings.conversations.folderRainbow !== false ? this.t('settingOn') : this.t('settingOff')); }); @@ -14048,10 +13932,10 @@ const convSettingsSection = this.createCollapsibleSection(this.t('conversationsSettingsTitle'), convSettingsContainer, { defaultExpanded: false }); - // 4.5 阅读历史设置 + // 4.5 Reading history settings const anchorContainer = createElement('div', {}); - // 持久化开关 + // persistence switch const anchorPersistenceItem = createElement('div', { className: 'setting-item' }); const anchorPersistenceInfo = createElement('div', { className: 'setting-item-info' }); anchorPersistenceInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('readingHistoryPersistence'))); @@ -14062,7 +13946,7 @@ id: 'toggle-anchor-persistence', }); - // 自动恢复开关 + // Automatic recovery switch const anchorAutoRestoreItem = createElement('div', { className: 'setting-item' }); const anchorAutoRestoreInfo = createElement('div', { className: 'setting-item-info' }); anchorAutoRestoreInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('autoRestore'))); @@ -14072,7 +13956,7 @@ id: 'toggle-anchor-auto-restore', }); - // 清理时间设置 + // Cleanup time settings const anchorCleanupItem = createElement('div', { className: 'setting-item' }); const anchorCleanupInfo = createElement('div', { className: 'setting-item-info' }); anchorCleanupInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('readingHistoryCleanup'))); @@ -14081,7 +13965,7 @@ const anchorCleanupControls = createElement('div', { className: 'setting-controls' }); const anchorCleanupInput = createElement('select', { className: 'setting-select' }); - // 填充清理选项 + // Populate cleaning options const cleanupOptions = [ { val: 1, label: `1 ${this.t('daysSuffix')}` }, { val: 3, label: `3 ${this.t('daysSuffix')}` }, @@ -14096,7 +13980,7 @@ anchorCleanupInput.appendChild(option); }); - // 联动逻辑函数 + // Linkage logic function const updateDependency = (enabled) => { if (enabled) { anchorAutoRestoreItem.style.opacity = '1'; @@ -14111,7 +13995,7 @@ } }; - // 初始化联动 + // Initialize linkage updateDependency(this.settings.readingHistory.persistence); anchorPersistenceToggle.addEventListener('click', () => { @@ -14156,10 +14040,10 @@ const anchorSection = this.createCollapsibleSection(this.t('readingNavigationSettings'), anchorContainer); - // 5. 大纲详细设置 + // 5. Outline detailed settings const outlineSettingsContainer = createElement('div', {}); - // 自动更新开关 + // Automatic update switch const autoUpdateItem = createElement('div', { className: 'setting-item' }); const autoUpdateInfo = createElement('div', { className: 'setting-item-info' }); autoUpdateInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('outlineAutoUpdateLabel'))); @@ -14180,7 +14064,7 @@ autoUpdateItem.appendChild(autoUpdateToggle); outlineSettingsContainer.appendChild(autoUpdateItem); - // 更新间隔 + // update interval const updateIntervalItem = createElement('div', { className: 'setting-item' }); const updateIntervalInfo = createElement('div', { className: 'setting-item-info' }); updateIntervalInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('outlineUpdateIntervalLabel'))); @@ -14194,11 +14078,11 @@ }); updateIntervalInput.addEventListener('change', () => { let val = parseInt(updateIntervalInput.value, 10); - if (val < 1) val = 1; // 最小 1 秒 + if (val < 1) val = 1; // Minimum 1 second updateIntervalInput.value = val; this.settings.outline.updateInterval = val; this.saveSettings(); - // OutlineManager 在触发下一次更新时会自动使用新间隔 + // OutlineManager will automatically use the new interval when triggering the next update showToast(this.t('outlineIntervalUpdated').replace('{val}', val)); }); updateIntervalControls.appendChild(updateIntervalInput); @@ -14206,7 +14090,7 @@ updateIntervalItem.appendChild(updateIntervalControls); outlineSettingsContainer.appendChild(updateIntervalItem); - // 同步滚动开关 + // Synchronous scroll switch const syncScrollItem = createElement('div', { className: 'setting-item' }); const syncScrollInfo = createElement('div', { className: 'setting-item-info' }); syncScrollInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('outlineSyncScrollLabel'))); @@ -14229,10 +14113,10 @@ const outlineSettingsSection = this.createCollapsibleSection(this.t('outlineSettings'), outlineSettingsContainer, { defaultExpanded: false }); - // 5.5 面板设置 + // 5.5 Panel settings const panelSettingsContainer = createElement('div', {}); - // 5.5.1 默认显示面板开关 + // 5.5.1 Default display panel switch const defaultPanelStateItem = createElement('div', { className: 'setting-item' }); const defaultPanelStateInfo = createElement('div', { className: 'setting-item-info' }); defaultPanelStateInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('defaultPanelStateLabel'))); @@ -14252,7 +14136,7 @@ defaultPanelStateItem.appendChild(defaultPanelStateToggle); panelSettingsContainer.appendChild(defaultPanelStateItem); - // 5.5.2 自动隐藏面板开关 + // 5.5.2 Automatically hide panel switch const autoHidePanelItem = createElement('div', { className: 'setting-item' }); const autoHidePanelInfo = createElement('div', { className: 'setting-item-info' }); autoHidePanelInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('autoHidePanelLabel'))); @@ -14272,7 +14156,7 @@ autoHidePanelItem.appendChild(autoHidePanelToggle); panelSettingsContainer.appendChild(autoHidePanelItem); - // 5.5.3 边缘吸附隐藏开关 + // 5.5.3 Edge adsorption hidden switch const edgeSnapHideItem = createElement('div', { className: 'setting-item' }); const edgeSnapHideInfo = createElement('div', { className: 'setting-item-info' }); edgeSnapHideInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('edgeSnapHideLabel'))); @@ -14286,7 +14170,7 @@ this.settings.edgeSnapHide = !this.settings.edgeSnapHide; edgeSnapHideToggle.classList.toggle('active', this.settings.edgeSnapHide); this.saveSettings(); - // 如果关闭功能且当前处于吸附状态,则恢复面板 + // Restores panel if feature is turned off and currently in snap state if (!this.settings.edgeSnapHide && this.edgeSnapState) { this.unsnap(); } @@ -14296,14 +14180,14 @@ edgeSnapHideItem.appendChild(edgeSnapHideToggle); panelSettingsContainer.appendChild(edgeSnapHideItem); - // 5.5.4 折叠面板按钮排序 + // 5.5.4 Collapse panel button sorting const collapsedBtnDesc = createElement( 'div', { className: 'setting-item-desc', style: 'padding: 0 12px 8px 12px; margin-bottom: 4px;', }, - this.t('collapsedButtonsOrderDesc') || '调整折叠面板按钮的显示顺序', + this.t('collapsedButtonsOrderDesc') || 'Adjust the display order of collapsed panel buttons', ); panelSettingsContainer.appendChild(collapsedBtnDesc); @@ -14327,7 +14211,7 @@ const controls = createElement('div', { className: 'setting-controls' }); - // 可切换的按钮(anchor/theme)添加开关 + // Switchable button (anchor/theme) adds switch if (def.canToggle) { const toggle = createElement('div', { className: 'setting-toggle' + (btnConfig.enabled ? ' active' : ''), @@ -14346,7 +14230,7 @@ controls.appendChild(toggle); } - // 上下移动按钮 + // Move button up and down const upBtn = createElement('button', { className: 'prompt-panel-btn', style: 'background: var(--gh-hover, #f3f4f6); color: #4b5563; width: 32px; height: 32px; font-size: 16px; margin-right: 4px; border: 1px solid var(--gh-border, #e5e7eb);', @@ -14415,10 +14299,10 @@ const panelSettingsSection = this.createCollapsibleSection(this.t('panelSettingsTitle'), panelSettingsContainer, { defaultExpanded: false }); - // 6. 标签页设置 + // 6. Tab settings const tabSettingsContainer = createElement('div', {}); - // 6.1 新标签页打开开关 + // 6.1 New tab page opening switch if (this.siteAdapter.supportsNewTab()) { const newTabItem = createElement('div', { className: 'setting-item' }); const newTabInfo = createElement('div', { className: 'setting-item-info' }); @@ -14446,7 +14330,7 @@ tabSettingsContainer.appendChild(newTabItem); } - // 6.2 自动重命名标签页开关 (仅支持的站点显示) + // 6.2 Automatically rename tabs switch (only displayed on supported sites) if (this.siteAdapter.supportsTabRename()) { const renameTabItem = createElement('div', { className: 'setting-item' }); const renameTabInfo = createElement('div', { className: 'setting-item-info' }); @@ -14461,7 +14345,7 @@ renameTabItem.appendChild(renameTabToggle); tabSettingsContainer.appendChild(renameTabItem); - // 6.3 检测频率 + // 6.3 Detection frequency const intervalItem = createElement('div', { className: 'setting-item' }); const intervalInfo = createElement('div', { className: 'setting-item-info' }); intervalInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('renameIntervalLabel'))); @@ -14492,7 +14376,7 @@ intervalItem.appendChild(intervalControls); tabSettingsContainer.appendChild(intervalItem); - // 定义状态更新函数 + // Define status update function const updateIntervalState = () => { const isEnabled = this.settings.tabSettings.autoRenameTab; intervalSelect.disabled = !isEnabled; @@ -14500,19 +14384,19 @@ intervalItem.style.pointerEvents = isEnabled ? 'auto' : 'none'; }; - // 初始化状态 + // initialization state updateIntervalState(); - // 绑定开关点击事件 + // Bind switch click event renameTabToggle.addEventListener('click', () => { this.settings.tabSettings.autoRenameTab = !this.settings.tabSettings.autoRenameTab; renameTabToggle.classList.toggle('active', this.settings.tabSettings.autoRenameTab); this.saveSettings(); - // 更新检测频率项状态 + // Update detection frequency item status updateIntervalState(); - // 启动/停止 TabRenameManager + // Start/Stop TabRenameManager if (this.tabRenameManager) { if (this.settings.tabSettings.autoRenameTab) { this.tabRenameManager.start(); @@ -14525,7 +14409,7 @@ }); } - // 6.4 显示生成状态 (showStatus) + // 6.4 Show build status (showStatus) if (this.siteAdapter.supportsTabRename()) { const showStatusItem = createElement('div', { className: 'setting-item' }); const showStatusInfo = createElement('div', { className: 'setting-item-info' }); @@ -14549,7 +14433,7 @@ tabSettingsContainer.appendChild(showStatusItem); } - // 6.5 标题格式 (titleFormat) + // 6.5 Title Format (titleFormat) if (this.siteAdapter.supportsTabRename()) { const formatItem = createElement('div', { className: 'setting-item' }); const formatInfo = createElement('div', { className: 'setting-item-info' }); @@ -14573,7 +14457,7 @@ tabSettingsContainer.appendChild(formatItem); } - // 6.6 发送桌面通知 (showNotification) + // 6.6 Send desktop notification (showNotification) if (this.siteAdapter.supportsTabRename()) { const notificationItem = createElement('div', { className: 'setting-item' }); const notificationInfo = createElement('div', { className: 'setting-item-info' }); @@ -14595,7 +14479,7 @@ notificationItem.appendChild(notificationToggle); tabSettingsContainer.appendChild(notificationItem); - // 6.6.1 通知声音 (notificationSound) + // 6.6.1 Notification Sound (notificationSound) const soundItem = createElement('div', { className: 'setting-item' }); const soundInfo = createElement('div', { className: 'setting-item-info' }); soundInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('notificationSoundLabel'))); @@ -14610,7 +14494,7 @@ soundItem.appendChild(soundToggle); tabSettingsContainer.appendChild(soundItem); - // 6.6.2 音量滑块 (notificationVolume) + // 6.6.2 Volume slider (notificationVolume) const volumeItem = createElement('div', { className: 'setting-item' }); const volumeInfo = createElement('div', { className: 'setting-item-info' }); volumeInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('notificationVolumeLabel'))); @@ -14649,7 +14533,7 @@ volumeItem.appendChild(volumeControls); tabSettingsContainer.appendChild(volumeItem); - // 联动逻辑:音量滑块根据通知声音开关状态置灰 + // Linkage logic: The volume slider is grayed out according to the status of the notification sound switch. const updateVolumeState = () => { const isEnabled = this.settings.tabSettings.notificationSound; volumeSlider.disabled = !isEnabled; @@ -14658,7 +14542,7 @@ }; updateVolumeState(); - // 绑定通知声音开关点击事件 + // Bind notification sound switch click event soundToggle.addEventListener('click', () => { this.settings.tabSettings.notificationSound = !this.settings.tabSettings.notificationSound; soundToggle.classList.toggle('active', this.settings.tabSettings.notificationSound); @@ -14667,7 +14551,7 @@ showToast(this.settings.tabSettings.notificationSound ? this.t('settingOn') : this.t('settingOff')); }); - // 6.6.3 前台时也通知 (notifyWhenFocused) + // 6.6.3 Notify when in the foreground (notifyWhenFocused) const focusNotifyItem = createElement('div', { className: 'setting-item' }); const focusNotifyInfo = createElement('div', { className: 'setting-item-info' }); focusNotifyInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('notifyWhenFocusedLabel'))); @@ -14689,7 +14573,7 @@ tabSettingsContainer.appendChild(focusNotifyItem); } - // 6.7 自动窗口置顶 (autoFocus) + // 6.7 Automatic window on top (autoFocus) if (this.siteAdapter.supportsTabRename()) { const autoFocusItem = createElement('div', { className: 'setting-item' }); const autoFocusInfo = createElement('div', { className: 'setting-item-info' }); @@ -14712,7 +14596,7 @@ tabSettingsContainer.appendChild(autoFocusItem); } - // 6.8 隐私模式 (privacyMode) + // 6.8 Privacy Mode (privacyMode) if (this.siteAdapter.supportsTabRename()) { const privacyItem = createElement('div', { className: 'setting-item' }); const privacyInfo = createElement('div', { className: 'setting-item-info' }); @@ -14728,7 +14612,7 @@ privacyItem.appendChild(privacyToggle); tabSettingsContainer.appendChild(privacyItem); - // 6.9 伪装标题输入框 (privacyTitle) + // 6.9 Disguise title input box (privacyTitle) const privacyTitleItem = createElement('div', { className: 'setting-item' }); const privacyTitleInfo = createElement('div', { className: 'setting-item-info' }); privacyTitleInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('privacyTitleLabel'))); @@ -14752,7 +14636,7 @@ privacyTitleItem.appendChild(privacyTitleInput); tabSettingsContainer.appendChild(privacyTitleItem); - // 定义状态更新函数(类似 renameInterval 的处理方式) + // Define status update function (similar to renameInterval processing) const updatePrivacyTitleState = () => { const isEnabled = this.settings.tabSettings.privacyMode; privacyTitleInput.disabled = !isEnabled; @@ -14760,27 +14644,118 @@ privacyTitleItem.style.pointerEvents = isEnabled ? 'auto' : 'none'; }; - // 初始化状态 + // initialization state updatePrivacyTitleState(); - // 绑定隐私模式开关点击事件 + // Bind privacy mode switch click event privacyToggle.addEventListener('click', () => { this.settings.tabSettings.privacyMode = !this.settings.tabSettings.privacyMode; privacyToggle.classList.toggle('active', this.settings.tabSettings.privacyMode); this.saveSettings(); if (this.tabRenameManager) this.tabRenameManager.updateTabName(true); - // 更新伪装标题项状态 + // Update disguise title item status updatePrivacyTitleState(); showToast(this.settings.tabSettings.privacyMode ? '🔒 ' + this.t('settingOn') : '🔓 ' + this.t('settingOff')); }); } - const tabSettingsSection = this.createCollapsibleSection(this.t('tabSettingsTitle'), tabSettingsContainer, { defaultExpanded: false }); + + // 6.10 Smart Enter Option (smartEnter) + const smartEnterItem = createElement('div', { className: 'setting-item' }); + const smartEnterInfo = createElement('div', { className: 'setting-item-info' }); + smartEnterInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('smartEnterLabel'))); + smartEnterInfo.appendChild(createElement('div', { className: 'setting-item-desc' }, this.t('smartEnterDesc'))); + + const smartEnterToggle = createElement('div', { + className: 'setting-toggle' + (this.settings.tabSettings?.smartEnter ? ' active' : ''), + id: 'toggle-smart-enter', + }); + smartEnterToggle.addEventListener('click', () => { + this.settings.tabSettings.smartEnter = !this.settings.tabSettings.smartEnter; + smartEnterToggle.classList.toggle('active', this.settings.tabSettings.smartEnter); + this.saveSettings(); + showToast(this.settings.tabSettings.smartEnter ? this.t('settingOn') : this.t('settingOff')); + }); + + smartEnterItem.appendChild(smartEnterInfo); + smartEnterItem.appendChild(smartEnterToggle); + tabSettingsContainer.appendChild(smartEnterItem); + + // 6.11 Scroll Button Option (showScrollBtn) + const scrollBtnItem = createElement('div', { className: 'setting-item' }); + const scrollBtnInfo = createElement('div', { className: 'setting-item-info' }); + scrollBtnInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('showScrollBtnLabel'))); + scrollBtnInfo.appendChild(createElement('div', { className: 'setting-item-desc' }, this.t('showScrollBtnDesc'))); + + const scrollBtnToggle = createElement('div', { + className: 'setting-toggle' + (this.settings.tabSettings?.showScrollBtn ? ' active' : ''), + id: 'toggle-show-scroll-btn', + }); + scrollBtnToggle.addEventListener('click', () => { + this.settings.tabSettings.showScrollBtn = !this.settings.tabSettings.showScrollBtn; + scrollBtnToggle.classList.toggle('active', this.settings.tabSettings.showScrollBtn); + this.saveSettings(); + if (this.smartEnterManager) { + this.smartEnterManager.toggleScrollButton(); + } + showToast(this.settings.tabSettings.showScrollBtn ? this.t('settingOn') : this.t('settingOff')); + }); + + scrollBtnItem.appendChild(scrollBtnInfo); + scrollBtnItem.appendChild(scrollBtnToggle); + tabSettingsContainer.appendChild(scrollBtnItem); + + // 6.12 Hide Disclaimer Option (hideDisclaimer) + const hideDisclaimerItem = createElement('div', { className: 'setting-item' }); + const hideDisclaimerInfo = createElement('div', { className: 'setting-item-info' }); + hideDisclaimerInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('hideDisclaimerLabel'))); + hideDisclaimerInfo.appendChild(createElement('div', { className: 'setting-item-desc' }, this.t('hideDisclaimerDesc'))); + + const hideDisclaimerToggle = createElement('div', { + className: 'setting-toggle' + (this.settings.tabSettings?.hideDisclaimer ? ' active' : ''), + id: 'toggle-hide-disclaimer', + }); + hideDisclaimerToggle.addEventListener('click', () => { + this.settings.tabSettings.hideDisclaimer = !this.settings.tabSettings.hideDisclaimer; + hideDisclaimerToggle.classList.toggle('active', this.settings.tabSettings.hideDisclaimer); + this.saveSettings(); + if (this.smartEnterManager) { + this.smartEnterManager.toggleDisclaimer(); + } + showToast(this.settings.tabSettings.hideDisclaimer ? this.t('settingOn') : this.t('settingOff')); + }); + + hideDisclaimerItem.appendChild(hideDisclaimerInfo); + hideDisclaimerItem.appendChild(hideDisclaimerToggle); + tabSettingsContainer.appendChild(hideDisclaimerItem); + + // 6.13 Aggressive Paste Focus Option (pasteFocusFix) + const pasteFocusItem = createElement('div', { className: 'setting-item' }); + const pasteFocusInfo = createElement('div', { className: 'setting-item-info' }); + pasteFocusInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('pasteFocusFixLabel'))); + pasteFocusInfo.appendChild(createElement('div', { className: 'setting-item-desc' }, this.t('pasteFocusFixDesc'))); + + const pasteFocusToggle = createElement('div', { + className: 'setting-toggle' + (this.settings.tabSettings?.pasteFocusFix ? ' active' : ''), + id: 'toggle-paste-focus-fix', + }); + pasteFocusToggle.addEventListener('click', () => { + this.settings.tabSettings.pasteFocusFix = !this.settings.tabSettings.pasteFocusFix; + pasteFocusToggle.classList.toggle('active', this.settings.tabSettings.pasteFocusFix); + this.saveSettings(); + showToast(this.settings.tabSettings.pasteFocusFix ? this.t('settingOn') : this.t('settingOff')); + }); + + pasteFocusItem.appendChild(pasteFocusInfo); + pasteFocusItem.appendChild(pasteFocusToggle); + tabSettingsContainer.appendChild(pasteFocusItem); + + const tabSettingsSection = this.createCollapsibleSection(this.t('tabSettingsTitle'), tabSettingsContainer, { defaultExpanded: false }); - // 内容设置 + // Content settings const exportContainer = createElement('div', {}); - // 水印移除开关 + // Watermark removal switch const watermarkRemovalItem = createElement('div', { className: 'setting-item' }); const watermarkRemovalInfo = createElement('div', { className: 'setting-item-info' }); watermarkRemovalInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('watermarkRemovalLabel'))); @@ -14794,7 +14769,7 @@ this.settings.watermarkRemoval = !this.settings.watermarkRemoval; watermarkRemovalToggle.classList.toggle('active', this.settings.watermarkRemoval); this.saveSettings(); - // 根据设置启动或停止水印移除 + // Start or stop watermark removal based on settings if (this.watermarkRemover) { if (this.settings.watermarkRemoval) { this.watermarkRemover.start(); @@ -14808,14 +14783,14 @@ watermarkRemovalItem.appendChild(watermarkRemovalToggle); exportContainer.appendChild(watermarkRemovalItem); - // Base64 图片导出开关 + // Base64 image export switch const base64ExportItem = createElement('div', { className: 'setting-item' }); const base64ExportInfo = createElement('div', { className: 'setting-item-info' }); base64ExportInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('exportImagesToBase64Label'))); base64ExportInfo.appendChild(createElement('div', { className: 'setting-item-desc' }, this.t('exportImagesToBase64Desc'))); const base64ExportToggle = createElement('div', { - // 默认为 false + // Default is false className: 'setting-toggle' + (this.settings.conversations?.exportImagesToBase64 ? ' active' : ''), id: 'toggle-export-base64', }); @@ -14833,10 +14808,10 @@ base64ExportItem.appendChild(base64ExportToggle); exportContainer.appendChild(base64ExportItem); - // 双击复制公式开关 (仅 Gemini 标准版显示) + // Double-click the copy formula switch (only displayed in Gemini standard version) const isNonBusinessGemini = !location.host.includes('business'); if (isNonBusinessGemini) { - // 初始化默认值(双击复制公式默认开启) + // Initialize default values (double-click to copy the formula is enabled by default) if (this.settings.formulaCopyEnabled === undefined) { this.settings.formulaCopyEnabled = true; } @@ -14858,7 +14833,7 @@ formulaCopyItem.appendChild(formulaCopyToggle); exportContainer.appendChild(formulaCopyItem); - // 分隔符开关 + // separator switch const delimiterItem = createElement('div', { className: 'setting-item' }); const delimiterInfo = createElement('div', { className: 'setting-item-info' }); delimiterInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('formulaDelimiterLabel'))); @@ -14873,7 +14848,7 @@ delimiterItem.appendChild(delimiterToggle); exportContainer.appendChild(delimiterItem); - // 联动逻辑:根据公式复制开关状态更新分隔符开关可用性 + // Linkage logic: update separator switch availability based on formula copy switch status const updateDelimiterState = () => { const isEnabled = this.settings.formulaCopyEnabled; delimiterToggle.style.opacity = isEnabled ? '1' : '0.4'; @@ -14882,25 +14857,25 @@ }; updateDelimiterState(); - // 公式复制开关点击事件 + // Formula copy switch click event formulaCopyToggle.addEventListener('click', () => { this.settings.formulaCopyEnabled = !this.settings.formulaCopyEnabled; formulaCopyToggle.classList.toggle('active', this.settings.formulaCopyEnabled); this.saveSettings(); - // 实时切换功能 + // Real-time switching function if (this.settings.formulaCopyEnabled) { this.copyManager.initFormulaCopy(); } else { this.copyManager.destroyFormulaCopy(); } - // 更新分隔符开关状态 + // Update separator switch state updateDelimiterState(); showToast(this.settings.formulaCopyEnabled ? this.t('settingOn') : this.t('settingOff')); }); - // 分隔符开关点击事件 + // separator switch click event delimiterToggle.addEventListener('click', () => { - if (!this.settings.formulaCopyEnabled) return; // 防止在禁用状态下点击 + if (!this.settings.formulaCopyEnabled) return; // Prevent clicking in disabled state this.settings.formulaDelimiterEnabled = !this.settings.formulaDelimiterEnabled; delimiterToggle.classList.toggle('active', this.settings.formulaDelimiterEnabled); this.saveSettings(); @@ -14908,8 +14883,8 @@ }); } - // 表格复制 Markdown 开关(通用功能,两个版本都支持) - // 初始化默认值 + // Table copy Markdown switch (common function, supported by both versions) + // Initialize default value if (this.settings.tableCopyEnabled === undefined) { this.settings.tableCopyEnabled = true; } @@ -14927,7 +14902,7 @@ this.settings.tableCopyEnabled = !this.settings.tableCopyEnabled; tableCopyToggle.classList.toggle('active', this.settings.tableCopyEnabled); this.saveSettings(); - // 实时切换功能 + // Real-time switching function if (this.settings.tableCopyEnabled) { this.copyManager.initTableCopy(); } else { @@ -14940,10 +14915,10 @@ tableCopyItem.appendChild(tableCopyToggle); exportContainer.appendChild(tableCopyItem); - // Gemini 专属设置 + // Gemini exclusive settings const isStandardGemini = this.siteAdapter instanceof GeminiAdapter; if (isStandardGemini) { - // Markdown 加粗修复开关 + // Markdown bold repair switch const mdFixSettings = GM_getValue(SETTING_KEYS.MARKDOWN_FIX, DEFAULT_MARKDOWN_FIX_SETTINGS); const mdFixItem = createElement('div', { className: 'setting-item' }); @@ -14960,7 +14935,7 @@ mdFixToggle.classList.toggle('active', mdFixSettings.enabled); GM_setValue(SETTING_KEYS.MARKDOWN_FIX, mdFixSettings); - // 实时切换 + // real time switching if (mdFixSettings.enabled) { if (!this.markdownFixer) { this.markdownFixer = new MarkdownFixer(); @@ -14980,10 +14955,10 @@ const contentAndExportSection = this.createCollapsibleSection(this.t('contentExportSettingsTitle'), exportContainer, { defaultExpanded: false }); - // 其他设置 + // Other settings const otherSettingsContainer = createElement('div', {}); - // Gemini Business 专属设置 + // Gemini Business exclusive settings if (this.siteAdapter instanceof GeminiBusinessAdapter) { const clearItem = createElement('div', { className: 'setting-item' }); const clearInfo = createElement('div', { className: 'setting-item-info' }); @@ -15006,27 +14981,27 @@ const otherSettingsSection = this.createCollapsibleSection(this.t('otherSettingsTitle'), otherSettingsContainer, { defaultExpanded: false }); - // 1. 通用设置(语言)- 已在上方添加 - // 2. 面板设置 (New) + // 1. General settings (language) - added above + // 2. Panel settings (New) content.appendChild(panelSettingsSection); - // 3. 界面排版 + // 3. Interface layout content.appendChild(layoutSection); - // 3.5. 会话设置 + // 3.5. Session settings content.appendChild(convSettingsSection); - // 内容设置 + // Content settings content.appendChild(contentAndExportSection); - // 4. 标签页设置 + // 4. Tab settings if (tabSettingsSection) content.appendChild(tabSettingsSection); - // 5. 阅读导航 + // 5. Reading navigation content.appendChild(anchorSection); - // 6. 大纲设置 + // 6. Outline settings content.appendChild(outlineSettingsSection); - // 7. 页面显示 + // 7. Page display content.appendChild(widthSection); - // 8. 模型锁定 + // 8. Model lock if (lockSection) content.appendChild(lockSection); - // last: 其他设置 + // last: other settings content.appendChild(otherSettingsSection); container.appendChild(content); @@ -15039,13 +15014,13 @@ this.isCollapsed = !this.isCollapsed; if (this.isCollapsed) { - // 折叠时隐藏触发条(如果有的话) + // Hide trigger bar when collapsed (if present) this.hideEdgeTrigger(); panel.classList.add('collapsed'); if (quickBtnGroup) quickBtnGroup.classList.add('collapsed'); if (toggleBtn) toggleBtn.textContent = '+'; } else { - // 展开面板时,如果处于边缘吸附状态,临时显示面板(保持 edgeSnapState 用于 mouseleave 恢复) + // When expanding the panel, if it is in the edge snap state, temporarily display the panel (keep edgeSnapState for mouseleave recovery) if (this.edgeSnapState) { panel.classList.remove('edge-snapped-left', 'edge-snapped-right'); this.hideEdgeTrigger(); @@ -15058,21 +15033,21 @@ // ==================== Auto-Resume & Anchor Logic ==================== - // 恢复阅读历史 (Auto-Resume) + // Resume reading history (Auto-Resume) async restoreReadingProgress() { - // 将 showToast 传给 manager 以显示加载进度 + // Pass showToast to manager to show loading progress const success = await this.readingProgressManager.restoreProgress((msg) => showToast(msg)); const onRestorationComplete = () => { - // 延迟一点开启记录,避开惯性滚动等干扰,确保后续的用户滚动能被正确记录 - // 使用 restartRecording 而非 startRecording,确保会话切换时重新绑定滚动容器 + // Start recording with a little delay to avoid interference such as inertial scrolling and ensure that subsequent user scrolling can be recorded correctly. + // Use restartRecording instead of startRecording to ensure the scroll container is rebound when switching sessions setTimeout(() => { this.readingProgressManager.restartRecording(); }, 500); }; if (success) { - // 恢复成功,获取恢复的位置设为“初始锚点” + // The recovery is successful, and the recovered position is set as the "initial anchor point" const restoredTop = this.readingProgressManager.restoredTop; if (restoredTop !== undefined) { this.anchorManager.setAnchor(restoredTop); @@ -15080,18 +15055,18 @@ showToast(this.t('restoredPosition')); } - // 无论成功失败,最后都开启记录 + // Regardless of success or failure, the record will be turned on in the end. onRestorationComplete(); } - // 清理过期阅读历史 + // Clear expired reading history cleanupReadingHistory() { this.readingProgressManager.cleanup(); } - // 锚点按钮点击 (Back functionality) + // Anchor button click (Back functionality) handleAnchorClick() { - // 取消进行中的历史加载 + // Cancel a history load in progress this.historyLoader.abort(); if (this.anchorManager.hasAnchor()) { this.anchorManager.backToAnchor(); @@ -15101,7 +15076,7 @@ } } - // 更新锚点按钮状态 (UI) + // Update anchor button state (UI) updateAnchorButtonState(hasAnchor) { [document.getElementById('quick-anchor-btn'), document.getElementById('scroll-anchor-btn')].forEach((btn) => { if (btn) { @@ -15112,99 +15087,99 @@ } else { btn.style.opacity = '0.4'; btn.style.cursor = 'default'; - btn.title = '暂无锚点'; + btn.title = 'No anchor point'; } } }); } - // 滚动到页面顶部 + // Scroll to top of page scrollToTop() { - // 点击去顶部时,自动记录当前位置为锚点 + // When you click to go to the top, the current position is automatically recorded as the anchor point. this.anchorManager.setAnchor(this.scrollManager.scrollTop); const container = this.scrollManager.container; if (!container) { - // 容器不存在时,走原逻辑 + // When the container does not exist, the original logic is followed this.historyLoader.loadAllAndScrollTop(); return; } - // 检测是否在 Flutter 图文并茂模式 + // Detect whether Flutter is in graphic and text mode const isFlutterView = container.tagName?.toLowerCase().startsWith('flt-'); if (isFlutterView) { - // Flutter 图文并茂:使用循环滚动确保真正到达顶部 - // transform: scale() 会导致 scrollTop 设置不准确 + // Flutter with pictures and text: Use circular scrolling to ensure you really reach the top + // transform: scale() will cause scrollTop setting to be inaccurate const scrollStep = () => { const before = container.scrollTop; container.scrollTop = 0; - // 如果滚动位置还在变化,继续滚动 + // If the scroll position is still changing, continue scrolling if (container.scrollTop < before) { requestAnimationFrame(scrollStep); } }; scrollStep(); } else { - // 普通模式:加载全部历史记录并滚动到真正的顶部 + // Normal mode: loads the entire history and scrolls to the true top this.historyLoader.loadAllAndScrollTop(); } } - // 滚动到页面底部 + // Scroll to bottom of page scrollToBottom() { - // 取消进行中的历史加载 + // Cancel a history load in progress this.historyLoader.abort(); - // 点击去底部时,自动记录当前位置为锚点 + // When you click to go to the bottom, the current position is automatically recorded as the anchor point. this.anchorManager.setAnchor(this.scrollManager.scrollTop); const container = this.scrollManager.container; if (!container) return; - // 检测是否在 Flutter 图文并茂模式(有 transform: scale 缩放) - // 在这种模式下,scrollHeight 报告的值可能不准确 + // Detect whether it is in Flutter graphic mode (with transform: scale scaling) + // In this mode, the value reported by scrollHeight may be inaccurate const isFlutterView = container.tagName?.toLowerCase().startsWith('flt-'); if (isFlutterView) { - // Flutter 图文并茂:使用循环滚动确保真正触底 - // transform: scale() 会导致 scrollHeight 与实际滚动距离不匹配 + // Flutter with pictures and text: Use circular scrolling to ensure you really hit the bottom + // transform: scale() will cause scrollHeight to not match the actual scroll distance const scrollStep = () => { const before = container.scrollTop; const hadBypass = container.__ghBypassLock; container.__ghBypassLock = true; container.scrollTop = container.scrollHeight; if (!hadBypass) delete container.__ghBypassLock; - // 如果滚动位置还在变化,继续滚动 + // If the scroll position is still changing, continue scrolling if (container.scrollTop > before) { requestAnimationFrame(scrollStep); } }; scrollStep(); } else { - // 普通模式:直接滚动 + // Normal mode: direct scrolling this.scrollManager.scrollTo({ top: this.scrollManager.scrollHeight, behavior: 'smooth', __bypassLock: true }); } } - // ========== 边缘吸附功能方法 ========== + // ========== Edge adsorption function method ========== - // 吸附到边缘 + // snap to edge snapToEdge(edge) { const panel = document.getElementById('gemini-helper-panel'); if (!panel) return; - // 移除已有的吸附类 + // Remove existing adsorption classes panel.classList.remove('edge-snapped-left', 'edge-snapped-right'); - // 添加对应边缘的吸附类 + // Add the adsorption class corresponding to the edge panel.classList.add(`edge-snapped-${edge}`); this.edgeSnapState = edge; - // 显示触发条 + // Show trigger bar this.showEdgeTrigger(edge); } - // 取消吸附 + // Cancel adsorption unsnap() { const panel = document.getElementById('gemini-helper-panel'); if (!panel) return; @@ -15212,13 +15187,13 @@ panel.classList.remove('edge-snapped-left', 'edge-snapped-right'); this.edgeSnapState = null; - // 隐藏触发条 + // Hide trigger bar this.hideEdgeTrigger(); } - // 显示边缘触发条 + // Show edge trigger bar showEdgeTrigger(edge) { - // 先移除已有的触发条 + // Remove the existing trigger bar first this.hideEdgeTrigger(); const trigger = createElement('div', { @@ -15226,7 +15201,7 @@ id: 'edge-snap-trigger', }); - // 点击触发条临时显示面板(保持 edgeSnapState 用于 mouseleave 恢复) + // Click the trigger bar to temporarily display the panel (keep edgeSnapState for mouseleave recovery) trigger.addEventListener('click', () => { const panel = document.getElementById('gemini-helper-panel'); if (panel) { @@ -15238,7 +15213,7 @@ document.body.appendChild(trigger); } - // 隐藏边缘触发条 + // Hide edge trigger bar hideEdgeTrigger() { const trigger = document.getElementById('edge-snap-trigger'); if (trigger) { @@ -15246,9 +15221,9 @@ } } - // ========== 手动锚点功能方法 ========== + // ========== Manual anchor function method ========== - // 设置手动锚点 + // Set manual anchor points setAnchorManually() { this.savedAnchorTop = this.scrollManager.scrollTop; this.showAnchorMarker(this.savedAnchorTop); @@ -15256,24 +15231,24 @@ showToast(this.t('setAnchorToast')); } - // 返回手动锚点 + // Return to manual anchor point backToManualAnchor() { if (this.savedAnchorTop !== null) { const container = this.scrollManager.container; const targetTop = this.savedAnchorTop; - // 检测是否在 Flutter 图文并茂模式 + // Detect whether Flutter is in graphic and text mode const isFlutterView = container?.tagName?.toLowerCase().startsWith('flt-'); if (isFlutterView && container) { - // Flutter 图文并茂:直接设置 scrollTop - // 由于锚点保存和恢复使用的是相同的坐标系,直接设置即可 + // Flutter with pictures and text: directly set scrollTop + // Since the anchor points are saved and restored using the same coordinate system, they can be set directly. const hadBypass = container.__ghBypassLock; container.__ghBypassLock = true; container.scrollTop = targetTop; if (!hadBypass) delete container.__ghBypassLock; } else { - // 普通模式:平滑滚动 + // Normal mode: smooth scrolling this.scrollManager.scrollTo({ top: targetTop, behavior: 'smooth', __bypassLock: true }); } showToast(this.t('backToAnchor')); @@ -15282,7 +15257,7 @@ } } - // 清除手动锚点 + // Clear manual anchor points clearAnchorManually() { this.savedAnchorTop = null; this.hideAnchorMarker(); @@ -15290,15 +15265,15 @@ showToast(this.t('clearAnchorToast')); } - // 显示锚点标记 + // Show anchor mark showAnchorMarker(scrollTop) { - // 先移除已有标记 + // Remove existing tags first this.hideAnchorMarker(); const container = this.scrollManager.container; if (!container) return; - // 确保容器有 position 定位 + // Make sure the container has position positioning const containerStyle = window.getComputedStyle(container); if (containerStyle.position === 'static') { container.style.position = 'relative'; @@ -15313,7 +15288,7 @@ container.appendChild(marker); } - // 隐藏锚点标记 + // Hide anchor tag hideAnchorMarker() { const marker = document.getElementById('manual-anchor-marker'); if (marker) { @@ -15321,7 +15296,7 @@ } } - // 更新手动锚点按钮状态 + // Update manual anchor button state updateManualAnchorButtonState(hasAnchor) { const backBtn = document.getElementById('manual-anchor-back-btn'); if (backBtn) { @@ -15353,7 +15328,7 @@ categories.forEach((cat) => { container.appendChild(createElement('span', { className: 'category-tag', 'data-category': cat }, cat)); }); - // 添加分类管理按钮 + // Add category management button const manageBtn = createElement( 'button', { @@ -15369,7 +15344,7 @@ container.appendChild(manageBtn); } - // 显示分类管理弹窗 + // Show category management pop-up window showCategoryModal() { const categories = this.getCategories(); const modal = createElement('div', { className: 'prompt-modal' }); @@ -15389,7 +15364,7 @@ const info = createElement('div', { className: 'category-item-info' }); info.appendChild(createElement('span', { className: 'category-item-name' }, cat)); - info.appendChild(createElement('span', { className: 'category-item-count' }, `${count} 个提示词`)); + info.appendChild(createElement('span', { className: 'category-item-count' }, `${count} prompts`)); const actions = createElement('div', { className: 'category-item-actions' }); const renameBtn = createElement('button', { className: 'category-action-btn rename' }, this.t('rename')); @@ -15435,7 +15410,7 @@ document.body.appendChild(modal); } - // 重命名分类 + // Rename category renameCategory(oldName, newName) { this.prompts.forEach((p) => { if (p.category === oldName) { @@ -15448,11 +15423,11 @@ showToast(this.t('categoryRenamedSuccess', { newName })); } - // 删除分类(将关联提示词移至"未分类") + // Delete categories (move related prompt words to "Uncategorized") deleteCategory(name) { this.prompts.forEach((p) => { if (p.category === name) { - p.category = '未分类'; + p.category = this.t('uncategorized'); } }); this.savePrompts(); @@ -15489,7 +15464,7 @@ const itemHeader = createElement('div', { className: 'prompt-item-header' }); itemHeader.appendChild(createElement('div', { className: 'prompt-item-title' }, prompt.title)); - itemHeader.appendChild(createElement('span', { className: 'prompt-item-category' }, prompt.category || '未分类')); + itemHeader.appendChild(createElement('span', { className: 'prompt-item-category' }, prompt.category || this.t('uncategorized'))); const itemContent = createElement('div', { className: 'prompt-item-content' }, prompt.content); const itemActions = createElement('div', { className: 'prompt-item-actions' }); @@ -15498,16 +15473,16 @@ { className: 'prompt-action-btn drag-prompt', 'data-id': prompt.id, - title: '拖动排序', + title: 'Drag to reorder', }, '☰', ); dragBtn.style.cursor = 'grab'; - // 仅当按下拖拽按钮时才允许拖动 + // Allow dragging only when drag button is pressed dragBtn.addEventListener('mousedown', () => { item.setAttribute('draggable', 'true'); - // 监听全局鼠标释放,恢复不可拖动 + // Monitor global mouse release and restore non-dragability const upHandler = () => { item.setAttribute('draggable', 'false'); window.removeEventListener('mouseup', upHandler); @@ -15522,7 +15497,7 @@ { className: 'prompt-action-btn copy-prompt', 'data-id': prompt.id, - title: '复制', + title: 'Copy', }, '📋', ), @@ -15533,7 +15508,7 @@ { className: 'prompt-action-btn edit-prompt', 'data-id': prompt.id, - title: '编辑', + title: 'Edit', }, '✏', ), @@ -15544,7 +15519,7 @@ { className: 'prompt-action-btn delete-prompt', 'data-id': prompt.id, - title: '删除', + title: 'Delete', }, '🗑', ), @@ -15558,7 +15533,7 @@ if (!e.target.closest('.prompt-item-actions')) this.selectPrompt(prompt, item); }); - // 拖拽事件处理 + // Drag event handling item.addEventListener('dragstart', (e) => { item.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; @@ -15582,7 +15557,7 @@ item.addEventListener('dragend', () => { item.classList.remove('dragging'); - item.setAttribute('draggable', 'false'); // 拖拽结束立即恢复 + item.setAttribute('draggable', 'false'); // Resume immediately after dragging this.updatePromptOrder(); }); @@ -15590,13 +15565,13 @@ }); } - // 更新提示词顺序 + // Update prompt word order updatePromptOrder() { const container = document.getElementById('prompt-list'); const items = Array.from(container.querySelectorAll('.prompt-item')); const newOrder = items.map((item) => item.dataset.promptId); - // 重新排列 prompts 数组 + // Rearrange prompts array const orderedPrompts = []; newOrder.forEach((id) => { const prompt = this.prompts.find((p) => p.id === id); @@ -15617,7 +15592,7 @@ document.querySelectorAll('.prompt-item').forEach((item) => item.classList.remove('selected')); itemElement.classList.add('selected'); - // 显示当前提示词悬浮条 + // Display the current prompt word floating bar const selectedBar = document.querySelector('.selected-prompt-bar'); const selectedText = document.getElementById('selected-prompt-text'); if (selectedBar && selectedText) { @@ -15625,12 +15600,12 @@ selectedBar.classList.add('show'); } - // 先插入内容 + // Insert content first this.insertPromptToTextarea(prompt.content); showToast(`${this.t('inserted')}: ${prompt.title}`); - // 多次延迟更新悬浮条位置,确保输入框高度完全更新 - // 第一次快速响应,后续作为补偿 + // Delay the update of the floating bar position multiple times to ensure that the height of the input box is completely updated + // Quick response for the first time, followed by compensation [50, 200, 400, 1200].forEach((delay) => { setTimeout(() => { this.updateSelectedBarPosition(); @@ -15645,12 +15620,12 @@ } const promiseOrResult = this.siteAdapter.insertPrompt(promptContent); - // 处理异步返回 (Gemini Business 是异步的) + // Handling asynchronous returns (Gemini Business is asynchronous) if (promiseOrResult instanceof Promise) { promiseOrResult.then((success) => { if (!success) { showToast(this.t('noTextarea')); - // 再次尝试查找 + // Try finding again this.siteAdapter.findTextarea(); } }); @@ -15666,25 +15641,25 @@ document.querySelectorAll('.prompt-item').forEach((item) => item.classList.remove('selected')); } - // 动态更新悬浮条位置(基于输入框容器位置) + // Dynamically update the position of the floating bar (based on the position of the input box container) updateSelectedBarPosition() { const bar = document.querySelector('.selected-prompt-bar'); const textarea = this.siteAdapter?.textarea; if (!bar) return; - // 如果没有输入框引用或输入框不在 DOM 中,使用默认位置 + // If there is no input box reference or the input box is not in the DOM, use the default position if (!textarea || !textarea.isConnected) { bar.style.bottom = '120px'; return; } - // 查找输入框的容器:向上遍历找到有边框的元素(Gemini 输入框容器有圆角边框) + // Find the container of the input box: traverse upward to find the element with a border (the Gemini input box container has a rounded border) let inputContainer = textarea; let parent = textarea.parentElement; for (let i = 0; i < 10 && parent && parent !== document.body; i++) { const style = window.getComputedStyle(parent); - // 查找有边框或圆角的容器 + // Find containers with borders or rounded corners if (style.borderRadius && parseFloat(style.borderRadius) > 0) { inputContainer = parent; break; @@ -15695,10 +15670,10 @@ const containerRect = inputContainer.getBoundingClientRect(); const viewportHeight = window.innerHeight; - // 悬浮条显示在输入容器上方,保持 20px 间距 + // The floating bar is displayed above the input container, maintaining 20px spacing const desiredBottom = viewportHeight - containerRect.top + 20; - // 确保不会太靠近顶部(最小 50px 距顶),也不会太靠近底部 + // Make sure it's not too close to the top (minimum 50px from top), nor too close to the bottom const clampedBottom = Math.max(50, Math.min(desiredBottom, viewportHeight - 50)); bar.style.bottom = clampedBottom + 'px'; } @@ -15776,16 +15751,16 @@ findElementByComposedPath(e) { if (!e) return null; - // 获取事件的完整传播路径(兼容没有 composedPath 的浏览器) + // Get the complete propagation path of the event (compatible with browsers without composedPath) const path = typeof e.composedPath === 'function' ? e.composedPath() : e.path || []; - // 获取提交按钮选择器数组并合并成 selector 字符串 + // Get the submit button selector array and merge it into a selector string const selectors = this.siteAdapter && typeof this.siteAdapter.getSubmitButtonSelectors === 'function' ? this.siteAdapter.getSubmitButtonSelectors() : []; const combinedSelector = selectors.length ? selectors.join(', ') : ''; if (!combinedSelector) return null; - // 查找路径中第一个符合条件的元素 + // Find the first matching element in the path const foundElement = path.find((element) => element && element instanceof Element && typeof element.matches === 'function' && element.matches(combinedSelector)); return foundElement || null; @@ -15805,19 +15780,19 @@ } }); } - // 全局快捷键监听 + // Global shortcut key monitoring document.addEventListener('keydown', (e) => { - // Alt + B: 滚动到底部 + // Alt + B: Scroll to bottom if (e.altKey && (e.key === 'b' || e.key === 'B')) { - e.preventDefault(); // 阻止浏览器默认行为 + e.preventDefault(); // Block browser default behavior this.scrollToBottom(); } - // Alt+Z 回到之前的锚点 + // Alt+Z returns to the previous anchor point if (e.altKey && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); this.handleAnchorClick(); } - // Alt+T (Top) 回顶部 + // Alt+T (Top) Return to top if (e.altKey && (e.key === 't' || e.key === 'T')) { e.preventDefault(); this.scrollToTop(); @@ -15842,7 +15817,7 @@ showToast(this.t('copied')); }) .catch(() => { - // 降级方案 + // Downgrade plan const textarea = document.createElement('textarea'); textarea.value = prompt.content; document.body.appendChild(textarea); @@ -15857,15 +15832,15 @@ document.getElementById('clear-prompt')?.addEventListener('click', () => { this.clearSelectedPrompt(); - // 针对 Gemini Business,根据设置决定是否用零宽字符清空 + // For Gemini Business, whether to clear with zero-width characters depends on the setting. if (this.siteAdapter instanceof GeminiBusinessAdapter) { if (this.settings.clearTextareaOnSend) { - this.siteAdapter.clearTextarea(); // 插入零宽字符 + this.siteAdapter.clearTextarea(); // Insert zero-width characters } else { - this.siteAdapter.clearTextareaNormal(); // 普通清空 + this.siteAdapter.clearTextareaNormal(); // Normal clear } } else { - // 其他适配器调用各自的 clearTextarea 方法 + // Other adapters call their respective clearTextarea methods this.siteAdapter.clearTextarea(); } showToast(this.t('cleared')); @@ -15873,9 +15848,9 @@ this.makeDraggable(); - // 2. 按钮点击监听 + // 2. Button click monitoring document.addEventListener('click', (e) => { - // 委托适配器检查是否为输入框,自动更新引用 + // The delegate adapter checks whether it is an input box and automatically updates the reference. if (this.siteAdapter.isValidTextarea(e.target)) { this.siteAdapter.textarea = e.target; } else { @@ -15885,10 +15860,10 @@ } } - // 检测是否点击了发送按钮 + // Detect if send button is clicked const found = this.findElementByComposedPath(e); let matched = !!found; - // 如果 composedPath 没命中,尝试使用 closest 回退(兼容 Shadow DOM 之外的情况) + // If composedPath misses, try to use closest fallback (compatible with situations other than Shadow DOM) if (!matched && e && e.target && typeof e.target.closest === 'function') { const selectors = this.siteAdapter && typeof this.siteAdapter.getSubmitButtonSelectors === 'function' ? this.siteAdapter.getSubmitButtonSelectors() : []; const combined = selectors.length ? selectors.join(', ') : ''; @@ -15902,13 +15877,13 @@ } if (matched) { - // 如果有选中的提示词,清除悬浮条 + // If there is a selected prompt word, clear the floating bar if (this.selectedPrompt) { setTimeout(() => { this.clearSelectedPrompt(); }, 100); } - // 针对 Gemini Business:无论是否使用提示词,发送后都修复中文输入 + // For Gemini Business: fix Chinese input after sending regardless of whether prompt word is used or not if (this.siteAdapter instanceof GeminiBusinessAdapter && this.settings.clearTextareaOnSend) { setTimeout(() => { this.siteAdapter.clearTextarea(); @@ -15917,26 +15892,26 @@ } }); - // 3. 回车键发送监听 + // 3. Enter key to send monitoring document.addEventListener( 'keydown', (e) => { - // 仅处理 Enter 键(不带 Shift 修饰符,避免干扰换行操作) + // Only handles the Enter key (without the Shift modifier to avoid interfering with line breaks) if (e.key !== 'Enter' || e.shiftKey) return; - // 使用 composedPath 检查事件源是否来自输入框(兼容 Shadow DOM) + // Use composedPath to check if the event source comes from the input box (Shadow DOM compatible) const path = typeof e.composedPath === 'function' ? e.composedPath() : e.path || []; const isFromTextarea = path.some((element) => element && element instanceof Element && this.siteAdapter.isValidTextarea(element)); if (!isFromTextarea) return; - // 清理逻辑 + // Clean up logic if (this.selectedPrompt) { setTimeout(() => { this.clearSelectedPrompt(); }, 100); } - // 针对 Gemini Business:无论是否使用提示词,发送后都修复中文输入 + // For Gemini Business: fix Chinese input after sending regardless of whether prompt word is used or not if (this.siteAdapter instanceof GeminiBusinessAdapter && this.settings.clearTextareaOnSend) { setTimeout(() => { this.siteAdapter.clearTextarea(); @@ -15944,29 +15919,29 @@ } }, true, - ); // 使用捕获阶段确保在 Shadow DOM 场景下也能捕获 + ); // Use the capture phase to ensure capture even in Shadow DOM scenarios document.getElementById('toggle-panel')?.addEventListener('click', (e) => { - e.stopPropagation(); // 阻止冒泡,避免触发 auto-hide + e.stopPropagation(); // Prevent bubbling and avoid triggering auto-hide this.togglePanel(); }); - // 4. 全局点击监听(处理自动隐藏) + // 4. Global click monitoring (handling automatic hiding) document.addEventListener('click', (e) => { - // 如果是自动隐藏开启,且面板是展开的 + // If auto-hide is turned on and the panel is expanded if (this.settings.autoHidePanel && !this.isCollapsed) { const panel = document.getElementById('gemini-helper-panel'); const toggleBtn = document.getElementById('toggle-panel'); const quickBtnGroup = document.getElementById('quick-btn-group'); - // 检查点击目标是否在面板外部 - // 注意:需要排除 toggleBtn 和 quickBtnGroup,以及 panel 本身 - // 同时排除面板内的任何元素 + // Check if the click target is outside the panel + // Note: toggleBtn and quickBtnGroup need to be excluded, as well as the panel itself + // Also exclude any elements within the panel if (panel && !panel.contains(e.target) && !toggleBtn?.contains(e.target) && !quickBtnGroup?.contains(e.target)) { - // 额外的安全检查:确保不是点击了面板内的弹出层(如 modal) - // 通常 modal 是直接挂在 body 上的,所以如果 modal 打开时,点击 modal 内容不应该隐藏 - // 但是 modal 通常覆盖全屏,点击 modal 遮罩通过 modal 自己的逻辑关闭。 - // 这里主要关注点击页面其他部分。 + // Additional security check: make sure you are not clicking on a popup within the panel (e.g. modal) + // Usually the modal is hung directly on the body, so if the modal is open and the modal content is clicked, it should not be hidden. + // But modals usually cover the full screen, and on click the modal mask is closed via the modal's own logic. + // The main focus here is to click on other parts of the page. this.togglePanel(); } @@ -15975,10 +15950,10 @@ this.makeDraggable(); - // 初始化 URL 监听 (处理 SPA 页面跳转) + // Initialize URL monitoring (processing SPA page jump) this.initUrlChangeObserver(); - // 窗口大小变化时更新悬浮条位置 + // Update the position of the floating bar when the window size changes window.addEventListener('resize', () => { if (this.selectedPrompt) { this.updateSelectedBarPosition(); @@ -15994,21 +15969,21 @@ if (currentUrl !== lastUrl) { lastUrl = currentUrl; - // URL 变化时,先停止录制(防止错误覆盖新会话的持久化数据) + // When the URL changes, stop recording first (to prevent errors from overwriting the persistent data of the new session) this.readingProgressManager.stopRecording(); - // 重置内存中的锚点状态 + // Reset anchor point state in memory this.anchorScrollTop = null; this.anchorManager.reset(); - // 会话切换时清除悬浮条和选中的提示词 + // Clear the floating bar and selected prompt words when switching sessions this.clearSelectedPrompt(); - // 会话切换时立即更新标签页标题 + // Update tab title immediately when session switches if (this.tabRenameManager && this.settings.tabSettings?.autoRenameTab) { - // 清除缓存的会话名称,强制从新会话获取 + // Clear cached session names and force retrieval from new sessions this.tabRenameManager.lastSessionName = null; - // 多次尝试更新,因为 Gemini 可能需要时间来更新页面标题 + // Try updating multiple times as Gemini may take time to update the page title [300, 800, 1500].forEach((delay) => { setTimeout(() => { this.tabRenameManager.updateTabName(true); @@ -16016,12 +15991,12 @@ }); } - // 给予页面渲染一点时间后尝试恢复 + // Give the page some time to render and then try to resume. setTimeout(() => { this.restoreReadingProgress(); - // 切换会话后 textarea 引用可能失效,需要重新查找 + // After switching sessions, the textarea reference may become invalid and needs to be searched again. this.siteAdapter.findTextarea(); - // 仅在成功找到输入框时才清空,避免全选问题 + // Only clear when the input box is successfully found to avoid the problem of selecting all if (this.siteAdapter.textarea) { this.siteAdapter.clearTextarea(); } @@ -16029,7 +16004,7 @@ } }; - // 1. 监听 popstate (后退/前进) + // 1. Monitor popstate (back/forward) window.addEventListener('popstate', checkUrl); // 2. Monkey patch pushState/replaceState @@ -16046,11 +16021,11 @@ checkUrl(); }; - // 3. 定时器兜底 (防止某些框架绕过 history API) - // 同时用于检测 Sidebar Observer 的存活状态 + // 3. The timer keeps track of everything (to prevent some frameworks from bypassing the history API) + // Also used to detect the survival status of Sidebar Observer setInterval(() => { checkUrl(); - // 周期性检查 Observer 是否存活 (Zombie Check) + // Periodically check whether the Observer is alive (Zombie Check) if (this.conversationManager) { this.conversationManager.checkObserverStatus(); } @@ -16063,43 +16038,43 @@ if (!panel || !header) return; let isDragging = false; - let offsetX = 0; // 鼠标相对于面板左上角的偏移 + let offsetX = 0; // The offset of the mouse relative to the upper left corner of the panel let offsetY = 0; - let hasDragged = false; // 标记是否曾经拖拽过(用于判断是否需要边界检测) + let hasDragged = false; // Whether the marker has been dragged before (used to determine whether boundary detection is required) header.addEventListener('mousedown', (e) => { if (e.target.closest('.prompt-panel-controls')) return; - e.preventDefault(); // 阻止文本选中 + e.preventDefault(); // Prevent text from being selected - // 如果当前处于吸附状态,先取消吸附 + // If it is currently in the adsorption state, cancel the adsorption first if (this.edgeSnapState) { this.unsnap(); } - // 读取面板当前的实际位置 + // Read the current actual position of the panel const rect = panel.getBoundingClientRect(); - // 计算鼠标相对于面板左上角的偏移(在整个拖拽过程中保持不变) + // Calculates the offset of the mouse relative to the upper left corner of the panel (remains constant during the entire dragging process) offsetX = e.clientX - rect.left; offsetY = e.clientY - rect.top; - // 首次拖拽时,将 CSS 定位从 right+transform 切换为 left+top - // 这样后续拖拽就不会有跳动问题 + // On first drag, switch CSS positioning from right+transform to left+top + // In this way, there will be no jumping problem when dragging later. panel.style.left = rect.left + 'px'; panel.style.top = rect.top + 'px'; - panel.style.right = 'auto'; // 清除 right 定位 - panel.style.transform = 'none'; // 清除 translateY(-50%) + panel.style.right = 'auto'; // Clear right positioning + panel.style.transform = 'none'; // Clear translateY(-50%) isDragging = true; - hasDragged = true; // 标记已拖拽过 - // 拖动时禁止全局文本选中 + hasDragged = true; // Mark has been dragged + // Disable global text selection when dragging document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (isDragging) { e.preventDefault(); - // 直接计算面板左上角位置 = 鼠标位置 - 初始偏移 + // Directly calculate the position of the upper left corner of the panel = mouse position - initial offset panel.style.left = e.clientX - offsetX + 'px'; panel.style.top = e.clientY - offsetY + 'px'; } @@ -16108,45 +16083,45 @@ document.addEventListener('mouseup', (e) => { if (isDragging) { isDragging = false; - // 恢复文本选中 + // Restore text selection document.body.style.userSelect = ''; - // 边缘吸附检测(仅当功能开启时) + // Edge snap detection (only when the function is turned on) if (this.settings.edgeSnapHide) { const rect = panel.getBoundingClientRect(); - const snapThreshold = 30; // 距离边缘30px时触发吸附 + const snapThreshold = 30; // Trigger adsorption when 30px away from edge if (rect.left < snapThreshold) { - // 吸附到左边缘 + // Snap to left edge this.snapToEdge('left'); } else if (window.innerWidth - rect.right < snapThreshold) { - // 吸附到右边缘 + // Snap to right edge this.snapToEdge('right'); } } } }); - // 边界检测:确保面板在视口内可见 + // Bounds detection: Make sure the panel is visible within the viewport const clampToViewport = () => { - // 跳过条件:未拖拽过 或 面板已收起 或 处于吸附状态 + // Skip condition: has not been dragged or the panel has been collapsed or is in adsorption state if (!hasDragged || panel.classList.contains('collapsed') || this.edgeSnapState) return; const rect = panel.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; - const margin = 10; // 边距 + const margin = 10; // margin let newLeft = parseFloat(panel.style.left); let newTop = parseFloat(panel.style.top); - // 超出右边界 + // beyond right boundary if (rect.right > vw) newLeft = vw - rect.width - margin; - // 超出下边界 + // beyond lower boundary if (rect.bottom > vh) newTop = vh - rect.height - margin; - // 超出左边界 + // beyond left boundary if (rect.left < 0) newLeft = margin; - // 超出上边界 + // Beyond the upper boundary if (rect.top < 0) newTop = margin; panel.style.left = newLeft + 'px'; @@ -16155,16 +16130,16 @@ window.addEventListener('resize', clampToViewport); - // 边缘吸附自动恢复:鼠标移出面板时,如果有记忆的吸附状态,恢复吸附 + // Automatic recovery of edge adsorption: When the mouse moves out of the panel, if there is a memorized adsorption state, the adsorption will be restored panel.addEventListener('mouseleave', (e) => { - // 条件检查:有吸附状态 + 面板未折叠 + 边缘吸附功能开启 + // Condition check: adsorption state + panel is not folded + edge adsorption function is on if (!this.edgeSnapState || this.isCollapsed || !this.settings.edgeSnapHide) return; - // 排除:鼠标移到快捷按钮组 + // Exclude: Move the mouse to the shortcut button group const quickBtnGroup = document.getElementById('quick-btn-group'); if (quickBtnGroup?.contains(e.relatedTarget)) return; - // 恢复吸附 CSS 类和触发条 + // Restore snapping CSS classes and trigger bars panel.classList.add(`edge-snapped-${this.edgeSnapState}`); this.showEdgeTrigger(this.edgeSnapState); }); @@ -16174,19 +16149,19 @@ function init() { try { console.log('Gemini Helper: Initializing...'); - // 初始化站点注册表 + // Initialize the site registry const siteRegistry = new SiteRegistry(); - siteRegistry.register(new GeminiBusinessAdapter()); // 优先检测 + siteRegistry.register(new GeminiBusinessAdapter()); // Prioritize detection siteRegistry.register(new GeminiAdapter()); const currentAdapter = siteRegistry.detect(); if (!currentAdapter) { - console.log('Gemini Helper: 未匹配到当前站点,跳过初始化。'); + console.log('Gemini Helper: Current site not matched, skipping initialization.'); return; } - console.log(`Gemini Helper: 已匹配站点 - ${currentAdapter.getName()}`); + console.log(`Gemini Helper: Matched site - ${currentAdapter.getName()}`); setTimeout(() => { try { @@ -16194,11 +16169,11 @@ window.geminiHelper = new GeminiHelper(siteRegistry); console.log('Gemini Helper: Instance created successfully.'); } catch (error) { - console.error('Gemini Helper: 启动失败 (Constructor Error)', error); + console.error('Gemini Helper: Startup failed (Constructor Error)', error); } }, 2000); } catch (e) { - console.error('Gemini Helper: 初始化失败 (Init Error)', e); + console.error('Gemini Helper: Initialization failed (Init Error)', e); } }