// ==UserScript== // @name Multi Markdown Editor // @name:zh-TW 多功能 Markdown 編輯器 // @name:zh-CN 多功能 Markdown 编辑器 // @namespace https://github.com/multi-markdown-editor // @version 1.0.0 // @description Launch multiple Markdown editors (EasyMDE, Toast UI, Cherry Markdown, Vditor) on any webpage. Features: quick slots, drag-drop import, file system backup, find & replace, comprehensive backup management, and customizable toolbar. // @description:zh-TW 在任意網頁上啟動多款 Markdown 編輯器(EasyMDE、Toast UI、Cherry Markdown、Vditor)。功能:快速存檔插槽、拖曳導入、檔案系統備份、尋找與取代、完整備份管理、工具列自訂。 // @description:zh-CN 在任意网页上启动多款 Markdown 编辑器(EasyMDE、Toast UI、Cherry Markdown、Vditor)。功能:快速存档插槽、拖拽导入、文件系统备份、查找与替换、完整备份管理、工具栏自定义。 // @author Marx Einstein // @match *://*/* // @match file:///* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_addStyle // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant unsafeWindow // @connect cdn.jsdelivr.net // @connect fastly.jsdelivr.net // @connect unpkg.com // @connect cdnjs.cloudflare.com // @connect uicdn.toast.com // @connect * // @run-at document-idle // @noframes // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/565117/Multi%20Markdown%20Editor.user.js // @updateURL https://update.greasyfork.icu/scripts/565117/Multi%20Markdown%20Editor.meta.js // ==/UserScript== (function() { 'use strict'; // ======================================== // 防止重複載入 // ======================================== if (window.__MULTI_MD_EDITOR_LOADED__) return; window.__MULTI_MD_EDITOR_LOADED__ = true; // ======================================== // 全域常量 // ======================================== /** @type {Window} 頁面 window 參考(用於訪問編輯器全局對象) */ const PAGE_WIN = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; /** @type {string} 腳本版本 */ const SCRIPT_VERSION = '1.0.0'; /** @type {boolean} 除錯模式 */ const DEBUG = true; /** * 除錯日誌(僅在 DEBUG 模式下輸出) * @param {...any} args - 日誌參數 */ const log = (...args) => { if (DEBUG) console.log('[MME]', ...args); }; /** * 錯誤日誌(始終輸出,用於重要錯誤) * @param {...any} args - 錯誤參數 */ const logError = (...args) => { console.error('[MME]', ...args); }; /** * 警告日誌(始終輸出,用於潛在問題) * @param {...any} args - 警告參數 */ const logWarn = (...args) => { console.warn('[MME]', ...args); }; // ======================================== // 全域配置 // ======================================== /** @type {Object} 全域配置物件 */ const CONFIG = { /** * 編輯器配置 * 每個編輯器包含:名稱、版本、圖標、描述、CDN 列表、檔案路徑、全局檢查函數等 */ editors: { easymde: { name: 'EasyMDE', version: '2.20.0', icon: '✏️', description: '簡潔輕量的純文本編輯器,穩定可靠', order: 1, cdn: [ 'https://cdn.jsdelivr.net/npm/easymde@2.20.0', 'https://fastly.jsdelivr.net/npm/easymde@2.20.0', 'https://unpkg.com/easymde@2.20.0' ], files: { js: '/dist/easymde.min.js', css: '/dist/easymde.min.css' }, globalCheck: () => typeof PAGE_WIN.EasyMDE !== 'undefined', extraDeps: { marked: { js: 'https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js', global: 'marked', optional: true } } }, toastui: { name: 'Toast UI', version: '3.2.2', icon: '🍞', description: 'NHN 開源編輯器,功能豐富(注意:此項目已停止維護)', order: 2, cdn: [ 'https://uicdn.toast.com/editor/3.2.2', 'https://cdn.jsdelivr.net/npm/@toast-ui/editor@3.2.2/dist', 'https://unpkg.com/@toast-ui/editor@3.2.2/dist' ], files: { js: '/toastui-editor-all.min.js', css: '/toastui-editor.min.css' }, extraCss: ['/theme/toastui-editor-dark.min.css'], globalCheck: () => !!(PAGE_WIN.toastui && PAGE_WIN.toastui.Editor) }, cherry: { name: 'Cherry Markdown', version: '0.10.3', icon: '🍒', description: '騰訊開源編輯器,雙欄預覽,功能豐富', order: 3, cdn: [ 'https://cdn.jsdelivr.net/npm/cherry-markdown@0.10.3', 'https://fastly.jsdelivr.net/npm/cherry-markdown@0.10.3', 'https://unpkg.com/cherry-markdown@0.10.3' ], files: { js: '/dist/cherry-markdown.min.js', css: '/dist/cherry-markdown.min.css' }, globalCheck: () => typeof PAGE_WIN.Cherry !== 'undefined', extraDeps: { katex: { css: [ 'https://cdn.jsdelivr.net/npm/katex@0.16.25/dist/katex.min.css', 'https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.25/katex.min.css' ], js: [ 'https://cdn.jsdelivr.net/npm/katex@0.16.25/dist/katex.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.25/katex.min.js' ], global: 'katex', optional: false, ready: () => !!(PAGE_WIN.katex && typeof PAGE_WIN.katex.renderToString === 'function') } } }, vditor: { name: 'Vditor', version: '3.11.2', icon: '📝', description: '功能豐富,支援圖表/公式/流程圖,多種編輯模式', order: 4, cdn: [ 'https://cdn.jsdelivr.net/npm/vditor@3.11.2', 'https://fastly.jsdelivr.net/npm/vditor@3.11.2', 'https://unpkg.com/vditor@3.11.2' ], files: { js: '/dist/index.min.js', css: '/dist/index.css' }, globalCheck: () => typeof PAGE_WIN.Vditor !== 'undefined', cacheId: 'mme_vditor_cache' } }, /** * 儲存鍵名 * 統一管理所有 localStorage/GM 儲存的鍵名 */ storageKeys: { // 基本設定 theme: 'mme_theme', content: 'mme_draft', editor: 'mme_editor', editorMode: 'mme_editor_mode', buttonPos: 'mme_btn_pos', modalSize: 'mme_modal_size', modalPos: 'mme_modal_pos', welcomed: 'mme_welcomed', lastSaveTime: 'mme_last_save_time', // Vditor 專用 vditorSnapshot: 'mme_vditor_snapshot', vditorSnapshotMeta: 'mme_vditor_snapshot_meta', vditorSafeReinitFlag: 'mme_vditor_safe_reinit', // 工具列與偏好設定 toolbarCfg: 'mme_toolbar_cfg', preferences: 'mme_preferences', focusMode: 'mme_focus_mode', // 備份管理 backupIndex: 'mme_backup_index', backupPrefix: 'mme_backup_', backupSettings: 'mme_backup_settings', backupPage: 'mme_backup_page', backupSizeWarningEnabled: 'mme_backup_size_warning', // 是否顯示備份大小警告(預設 true) backupSizeWarningThreshold: 'mme_backup_size_threshold', // 警告閾值(位元組,預設 1MB) // 診斷 diagEnabled: 'mme_diag_enabled', // 語言 locale: 'mme_locale', // ======================================== // 第一階段預留:快速插槽系統 (Phase 2) // ======================================== slotPrefix: 'mme_slot_', // 插槽內容前綴(後接 1-9) slotMetaPrefix: 'mme_slot_meta_', // 插槽 meta 前綴 slotSettings: 'mme_slot_settings', // 插槽設定(啟用數量、顯示位置等) // ======================================== // 第一階段預留:拖曳導入功能 (Phase 3) // ======================================== dragDropHintShown: 'mme_dragdrop_hint_shown', // 拖曳提示是否已顯示 // ======================================== // 第一階段預留:檔案系統管理 (Phase 4) // ======================================== fsDirectoryName: 'mme_fs_dir_name', // 已選擇的資料夾名稱 fsEnabled: 'mme_fs_enabled', // 是否啟用檔案系統備份 fsAutoBackup: 'mme_fs_auto_backup' // 是否自動備份到資料夾 }, /** 預設編輯器 */ defaultEditor: 'easymde', /** 預設主題 */ defaultTheme: 'light', /** * 時間設定 (毫秒) */ autoSaveInterval: 30000, // 自動保存間隔:30 秒 toastDuration: 3000, // Toast 顯示時間:3 秒 loadTimeout: 60000, // 編輯器載入超時:60 秒 cdnTestTimeout: 8000, // CDN 測試超時:8 秒 /** * 備份設定 * 採用分層保留策略,平衡儲存空間與備份密度 */ backup: { maxBackups: 50, // 最大備份數量 autoInterval: 120000, // 自動備份間隔:2 分鐘 minChangeChars: 30, // 觸發備份的最小變更字數 pageSize: 20, /** * 保留策略分層: * - 1 小時內:每 2 分鐘保留一筆 * - 24 小時內:每 10 分鐘保留一筆 * - 7 天內:每天保留一筆 * - 超過 7 天:自動刪除(除非已釘選) */ retentionTiers: [ { age: 3600000, interval: 120000 }, // 1 小時內 { age: 86400000, interval: 600000 }, // 24 小時內 { age: 604800000, interval: 86400000 } // 7 天內 ] }, /** 草稿大小限制 (5 MB) */ maxDraftBytes: 5 * 1024 * 1024, /** * 時間相關設定(毫秒) * 統一管理各種延遲和間隔,便於調整和理解 */ timing: { // Vditor 相關 vditorSnapshotInterval: 3000, // SV 快照自動保存間隔 vditorContentCheckInterval: 1000, // 內容完整性檢查間隔 vditorRestoreCooldown: 2000, // 還原操作冷卻時間 vditorSafeInitDelay: 200, // 安全初始化後的延遲 vditorModeChangeCheckDelay: 500, // 模式切換後檢查延遲 // Modal 相關 modalTransitionWait: 80, // Modal 開啟後的過渡等待 editorRefreshDelay: 60, // 編輯器刷新延遲 editorSwitchDelay: 120, // 編輯器切換後的穩定延遲 // UI 相關 tooltipHideDelay: 100, // Tooltip 隱藏延遲 wordCountUpdateInterval: 3000, // 字數統計更新間隔 dragDropHintDelay: 5000, // 拖曳導入提示延遲顯示 dragDropHintDuration: 10000, // 拖曳導入提示顯示時長 focusModeHintDuration: 4500, // 專注模式提示顯示時長 // FAB 相關 fabLongPressDelay: 520, // FAB 長按觸發延遲 fabDragEndDelay: 120 // FAB 拖曳結束後的 click 防護延遲 }, /** * UI 尺寸設定(像素) */ dimensions: { focusTriggerZoneHeight: 60, // 專注模式底部觸發區高度 modalMinWidth: 380, // Modal 最小寬度 modalMinHeight: 350, // Modal 最小高度 panelMaxWidth: 320, // 彈出面板最大寬度 tooltipPadding: 5 // Tooltip 與邊界的間距 }, /** UI 設定 */ zIndex: 2147483640, // 極高的 z-index 確保在最上層 prefix: 'mme-' // CSS class 前綴,避免與頁面樣式衝突 }; // ======================================== // Vditor 工具列配置 // ======================================== /** * Vditor 工具列配置 * 定義 Vditor 編輯器的工具列按鈕佈局 */ const VDITOR_TOOLBAR = [ 'emoji', 'headings', 'bold', 'italic', 'strike', 'link', '|', 'list', 'ordered-list', 'check', 'outdent', 'indent', '|', 'quote', 'line', 'code', 'inline-code', 'insert-before', 'insert-after', '|', 'upload', 'table', '|', 'undo', 'redo', '|', 'fullscreen', 'edit-mode', { name: 'more', toolbar: [ 'both', 'code-theme', 'content-theme', 'export', 'outline', 'preview', 'devtools', 'info', 'help' ] } ]; // ======================================== // 快捷鍵定義 // ======================================== /** * 快捷鍵一覽 * 用於快捷鍵說明面板 */ const KEYBOARD_SHORTCUTS = [ { category: '基本操作', items: [ { key: 'Alt + M', desc: '開啟/關閉編輯器' }, { key: 'Ctrl + S', desc: '保存草稿' }, { key: 'Escape', desc: '關閉面板 → 退出專注模式 → 關閉編輯器' } ] }, { category: '檔案操作', items: [ { key: 'Ctrl + O', desc: '開啟檔案' }, { key: 'Ctrl + Shift + C', desc: '複製 Markdown 到剪貼簿' } ] }, { category: '編輯', items: [ { key: 'Ctrl + F', desc: '尋找' }, { key: 'Ctrl + H', desc: '尋找與取代' }, { key: 'Enter', desc: '尋找下一個(在尋找框中)' }, { key: 'Shift + Enter', desc: '尋找上一個(在尋找框中)' } ] }, { category: '視窗控制', items: [ { key: 'F9', desc: '切換全螢幕模式' }, { key: '雙擊標題列', desc: '切換全螢幕模式' } ] }, { category: '快速存檔插槽', items: [ { key: 'Ctrl + 1~9', desc: '載入對應插槽' }, { key: 'Ctrl + Shift + 1~9', desc: '儲存到對應插槽' } ] }, { category: '其他', items: [ { key: '拖曳檔案到按鈕', desc: '導入 Markdown 檔案' }, { key: '拖曳文字到按鈕', desc: '插入選取的文字' } ] } ]; /** * 取得排序後的編輯器列表 * @returns {Array<[string, Object]>} 排序後的編輯器 entries */ function getSortedEditors() { return Object.entries(CONFIG.editors) .sort((a, b) => (a[1].order || 99) - (b[1].order || 99)); } // ======================================== // 工具函數 // ======================================== /** * 工具函數集合 * 提供各種通用的輔助函數 */ const Utils = { /** * 防抖函數 * 在延遲時間內的多次調用只會執行最後一次 * @param {Function} fn - 要防抖的函數 * @param {number} delay - 延遲時間 (毫秒) * @returns {Function} 防抖後的函數 */ debounce(fn, delay) { let timer = null; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; }, /** * 節流函數 * 在間隔時間內最多執行一次 * @param {Function} fn - 要節流的函數 * @param {number} delay - 間隔時間 (毫秒) * @returns {Function} 節流後的函數 */ throttle(fn, delay) { let last = 0; return function(...args) { const now = Date.now(); if (now - last >= delay) { last = now; fn.apply(this, args); } }; }, /** * 簡易 hash (FNV-1a 32-bit) * 用於快速比對內容是否變更 * @param {string} str - 字串 * @returns {string} 8 位 16 進制 hash 值 */ hash32(str) { let hash = 2166136261; for (let i = 0; i < str.length; i++) { hash ^= str.charCodeAt(i); hash = (hash * 16777619) >>> 0; } return hash.toString(16).padStart(8, '0'); }, /** * 儲存工具 * 優先使用 GM_* API,fallback 到 localStorage */ storage: { /** * 讀取儲存值 * @param {string} key - 鍵名 * @param {*} defaultVal - 預設值 * @returns {*} 儲存的值或預設值 */ get(key, defaultVal = null) { try { if (typeof GM_getValue === 'function') { const v = GM_getValue(key, null); return v !== null ? v : defaultVal; } } catch (e) { // GM_getValue 不可用,嘗試 localStorage } try { const v = localStorage.getItem(key); if (v === null) return defaultVal; try { return JSON.parse(v); } catch (e) { return v; } } catch (e) { // localStorage 也不可用 } return defaultVal; }, /** * 寫入儲存值 * @param {string} key - 鍵名 * @param {*} value - 值 * @returns {boolean} 是否成功 */ set(key, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, value); return true; } } catch (e) { // GM_setValue 不可用 } try { const str = typeof value === 'string' ? value : JSON.stringify(value); localStorage.setItem(key, str); return true; } catch (e) { logWarn('Storage set failed:', e); return false; } }, /** * 刪除儲存值 * @param {string} key - 鍵名 */ remove(key) { try { if (typeof GM_deleteValue === 'function') { GM_deleteValue(key); } } catch (e) { // GM_deleteValue 不可用 } try { localStorage.removeItem(key); } catch (e) { // localStorage 不可用 } }, /** * 列出所有鍵名 * @returns {Array} 鍵名列表 */ listKeys() { try { if (typeof GM_listValues === 'function') { return GM_listValues(); } } catch (e) { // GM_listValues 不可用 } try { const keys = []; for (let i = 0; i < localStorage.length; i++) { keys.push(localStorage.key(i)); } return keys; } catch (e) { // localStorage 不可用 } return []; }, /** * 估算已使用的儲存空間 * @returns {Object} { used: number, available: number, usedMB: string } */ estimateUsage() { try { let totalSize = 0; const keys = this.listKeys(); for (const key of keys) { if (key.startsWith('mme_')) { const value = this.get(key, ''); if (typeof value === 'string') { totalSize += value.length * 2; // UTF-16 估算 } else { totalSize += JSON.stringify(value).length * 2; } } } // localStorage 通常限制 5-10MB const estimatedLimit = 5 * 1024 * 1024; return { used: totalSize, available: Math.max(0, estimatedLimit - totalSize), usedMB: (totalSize / 1024 / 1024).toFixed(2), percentage: Math.min(100, (totalSize / estimatedLimit * 100)).toFixed(1) }; } catch (e) { return { used: 0, available: 0, usedMB: '0', percentage: '0' }; } }, }, /** * 複製到剪貼簿 * 嘗試多種方法確保兼容性 * @param {string} text - 要複製的文字 * @returns {Promise} 是否成功 */ async copyToClipboard(text) { // 方法 1:GM_setClipboard try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); return true; } } catch (e) { // 繼續嘗試其他方法 } // 方法 2:Clipboard API try { await navigator.clipboard.writeText(text); return true; } catch (e) { // 繼續嘗試其他方法 } // 方法 3:execCommand (deprecated but widely supported) try { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0;pointer-events:none;'; document.body.appendChild(ta); ta.focus(); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return ok; } catch (e) { return false; } }, /** * 取得頁面選取文字 * @returns {string} 選取的文字 */ getSelectedText() { try { return window.getSelection()?.toString() || ''; } catch (e) { return ''; } }, /** * 下載檔案 * @param {string} content - 檔案內容 * @param {string} filename - 檔案名稱 * @param {string} mimeType - MIME 類型 * @returns {boolean} 是否成功 */ downloadFile(content, filename, mimeType = 'text/plain;charset=utf-8') { try { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 150); return true; } catch (e) { logError('Download failed:', e); return false; } }, /** * 讀取檔案內容 * @param {File} file - 檔案物件 * @returns {Promise} 檔案內容 */ readFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error); reader.readAsText(file); }); }, /** * 注入樣式 * @param {string} css - CSS 內容 * @param {string} id - 樣式元素 ID * @returns {HTMLStyleElement} 樣式元素 */ addStyle(css, id) { try { // 如果已存在,更新內容 if (id) { const exist = document.getElementById(id); if (exist) { exist.textContent = css; return exist; } } // 嘗試使用 GM_addStyle if (typeof GM_addStyle === 'function') { const el = GM_addStyle(css); if (id && el?.setAttribute) { el.setAttribute('id', id); } return el; } } catch (e) { // GM_addStyle 不可用 } // Fallback: 手動創建 style 元素 const style = document.createElement('style'); if (id) style.id = id; style.textContent = css; (document.head || document.documentElement).appendChild(style); return style; }, /** * 格式化時間 * @param {Date} date - 日期物件 * @returns {string} 格式化的時間字串 (HH:MM:SS) */ formatTime(date = new Date()) { return date.toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }, /** * 格式化日期 * @param {Date} date - 日期物件 * @returns {string} 格式化的日期字串 (YYYY-MM-DD) */ formatDate(date = new Date()) { return date.toISOString().slice(0, 10); }, /** * 格式化相對時間 * @param {number} ts - 時間戳 * @returns {string} 相對時間描述 */ formatRelativeTime(ts) { const diff = Date.now() - ts; if (diff < 60000) return '剛才'; if (diff < 3600000) return `${Math.floor(diff / 60000)} 分鐘前`; if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小時前`; if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`; return new Date(ts).toLocaleDateString('zh-TW'); }, /** * 限制數值範圍 * @param {number} val - 數值 * @param {number} min - 最小值 * @param {number} max - 最大值 * @returns {number} 限制後的數值 */ clamp(val, min, max) { return Math.min(Math.max(val, min), max); }, /** * 計算文字統計 * @param {string} text - 文字內容 * @returns {Object} 統計結果 */ countText(text) { if (!text) return { chars: 0, charsNoSpace: 0, words: 0, lines: 0, readingTime: 0 }; const chars = text.length; const charsNoSpace = text.replace(/\s/g, '').length; const words = text.trim() ? text.trim().split(/\s+/).length : 0; const lines = text.split('\n').length; // 閱讀時間估算 // 中文閱讀速度:約 300-500 字/分鐘,取 400 // 英文閱讀速度:約 200-250 單詞/分鐘 // 這裡使用字元數估算,適用於中文為主的內容 // 最小為 1 分鐘 const readingTime = Math.max(1, Math.ceil(charsNoSpace / 400)); return { chars, charsNoSpace, words, lines, readingTime }; }, /** * 安全解析 JSON * @param {string} str - JSON 字串 * @param {*} defaultVal - 預設值 * @returns {*} 解析結果或預設值 */ safeJsonParse(str, defaultVal = null) { try { return JSON.parse(str); } catch (e) { return defaultVal; } }, /** * HTML 跳脫 * @param {string} str - 原始字串 * @returns {string} 跳脫後的字串 */ escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }, /** * 產生唯一 ID * @param {string} prefix - 前綴 * @returns {string} 唯一 ID */ generateId(prefix = 'mme') { return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; }, /** * GM_xmlhttpRequest 封裝 * 用於跨域請求(繞過 CORS) * @param {string} url - 請求 URL * @param {number} timeout - 超時時間 * @returns {Promise} 回應內容 */ gmFetch(url, timeout = 30000) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { // Fallback: 使用原生 fetch fetch(url, { cache: 'no-store' }) .then(r => r.ok ? r.text() : Promise.reject(new Error(`HTTP ${r.status}`))) .then(resolve) .catch(reject); return; } GM_xmlhttpRequest({ method: 'GET', url, timeout, anonymous: true, onload: (res) => { if (res.status >= 200 && res.status < 400) { resolve(res.responseText); } else { reject(new Error(`HTTP ${res.status}`)); } }, onerror: () => reject(new Error('Network error')), ontimeout: () => reject(new Error('Timeout')) }); }); }, /** * 載入外部腳本 * @param {string} url - 腳本 URL * @param {number} timeout - 超時時間 * @returns {Promise} */ loadScript(url, timeout = 30000) { return new Promise((resolve, reject) => { // 檢查是否已載入 if (document.querySelector(`script[src="${url}"]`)) { return resolve(); } const s = document.createElement('script'); s.src = url; s.async = true; const timer = setTimeout(() => { s.remove(); reject(new Error(`Script load timeout: ${url}`)); }, timeout); s.onload = () => { clearTimeout(timer); resolve(); }; s.onerror = () => { clearTimeout(timer); s.remove(); reject(new Error(`Failed to load script: ${url}`)); }; document.head.appendChild(s); }); }, /** * 載入外部樣式表 * @param {string} url - 樣式表 URL * @param {number} timeout - 超時時間 * @returns {Promise} */ loadStylesheet(url, timeout = 15000) { return new Promise((resolve, reject) => { // 檢查是否已載入 if (document.querySelector(`link[href="${url}"]`)) { return resolve(); } const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = url; const timer = setTimeout(() => { // 樣式表載入超時時不報錯,只是 resolve resolve(); }, timeout); link.onload = () => { clearTimeout(timer); resolve(); }; link.onerror = () => { clearTimeout(timer); reject(new Error(`Failed to load CSS: ${url}`)); }; document.head.appendChild(link); }); }, /** * 修復 CSS 中的相對 URL * 將相對路徑轉換為絕對路徑 * @param {string} css - CSS 內容 * @param {string} baseUrl - 基礎 URL * @returns {string} 修復後的 CSS */ fixCssUrls(css, baseUrl) { const base = baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1); return css.replace( /url\(\s*(['"]?)(?!data:|https?:|blob:|\/\/)([^'")]+)\1\s*\)/gi, (match, quote, path) => { try { return `url("${new URL(path, base).href}")`; } catch (e) { return match; } } ); }, /** * 等待條件成立 * @param {Function} conditionFn - 條件函數(返回 truthy 值表示條件成立) * @param {number} timeout - 超時時間 * @param {number} interval - 檢查間隔 * @returns {Promise} */ waitFor(conditionFn, timeout = 10000, interval = 100) { return new Promise((resolve, reject) => { const start = Date.now(); const tick = () => { let ok = false; try { ok = !!conditionFn(); } catch (e) { ok = false; } if (ok) return resolve(); if (Date.now() - start >= timeout) { return reject(new Error('Wait timeout')); } setTimeout(tick, interval); }; tick(); }); }, /** * 清除編輯器快取 * @param {string} editorKey - 編輯器鍵名 */ clearEditorCache(editorKey) { const cfg = CONFIG.editors[editorKey]; if (cfg?.cacheId) { try { localStorage.removeItem(cfg.cacheId); } catch (e) { // 忽略清除失敗 } } }, /** * 深度合併物件 * @param {Object} target - 目標物件 * @param {Object} source - 來源物件 * @returns {Object} 合併後的物件 */ deepMerge(target, source) { const result = { ...target }; for (const key of Object.keys(source)) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { result[key] = this.deepMerge(result[key] || {}, source[key]); } else { result[key] = source[key]; } } return result; }, /** * 根據路徑獲取物件屬性 * @param {Object} obj - 物件 * @param {string} path - 路徑(如 'a.b.c') * @returns {*} 屬性值 */ getByPath(obj, path) { return path.split('.').reduce((o, k) => o?.[k], obj); }, /** * 根據路徑設置物件屬性 * @param {Object} obj - 物件 * @param {string} path - 路徑(如 'a.b.c') * @param {*} value - 值 */ setByPath(obj, path, value) { const keys = path.split('.'); const last = keys.pop(); const target = keys.reduce((o, k) => { if (!o[k]) o[k] = {}; return o[k]; }, obj); target[last] = value; } }; // ======================================== // SafeExecute 安全執行工具 // ======================================== /** * 安全執行工具 * * 設計意圖: * - 提供統一的錯誤捕獲機制 * - 減少重複的 try-catch 代碼 * - 確保錯誤被正確記錄而不中斷程式 * - 支援同步和異步函數 */ const SafeExecute = { /** * 安全執行異步函數 * @param {Function} fn - 要執行的異步函數 * @param {*} fallback - 失敗時的返回值 * @param {string} context - 上下文描述(用於日誌) * @returns {Promise<*>} 執行結果或 fallback */ async run(fn, fallback = null, context = 'unknown') { try { return await fn(); } catch (e) { logError(`SafeExecute [${context}]:`, e?.message || e); if (DEBUG) { console.error(`SafeExecute [${context}] stack:`, e); } return fallback; } }, /** * 包裝函數使其安全執行 * @param {Function} fn - 要包裝的函數 * @param {*} fallback - 失敗時的返回值 * @param {string} context - 上下文描述 * @returns {Function} 包裝後的安全函數 */ wrap(fn, fallback = null, context = 'unknown') { return (...args) => { try { const result = fn(...args); // 處理 Promise if (result && typeof result.then === 'function') { return result.catch(e => { logError(`SafeExecute.wrap [${context}]:`, e?.message || e); return fallback; }); } return result; } catch (e) { logError(`SafeExecute.wrap [${context}]:`, e?.message || e); return fallback; } }; }, /** * 安全執行 DOM 操作 * @param {Function} fn - DOM 操作函數 * @param {string} context - 上下文描述 * @returns {boolean} 是否成功 */ dom(fn, context = 'DOM operation') { try { fn(); return true; } catch (e) { logWarn(`SafeExecute.dom [${context}]:`, e?.message || e); return false; } }, /** * 帶重試的安全執行 * @param {Function} fn - 要執行的異步函數 * @param {number} maxRetries - 最大重試次數 * @param {number} delay - 重試延遲(毫秒) * @param {string} context - 上下文描述 * @returns {Promise<*>} 執行結果 */ async withRetry(fn, maxRetries = 3, delay = 1000, context = 'retry') { let lastError; for (let i = 0; i <= maxRetries; i++) { try { return await fn(); } catch (e) { lastError = e; log(`SafeExecute.withRetry [${context}]: Attempt ${i + 1} failed`); if (i < maxRetries) { await new Promise(r => setTimeout(r, delay)); } } } logError(`SafeExecute.withRetry [${context}]: All attempts failed`); throw lastError; } }; // ======================================== // 儲存遷移 // ======================================== /** * 執行儲存資料遷移(相容舊版本) * * 設計意圖: * - 確保從舊版本升級的用戶不會丟失資料 * - 將舊的儲存鍵名遷移到新的統一鍵名 * - 只遷移一次,避免覆蓋用戶的新資料 */ function migrateStorage() { const keys = CONFIG.storageKeys; // 草稿遷移 const currentDraft = Utils.storage.get(keys.content, null); if (currentDraft === null || currentDraft === undefined || currentDraft === '') { const candidates = ['mme_draft_v3', 'mme_draft_v31']; for (const k of candidates) { const v = Utils.storage.get(k, null); if (typeof v === 'string' && v.trim()) { Utils.storage.set(keys.content, v); log('Migrated draft from', k); break; } } } // 主題遷移 const currentTheme = Utils.storage.get(keys.theme, null); if (currentTheme === null) { const candidates = ['mme_theme_v3', 'mme_theme_v31']; for (const k of candidates) { const v = Utils.storage.get(k, null); if (typeof v === 'string' && v) { Utils.storage.set(keys.theme, v); log('Migrated theme from', k); break; } } } // 編輯器選擇遷移 const currentEditor = Utils.storage.get(keys.editor, null); if (currentEditor === null) { const candidates = ['mme_editor_v3', 'mme_editor_v31']; for (const k of candidates) { const v = Utils.storage.get(k, null); if (typeof v === 'string' && v) { Utils.storage.set(keys.editor, v); log('Migrated editor from', k); break; } } } } // 執行遷移(腳本載入時立即執行) migrateStorage(); // ======================================== // 主題管理 // ======================================== /** * 主題管理器 * * 設計意圖: * - 集中管理主題狀態 * - 支援監聽系統主題變化(prefers-color-scheme) * - 提供訂閱機制讓其他模組響應主題變化 */ const Theme = { /** @type {string|null} 當前主題 ('light' | 'dark') */ current: null, /** @type {Set} 主題變更監聽器集合 */ listeners: new Set(), /** * 初始化主題系統 * 應在腳本啟動時調用一次 */ init() { // 從儲存讀取主題,若無則使用預設值 this.current = Utils.storage.get(CONFIG.storageKeys.theme, CONFIG.defaultTheme); // 監聽系統主題變化 try { const mq = window.matchMedia('(prefers-color-scheme: dark)'); const handler = () => { // 只有當用戶設定為 'auto' 時才響應系統主題 if (Utils.storage.get(CONFIG.storageKeys.theme) === 'auto') { const newTheme = mq.matches ? 'dark' : 'light'; this.current = newTheme; this.notify(); } }; // 兼容舊版瀏覽器 if (mq.addEventListener) { mq.addEventListener('change', handler); } else if (mq.addListener) { mq.addListener(handler); } } catch (e) { // 某些環境不支援 matchMedia log('matchMedia not supported:', e.message); } }, /** * 取得當前主題 * @returns {string} 'light' 或 'dark' */ get() { return this.current; }, /** * 設定主題 * @param {string} theme - 'light' 或 'dark' */ set(theme) { this.current = theme; Utils.storage.set(CONFIG.storageKeys.theme, theme); this.notify(); }, /** * 切換主題(深色 ↔ 淺色) * @returns {string} 切換後的主題 */ toggle() { const next = this.current === 'dark' ? 'light' : 'dark'; this.set(next); return next; }, /** * 註冊主題變更監聽器 * @param {Function} fn - 監聽函數,接收新主題作為參數 * @returns {Function} 取消註冊函數 */ onChange(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }, /** * 通知所有監聽器主題已變更 */ notify() { this.listeners.forEach(fn => { try { fn(this.current); } catch (e) { logError('Theme listener error:', e); } }); }, /** * 檢查是否為深色主題 * @returns {boolean} */ isDark() { return this.current === 'dark'; } }; // ======================================== // 編輯器通用預覽樣式 // ======================================== /** * 編輯器預覽樣式管理器 * * 設計意圖: * - 統一各編輯器預覽區的配色,確保一致的閱讀體驗 * - 修復某些編輯器原生樣式與我們主題不匹配的問題 * - 確保代碼塊、語法高亮在亮/暗模式下都有良好的可讀性 * * 注意: * - 使用 !important 是必要的,因為需要覆蓋編輯器原生樣式 * - 這些樣式會在編輯器容器內生效,不會影響頁面其他部分 */ const EditorPreviewStyles = { /** * 注入所有編輯器的預覽區配色修復 * 應在 Modal 創建時調用 */ inject() { const p = CONFIG.prefix; const styleId = `${p}editor-preview-fix`; // 避免重複注入 if (document.getElementById(styleId)) return; Utils.addStyle(` /* ======================================== 編輯器預覽區通用配色修復 ======================================== */ /* ===== 亮色模式 ===== */ /* EasyMDE 預覽區 */ .${p}editor .editor-preview, .${p}editor .editor-preview-side { background: #fff !important; color: #24292e !important; font-size: 14px !important; line-height: 1.6 !important; } /* 預覽區代碼塊 - 亮色 */ .${p}editor .editor-preview pre, .${p}editor .editor-preview-side pre, .${p}editor .editor-preview code, .${p}editor .editor-preview-side code { background: #f6f8fa !important; color: #24292e !important; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace !important; } .${p}editor .editor-preview pre, .${p}editor .editor-preview-side pre { padding: 16px !important; border-radius: 6px !important; overflow-x: auto !important; border: 1px solid #e1e4e8 !important; } .${p}editor .editor-preview pre code, .${p}editor .editor-preview-side pre code { background: transparent !important; padding: 0 !important; border: none !important; font-size: 13px !important; line-height: 1.5 !important; } .${p}editor .editor-preview code, .${p}editor .editor-preview-side code { padding: 2px 6px !important; border-radius: 4px !important; font-size: 85% !important; } /* 預覽區標題 */ .${p}editor .editor-preview h1, .${p}editor .editor-preview h2, .${p}editor .editor-preview h3, .${p}editor .editor-preview h4, .${p}editor .editor-preview h5, .${p}editor .editor-preview h6, .${p}editor .editor-preview-side h1, .${p}editor .editor-preview-side h2, .${p}editor .editor-preview-side h3, .${p}editor .editor-preview-side h4, .${p}editor .editor-preview-side h5, .${p}editor .editor-preview-side h6 { color: #24292e !important; border-bottom-color: #e1e4e8 !important; margin-top: 24px !important; margin-bottom: 16px !important; font-weight: 600 !important; } /* 預覽區連結 */ .${p}editor .editor-preview a, .${p}editor .editor-preview-side a { color: #0366d6 !important; text-decoration: none !important; } .${p}editor .editor-preview a:hover, .${p}editor .editor-preview-side a:hover { text-decoration: underline !important; } /* 預覽區引用 */ .${p}editor .editor-preview blockquote, .${p}editor .editor-preview-side blockquote { border-left: 4px solid #dfe2e5 !important; color: #6a737d !important; padding: 0 16px !important; margin: 16px 0 !important; background: transparent !important; } /* 預覽區表格 */ .${p}editor .editor-preview table, .${p}editor .editor-preview-side table { border-collapse: collapse !important; width: 100% !important; margin: 16px 0 !important; } .${p}editor .editor-preview th, .${p}editor .editor-preview td, .${p}editor .editor-preview-side th, .${p}editor .editor-preview-side td { border: 1px solid #dfe2e5 !important; padding: 8px 12px !important; text-align: left !important; } .${p}editor .editor-preview th, .${p}editor .editor-preview-side th { background: #f6f8fa !important; font-weight: 600 !important; } /* 預覽區列表 */ .${p}editor .editor-preview ul, .${p}editor .editor-preview ol, .${p}editor .editor-preview-side ul, .${p}editor .editor-preview-side ol { padding-left: 2em !important; margin: 16px 0 !important; } .${p}editor .editor-preview li, .${p}editor .editor-preview-side li { margin: 4px 0 !important; } /* 預覽區分隔線 */ .${p}editor .editor-preview hr, .${p}editor .editor-preview-side hr { border: none !important; border-top: 1px solid #e1e4e8 !important; margin: 24px 0 !important; } /* 預覽區圖片 */ .${p}editor .editor-preview img, .${p}editor .editor-preview-side img { max-width: 100% !important; border-radius: 4px !important; } /* ===== 深色模式 ===== */ /* EasyMDE 深色預覽區 */ .${p}editor .editor-preview.editor-preview-dark, .${p}editor .editor-preview-side.editor-preview-dark, .${p}editor-dark .editor-preview, .${p}editor-dark .editor-preview-side { background: #0d1117 !important; color: #c9d1d9 !important; } /* 深色代碼塊 */ .${p}editor .editor-preview.editor-preview-dark pre, .${p}editor .editor-preview-side.editor-preview-dark pre, .${p}editor .editor-preview.editor-preview-dark code, .${p}editor .editor-preview-side.editor-preview-dark code, .${p}editor-dark .editor-preview pre, .${p}editor-dark .editor-preview code, .${p}editor-dark .editor-preview-side pre, .${p}editor-dark .editor-preview-side code { background: #161b22 !important; color: #c9d1d9 !important; border-color: #30363d !important; } .${p}editor .editor-preview.editor-preview-dark pre code, .${p}editor .editor-preview-side.editor-preview-dark pre code, .${p}editor-dark .editor-preview pre code, .${p}editor-dark .editor-preview-side pre code { background: transparent !important; } /* 深色標題 */ .${p}editor .editor-preview.editor-preview-dark h1, .${p}editor .editor-preview.editor-preview-dark h2, .${p}editor .editor-preview.editor-preview-dark h3, .${p}editor .editor-preview.editor-preview-dark h4, .${p}editor .editor-preview.editor-preview-dark h5, .${p}editor .editor-preview.editor-preview-dark h6, .${p}editor .editor-preview-side.editor-preview-dark h1, .${p}editor .editor-preview-side.editor-preview-dark h2, .${p}editor .editor-preview-side.editor-preview-dark h3, .${p}editor .editor-preview-side.editor-preview-dark h4, .${p}editor .editor-preview-side.editor-preview-dark h5, .${p}editor .editor-preview-side.editor-preview-dark h6 { color: #c9d1d9 !important; border-bottom-color: #21262d !important; } /* 深色連結 */ .${p}editor .editor-preview.editor-preview-dark a, .${p}editor .editor-preview-side.editor-preview-dark a { color: #58a6ff !important; } /* 深色引用 */ .${p}editor .editor-preview.editor-preview-dark blockquote, .${p}editor .editor-preview-side.editor-preview-dark blockquote { border-left-color: #3b5998 !important; color: #8b949e !important; } /* 深色表格 */ .${p}editor .editor-preview.editor-preview-dark th, .${p}editor .editor-preview.editor-preview-dark td, .${p}editor .editor-preview-side.editor-preview-dark th, .${p}editor .editor-preview-side.editor-preview-dark td { border-color: #30363d !important; } .${p}editor .editor-preview.editor-preview-dark th, .${p}editor .editor-preview-side.editor-preview-dark th { background: #161b22 !important; } /* 深色分隔線 */ .${p}editor .editor-preview.editor-preview-dark hr, .${p}editor .editor-preview-side.editor-preview-dark hr { border-top-color: #30363d !important; } /* ===== Toast UI 預覽區修復 ===== */ .${p}editor .toastui-editor-md-preview { color: #24292e !important; } .${p}editor .toastui-editor-md-preview pre, .${p}editor .toastui-editor-md-preview code { background: #f6f8fa !important; color: #24292e !important; } .${p}editor .toastui-editor-md-preview pre { padding: 16px !important; border-radius: 6px !important; border: 1px solid #e1e4e8 !important; } .${p}editor .toastui-editor-md-preview pre code { background: transparent !important; } /* Toast UI 深色 */ .${p}editor .toastui-editor-dark .toastui-editor-md-preview { color: #c9d1d9 !important; } .${p}editor .toastui-editor-dark .toastui-editor-md-preview pre, .${p}editor .toastui-editor-dark .toastui-editor-md-preview code { background: #161b22 !important; color: #c9d1d9 !important; border-color: #30363d !important; } /* ===== Cherry Markdown 預覽區修復 ===== */ .${p}editor .cherry-previewer { color: #24292e !important; } .${p}editor .cherry-previewer pre, .${p}editor .cherry-previewer code { background: #f6f8fa !important; color: #24292e !important; } .${p}editor .cherry-previewer pre { padding: 16px !important; border-radius: 6px !important; border: 1px solid #e1e4e8 !important; } .${p}editor .cherry-previewer pre code { background: transparent !important; } /* Cherry 深色 - 使用 .cherry 容器上的類名判斷 */ .${p}editor .cherry.theme__dark .cherry-previewer, .${p}editor .cherry[data-theme="dark"] .cherry-previewer { color: #c9d1d9 !important; } /* Cherry 深色 - KaTeX 數學公式顏色保護 */ .${p}editor .cherry.theme__dark .cherry-previewer .katex, .${p}editor .cherry.theme__dark .cherry-previewer .katex *, .${p}editor .cherry[data-theme="dark"] .cherry-previewer .katex, .${p}editor .cherry[data-theme="dark"] .cherry-previewer .katex * { color: inherit; } .${p}editor .cherry.theme__dark .cherry-previewer .katex [style*="color"], .${p}editor .cherry[data-theme="dark"] .cherry-previewer .katex [style*="color"], .${p}editor .cherry.theme__dark .cherry-previewer [style*="color"], .${p}editor .cherry[data-theme="dark"] .cherry-previewer [style*="color"] { color: unset !important; } .${p}editor .cherry.theme__dark .cherry-previewer pre, .${p}editor .cherry.theme__dark .cherry-previewer code, .${p}editor .cherry[data-theme="dark"] .cherry-previewer pre, .${p}editor .cherry[data-theme="dark"] .cherry-previewer code { background: #161b22 !important; color: #c9d1d9 !important; border-color: #30363d !important; } /* ===== Vditor 預覽區修復 ===== */ .${p}editor .vditor-preview { color: #24292e !important; } .${p}editor .vditor-preview pre, .${p}editor .vditor-preview code { background: #f6f8fa !important; color: #24292e !important; } .${p}editor .vditor-preview pre { padding: 16px !important; border-radius: 6px !important; border: 1px solid #e1e4e8 !important; } .${p}editor .vditor-preview pre code { background: transparent !important; } /* Vditor 深色 */ .${p}editor .vditor--dark .vditor-preview, .vditor--dark .vditor-preview { color: #c9d1d9 !important; } .${p}editor .vditor--dark .vditor-preview pre, .${p}editor .vditor--dark .vditor-preview code, .vditor--dark .vditor-preview pre, .vditor--dark .vditor-preview code { background: #161b22 !important; color: #c9d1d9 !important; border-color: #30363d !important; } /* ===== 語法高亮通用修復 ===== */ /* 亮色模式語法高亮 */ .${p}editor .hljs-keyword, .${p}editor .cm-keyword, .${p}editor .token.keyword { color: #cf222e !important; } .${p}editor .hljs-string, .${p}editor .cm-string, .${p}editor .token.string { color: #0a3069 !important; } .${p}editor .hljs-comment, .${p}editor .cm-comment, .${p}editor .token.comment { color: #6e7781 !important; } .${p}editor .hljs-function, .${p}editor .hljs-title, .${p}editor .cm-def, .${p}editor .token.function { color: #8250df !important; } .${p}editor .hljs-number, .${p}editor .cm-number, .${p}editor .token.number { color: #0550ae !important; } /* 深色模式語法高亮 */ .vditor--dark .hljs-keyword, .toastui-editor-dark .hljs-keyword, .editor-preview-dark .hljs-keyword, .${p}editor-dark .hljs-keyword { color: #ff7b72 !important; } .vditor--dark .hljs-string, .toastui-editor-dark .hljs-string, .editor-preview-dark .hljs-string, .${p}editor-dark .hljs-string { color: #a5d6ff !important; } .vditor--dark .hljs-comment, .toastui-editor-dark .hljs-comment, .editor-preview-dark .hljs-comment, .${p}editor-dark .hljs-comment { color: #8b949e !important; } .vditor--dark .hljs-function, .vditor--dark .hljs-title, .toastui-editor-dark .hljs-function, .toastui-editor-dark .hljs-title, .editor-preview-dark .hljs-function, .editor-preview-dark .hljs-title, .${p}editor-dark .hljs-function, .${p}editor-dark .hljs-title { color: #d2a8ff !important; } .vditor--dark .hljs-number, .toastui-editor-dark .hljs-number, .editor-preview-dark .hljs-number, .${p}editor-dark .hljs-number { color: #79c0ff !important; } `, styleId); } }; // ======================================== // Portal 傳送門系統 // ======================================== /** * Portal 傳送門管理器 * * 設計意圖: * - 將彈出元素(選單、面板等)渲染到 body 的直接子元素 * - 避免被父容器的 overflow: hidden 裁切 * - 提供統一的定位、拖曳、縮放功能 * * 使用方式: * 1. Portal.append(element) - 將元素加入 Portal * 2. Portal.positionAt(element, anchor) - 相對於錨點定位 * 3. Portal.enableDrag(element, handle) - 啟用拖曳 * 4. Portal.remove(element) - 移除元素 */ const Portal = { /** @type {HTMLElement|null} Portal 容器 */ container: null, /** @type {Map} 面板拖曳狀態 */ dragStates: new Map(), /** @type {Set} 全域事件監聽器清理函數 */ _globalCleanups: new Set(), /** @type {boolean} 是否已綁定全域事件 */ _globalEventsBound: false, /** * 初始化 Portal(惰性初始化) */ init() { if (this.container) return; const p = CONFIG.prefix; this.container = document.createElement('div'); this.container.id = `${p}portal`; this.container.style.cssText = ` position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: ${CONFIG.zIndex + 500}; pointer-events: none; `; document.body.appendChild(this.container); // 綁定全域滑鼠事件(用於拖曳) this._bindGlobalEvents(); }, /** * 綁定全域事件 * @private */ _bindGlobalEvents() { if (this._globalEventsBound) return; const onMouseMove = (e) => { this.dragStates.forEach((state, panel) => { if (state.isDragging) { this._handleDragMove(e, panel, state); } }); }; const onMouseUp = () => { this.dragStates.forEach((state, panel) => { if (state.isDragging) { this._handleDragEnd(panel, state); } }); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); this._globalCleanups.add(() => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }); this._globalEventsBound = true; }, /** * 將元素加入 Portal * @param {HTMLElement} el - 元素 * @returns {HTMLElement} 元素 */ append(el) { this.init(); el.style.pointerEvents = 'auto'; this.container.appendChild(el); return el; }, /** * 從 Portal 移除元素 * @param {HTMLElement} el - 元素 */ remove(el) { if (!el) return; // 清理拖曳狀態 if (this.dragStates.has(el)) { const state = this.dragStates.get(el); if (state.cleanup) { state.cleanup(); } this.dragStates.delete(el); } // 清理元素上的清理函數 if (el._cleanupDrag) { el._cleanupDrag(); delete el._cleanupDrag; } // 從 DOM 移除 if (el.parentNode === this.container) { this.container.removeChild(el); } }, /** * 根據錨點定位彈出面板 * @param {HTMLElement} panel - 面板元素 * @param {HTMLElement|null} anchor - 錨點元素(null 表示置中) * @param {Object} options - 選項 */ positionAt(panel, anchor, options = {}) { const { placement = 'bottom-end', // 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' | 'center' offsetX = 0, offsetY = 8 } = options; if (!panel) return; // 先顯示以計算尺寸 const wasHidden = panel.style.display === 'none'; if (wasHidden) { panel.style.visibility = 'hidden'; panel.style.display = 'flex'; } const panelRect = panel.getBoundingClientRect(); const viewW = window.innerWidth; const viewH = window.innerHeight; let top, left; if (!anchor || placement === 'center') { // 置中 left = (viewW - panelRect.width) / 2; top = (viewH - panelRect.height) / 2; } else { const anchorRect = anchor.getBoundingClientRect(); const isTop = placement.startsWith('top'); const isEnd = placement.endsWith('end'); // 計算垂直位置 if (isTop) { top = anchorRect.top - panelRect.height - offsetY; } else { top = anchorRect.bottom + offsetY; } // 計算水平位置 if (isEnd) { left = anchorRect.right - panelRect.width + offsetX; } else { left = anchorRect.left + offsetX; } // 邊界檢查 - 垂直 if (top < 10) { top = anchorRect.bottom + offsetY; } if (top + panelRect.height > viewH - 10) { top = anchorRect.top - panelRect.height - offsetY; } // 邊界檢查 - 水平 if (left < 10) { left = 10; } if (left + panelRect.width > viewW - 10) { left = viewW - panelRect.width - 10; } } panel.style.position = 'fixed'; panel.style.top = `${Math.max(10, top)}px`; panel.style.left = `${Math.max(10, left)}px`; if (wasHidden) { panel.style.visibility = 'visible'; } }, /** * 為面板啟用拖曳功能 * @param {HTMLElement} panel - 面板元素 * @param {HTMLElement} handle - 拖曳手柄(通常是標題列) */ enableDrag(panel, handle) { if (!panel || !handle) return; const state = { isDragging: false, startX: 0, startY: 0, startLeft: 0, startTop: 0, cleanup: null }; this.dragStates.set(panel, state); const onMouseDown = (e) => { // 排除按鈕等互動元素 if (e.target.closest('button, input, select, a')) return; e.preventDefault(); state.isDragging = true; state.startX = e.clientX; state.startY = e.clientY; const rect = panel.getBoundingClientRect(); state.startLeft = rect.left; state.startTop = rect.top; panel.style.transition = 'none'; handle.style.cursor = 'grabbing'; }; handle.style.cursor = 'move'; handle.addEventListener('mousedown', onMouseDown); // 儲存清理函數 state.cleanup = () => { handle.removeEventListener('mousedown', onMouseDown); }; panel._cleanupDrag = state.cleanup; }, /** * 處理拖曳移動 * @private */ _handleDragMove(e, panel, state) { const dx = e.clientX - state.startX; const dy = e.clientY - state.startY; let newLeft = state.startLeft + dx; let newTop = state.startTop + dy; // 邊界限制 const rect = panel.getBoundingClientRect(); newLeft = Utils.clamp(newLeft, 10, window.innerWidth - rect.width - 10); newTop = Utils.clamp(newTop, 10, window.innerHeight - rect.height - 10); panel.style.left = `${newLeft}px`; panel.style.top = `${newTop}px`; }, /** * 處理拖曳結束 * @private */ _handleDragEnd(panel, state) { state.isDragging = false; panel.style.transition = ''; // 找到對應的 handle 並恢復游標 const handle = panel.querySelector('[style*="cursor: grabbing"], [style*="cursor:grabbing"]'); if (handle) { handle.style.cursor = 'move'; } }, /** * 為面板啟用縮放功能 * @param {HTMLElement} panel - 面板元素 * @param {Object} options - 選項 */ enableResize(panel, options = {}) { const { minWidth = 300, minHeight = 200, maxWidth = window.innerWidth * 0.95, maxHeight = window.innerHeight * 0.9 } = options; const p = CONFIG.prefix; // 建立縮放手柄 const resizeHandle = document.createElement('div'); resizeHandle.className = `${p}resize-handle`; resizeHandle.style.cssText = ` position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 50%, rgba(128,128,128,0.3) 50%); border-radius: 0 0 8px 0; `; panel.appendChild(resizeHandle); let isResizing = false; let startX, startY, startWidth, startHeight; const onMouseDown = (e) => { e.preventDefault(); e.stopPropagation(); isResizing = true; startX = e.clientX; startY = e.clientY; startWidth = panel.offsetWidth; startHeight = panel.offsetHeight; panel.style.transition = 'none'; }; const onMouseMove = (e) => { if (!isResizing) return; const dx = e.clientX - startX; const dy = e.clientY - startY; const newWidth = Utils.clamp(startWidth + dx, minWidth, maxWidth); const newHeight = Utils.clamp(startHeight + dy, minHeight, maxHeight); panel.style.width = `${newWidth}px`; panel.style.height = `${newHeight}px`; }; const onMouseUp = () => { if (isResizing) { isResizing = false; panel.style.transition = ''; } }; resizeHandle.addEventListener('mousedown', onMouseDown); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); // 儲存清理函數 const cleanup = () => { resizeHandle.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); resizeHandle.remove(); }; if (!panel._cleanupResize) { panel._cleanupResize = cleanup; } else { const oldCleanup = panel._cleanupResize; panel._cleanupResize = () => { oldCleanup(); cleanup(); }; } }, /** * 銷毀所有 Portal 內容和事件 * 用於腳本完全卸載時 */ destroyAll() { // 清理所有拖曳狀態 this.dragStates.forEach((state, panel) => { if (state.cleanup) { state.cleanup(); } }); this.dragStates.clear(); // 清理全域事件 this._globalCleanups.forEach(cleanup => { try { cleanup(); } catch (e) { // 忽略清理錯誤 } }); this._globalCleanups.clear(); this._globalEventsBound = false; // 移除容器 if (this.container && this.container.parentNode) { this.container.parentNode.removeChild(this.container); } this.container = null; } }; // ======================================== // ResizeManager 縮放管理器 // ======================================== /** * 縮放管理器 * * 設計意圖: * - 為主視窗和面板提供邊框縮放功能 * - 支援多邊緣縮放(右、下、角落、左、上) * - 提供縮放回調以便其他模組響應尺寸變化 * * 與 Portal.enableResize 的區別: * - Portal.enableResize 是為 Portal 內的小面板設計的簡化版 * - ResizeManager 是為主視窗設計的完整版,支援更多邊緣和回調 */ const ResizeManager = { /** @type {Map} 各元素的縮放狀態 */ states: new Map(), /** @type {boolean} 是否已綁定全域事件 */ _globalEventsBound: false, /** * 為元素啟用邊框縮放 * @param {HTMLElement} el - 目標元素 * @param {Object} options - 選項 */ enable(el, options = {}) { const { minWidth = 380, minHeight = 350, maxWidth = window.innerWidth - 20, maxHeight = window.innerHeight - 20, edges = ['right', 'bottom', 'corner'], onResize = null, onResizeEnd = null } = options; const p = CONFIG.prefix; // 建立邊緣手柄 const handles = {}; if (edges.includes('right')) { handles.right = this._createHandle(el, 'right', p); } if (edges.includes('bottom')) { handles.bottom = this._createHandle(el, 'bottom', p); } if (edges.includes('corner')) { handles.corner = this._createHandle(el, 'corner', p); } if (edges.includes('left')) { handles.left = this._createHandle(el, 'left', p); } if (edges.includes('top')) { handles.top = this._createHandle(el, 'top', p); } const state = { isResizing: false, edge: null, startX: 0, startY: 0, startWidth: 0, startHeight: 0, startLeft: 0, startTop: 0, handles, options: { minWidth, minHeight, maxWidth, maxHeight, onResize, onResizeEnd } }; this.states.set(el, state); // 綁定手柄事件 Object.entries(handles).forEach(([edge, handle]) => { const mouseDownHandler = (e) => this._onMouseDown(e, el, edge); handle.addEventListener('mousedown', mouseDownHandler); // 儲存以便清理 handle._mouseDownHandler = mouseDownHandler; }); // 確保全域事件已綁定 this._bindGlobalEvents(); }, /** * 建立縮放手柄 * @private */ _createHandle(el, edge, prefix) { const handle = document.createElement('div'); handle.className = `${prefix}resize-handle ${prefix}resize-${edge}`; const styles = { right: ` position: absolute; right: -4px; top: 10%; width: 8px; height: 80%; cursor: ew-resize; z-index: 10; `, bottom: ` position: absolute; bottom: -4px; left: 10%; width: 80%; height: 8px; cursor: ns-resize; z-index: 10; `, corner: ` position: absolute; right: -4px; bottom: -4px; width: 16px; height: 16px; cursor: nwse-resize; z-index: 11; background: linear-gradient(135deg, transparent 30%, rgba(128,128,128,0.4) 30%, rgba(128,128,128,0.4) 40%, transparent 40%, transparent 60%, rgba(128,128,128,0.4) 60%, rgba(128,128,128,0.4) 70%, transparent 70%); border-radius: 0 0 8px 0; `, left: ` position: absolute; left: -4px; top: 10%; width: 8px; height: 80%; cursor: ew-resize; z-index: 10; `, top: ` position: absolute; top: -4px; left: 10%; width: 80%; height: 8px; cursor: ns-resize; z-index: 10; ` }; handle.style.cssText = styles[edge] || ''; el.appendChild(handle); return handle; }, /** * 綁定全域滑鼠事件 * @private */ _bindGlobalEvents() { if (this._globalEventsBound) return; // 使用箭頭函數保留 this 上下文 this._globalMouseMoveHandler = (e) => { this.states.forEach((state, el) => { if (state.isResizing) { this._onMouseMove(e, el); } }); }; this._globalMouseUpHandler = (e) => { this.states.forEach((state, el) => { if (state.isResizing) { this._onMouseUp(e, el); } }); }; document.addEventListener('mousemove', this._globalMouseMoveHandler); document.addEventListener('mouseup', this._globalMouseUpHandler); this._globalEventsBound = true; }, /** * 處理滑鼠按下 * @private */ _onMouseDown(e, el, edge) { e.preventDefault(); e.stopPropagation(); const state = this.states.get(el); if (!state) return; const rect = el.getBoundingClientRect(); state.isResizing = true; state.edge = edge; state.startX = e.clientX; state.startY = e.clientY; state.startWidth = rect.width; state.startHeight = rect.height; state.startLeft = rect.left; state.startTop = rect.top; el.style.transition = 'none'; el.classList.add(`${CONFIG.prefix}resizing`); document.body.style.cursor = this._getCursor(edge); document.body.style.userSelect = 'none'; }, /** * 處理滑鼠移動 * @private */ _onMouseMove(e, el) { const state = this.states.get(el); if (!state || !state.isResizing) return; const { edge, startX, startY, startWidth, startHeight, startLeft, startTop, options } = state; const dx = e.clientX - startX; const dy = e.clientY - startY; let newWidth = startWidth; let newHeight = startHeight; let newLeft = parseFloat(el.style.left) || startLeft; let newTop = parseFloat(el.style.top) || startTop; // 根據邊緣計算新尺寸 switch (edge) { case 'right': newWidth = Utils.clamp(startWidth + dx, options.minWidth, options.maxWidth); break; case 'corner': newWidth = Utils.clamp(startWidth + dx, options.minWidth, options.maxWidth); newHeight = Utils.clamp(startHeight + dy, options.minHeight, options.maxHeight); break; case 'bottom': newHeight = Utils.clamp(startHeight + dy, options.minHeight, options.maxHeight); break; case 'left': const widthChangeLeft = -dx; newWidth = Utils.clamp(startWidth + widthChangeLeft, options.minWidth, options.maxWidth); if (newWidth !== startWidth) { newLeft = startLeft - (newWidth - startWidth); } break; case 'top': const heightChangeTop = -dy; newHeight = Utils.clamp(startHeight + heightChangeTop, options.minHeight, options.maxHeight); if (newHeight !== startHeight) { newTop = startTop - (newHeight - startHeight); } break; } // 應用新尺寸 el.style.width = `${newWidth}px`; el.style.height = `${newHeight}px`; if (edge === 'left') { el.style.left = `${Math.max(10, newLeft)}px`; } if (edge === 'top') { el.style.top = `${Math.max(10, newTop)}px`; } // 回調 if (typeof options.onResize === 'function') { options.onResize({ width: newWidth, height: newHeight }); } }, /** * 處理滑鼠放開 * @private */ _onMouseUp(e, el) { const state = this.states.get(el); if (!state || !state.isResizing) return; state.isResizing = false; state.edge = null; el.style.transition = ''; el.classList.remove(`${CONFIG.prefix}resizing`); document.body.style.cursor = ''; document.body.style.userSelect = ''; // 回調 if (typeof state.options.onResizeEnd === 'function') { const rect = el.getBoundingClientRect(); state.options.onResizeEnd({ width: rect.width, height: rect.height }); } }, /** * 取得邊緣對應的游標樣式 * @private */ _getCursor(edge) { const cursors = { right: 'ew-resize', left: 'ew-resize', top: 'ns-resize', bottom: 'ns-resize', corner: 'nwse-resize' }; return cursors[edge] || 'default'; }, /** * 移除縮放功能 * @param {HTMLElement} el - 目標元素 */ disable(el) { const state = this.states.get(el); if (!state) return; // 移除手柄和事件監聽 Object.values(state.handles).forEach(handle => { if (handle._mouseDownHandler) { handle.removeEventListener('mousedown', handle._mouseDownHandler); } handle.remove(); }); this.states.delete(el); // 如果沒有任何元素需要縮放,清理全域事件 if (this.states.size === 0) { this._unbindGlobalEvents(); } }, /** * 解除全域事件綁定 * @private */ _unbindGlobalEvents() { if (!this._globalEventsBound) return; if (this._globalMouseMoveHandler) { document.removeEventListener('mousemove', this._globalMouseMoveHandler); } if (this._globalMouseUpHandler) { document.removeEventListener('mouseup', this._globalMouseUpHandler); } this._globalMouseMoveHandler = null; this._globalMouseUpHandler = null; this._globalEventsBound = false; } }; // ======================================== // Toast 通知系統 // ======================================== /** * Toast 通知管理器 * * 設計意圖: * - 提供統一的使用者通知機制 * - 支援多種類型:info/success/warning/error * - 自動消失 + 手動關閉 * - 堆疊顯示多個通知 * * 使用方式: * Toast.success('操作成功'); * Toast.error('操作失敗', 5000); // 自定義顯示時間 * const t = Toast.info('處理中...', 0); // 不自動消失 * t.close(); // 手動關閉 */ const Toast = { /** @type {HTMLElement|null} Toast 容器 */ container: null, /** @type {boolean} 樣式是否已注入 */ styleInjected: false, /** * 初始化 Toast 系統(惰性初始化) */ init() { if (this.container) return; const p = CONFIG.prefix; // 注入樣式(只注入一次) if (!this.styleInjected) { Utils.addStyle(` @keyframes ${p}toast-in { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @keyframes ${p}toast-out { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-20px); opacity: 0; } } .${p}toast-container { position: fixed; bottom: 20px; left: 20px; z-index: ${CONFIG.zIndex + 1000}; display: flex; flex-direction: column-reverse; gap: 8px; pointer-events: none; max-width: 360px; } .${p}toast { display: flex; align-items: flex-start; gap: 8px; padding: 10px 14px; border-radius: 8px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; box-shadow: 0 4px 12px rgba(0,0,0,0.15); pointer-events: auto; animation: ${p}toast-in 0.25s ease; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } .${p}toast.${p}out { animation: ${p}toast-out 0.2s ease forwards; } .${p}toast-info { background: rgba(227,242,253,0.95); border-left: 3px solid #2196f3; color: #1565c0; } .${p}toast-success { background: rgba(232,245,233,0.95); border-left: 3px solid #4caf50; color: #2e7d32; } .${p}toast-warning { background: rgba(255,243,224,0.95); border-left: 3px solid #ff9800; color: #e65100; } .${p}toast-error { background: rgba(255,235,238,0.95); border-left: 3px solid #f44336; color: #c62828; } .${p}toast-icon { font-size: 16px; flex-shrink: 0; } .${p}toast-msg { flex: 1; word-break: break-word; font-size: 12px; white-space: pre-wrap; } .${p}toast-close { cursor: pointer; opacity: 0.5; font-size: 14px; padding: 2px; margin: -2px -2px -2px 4px; transition: opacity 0.15s; border: none; background: none; color: inherit; line-height: 1; } .${p}toast-close:hover { opacity: 1; } `, `${p}toast-style`); this.styleInjected = true; } // 建立容器 this.container = document.createElement('div'); this.container.className = `${p}toast-container`; document.body.appendChild(this.container); }, /** * 顯示 Toast 通知 * @param {string} message - 訊息內容 * @param {string} type - 類型 ('info' | 'success' | 'warning' | 'error') * @param {number} duration - 顯示時間 (毫秒),0 表示不自動消失 * @returns {Object} 包含 close 方法的物件 */ show(message, type = 'info', duration = CONFIG.toastDuration) { this.init(); const p = CONFIG.prefix; const icons = { info: 'ℹ️', success: '✅', warning: '⚠️', error: '❌' }; const toast = document.createElement('div'); toast.className = `${p}toast ${p}toast-${type}`; toast.innerHTML = ` ${icons[type] || icons.info} ${Utils.escapeHtml(message)} `; // 關閉函數 const close = () => { if (toast.classList.contains(`${p}out`)) return; toast.classList.add(`${p}out`); setTimeout(() => { if (toast.parentNode) { toast.remove(); } }, 300); }; // 綁定關閉按鈕 toast.querySelector(`.${p}toast-close`).addEventListener('click', close); // 加入容器 this.container.appendChild(toast); // 自動消失 if (duration > 0) { setTimeout(close, duration); } return { close }; }, /** * 顯示 info 類型通知 * @param {string} msg - 訊息 * @param {number} duration - 顯示時間 * @returns {Object} 包含 close 方法的物件 */ info(msg, duration) { return this.show(msg, 'info', duration); }, /** * 顯示 success 類型通知 * @param {string} msg - 訊息 * @param {number} duration - 顯示時間 * @returns {Object} 包含 close 方法的物件 */ success(msg, duration) { return this.show(msg, 'success', duration); }, /** * 顯示 warning 類型通知 * @param {string} msg - 訊息 * @param {number} duration - 顯示時間 * @returns {Object} 包含 close 方法的物件 */ warning(msg, duration) { return this.show(msg, 'warning', duration); }, /** * 顯示 error 類型通知 * @param {string} msg - 訊息 * @param {number} duration - 顯示時間 * @returns {Object} 包含 close 方法的物件 */ error(msg, duration) { return this.show(msg, 'error', duration); } }; // ======================================== // Vditor 診斷系統 // ======================================== /** * Vditor 診斷管理器 * * 設計意圖: * - 追蹤和診斷 Vditor 模式切換時的內容丟失問題 * - 記錄快照、模式切換、還原等關鍵事件 * - 提供診斷報告供開發者分析問題 * * 核心策略: * - 以 SV 模式的內容作為「真相來源」 * - 因為 SV 模式是最穩定的,getValue() 返回的內容最可靠 * - 在切換到其他模式時,保存 SV 快照以便恢復 * * 使用方式: * VditorDiag.log('event-type', { data }); * VditorDiag.printReport(); // 在控制台輸出診斷報告 */ const VditorDiag = { /** @type {boolean} 是否啟用 */ enabled: true, /** @type {Array} 診斷日誌 */ logs: [], /** @type {number} 最大日誌數 */ maxLogs: 200, /** @type {Object|null} 最後一次 SV 模式的完整快照 */ lastSVSnapshot: null, /** * 記錄診斷資訊 * @param {string} type - 事件類型 * @param {Object} data - 事件資料 */ log(type, data) { if (!this.enabled) return; const entry = { ts: Date.now(), type, ...data }; this.logs.push(entry); // 限制日誌數量 if (this.logs.length > this.maxLogs) { this.logs.shift(); } // 在 DEBUG 模式下輸出到控制台 if (DEBUG) { console.log('[MME Diag]', type, data); } }, /** * 記錄快照事件 * @param {string} reason - 快照原因 * @param {string} mode - 當前模式 * @param {string} content - 內容 */ logSnapshot(reason, mode, content) { const len = (content || '').replace(/\s/g, '').length; const hash = Utils.hash32(content || ''); this.log('snapshot', { reason, mode, len, hash, preview: (content || '').substring(0, 50) }); // 記錄 SV 模式的完整快照 if (mode === 'sv' && content) { this.lastSVSnapshot = { content, len, hash, ts: Date.now() }; this.log('sv-snapshot-saved', { len, hash }); } }, /** * 記錄模式切換事件 * @param {string} fromMode - 來源模式 * @param {string} toMode - 目標模式 * @param {number} beforeLen - 切換前內容長度(去空白) * @param {number} afterLen - 切換後內容長度(去空白) */ logModeSwitch(fromMode, toMode, beforeLen, afterLen) { const lostRatio = beforeLen > 0 ? (beforeLen - afterLen) / beforeLen : 0; this.log('mode-switch', { from: fromMode, to: toMode, beforeLen, afterLen, lost: beforeLen - afterLen, lostRatio: (lostRatio * 100).toFixed(1) + '%', suspicious: lostRatio > 0.2 }); // 警告:內容異常縮水 if (lostRatio > 0.2 && beforeLen > 100) { console.warn('[MME Diag] 🚨 內容異常縮水!', { from: fromMode, to: toMode, lost: beforeLen - afterLen, ratio: lostRatio }); } }, /** * 記錄還原事件 * @param {string} reason - 還原原因 * @param {number} restoredLen - 還原後內容長度 */ logRestore(reason, restoredLen) { this.log('restore', { reason, restoredLen }); }, /** * 取得診斷報告 * @returns {Object} 診斷報告 */ getReport() { const modeSwitches = this.logs.filter(l => l.type === 'mode-switch'); const snapshots = this.logs.filter(l => l.type === 'snapshot'); const restores = this.logs.filter(l => l.type === 'restore'); const modeChanges = this.logs.filter(l => l.type === 'mode-change-detected'); const clicks = this.logs.filter(l => l.type.startsWith('click-')); return { totalLogs: this.logs.length, modeSwitches: modeSwitches.length, modeChangesDetected: modeChanges.length, clickEvents: clicks.length, snapshots: snapshots.length, restores: restores.length, suspiciousSwitches: modeSwitches.filter(l => l.suspicious).length, lastSVSnapshot: this.lastSVSnapshot ? { len: this.lastSVSnapshot.len, hash: this.lastSVSnapshot.hash, age: Math.round((Date.now() - this.lastSVSnapshot.ts) / 1000) + 's' } : null, modeDistribution: this._getModeDistribution(), recentLogs: this.logs.slice(-30) }; }, /** * 統計各模式的分佈 * @private */ _getModeDistribution() { const modes = { sv: 0, ir: 0, wysiwyg: 0, null: 0 }; this.logs.forEach(l => { if (l.mode !== undefined) { modes[l.mode || 'null']++; } }); return modes; }, /** * 清除日誌 */ clear() { this.logs = []; this.lastSVSnapshot = null; }, /** * 輸出報告到控制台 */ printReport() { console.group('[MME] Vditor 診斷報告'); const report = this.getReport(); console.log('📊 統計摘要:'); console.table({ '總日誌數': report.totalLogs, '模式切換偵測': report.modeChangesDetected, '模式切換完成': report.modeSwitches, '點擊事件': report.clickEvents, '快照數': report.snapshots, '還原次數': report.restores, '可疑切換': report.suspiciousSwitches }); console.log('📈 模式分佈:', report.modeDistribution); console.log('💾 最後 SV 快照:', report.lastSVSnapshot); console.log('📝 最近 30 筆日誌:'); console.table(report.recentLogs); console.log('🔍 完整日誌:', this.logs); console.groupEnd(); } }; // ======================================== // PerfMonitor 效能監控(僅 DEBUG 模式) // ======================================== /** * 效能監控工具 * * 設計意圖: * - 提供簡單的效能測量能力 * - 幫助識別效能瓶頸 * - 僅在 DEBUG 模式下有實際作用 * * 使用方式: * const timer = PerfMonitor.start('operation-name'); * // ... 執行操作 ... * timer.end(); // 會輸出耗時 */ const PerfMonitor = { /** @type {Object} 計時標記 */ _marks: {}, /** @type {Array} 測量記錄 */ _records: [], /** @type {number} 最大記錄數 */ _maxRecords: 100, /** * 開始計時 * @param {string} name - 操作名稱 * @returns {Object} 包含 end() 方法的物件 */ start(name) { if (!DEBUG) { return { end: () => {} }; } const startTime = performance.now(); this._marks[name] = startTime; return { end: () => this.end(name) }; }, /** * 結束計時並記錄 * @param {string} name - 操作名稱 * @returns {number} 耗時(毫秒) */ end(name) { if (!DEBUG) return 0; const startTime = this._marks[name]; if (!startTime) { log(`PerfMonitor: No start mark for "${name}"`); return 0; } const duration = performance.now() - startTime; delete this._marks[name]; // 記錄 this._records.push({ name, duration, timestamp: Date.now() }); // 限制記錄數量 if (this._records.length > this._maxRecords) { this._records.shift(); } // 輸出日誌 log(`[Perf] ${name}: ${duration.toFixed(2)}ms`); return duration; }, /** * 測量函數執行時間 * @param {string} name - 操作名稱 * @param {Function} fn - 要測量的函數 * @returns {*} 函數返回值 */ measure(name, fn) { if (!DEBUG) { return fn(); } const timer = this.start(name); try { const result = fn(); // 處理 Promise if (result && typeof result.then === 'function') { return result.finally(() => timer.end()); } timer.end(); return result; } catch (e) { timer.end(); throw e; } }, /** * 取得效能報告 * @returns {Object} 效能報告 */ getReport() { if (!DEBUG) { return { message: 'PerfMonitor only available in DEBUG mode' }; } const grouped = {}; this._records.forEach(record => { if (!grouped[record.name]) { grouped[record.name] = []; } grouped[record.name].push(record.duration); }); const stats = {}; Object.entries(grouped).forEach(([name, durations]) => { const sum = durations.reduce((a, b) => a + b, 0); stats[name] = { count: durations.length, total: sum.toFixed(2) + 'ms', avg: (sum / durations.length).toFixed(2) + 'ms', min: Math.min(...durations).toFixed(2) + 'ms', max: Math.max(...durations).toFixed(2) + 'ms' }; }); return { recordCount: this._records.length, operations: stats }; }, /** * 輸出效能報告到控制台 */ printReport() { if (!DEBUG) { console.log('PerfMonitor only available in DEBUG mode'); return; } console.group('[MME] 效能報告'); console.table(this.getReport().operations); console.groupEnd(); }, /** * 清除記錄 */ clear() { this._marks = {}; this._records = []; } }; // ======================================== // BackupManager 備份管理器 // ======================================== /** * 備份管理器 * * 設計意圖: * - 提供完整的備份生命週期管理 * - 使用分層保留策略平衡儲存空間與備份密度 * - 支援釘選功能保護重要備份 * - 自動備份與手動備份並行 * * 儲存結構: * - mme_backup_index: 所有備份的 metadata 陣列(快速列表) * - mme_backup_: 單個備份的實際內容 * * 分層保留策略(CONFIG.backup.retentionTiers): * - 1 小時內:每 2 分鐘保留一筆 * - 24 小時內:每 10 分鐘保留一筆 * - 7 天內:每天保留一筆 * - 超過 7 天:自動刪除(除非已釘選) */ const BackupManager = { /** @type {number|null} 自動備份計時器 */ autoTimer: null, /** @type {string|null} 上次備份的 hash(用於避免重複備份) */ lastBackupHash: null, /** * 取得備份索引 * @returns {Array} 備份 metadata 陣列 */ getIndex() { return Utils.storage.get(CONFIG.storageKeys.backupIndex, []); }, /** * 儲存備份索引 * @param {Array} index - 備份 metadata 陣列 */ saveIndex(index) { Utils.storage.set(CONFIG.storageKeys.backupIndex, index); }, /** * 取得備份內容 * @param {string} id - 備份 ID * @returns {string|null} 備份內容 */ getBackup(id) { return Utils.storage.get(CONFIG.storageKeys.backupPrefix + id, null); }, /** * 儲存備份內容 * @param {string} id - 備份 ID * @param {string} content - 內容 * @returns {boolean} 是否成功 */ saveBackup(id, content) { const ok = Utils.storage.set(CONFIG.storageKeys.backupPrefix + id, content); if (!ok) { logWarn('Backup save failed:', id); } return ok; }, /** * 刪除備份內容 * @param {string} id - 備份 ID */ deleteBackup(id) { Utils.storage.remove(CONFIG.storageKeys.backupPrefix + id); }, /** * 建立備份 * * 設計意圖: * - 自動跳過空內容和無變更的內容 * - 使用分層保留策略管理備份數量 * - 支援手動和自動備份兩種模式 * * @param {string} content - 要備份的內容 * @param {Object} options - 選項 * @param {string} [options.editorKey] - 編輯器鍵名 * @param {string} [options.mode] - 編輯器模式(用於 Vditor) * @param {boolean} [options.manual=false] - 是否為手動備份 * @param {boolean} [options.pinned=false] - 是否釘選 * @returns {Object|null} 備份 metadata,若跳過則返回 null */ create(content, options = {}) { // 空內容不備份 if (!content || !content.trim()) { return null; } const { editorKey = null, mode = null, manual = false, pinned = false } = options; // 檢查備份大小並顯示警告(若啟用) this._checkAndWarnBackupSize(content, manual); const hash = Utils.hash32(content); // 檢查是否與上次備份相同(非手動備份時) if (!manual && hash === this.lastBackupHash) { log('Backup skipped: same content'); return null; } // 檢查最小變更量(非手動備份時) const index = this.getIndex(); if (!manual && index.length > 0) { const lastBackup = this.getBackup(index[0].id); if (lastBackup) { const lastLen = lastBackup.replace(/\s/g, '').length; const curLen = content.replace(/\s/g, '').length; const diff = Math.abs(curLen - lastLen); if (diff < CONFIG.backup.minChangeChars) { log('Backup skipped: minimal change', { diff }); return null; } } } // 建立備份 ID 和 metadata const id = Utils.generateId('bk'); const stats = Utils.countText(content); const meta = { id, ts: Date.now(), chars: stats.charsNoSpace, lines: stats.lines, editorKey, mode, hash, pinned, url: location.href, title: document.title?.substring(0, 50) || '' }; // 儲存備份內容 const saveOk = this.saveBackup(id, content); if (!saveOk) { logWarn('Failed to save backup content'); return null; } // 更新索引(新備份插入到開頭) index.unshift(meta); this.saveIndex(index); this.lastBackupHash = hash; log('Backup created:', meta); // 執行清理(異步,不阻塞) setTimeout(() => this.cleanup(), 100); return meta; }, /** * 檢查備份大小並顯示警告 * * 設計意圖: * - 當備份內容較大時提醒使用者 * - 建議使用匯出功能備份到本機 * - 使用者可在設定中關閉此警告 * * @param {string} content - 備份內容 * @param {boolean} manual - 是否為手動備份(手動備份時更明確提示) */ _checkAndWarnBackupSize(content, manual = false) { try { // 檢查是否啟用警告功能 const warningEnabled = Utils.storage.get( CONFIG.storageKeys.backupSizeWarningEnabled, true // 預設啟用 ); if (!warningEnabled) { return; } // 取得警告閾值(預設 1MB) const threshold = Utils.storage.get( CONFIG.storageKeys.backupSizeWarningThreshold, 1 * 1024 * 1024 // 1 MB ); // 計算內容大小 const bytes = new Blob([content]).size; if (bytes > threshold) { const sizeMB = (bytes / 1024 / 1024).toFixed(2); const thresholdMB = (threshold / 1024 / 1024).toFixed(1); // 根據是否為手動備份調整訊息 const message = manual ? `📦 備份內容較大(${sizeMB} MB)\n` + `瀏覽器儲存空間有限,建議使用「匯出」功能\n` + `將重要內容備份到本機。\n\n` + `(此提示可在「偏好設定」中關閉)` : `📦 自動備份內容較大(${sizeMB} MB)\n` + `建議使用「匯出」備份到本機\n` + `(可在設定中關閉此提示)`; Toast.warning(message, manual ? 8000 : 5000); log('Backup size warning:', { bytes, sizeMB, threshold, manual }); } } catch (e) { // 大小檢查失敗不應阻擋備份流程 log('Backup size check error:', e.message); } }, /** * 取得備份大小警告設定 * @returns {Object} { enabled: boolean, thresholdMB: number } */ getSizeWarningSettings() { return { enabled: Utils.storage.get(CONFIG.storageKeys.backupSizeWarningEnabled, true), thresholdMB: Utils.storage.get( CONFIG.storageKeys.backupSizeWarningThreshold, 1 * 1024 * 1024 ) / 1024 / 1024 }; }, /** * 設定備份大小警告 * @param {boolean} enabled - 是否啟用 * @param {number} thresholdMB - 閾值(MB) */ setSizeWarningSettings(enabled, thresholdMB = 1) { Utils.storage.set(CONFIG.storageKeys.backupSizeWarningEnabled, enabled); Utils.storage.set( CONFIG.storageKeys.backupSizeWarningThreshold, thresholdMB * 1024 * 1024 ); log('Backup size warning settings updated:', { enabled, thresholdMB }); }, /** * 還原備份 * @param {string} id - 備份 ID * @returns {string|null} 備份內容 */ restore(id) { const content = this.getBackup(id); if (!content) { logWarn('Backup not found:', id); return null; } // 更新索引中的訪問時間 const index = this.getIndex(); const meta = index.find(m => m.id === id); if (meta) { meta.lastAccess = Date.now(); this.saveIndex(index); } log('Backup restored:', id); return content; }, /** * 刪除備份 * @param {string} id - 備份 ID */ delete(id) { this.deleteBackup(id); const index = this.getIndex().filter(m => m.id !== id); this.saveIndex(index); log('Backup deleted:', id); }, /** * 切換釘選狀態 * @param {string} id - 備份 ID * @returns {boolean} 新的釘選狀態 */ togglePin(id) { const index = this.getIndex(); const meta = index.find(m => m.id === id); if (meta) { meta.pinned = !meta.pinned; this.saveIndex(index); log('Backup pin toggled:', id, meta.pinned); return meta.pinned; } return false; }, /** * 清理舊備份(分層保留策略) * * 演算法說明: * 1. 分離釘選和未釘選的備份 * 2. 對未釘選的備份,根據時間層級分配到對應的時間桶 * 3. 同一時間桶內只保留一筆備份 * 4. 超過所有層級的備份直接丟棄 * 5. 限制總數不超過 maxBackups */ cleanup() { const index = this.getIndex(); const now = Date.now(); const tiers = CONFIG.backup.retentionTiers; const maxBackups = CONFIG.backup.maxBackups; // 分離釘選和未釘選 const pinned = index.filter(m => m.pinned); const unpinned = index.filter(m => !m.pinned); // 根據分層策略過濾 const kept = []; const tierBuckets = tiers.map(() => ({})); // 每個層級的時間桶 for (const meta of unpinned) { const age = now - meta.ts; let shouldKeep = false; // 遍歷各層級,找到適用的層級 for (let i = 0; i < tiers.length; i++) { const tier = tiers[i]; if (age <= tier.age) { // 計算時間桶(同一桶內只保留一筆) const bucket = Math.floor(meta.ts / tier.interval); if (!tierBuckets[i][bucket]) { tierBuckets[i][bucket] = meta; shouldKeep = true; } break; } } // 如果在某個層級內被保留,加入 kept 陣列 if (shouldKeep) { kept.push(meta); } else { // 檢查是否在最後一個層級內(但可能與同桶的其他備份重複) const lastTier = tiers[tiers.length - 1]; if (age <= lastTier.age) { const bucket = Math.floor(meta.ts / lastTier.interval); const lastIdx = tiers.length - 1; if (!tierBuckets[lastIdx][bucket]) { tierBuckets[lastIdx][bucket] = meta; kept.push(meta); } } // 超過最後一個層級的備份會被丟棄 } } // 限制未釘選備份的最大數量 let finalUnpinned = kept; const maxUnpinned = maxBackups - pinned.length; if (finalUnpinned.length > maxUnpinned) { finalUnpinned = finalUnpinned.slice(0, Math.max(0, maxUnpinned)); } // 找出需要刪除的備份 const keptIds = new Set([...pinned, ...finalUnpinned].map(m => m.id)); let deletedCount = 0; for (const meta of unpinned) { if (!keptIds.has(meta.id)) { this.deleteBackup(meta.id); deletedCount++; } } if (deletedCount > 0) { log('Backup cleanup: deleted', deletedCount, 'old backups'); } // 更新索引(按時間排序:新的在前) const newIndex = [...pinned, ...finalUnpinned].sort((a, b) => b.ts - a.ts); this.saveIndex(newIndex); }, /** * 清除所有備份 */ clearAll() { const index = this.getIndex(); for (const meta of index) { this.deleteBackup(meta.id); } this.saveIndex([]); this.lastBackupHash = null; log('All backups cleared'); }, /** * 開始自動備份 */ startAuto() { this.stopAuto(); this.autoTimer = setInterval(() => { // 這裡只是觸發備份,實際的 EditorManager 引用在 Modal 中處理 // 為了避免循環依賴,我們使用事件或回調機制 if (typeof this._autoBackupCallback === 'function') { this._autoBackupCallback(); } }, CONFIG.backup.autoInterval); log('Auto backup started, interval:', CONFIG.backup.autoInterval); }, /** * 停止自動備份 */ stopAuto() { if (this.autoTimer) { clearInterval(this.autoTimer); this.autoTimer = null; log('Auto backup stopped'); } }, /** * 設定自動備份回調 * @param {Function} callback - 回調函數 */ setAutoBackupCallback(callback) { this._autoBackupCallback = callback; }, /** * 取得統計資訊 * @returns {Object} 統計資訊 */ getStats() { const index = this.getIndex(); return { total: index.length, pinned: index.filter(m => m.pinned).length, oldest: index.length > 0 ? index[index.length - 1].ts : null, newest: index.length > 0 ? index[0].ts : null }; }, // ======================================== // 匯出/匯入功能(傳統 fallback) // ======================================== /** * 匯出所有備份為 JSON * @returns {string} JSON 字串 */ exportAllBackupsAsJson() { const index = this.getIndex(); const data = { version: SCRIPT_VERSION, exportedAt: Date.now(), source: 'Multi Markdown Editor', backupCount: 0, backups: [] }; for (const meta of index) { const content = this.getBackup(meta.id); if (content) { data.backups.push({ meta: { ...meta }, content }); data.backupCount++; } } log('BackupManager: Exported', data.backupCount, 'backups'); return JSON.stringify(data, null, 2); }, /** * 從 JSON 匯入備份 * @param {string} jsonString - JSON 字串 * @param {Object} options - 選項 * @returns {Object} { success, imported, skipped, message } */ importBackupsFromJson(jsonString, options = {}) { const { skipDuplicates = true, // 跳過重複的備份(根據 hash) preserveTimestamp = true // 保留原始時間戳 } = options; try { const data = JSON.parse(jsonString); // 驗證格式 if (!data || typeof data !== 'object') { return { success: false, imported: 0, skipped: 0, message: '無效的 JSON 格式' }; } if (!data.backups || !Array.isArray(data.backups)) { return { success: false, imported: 0, skipped: 0, message: '找不到備份資料' }; } // 取得現有備份的 hash 集合 const existingHashes = new Set(); if (skipDuplicates) { const existingIndex = this.getIndex(); existingIndex.forEach(m => { if (m.hash) existingHashes.add(m.hash); }); } let imported = 0; let skipped = 0; for (const item of data.backups) { if (!item.content || typeof item.content !== 'string') { skipped++; continue; } // 檢查重複 const hash = Utils.hash32(item.content); if (skipDuplicates && existingHashes.has(hash)) { skipped++; continue; } // 建立新備份 const stats = Utils.countText(item.content); const id = Utils.generateId('bk'); const meta = { id, ts: preserveTimestamp && item.meta?.ts ? item.meta.ts : Date.now(), chars: stats.charsNoSpace, lines: stats.lines, editorKey: item.meta?.editorKey || null, mode: item.meta?.mode || null, hash, pinned: item.meta?.pinned || false, url: item.meta?.url || '', title: item.meta?.title || '', importedAt: Date.now() }; // 儲存 const contentOk = this.saveBackup(id, item.content); if (contentOk) { const index = this.getIndex(); index.push(meta); this.saveIndex(index.sort((a, b) => b.ts - a.ts)); existingHashes.add(hash); imported++; } else { skipped++; } } log('BackupManager: Import completed', { imported, skipped }); // 執行清理 setTimeout(() => this.cleanup(), 100); let message = `已匯入 ${imported} 筆備份`; if (skipped > 0) { message += `(跳過 ${skipped} 筆)`; } return { success: true, imported, skipped, message }; } catch (e) { logError('BackupManager: Import error:', e); return { success: false, imported: 0, skipped: 0, message: '無法解析備份檔案:' + e.message }; } }, /** * 下載所有備份為 JSON 檔案 * @returns {boolean} */ downloadAllBackups() { const json = this.exportAllBackupsAsJson(); const date = Utils.formatDate(); const filename = `mme_backups_${date}.json`; return Utils.downloadFile(json, filename, 'application/json;charset=utf-8'); }, /** * 從檔案匯入備份(觸發檔案選擇器) * @returns {Promise} 匯入結果 */ async importBackupsFromFile() { return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.style.display = 'none'; input.onchange = async (e) => { const file = e.target.files?.[0]; if (!file) { resolve({ success: false, message: '未選擇檔案' }); input.remove(); return; } try { const text = await Utils.readFile(file); const result = this.importBackupsFromJson(text); resolve(result); } catch (err) { resolve({ success: false, message: '檔案讀取失敗' }); } input.remove(); }; input.oncancel = () => { resolve({ success: false, message: '已取消' }); input.remove(); }; document.body.appendChild(input); input.click(); }); } }; // ======================================== // QuickSlots 快速插槽系統 // ======================================== /** * 快速存檔插槽管理器 * * 設計意圖: * - 提供 0-9 組可配置的快速存檔插槽 * - 資料儲存於硬碟(localStorage/GM storage),避免資料遺失 * - 使用者可在偏好設定中自訂啟用數量和顯示位置 * - 插槽是「使用者主動管理的文件」,與備份(系統自動保護)獨立 * * 儲存結構: * - mme_slot_: 第 n 個插槽的內容 * - mme_slot_meta_: 第 n 個插槽的 metadata(時間、字數、標籤等) * - mme_slot_settings: 插槽系統設定(啟用數量、顯示位置等) */ const QuickSlots = { /** @type {number} 最大插槽數量 */ MAX_SLOTS: 9, /** @type {number} 預設啟用插槽數量 */ DEFAULT_ENABLED_COUNT: 5, /** @type {number} 單一插槽最大容量(位元組),超過時警告 */ MAX_SLOT_SIZE: 2 * 1024 * 1024, // 2 MB /** * 取得插槽設定 * @returns {Object} 設定物件 */ getSettings() { const defaults = { enabledCount: this.DEFAULT_ENABLED_COUNT, // 啟用的插槽數量 (0-9) showInToolbar: true, // 是否在工具列顯示按鈕 confirmBeforeOverwrite: true, // 覆蓋前是否確認 confirmBeforeLoad: true, // 載入前是否確認(當前有內容時) autoBackupBeforeLoad: true, // 載入前是否自動備份當前內容 // 迷你插槽列(工具列快速按鈕) // 預設策略(建議): // - enabledCount <= 5:預設顯示(不太擁擠,符合「快速」) // - enabledCount > 5:預設不顯示(避免工具列太滿) showMiniBar: true, miniBarCount: 5, }; const saved = Utils.storage.get(CONFIG.storageKeys.slotSettings, null); if (!saved) { // 根據 enabledCount 做智慧預設 defaults.showMiniBar = defaults.enabledCount <= 5; defaults.miniBarCount = Math.min(defaults.enabledCount, 5); return defaults; } const merged = { ...defaults, ...saved }; // 向後相容:舊版沒有 showMiniBar/miniBarCount 時,給智慧預設 if (typeof merged.showMiniBar !== 'boolean') { merged.showMiniBar = (merged.enabledCount || 0) <= 5; } if (!Number.isFinite(merged.miniBarCount)) { merged.miniBarCount = Math.min(merged.enabledCount || 0, 5) || 5; } return merged; }, /** * 儲存插槽設定 * @param {Object} settings - 設定物件 */ saveSettings(settings) { const current = this.getSettings(); const merged = { ...current, ...settings }; Utils.storage.set(CONFIG.storageKeys.slotSettings, merged); log('QuickSlots: Settings saved', merged); }, /** * 驗證插槽編號 * @param {number} slot - 插槽編號 * @returns {boolean} 是否有效 */ _isValidSlot(slot) { return Number.isInteger(slot) && slot >= 1 && slot <= this.MAX_SLOTS; }, /** * 檢查插槽是否在啟用範圍內 * @param {number} slot - 插槽編號 * @returns {boolean} */ isSlotEnabled(slot) { if (!this._isValidSlot(slot)) return false; const settings = this.getSettings(); return slot <= settings.enabledCount; }, /** * 儲存內容到指定插槽 * @param {number} slot - 插槽編號 (1-9) * @param {string} content - 內容 * @param {Object} options - 選項 * @returns {Object} 結果 { success, message, meta } */ saveToSlot(slot, content, options = {}) { const { label = null, // 自訂標籤 skipSizeCheck = false, // 跳過大小檢查 editorKey = null // 編輯器鍵名(由呼叫者傳入,避免過早引用 EditorManager) } = options; // 驗證插槽編號 if (!this._isValidSlot(slot)) { logWarn('QuickSlots: Invalid slot number:', slot); return { success: false, message: '無效的插槽編號' }; } // 驗證內容 if (content === null || content === undefined) { return { success: false, message: '內容不可為空' }; } // 檢查大小 if (!skipSizeCheck) { try { const bytes = new Blob([content]).size; if (bytes > this.MAX_SLOT_SIZE) { const sizeMB = (bytes / 1024 / 1024).toFixed(2); return { success: false, message: `內容過大(${sizeMB} MB),超過單一插槽限制(2 MB)` }; } } catch (e) { // 無法計算大小,繼續儲存 } } const key = CONFIG.storageKeys.slotPrefix + slot; const metaKey = CONFIG.storageKeys.slotMetaPrefix + slot; // 建立 metadata const stats = Utils.countText(content); // 解耦:editorKey 僅使用呼叫者傳入值 // 設計理由:QuickSlots 不應依賴 EditorManager(避免循環依賴與初始化時序問題) const resolvedEditorKey = editorKey || null; const meta = { ts: Date.now(), chars: stats.charsNoSpace, lines: stats.lines, words: stats.words, hash: Utils.hash32(content), editorKey: resolvedEditorKey, label: label || this.getSlotLabel(slot) || null, size: content.length }; // 儲存內容和 metadata const contentOk = Utils.storage.set(key, content); const metaOk = Utils.storage.set(metaKey, meta); if (contentOk && metaOk) { log('QuickSlots: Saved to slot', slot, meta); return { success: true, message: '儲存成功', meta }; } logWarn('QuickSlots: Failed to save to slot', slot); return { success: false, message: '儲存失敗,可能是儲存空間不足' }; }, /** * 從指定插槽載入內容 * @param {number} slot - 插槽編號 (1-9) * @returns {Object} 結果 { success, content, meta, message } */ loadFromSlot(slot) { if (!this._isValidSlot(slot)) { return { success: false, content: null, meta: null, message: '無效的插槽編號' }; } const key = CONFIG.storageKeys.slotPrefix + slot; const content = Utils.storage.get(key, null); if (content === null) { return { success: false, content: null, meta: null, message: '此插槽為空' }; } const meta = this.getSlotMeta(slot); // 更新最後存取時間 if (meta) { meta.lastAccess = Date.now(); Utils.storage.set(CONFIG.storageKeys.slotMetaPrefix + slot, meta); } log('QuickSlots: Loaded from slot', slot); return { success: true, content, meta, message: '載入成功' }; }, /** * 取得指定插槽的 metadata * @param {number} slot - 插槽編號 (1-9) * @returns {Object|null} metadata */ getSlotMeta(slot) { if (!this._isValidSlot(slot)) return null; const metaKey = CONFIG.storageKeys.slotMetaPrefix + slot; return Utils.storage.get(metaKey, null); }, /** * 取得插槽標籤 * @param {number} slot - 插槽編號 * @returns {string|null} */ getSlotLabel(slot) { const meta = this.getSlotMeta(slot); return meta?.label || null; }, /** * 設定插槽標籤 * @param {number} slot - 插槽編號 * @param {string} label - 標籤(空字串表示清除) * @returns {boolean} 是否成功 */ setSlotLabel(slot, label) { if (!this._isValidSlot(slot)) return false; const meta = this.getSlotMeta(slot); if (!meta) return false; // 插槽為空,無法設定標籤 meta.label = label || null; Utils.storage.set(CONFIG.storageKeys.slotMetaPrefix + slot, meta); log('QuickSlots: Set label for slot', slot, label); return true; }, /** * 清空指定插槽 * @param {number} slot - 插槽編號 (1-9) * @returns {boolean} 是否成功 */ clearSlot(slot) { if (!this._isValidSlot(slot)) return false; Utils.storage.remove(CONFIG.storageKeys.slotPrefix + slot); Utils.storage.remove(CONFIG.storageKeys.slotMetaPrefix + slot); log('QuickSlots: Cleared slot', slot); return true; }, /** * 清空所有插槽 * @returns {number} 清空的插槽數量 */ clearAllSlots() { let count = 0; for (let i = 1; i <= this.MAX_SLOTS; i++) { if (this.getSlotMeta(i)) { this.clearSlot(i); count++; } } log('QuickSlots: Cleared all slots, count:', count); return count; }, /** * 取得所有插槽狀態 * @param {boolean} onlyEnabled - 是否只返回已啟用的插槽 * @returns {Array} 插槽狀態陣列 */ getAllSlotStatus(onlyEnabled = true) { const settings = this.getSettings(); const maxSlot = onlyEnabled ? settings.enabledCount : this.MAX_SLOTS; const slots = []; for (let i = 1; i <= maxSlot; i++) { const meta = this.getSlotMeta(i); slots.push({ slot: i, isEmpty: !meta, isEnabled: i <= settings.enabledCount, meta: meta }); } return slots; }, /** * 檢查插槽是否為空 * @param {number} slot - 插槽編號 * @returns {boolean} */ isSlotEmpty(slot) { return !this.getSlotMeta(slot); }, /** * 取得已使用的插槽數量 * @returns {number} */ getUsedCount() { let count = 0; const settings = this.getSettings(); for (let i = 1; i <= settings.enabledCount; i++) { if (this.getSlotMeta(i)) count++; } return count; }, /** * 取得下一個空插槽編號 * @returns {number|null} 空插槽編號,若無則返回 null */ getNextEmptySlot() { const settings = this.getSettings(); for (let i = 1; i <= settings.enabledCount; i++) { if (!this.getSlotMeta(i)) return i; } return null; }, /** * 取得最舊的插槽編號(用於自動覆蓋) * @returns {number|null} 最舊插槽編號,若全空則返回 null */ getOldestSlot() { const settings = this.getSettings(); let oldest = null; let oldestTs = Infinity; for (let i = 1; i <= settings.enabledCount; i++) { const meta = this.getSlotMeta(i); if (meta && meta.ts < oldestTs) { oldestTs = meta.ts; oldest = i; } } return oldest; }, /** * 取得最近使用的插槽編號 * @returns {number|null} */ getMostRecentSlot() { const settings = this.getSettings(); let recent = null; let recentTs = 0; for (let i = 1; i <= settings.enabledCount; i++) { const meta = this.getSlotMeta(i); if (meta) { const ts = meta.lastAccess || meta.ts; if (ts > recentTs) { recentTs = ts; recent = i; } } } return recent; }, /** * 匯出所有插槽為 JSON * @returns {Object} 匯出資料 */ exportAllSlots() { const settings = this.getSettings(); const slots = {}; for (let i = 1; i <= this.MAX_SLOTS; i++) { const content = Utils.storage.get(CONFIG.storageKeys.slotPrefix + i, null); const meta = this.getSlotMeta(i); if (content !== null) { slots[i] = { content, meta }; } } return { version: SCRIPT_VERSION, exportedAt: Date.now(), settings, slots }; }, /** * 從 JSON 匯入插槽 * @param {Object} data - 匯入資料 * @param {Object} options - 選項 * @returns {Object} 結果 { success, imported, skipped, message } */ importSlots(data, options = {}) { const { overwrite = false, // 是否覆蓋非空插槽 importSettings = false // 是否匯入設定 } = options; if (!data || typeof data !== 'object') { return { success: false, imported: 0, skipped: 0, message: '無效的匯入資料' }; } let imported = 0; let skipped = 0; // 匯入設定 if (importSettings && data.settings) { this.saveSettings(data.settings); } // 匯入插槽 if (data.slots && typeof data.slots === 'object') { for (const [slotStr, slotData] of Object.entries(data.slots)) { const slot = parseInt(slotStr); if (!this._isValidSlot(slot)) continue; const exists = !this.isSlotEmpty(slot); if (exists && !overwrite) { skipped++; continue; } if (slotData.content !== undefined) { const result = this.saveToSlot(slot, slotData.content, { label: slotData.meta?.label, skipSizeCheck: true, editorKey: slotData.meta?.editorKey || null }); if (result.success) { imported++; } else { skipped++; } } } } log('QuickSlots: Import completed', { imported, skipped }); return { success: true, imported, skipped, message: `已匯入 ${imported} 個插槽${skipped > 0 ? `,跳過 ${skipped} 個` : ''}` }; }, /** * 取得插槽系統統計資訊 * @returns {Object} */ getStats() { const settings = this.getSettings(); const allStatus = this.getAllSlotStatus(false); const used = allStatus.filter(s => !s.isEmpty && s.isEnabled).length; const total = settings.enabledCount; const totalChars = allStatus .filter(s => !s.isEmpty) .reduce((sum, s) => sum + (s.meta?.chars || 0), 0); return { used, total, available: total - used, totalChars, oldestSlot: this.getOldestSlot(), newestSlot: this.getMostRecentSlot() }; } }; // ======================================== // DragDropManager 拖曳導入管理器 // ======================================== /** * 拖曳導入管理器 * * 設計意圖: * - 支援將文字或檔案拖曳到 FAB 或 Modal 進行導入 * - 拖曳導入前自動備份當前內容(若有) * - 首次使用時顯示溫和的提示(10秒後消失,最多顯示2次) * - 提供清晰的視覺回饋(覆蓋層、高亮效果) * * 支援的檔案類型: * - .md, .txt, .markdown, .mdown, .mkd, .mkdn, .mdwn, .mdtxt, .mdtext, .text * * 事件處理策略: * - 使用 dragenter/dragleave 計數器處理巢狀元素問題 * - FAB 和 Modal 各自處理拖曳事件,共用核心邏輯 * - 全域監聽用於顯示/隱藏覆蓋層 */ const DragDropManager = { /** @type {boolean} 是否已初始化 */ _initialized: false, /** @type {HTMLElement|null} 拖曳提示覆蓋層 */ _dropOverlay: null, /** @type {boolean} 覆蓋層是否正在顯示 */ _overlayVisible: false, /** @type {number} dragenter 計數器(處理巢狀元素) */ _dragEnterCount: 0, /** @type {boolean} FAB 是否正在高亮 */ _fabHighlighted: false, /** @type {boolean} Modal 是否正在高亮 */ _modalHighlighted: false, /** @type {Array} 事件清理函數列表 */ _cleanupFns: [], /** @type {Array} 支援的檔案副檔名 */ SUPPORTED_EXTENSIONS: [ '.md', '.txt', '.markdown', '.mdown', '.mkd', '.mkdn', '.mdwn', '.mdtxt', '.mdtext', '.text' ], /** @type {number} 最大檔案大小(5MB) */ MAX_FILE_SIZE: 5 * 1024 * 1024, /** @type {number} 提示最大顯示次數 */ MAX_HINT_COUNT: 2, /** @type {number} 提示延遲顯示時間(毫秒) */ HINT_DELAY: 5000, /** @type {number} 提示顯示時長(毫秒) */ HINT_DURATION: 10000, // ======================================== // 初始化與清理 // ======================================== /** * 初始化拖曳導入功能 */ init() { if (this._initialized) return; this._initialized = true; log('DragDropManager: Initializing...'); // 建立覆蓋層 this._createDropOverlay(); // 綁定全域拖曳事件(用於顯示覆蓋層) this._bindGlobalDragEvents(); // 綁定 FAB 拖曳事件 this._bindFABDragEvents(); // 注意:Modal 拖曳事件在 Modal 初始化後綁定 // 這裡提供一個方法供 Modal 調用 log('DragDropManager: Initialized'); }, /** * 綁定 Modal 拖曳事件(由 Modal 在初始化後調用) * * 設計意圖: * - 為 Modal 視窗啟用拖曳導入功能 * - 需在 Modal DOM 創建後調用 * * @param {HTMLElement} modalElement - Modal 元素 */ bindModalDragEvents(modalElement) { if (!modalElement) { logWarn('DragDropManager.bindModalDragEvents: modalElement is null, binding skipped'); return; } log('DragDropManager: Binding Modal drag events'); const onDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); this._setModalHighlight(true); }; const onDragOver = (e) => { e.preventDefault(); e.stopPropagation(); // 設置拖曳效果 if (e.dataTransfer) { e.dataTransfer.dropEffect = 'copy'; } }; const onDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); // 檢查是否真的離開了 Modal const rect = modalElement.getBoundingClientRect(); const x = e.clientX; const y = e.clientY; if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { this._setModalHighlight(false); } }; const onDrop = (e) => { e.preventDefault(); e.stopPropagation(); this._setModalHighlight(false); this._hideDropOverlay(); this.handleDrop(e); }; modalElement.addEventListener('dragenter', onDragEnter); modalElement.addEventListener('dragover', onDragOver); modalElement.addEventListener('dragleave', onDragLeave); modalElement.addEventListener('drop', onDrop); // 儲存清理函數 this._cleanupFns.push(() => { modalElement.removeEventListener('dragenter', onDragEnter); modalElement.removeEventListener('dragover', onDragOver); modalElement.removeEventListener('dragleave', onDragLeave); modalElement.removeEventListener('drop', onDrop); }); }, /** * 銷毀拖曳導入功能 */ destroy() { log('DragDropManager: Destroying...'); // 執行所有清理函數 this._cleanupFns.forEach(fn => { try { fn(); } catch (e) { // 忽略清理錯誤 } }); this._cleanupFns = []; // 移除覆蓋層 if (this._dropOverlay && this._dropOverlay.parentNode) { this._dropOverlay.parentNode.removeChild(this._dropOverlay); } this._dropOverlay = null; this._initialized = false; this._dragEnterCount = 0; this._overlayVisible = false; this._fabHighlighted = false; this._modalHighlighted = false; log('DragDropManager: Destroyed'); }, // ======================================== // 覆蓋層管理 // ======================================== /** * 建立拖曳覆蓋層 */ _createDropOverlay() { if (this._dropOverlay) return; const p = CONFIG.prefix; this._dropOverlay = document.createElement('div'); this._dropOverlay.className = `${p}drop-overlay`; this._dropOverlay.innerHTML = `
${Icons.import}
拖曳到目標位置
請將檔案拖曳到 右下角按鈕編輯器視窗
支援 .md、.txt、.markdown 等格式
`; document.body.appendChild(this._dropOverlay); // 覆蓋層上的 dragover 事件 this._dropOverlay.addEventListener('dragover', (e) => { e.preventDefault(); if (e.dataTransfer) { // 顯示為「不可放置」,引導使用者拖到正確位置 e.dataTransfer.dropEffect = 'none'; } }); // 覆蓋層上的 drop 事件 - 只取消操作,不導入 this._dropOverlay.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); // 隱藏覆蓋層,但不執行導入 this._hideDropOverlay(); this._setFABHighlight(false); this._setModalHighlight(false); // 提示使用者正確的操作方式 Toast.info('請將檔案拖曳到右下角按鈕或編輯器視窗上'); }); // 點擊覆蓋層關閉 this._dropOverlay.addEventListener('click', () => { this._hideDropOverlay(); }); }, /** * 顯示拖曳覆蓋層 */ _showDropOverlay() { if (!this._dropOverlay || this._overlayVisible) return; const p = CONFIG.prefix; this._dropOverlay.classList.add(`${p}active`); this._overlayVisible = true; }, /** * 隱藏拖曳覆蓋層 */ _hideDropOverlay() { if (!this._dropOverlay || !this._overlayVisible) return; const p = CONFIG.prefix; this._dropOverlay.classList.remove(`${p}active`); this._overlayVisible = false; this._dragEnterCount = 0; }, // ======================================== // FAB 拖曳處理 // ======================================== /** * 綁定 FAB 拖曳事件 */ _bindFABDragEvents() { // FAB 可能尚未建立,延遲綁定(設定最大重試次數避免無限輪詢) let retryCount = 0; const maxRetries = 20; // 最多重試 20 次(約 10 秒) const bindWhenReady = () => { const fab = FAB?.el; if (!fab) { retryCount++; if (retryCount < maxRetries) { setTimeout(bindWhenReady, 500); } else { log('DragDropManager: FAB not found after', maxRetries, 'retries, giving up'); } return; } log('DragDropManager: Binding FAB drag events'); const onDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); this._setFABHighlight(true); }; const onDragOver = (e) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) { e.dataTransfer.dropEffect = 'copy'; } }; const onDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); this._setFABHighlight(false); }; const onDrop = (e) => { e.preventDefault(); e.stopPropagation(); this._setFABHighlight(false); this._hideDropOverlay(); this.handleDrop(e); }; fab.addEventListener('dragenter', onDragEnter); fab.addEventListener('dragover', onDragOver); fab.addEventListener('dragleave', onDragLeave); fab.addEventListener('drop', onDrop); // 儲存清理函數 this._cleanupFns.push(() => { fab.removeEventListener('dragenter', onDragEnter); fab.removeEventListener('dragover', onDragOver); fab.removeEventListener('dragleave', onDragLeave); fab.removeEventListener('drop', onDrop); }); }; bindWhenReady(); }, /** * 設置 FAB 高亮狀態 * @param {boolean} highlighted */ _setFABHighlight(highlighted) { const fab = FAB?.el; if (!fab) return; const p = CONFIG.prefix; if (highlighted && !this._fabHighlighted) { fab.classList.add(`${p}drag-over`); this._fabHighlighted = true; } else if (!highlighted && this._fabHighlighted) { fab.classList.remove(`${p}drag-over`); this._fabHighlighted = false; } }, /** * 設置 Modal 高亮狀態 * @param {boolean} highlighted */ _setModalHighlight(highlighted) { const modal = Modal?.modal; if (!modal) return; const p = CONFIG.prefix; if (highlighted && !this._modalHighlighted) { modal.classList.add(`${p}drag-over`); this._modalHighlighted = true; } else if (!highlighted && this._modalHighlighted) { modal.classList.remove(`${p}drag-over`); this._modalHighlighted = false; } }, // ======================================== // 全域拖曳監聽 // ======================================== /** * 綁定全域拖曳事件 * 用於在拖曳進入頁面時顯示覆蓋層 */ _bindGlobalDragEvents() { const onDragEnter = (e) => { // 只有當拖曳包含我們關心的資料類型時才顯示覆蓋層 if (!this._hasSupportedData(e)) return; this._dragEnterCount++; if (this._dragEnterCount === 1) { this._showDropOverlay(); } }; const onDragLeave = (e) => { this._dragEnterCount--; if (this._dragEnterCount <= 0) { this._dragEnterCount = 0; this._hideDropOverlay(); this._setFABHighlight(false); this._setModalHighlight(false); } }; const onDragOver = (e) => { // 必須 preventDefault 才能接收 drop if (this._hasSupportedData(e)) { e.preventDefault(); } }; const onDrop = (e) => { // 重置狀態 this._dragEnterCount = 0; this._hideDropOverlay(); this._setFABHighlight(false); this._setModalHighlight(false); // 注意:不在這裡處理 drop,讓它冒泡到具體的目標元素 }; const onDragEnd = () => { // 拖曳結束(例如按 Esc 取消) this._dragEnterCount = 0; this._hideDropOverlay(); this._setFABHighlight(false); this._setModalHighlight(false); }; document.addEventListener('dragenter', onDragEnter); document.addEventListener('dragleave', onDragLeave); document.addEventListener('dragover', onDragOver); document.addEventListener('drop', onDrop); document.addEventListener('dragend', onDragEnd); // 儲存清理函數 this._cleanupFns.push(() => { document.removeEventListener('dragenter', onDragEnter); document.removeEventListener('dragleave', onDragLeave); document.removeEventListener('dragover', onDragOver); document.removeEventListener('drop', onDrop); document.removeEventListener('dragend', onDragEnd); }); }, /** * 檢查拖曳事件是否包含我們支援的資料類型 * @param {DragEvent} e * @returns {boolean} */ _hasSupportedData(e) { const dt = e.dataTransfer; if (!dt) return false; // 檢查是否有檔案 if (dt.types.includes('Files')) { return true; } // 檢查是否有文字 if (dt.types.includes('text/plain') || dt.types.includes('text/html')) { return true; } return false; }, // ======================================== // 拖曳內容處理 // ======================================== /** * 處理拖曳放置事件 * @param {DragEvent} e */ async handleDrop(e) { log('DragDropManager: handleDrop called'); // 取得拖曳內容 const dropContent = this._getDropContent(e); if (!dropContent) { Toast.warning('無法識別拖曳的內容'); return; } // 備份當前內容 const backupSuccess = await this._backupCurrentContent(); if (backupSuccess === false) { // 備份失敗且有內容,詢問是否繼續 const hasContent = EditorManager.isReady() && EditorManager.getValue()?.trim(); if (hasContent) { if (!confirm('無法備份當前內容。是否仍要繼續導入?')) { return; } } } // 確保 Modal 開啟 if (!Modal.isOpen) { try { await Modal.open(); // 等待 Modal 和編輯器就緒 await new Promise(r => setTimeout(r, 500)); } catch (err) { Toast.error('無法開啟編輯器'); return; } } // 確保編輯器就緒 if (!EditorManager.isReady()) { await new Promise(r => setTimeout(r, 300)); if (!EditorManager.isReady()) { Toast.error('編輯器尚未就緒,請稍後重試'); return; } } // 根據內容類型處理 if (dropContent.type === 'file') { await this._handleFileDrop(dropContent.data); } else { await this._handleTextDrop(dropContent.data); } }, /** * 從拖曳事件中提取內容 * @param {DragEvent} e * @returns {Object|null} { type: 'file'|'text', data: File|string } */ _getDropContent(e) { const dt = e.dataTransfer; if (!dt) return null; // 優先處理檔案 if (dt.files && dt.files.length > 0) { const file = dt.files[0]; // 只處理第一個檔案 if (this.isFileSupported(file)) { return { type: 'file', data: file }; } else { Toast.warning(`不支援的檔案格式:${file.name}`); return null; } } // 處理純文字 const text = dt.getData('text/plain'); if (text && text.trim()) { return { type: 'text', data: text }; } // 處理 HTML(轉換為純文字) const html = dt.getData('text/html'); if (html) { const div = document.createElement('div'); div.innerHTML = html; const extractedText = div.textContent || div.innerText || ''; if (extractedText.trim()) { return { type: 'text', data: extractedText }; } } return null; }, /** * 處理檔案拖曳 * @param {File} file */ async _handleFileDrop(file) { log('DragDropManager: Handling file drop:', file.name); // 驗證檔案 const validation = this._validateFile(file); if (!validation.valid) { Toast.error(validation.reason); return; } try { // 讀取檔案內容 const content = await Utils.readFile(file); // 設定編輯器內容 EditorManager.setValue(content); Toast.success(`已導入檔案:${file.name}`); Modal.updateWordCount?.(); // 記錄到診斷 log('DragDropManager: File imported successfully', { name: file.name, size: file.size, contentLength: content.length }); } catch (err) { logError('DragDropManager: File read error:', err); Toast.error('檔案讀取失敗'); } }, /** * 處理文字拖曳 * @param {string} text */ async _handleTextDrop(text) { log('DragDropManager: Handling text drop, length:', text.length); if (!text || !text.trim()) { Toast.warning('拖曳的內容為空'); return; } // 插入文字到編輯器 EditorManager.insertValue(text); const charCount = text.length; Toast.success(`已導入文字(${charCount} 字元)`); Modal.updateWordCount?.(); }, /** * 備份當前編輯器內容 * @returns {Promise} true=成功, false=失敗, null=無需備份 */ async _backupCurrentContent() { if (!EditorManager.isReady()) { return null; // 編輯器未就緒,無需備份 } const content = EditorManager.getValue(); if (!content || !content.trim()) { return null; // 無內容,無需備份 } try { const info = EditorManager.getCurrentInfo(); const meta = BackupManager.create(content, { editorKey: info?.key, mode: info?.adapter?._detectModeFromDOM?.(), manual: false }); if (meta) { Toast.info('已自動備份當前內容', 2500); log('DragDropManager: Auto-backed up current content'); return true; } return false; } catch (err) { logError('DragDropManager: Backup failed:', err); return false; } }, // ======================================== // 檔案驗證 // ======================================== /** * 檢查檔案是否為支援的類型 * @param {File} file - 檔案物件 * @returns {boolean} */ isFileSupported(file) { if (!file?.name) return false; const name = file.name.toLowerCase(); const ext = '.' + name.split('.').pop(); return this.SUPPORTED_EXTENSIONS.includes(ext); }, /** * 驗證檔案 * @param {File} file * @returns {Object} { valid: boolean, reason?: string } */ _validateFile(file) { // 檢查副檔名 if (!this.isFileSupported(file)) { const ext = file.name.split('.').pop(); return { valid: false, reason: `不支援的檔案格式(.${ext})\n支援的格式:.md, .txt, .markdown 等` }; } // 檢查大小 if (file.size > this.MAX_FILE_SIZE) { const sizeMB = (file.size / 1024 / 1024).toFixed(2); return { valid: false, reason: `檔案過大(${sizeMB} MB)\n最大支援 5 MB` }; } // 檢查是否為空檔案 if (file.size === 0) { return { valid: false, reason: '檔案內容為空' }; } return { valid: true }; }, // ======================================== // 首次使用提示 // ======================================== /** * 顯示首次使用提示(若尚未達到最大次數) */ showHintIfNeeded() { const key = CONFIG.storageKeys.dragDropHintShown; const shownCount = Utils.storage.get(key, 0); // 檢查是否已達到最大顯示次數 if (shownCount >= this.MAX_HINT_COUNT) { return; } // 延遲顯示,避免干擾使用者的初始操作 setTimeout(() => { // 再次檢查(可能在延遲期間已經顯示過) const currentCount = Utils.storage.get(key, 0); if (currentCount >= this.MAX_HINT_COUNT) return; Toast.info( '💡 小技巧:您可以將文字或 Markdown 檔案拖曳到右下角按鈕上直接導入', this.HINT_DURATION ); // 更新計數 Utils.storage.set(key, currentCount + 1); log('DragDropManager: Hint shown, count:', currentCount + 1); }, this.HINT_DELAY); }, /** * 重置提示計數(供測試或設定使用) */ resetHintCount() { Utils.storage.set(CONFIG.storageKeys.dragDropHintShown, 0); log('DragDropManager: Hint count reset'); } }; // ======================================== // FileSystemManager 檔案系統管理器 // ======================================== /** * 檔案系統管理器 * * 設計意圖: * - 讓使用者選擇自訂的備份/暫存資料夾 * - 使用 File System Access API(Chrome/Edge 86+) * - 同時提供傳統的手動匯入/匯出作為 fallback(所有瀏覽器) * * 注意事項: * - File System Access API 僅在 Chrome/Edge 支援 * - 權限在頁面重新載入後失效,需重新授權 * - 目錄句柄無法持久化儲存,只能記住名稱供顯示 * * 儲存結構: * - mme_fs_enabled: 是否啟用檔案系統備份 * - mme_fs_auto_backup: 是否自動備份到資料夾 * - mme_fs_dir_name: 已選擇的資料夾名稱(僅供顯示) */ const FileSystemManager = { /** @type {FileSystemDirectoryHandle|null} 已選擇的目錄句柄 */ _directoryHandle: null, /** @type {boolean|null} API 支援狀態(快取) */ _supported: null, /** @type {boolean} 是否有有效權限 */ _hasPermission: false, /** @type {number} 最後一次操作時間 */ _lastOperationTime: 0, /** @type {number} 資料夾備份保留數量 */ MAX_FOLDER_BACKUPS: 30, /** @type {string} 備份檔名前綴 */ BACKUP_PREFIX: 'mme_backup_', // ======================================== // API 支援與設定 // ======================================== /** * 檢查 File System Access API 是否可用 * @returns {boolean} */ isSupported() { if (this._supported !== null) return this._supported; try { this._supported = ( typeof window.showDirectoryPicker === 'function' && typeof window.showOpenFilePicker === 'function' && typeof window.showSaveFilePicker === 'function' && typeof FileSystemDirectoryHandle !== 'undefined' && typeof FileSystemFileHandle !== 'undefined' ); } catch (e) { this._supported = false; } log('FileSystemManager: API supported:', this._supported); return this._supported; }, /** * 取得設定 * @returns {Object} */ getSettings() { return { enabled: Utils.storage.get(CONFIG.storageKeys.fsEnabled, false), autoBackup: Utils.storage.get(CONFIG.storageKeys.fsAutoBackup, false), directoryName: Utils.storage.get(CONFIG.storageKeys.fsDirectoryName, null) }; }, /** * 儲存設定 * @param {Object} settings */ saveSettings(settings) { if (settings.enabled !== undefined) { Utils.storage.set(CONFIG.storageKeys.fsEnabled, settings.enabled); } if (settings.autoBackup !== undefined) { Utils.storage.set(CONFIG.storageKeys.fsAutoBackup, settings.autoBackup); } if (settings.directoryName !== undefined) { Utils.storage.set(CONFIG.storageKeys.fsDirectoryName, settings.directoryName); } log('FileSystemManager: Settings saved', settings); }, /** * 取得當前狀態 * @returns {Object} */ getStatus() { const settings = this.getSettings(); return { supported: this.isSupported(), enabled: settings.enabled, autoBackup: settings.autoBackup, directoryName: settings.directoryName, hasHandle: !!this._directoryHandle, hasPermission: this._hasPermission }; }, // ======================================== // 資料夾選擇與權限 // ======================================== /** * 讓使用者選擇備份資料夾 * @returns {Promise} */ async selectDirectory() { if (!this.isSupported()) { Toast.warning( '您的瀏覽器不支援檔案系統存取 API\n' + '請使用 Chrome 或 Edge 86 以上版本,\n' + '或使用下方的「手動匯出/匯入」功能' ); return null; } try { // 開啟資料夾選擇器 const handle = await window.showDirectoryPicker({ mode: 'readwrite', startIn: 'documents' }); // 驗證並請求權限 let permission = await handle.queryPermission({ mode: 'readwrite' }); if (permission !== 'granted') { permission = await handle.requestPermission({ mode: 'readwrite' }); if (permission !== 'granted') { Toast.warning('未獲得資料夾寫入權限\n請在彈出視窗中點擊「允許」'); return null; } } // 儲存句柄和狀態 this._directoryHandle = handle; this._hasPermission = true; this.saveSettings({ directoryName: handle.name, enabled: true }); Toast.success(`已選擇備份資料夾:${handle.name}`); log('FileSystemManager: Directory selected:', handle.name); return handle; } catch (e) { if (e.name === 'AbortError') { // 使用者取消選擇,不顯示錯誤 log('FileSystemManager: User cancelled directory selection'); return null; } if (e.name === 'SecurityError') { Toast.warning('安全限制:無法存取檔案系統\n請確認網站有權限存取檔案'); return null; } logError('FileSystemManager: selectDirectory error:', e); Toast.error('無法選擇資料夾:' + (e.message || '未知錯誤')); return null; } }, /** * 檢查權限狀態 * @returns {Promise} 'granted' | 'denied' | 'prompt' | 'none' | 'error' */ async checkPermission() { if (!this._directoryHandle) { return 'none'; } try { const permission = await this._directoryHandle.queryPermission({ mode: 'readwrite' }); this._hasPermission = (permission === 'granted'); return permission; } catch (e) { logError('FileSystemManager: checkPermission error:', e); return 'error'; } }, /** * 確保有有效權限(必要時請求) * @returns {Promise} */ async ensurePermission() { const status = await this.checkPermission(); if (status === 'granted') { return true; } if (status === 'prompt' && this._directoryHandle) { try { const request = await this._directoryHandle.requestPermission({ mode: 'readwrite' }); this._hasPermission = (request === 'granted'); return this._hasPermission; } catch (e) { logError('FileSystemManager: ensurePermission error:', e); return false; } } return false; }, /** * 清除已選擇的資料夾 */ clearDirectory() { this._directoryHandle = null; this._hasPermission = false; this.saveSettings({ directoryName: null, enabled: false, autoBackup: false }); log('FileSystemManager: Directory cleared'); Toast.info('已清除備份資料夾設定'); }, // ======================================== // 檔案操作 // ======================================== /** * 儲存檔案到資料夾 * @param {string} filename - 檔案名稱 * @param {string} content - 檔案內容 * @param {Object} options - 選項 * @returns {Promise} */ async saveToDirectory(filename, content, options = {}) { const { showToast = true, showError = true } = options; if (!this._directoryHandle) { if (showError) Toast.warning('請先選擇備份資料夾'); return false; } try { // 確保有權限 const hasPermission = await this.ensurePermission(); if (!hasPermission) { if (showError) { Toast.warning('資料夾存取權限已過期\n請重新選擇資料夾'); } return false; } // 建立或覆蓋檔案 const fileHandle = await this._directoryHandle.getFileHandle(filename, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(content); await writable.close(); this._lastOperationTime = Date.now(); log('FileSystemManager: File saved:', filename); if (showToast) { Toast.success(`已儲存到資料夾:${filename}`); } return true; } catch (e) { logError('FileSystemManager: saveToDirectory error:', e); if (e.name === 'NotAllowedError' || e.name === 'SecurityError') { this._hasPermission = false; if (showError) { Toast.warning('資料夾存取被拒絕\n請重新選擇資料夾'); } } else if (e.name === 'QuotaExceededError') { if (showError) { Toast.error('磁碟空間不足'); } } else { if (showError) { Toast.error('儲存失敗:' + (e.message || '未知錯誤')); } } return false; } }, /** * 從資料夾讀取檔案 * @param {string} filename - 檔案名稱 * @returns {Promise} */ async readFromDirectory(filename) { if (!this._directoryHandle) { return null; } try { const hasPermission = await this.ensurePermission(); if (!hasPermission) { return null; } const fileHandle = await this._directoryHandle.getFileHandle(filename); const file = await fileHandle.getFile(); const content = await file.text(); log('FileSystemManager: File read:', filename); return content; } catch (e) { if (e.name === 'NotFoundError') { // 檔案不存在,正常情況 return null; } logError('FileSystemManager: readFromDirectory error:', e); return null; } }, /** * 列出資料夾中的備份檔案 * @returns {Promise} */ async listBackupFiles() { if (!this._directoryHandle) { return []; } try { const hasPermission = await this.ensurePermission(); if (!hasPermission) { return []; } const files = []; for await (const entry of this._directoryHandle.values()) { if (entry.kind === 'file' && entry.name.startsWith(this.BACKUP_PREFIX)) { try { const file = await entry.getFile(); files.push({ name: entry.name, size: file.size, lastModified: file.lastModified, handle: entry }); } catch (e) { // 單個檔案讀取失敗,跳過 } } } // 按時間排序(新的在前) files.sort((a, b) => b.lastModified - a.lastModified); log('FileSystemManager: Listed', files.length, 'backup files'); return files; } catch (e) { logError('FileSystemManager: listBackupFiles error:', e); return []; } }, /** * 刪除資料夾中的檔案 * @param {string} filename - 檔案名稱 * @returns {Promise} */ async deleteFromDirectory(filename) { if (!this._directoryHandle) { return false; } try { const hasPermission = await this.ensurePermission(); if (!hasPermission) { return false; } await this._directoryHandle.removeEntry(filename); log('FileSystemManager: File deleted:', filename); return true; } catch (e) { if (e.name === 'NotFoundError') { return true; // 檔案本來就不存在 } logError('FileSystemManager: deleteFromDirectory error:', e); return false; } }, // ======================================== // 自動備份 // ======================================== /** * 執行自動備份到資料夾 * @param {string} content - 備份內容 * @returns {Promise} */ async autoBackupToDirectory(content) { const settings = this.getSettings(); // 檢查是否啟用 if (!settings.enabled || !settings.autoBackup) { return false; } // 檢查是否有目錄句柄 if (!this._directoryHandle) { log('FileSystemManager: Auto backup skipped - no directory handle'); return false; } // 檢查內容 if (!content || !content.trim()) { return false; } // 生成檔名 const date = new Date().toISOString() .replace(/[:.]/g, '-') .slice(0, 19); const filename = `${this.BACKUP_PREFIX}${date}.md`; // 儲存檔案 const success = await this.saveToDirectory(filename, content, { showToast: false, showError: false }); if (success) { log('FileSystemManager: Auto backup saved:', filename); // 清理舊備份 await this._cleanupOldBackups(); } return success; }, /** * 清理舊備份(保留最近 N 個) */ async _cleanupOldBackups() { try { const files = await this.listBackupFiles(); if (files.length <= this.MAX_FOLDER_BACKUPS) { return; } // 刪除超出數量的舊檔案 const toDelete = files.slice(this.MAX_FOLDER_BACKUPS); for (const file of toDelete) { await this.deleteFromDirectory(file.name); } log('FileSystemManager: Cleaned up', toDelete.length, 'old backups'); } catch (e) { // 清理失敗不影響其他操作 logError('FileSystemManager: cleanup error:', e); } }, // ======================================== // 手動備份/還原(使用 File System API 但不依賴預選資料夾) // ======================================== /** * 手動儲存檔案(讓使用者選擇位置) * @param {string} content - 內容 * @param {string} suggestedName - 建議檔名 * @returns {Promise} */ async saveFileAs(content, suggestedName = 'document.md') { if (!this.isSupported()) { // Fallback:使用傳統下載 return Utils.downloadFile(content, suggestedName, 'text/markdown;charset=utf-8'); } try { const handle = await window.showSaveFilePicker({ suggestedName, types: [{ description: 'Markdown 文件', accept: { 'text/markdown': ['.md'] } }, { description: '文字文件', accept: { 'text/plain': ['.txt'] } }] }); const writable = await handle.createWritable(); await writable.write(content); await writable.close(); Toast.success(`已儲存:${handle.name}`); return true; } catch (e) { if (e.name === 'AbortError') { return false; } // Fallback:使用傳統下載 logError('FileSystemManager: saveFileAs error, falling back:', e); return Utils.downloadFile(content, suggestedName, 'text/markdown;charset=utf-8'); } }, /** * 手動開啟檔案(讓使用者選擇) * @returns {Promise<{content: string, name: string}|null>} */ async openFile() { if (!this.isSupported()) { // Fallback:使用傳統 file input return this._openFileTraditional(); } try { const [handle] = await window.showOpenFilePicker({ types: [{ description: 'Markdown 文件', accept: { 'text/markdown': ['.md', '.markdown', '.mdown', '.mkd'], 'text/plain': ['.txt', '.text'] } }], multiple: false }); const file = await handle.getFile(); const content = await file.text(); return { content, name: file.name }; } catch (e) { if (e.name === 'AbortError') { return null; } logError('FileSystemManager: openFile error:', e); return this._openFileTraditional(); } }, /** * 傳統方式開啟檔案 * @returns {Promise<{content: string, name: string}|null>} */ _openFileTraditional() { return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.md,.txt,.markdown,.mdown,.mkd,.mkdn,.mdwn,.mdtxt,.mdtext,.text'; input.style.display = 'none'; input.onchange = async (e) => { const file = e.target.files?.[0]; if (!file) { resolve(null); return; } try { const content = await Utils.readFile(file); resolve({ content, name: file.name }); } catch (err) { Toast.error('檔案讀取失敗'); resolve(null); } input.remove(); }; input.oncancel = () => { resolve(null); input.remove(); }; document.body.appendChild(input); input.click(); }); } }; // ======================================== // FindReplace 尋找與取代 // ======================================== /** * 尋找與取代管理器 * * 設計意圖: * - 提供基本的文字搜尋和取代功能 * - 支援大小寫敏感和正規表達式 * - 使用浮動式搜尋框,不干擾編輯 * * 限制: * - 由於不同編輯器 API 差異,高亮功能可能受限 * - 主要依賴編輯器的 getValue/setValue 操作 */ const FindReplace = { /** @type {boolean} 面板是否開啟 */ isOpen: false, /** @type {HTMLElement|null} 面板元素 */ panel: null, /** @type {string} 搜尋字串 */ query: '', /** @type {string} 取代字串 */ replacement: '', /** @type {Object} 搜尋選項 */ options: { caseSensitive: false, useRegex: false, wholeWord: false }, /** @type {Array} 匹配結果 */ matches: [], /** @type {number} 當前匹配索引 */ currentIndex: -1, /** @type {string} 上次搜尋的內容快照 */ _lastContent: '', /** * 顯示尋找面板 * @param {boolean} showReplace - 是否顯示取代欄位 */ show(showReplace = false) { if (!this.panel) { this._createPanel(); } this.isOpen = true; this.panel.style.display = 'flex'; const p = CONFIG.prefix; const replaceRow = this.panel.querySelector(`.${p}find-replace-row`); if (replaceRow) { replaceRow.style.display = showReplace ? 'flex' : 'none'; } // 定位在 Modal 右上角 if (Modal.modal) { const modalRect = Modal.modal.getBoundingClientRect(); this.panel.style.top = `${modalRect.top + 50}px`; this.panel.style.right = `${window.innerWidth - modalRect.right + 10}px`; this.panel.style.left = 'auto'; } // 聚焦搜尋框 const input = this.panel.querySelector(`#${p}find-input`); if (input) { input.focus(); input.select(); } // 如果有選取文字,使用它作為搜尋詞 const selectedText = this._getEditorSelection(); if (selectedText && selectedText.length < 100) { this.query = selectedText; if (input) input.value = selectedText; this._doFind(); } }, /** * 隱藏尋找面板 */ hide() { if (this.panel) { this.panel.style.display = 'none'; } this.isOpen = false; this.matches = []; this.currentIndex = -1; this._updateStatus(); }, /** * 切換面板顯示 * @param {boolean} showReplace */ toggle(showReplace = false) { if (this.isOpen) { this.hide(); } else { this.show(showReplace); } }, /** * 建立面板 DOM */ _createPanel() { const p = CONFIG.prefix; this.panel = document.createElement('div'); this.panel.className = `${p}find-panel`; this.panel.innerHTML = `
就緒
`; Portal.append(this.panel); this._bindEvents(); }, /** * 綁定事件 */ _bindEvents() { const p = CONFIG.prefix; // 搜尋框輸入 const findInput = this.panel.querySelector(`#${p}find-input`); findInput?.addEventListener('input', Utils.debounce(() => { this.query = findInput.value; this._doFind(); }, 200)); findInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) { this.findPrev(); } else { this.findNext(); } } else if (e.key === 'Escape') { this.hide(); } }); // 取代框 const replaceInput = this.panel.querySelector(`#${p}replace-input`); replaceInput?.addEventListener('input', () => { this.replacement = replaceInput.value; }); replaceInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.replaceOne(); } else if (e.key === 'Escape') { this.hide(); } }); // 選項 this.panel.querySelector(`#${p}find-case`)?.addEventListener('change', (e) => { this.options.caseSensitive = e.target.checked; this._doFind(); }); this.panel.querySelector(`#${p}find-regex`)?.addEventListener('change', (e) => { this.options.useRegex = e.target.checked; this._doFind(); }); this.panel.querySelector(`#${p}find-whole`)?.addEventListener('change', (e) => { this.options.wholeWord = e.target.checked; this._doFind(); }); // 按鈕 this.panel.querySelector('[data-action="find-prev"]')?.addEventListener('click', () => { this.findPrev(); }); this.panel.querySelector('[data-action="find-next"]')?.addEventListener('click', () => { this.findNext(); }); this.panel.querySelector('[data-action="find-close"]')?.addEventListener('click', () => { this.hide(); }); this.panel.querySelector('[data-action="replace-one"]')?.addEventListener('click', () => { this.replaceOne(); }); this.panel.querySelector('[data-action="replace-all"]')?.addEventListener('click', () => { this.replaceAll(); }); }, /** * 執行搜尋 */ _doFind() { if (!this.query) { this.matches = []; this.currentIndex = -1; this._updateStatus(); return; } const content = EditorManager.getValue() || ''; this._lastContent = content; try { let regex; let pattern = this.query; if (!this.options.useRegex) { // 跳脫特殊字元 pattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } if (this.options.wholeWord) { pattern = `\\b${pattern}\\b`; } const flags = this.options.caseSensitive ? 'g' : 'gi'; regex = new RegExp(pattern, flags); this.matches = []; let match; while ((match = regex.exec(content)) !== null) { this.matches.push({ index: match.index, length: match[0].length, text: match[0] }); // 防止無限迴圈 if (match.index === regex.lastIndex) { regex.lastIndex++; } } // 重置到第一個匹配 this.currentIndex = this.matches.length > 0 ? 0 : -1; } catch (e) { // 正規表達式錯誤 this.matches = []; this.currentIndex = -1; log('FindReplace: Regex error:', e.message); } this._updateStatus(); }, /** * 尋找下一個 */ findNext() { if (this.matches.length === 0) { this._doFind(); if (this.matches.length === 0) return; } this.currentIndex = (this.currentIndex + 1) % this.matches.length; this._goToMatch(); }, /** * 尋找上一個 */ findPrev() { if (this.matches.length === 0) { this._doFind(); if (this.matches.length === 0) return; } this.currentIndex = (this.currentIndex - 1 + this.matches.length) % this.matches.length; this._goToMatch(); }, /** * 跳轉到當前匹配並選取 * * 設計意圖: * - 使用統一的適配器介面 selectRange * - 若適配器未完整實現,至少聚焦編輯器 */ _goToMatch() { if (this.currentIndex < 0 || this.currentIndex >= this.matches.length) return; const match = this.matches[this.currentIndex]; const adapter = EditorManager.currentAdapter; if (!adapter) { log('FindReplace: No adapter available'); this._updateStatus(); return; } try { // 嘗試使用統一的 selectRange 介面 if (typeof adapter.selectRange === 'function') { const success = adapter.selectRange(match.index, match.index + match.length); if (success) { this._updateStatus(); return; } } // Fallback: 至少聚焦編輯器 if (typeof adapter.focus === 'function') { adapter.focus(); } } catch (e) { log('FindReplace: goToMatch error:', e.message); } this._updateStatus(); }, /** * 取代當前匹配 */ replaceOne() { if (this.currentIndex < 0 || this.currentIndex >= this.matches.length) return; const match = this.matches[this.currentIndex]; const content = EditorManager.getValue() || ''; const newContent = content.substring(0, match.index) + this.replacement + content.substring(match.index + match.length); EditorManager.setValue(newContent); // 重新搜尋 this._doFind(); Toast.success('已取代 1 處'); Modal.updateWordCount?.(); }, /** * 取代所有匹配 */ replaceAll() { if (this.matches.length === 0) { this._doFind(); if (this.matches.length === 0) { Toast.info('沒有找到匹配項'); return; } } const count = this.matches.length; let content = EditorManager.getValue() || ''; try { let pattern = this.query; if (!this.options.useRegex) { pattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } if (this.options.wholeWord) { pattern = `\\b${pattern}\\b`; } const flags = this.options.caseSensitive ? 'g' : 'gi'; const regex = new RegExp(pattern, flags); content = content.replace(regex, this.replacement); EditorManager.setValue(content); // 重新搜尋 this._doFind(); Toast.success(`已取代 ${count} 處`); Modal.updateWordCount?.(); } catch (e) { Toast.error('取代失敗:' + e.message); } }, /** * 更新狀態顯示 */ _updateStatus() { const p = CONFIG.prefix; const statusEl = this.panel?.querySelector(`#${p}find-status`); if (!statusEl) return; if (!this.query) { statusEl.textContent = '輸入搜尋詞'; statusEl.style.color = ''; } else if (this.matches.length === 0) { statusEl.textContent = '無匹配結果'; statusEl.style.color = '#dc3545'; } else { statusEl.textContent = `${this.currentIndex + 1} / ${this.matches.length} 個匹配`; statusEl.style.color = ''; } }, /** * 取得編輯器選取文字 */ _getEditorSelection() { try { const adapter = EditorManager.currentAdapter; if (adapter?.instance?.codemirror) { return adapter.instance.codemirror.getSelection(); } return ''; } catch (e) { return ''; } } }; // ======================================== // Loader 資源載入器 // ======================================== /** * 資源載入管理器 * * 設計意圖: * - 統一管理編輯器資源(JS/CSS)的載入 * - 多 CDN 容錯:自動測試並選擇可用的 CDN * - 防止重複載入:使用 loadingPromises 追蹤載入中的請求 * - 依賴處理:某些編輯器有額外依賴(如 KaTeX) * * 載入流程: * 1. 檢查是否已載入 * 2. 測試可用的 CDN * 3. 載入額外依賴 * 4. 載入編輯器 CSS * 5. 載入編輯器 JS * 6. 等待全局物件可用 */ const Loader = { /** @type {Object} 已載入的編輯器(key: editorKey, value: cdnBase) */ loaded: {}, /** @type {Object} 已驗證可用的 CDN(key: editorKey, value: cdnBase) */ cdnCache: {}, /** @type {Object} 載入中的 Promise(防止重複載入) */ loadingPromises: {}, /** @type {Object} 內聯樣式元素(用於 CSS inline 載入) */ styleElements: {}, /** @type {Set} 曾失敗的 CDN(避免重複嘗試) */ failedCdnOnce: new Set(), /** * 測試 CDN 可用性 * @param {string} url - 測試 URL * @param {number} timeout - 超時時間 * @returns {Promise} 是否可用 */ async testCdn(url, timeout = CONFIG.cdnTestTimeout) { // 嘗試 HEAD 請求(更輕量) const headOk = await new Promise((resolve) => { if (typeof GM_xmlhttpRequest !== 'function') { return resolve(null); // 不支援,跳過 } GM_xmlhttpRequest({ method: 'HEAD', url, timeout, anonymous: true, onload: (r) => resolve(r.status >= 200 && r.status < 400), onerror: () => resolve(null), ontimeout: () => resolve(null) }); }); if (headOk === true) return true; if (headOk === false) return false; // HEAD 不支援或失敗,嘗試 GET try { await Utils.gmFetch(url, timeout); return true; } catch (e) { return false; } }, /** * 取得可用的 CDN * @param {string} editorKey - 編輯器鍵名 * @param {Function} onProgress - 進度回調 * @returns {Promise} CDN 基礎路徑 */ async getAvailableCdn(editorKey, onProgress) { const cfg = CONFIG.editors[editorKey]; if (!cfg) throw new Error(`Unknown editor: ${editorKey}`); // 檢查快取 if (this.cdnCache[editorKey]) { return this.cdnCache[editorKey]; } // 第一輪:跳過曾失敗的 CDN for (const cdnBase of cfg.cdn) { if (this.failedCdnOnce.has(cdnBase)) continue; const testUrl = cdnBase + cfg.files.js; try { const host = new URL(cdnBase).hostname; onProgress?.(`測試 CDN: ${host}...`); } catch (e) { onProgress?.(`測試 CDN...`); } const ok = await this.testCdn(testUrl, CONFIG.cdnTestTimeout); if (ok) { this.cdnCache[editorKey] = cdnBase; log('CDN available:', cdnBase); return cdnBase; } else { this.failedCdnOnce.add(cdnBase); log('CDN failed:', cdnBase); } } // 第二輪:重新嘗試所有 CDN for (const cdnBase of cfg.cdn) { const testUrl = cdnBase + cfg.files.js; onProgress?.(`重新測試 CDN...`); const ok = await this.testCdn(testUrl, CONFIG.cdnTestTimeout); if (ok) { this.cdnCache[editorKey] = cdnBase; log('CDN available (retry):', cdnBase); return cdnBase; } } throw new Error(`無法連接到 ${cfg.name} 的任何 CDN`); }, /** * 通過 link 標籤載入 CSS * @param {string} url - CSS URL * @returns {Promise} 是否成功 */ async loadCSSByLink(url) { try { await Utils.loadStylesheet(url, 15000); return true; } catch (e) { log('CSS link load failed:', url, e.message); return false; } }, /** * 內聯載入 CSS(用於跨域情況) * @param {string} url - CSS URL * @param {string} styleId - style 元素 ID * @returns {Promise} 是否成功 */ async loadCSSInline(url, styleId) { try { const cssRaw = await Utils.gmFetch(url, 30000); const css = Utils.fixCssUrls(cssRaw, url); Utils.addStyle(css, styleId); return true; } catch (e) { log('CSS inline load failed:', url, e.message); return false; } }, /** * 載入編輯器 CSS * @param {string} editorKey - 編輯器鍵名 * @param {string} cdnBase - CDN 基礎路徑 * @param {Function} onProgress - 進度回調 */ async loadEditorCSS(editorKey, cdnBase, onProgress) { const cfg = CONFIG.editors[editorKey]; const cssUrl = cdnBase + cfg.files.css; const styleId = `${CONFIG.prefix}${editorKey}-css`; // 檢查是否已載入 if (document.getElementById(styleId) || document.querySelector(`link[href="${cssUrl}"]`)) { return; } onProgress?.(`載入 ${cfg.name} CSS...`); // 嘗試 link 載入 const ok = await this.loadCSSByLink(cssUrl); if (!ok) { // Fallback: 內聯載入 try { await this.loadCSSInline(cssUrl, styleId); } catch (e) { logWarn(`CSS load failed (${editorKey}):`, e); } } // 載入額外 CSS(如 Toast UI 的深色主題) if (cfg.extraCss && Array.isArray(cfg.extraCss)) { for (const extraPath of cfg.extraCss) { const extraUrl = cdnBase + extraPath; await this.loadCSSByLink(extraUrl).catch(() => { log('Extra CSS load failed:', extraPath); }); } } }, /** * 載入編輯器 JS * @param {string} editorKey - 編輯器鍵名 * @param {string} cdnBase - CDN 基礎路徑 * @param {Function} onProgress - 進度回調 */ async loadEditorJS(editorKey, cdnBase, onProgress) { const cfg = CONFIG.editors[editorKey]; const jsUrl = cdnBase + cfg.files.js; onProgress?.(`載入 ${cfg.name} JS...`); // 嘗試 script 標籤載入 try { await Utils.loadScript(jsUrl, CONFIG.loadTimeout); return; } catch (e) { log('Script tag load failed, trying GM fetch:', e.message); } // Fallback: 使用 GM_xmlhttpRequest 獲取並執行 const js = await Utils.gmFetch(jsUrl, CONFIG.loadTimeout); try { const fn = new Function(js); fn.call(PAGE_WIN); } catch (e) { throw new Error(`${cfg.name} 初始化失敗: ${e.message}`); } }, /** * 載入依賴 CSS * @param {Object} dep - 依賴配置 * @param {Function} onProgress - 進度回調 */ async loadDepCss(dep, onProgress) { if (!dep.css) return; const cssList = Array.isArray(dep.css) ? dep.css : [dep.css]; for (const url of cssList) { try { onProgress?.(`載入依賴樣式...`); await Utils.loadStylesheet(url, 15000); return; // 成功則返回 } catch (e) { log('Dep CSS load failed:', url); // 繼續嘗試下一個 } } }, /** * 載入依賴 JS * @param {Object} dep - 依賴配置 * @param {Function} onProgress - 進度回調 */ async loadDepJs(dep, onProgress) { if (!dep.js) return; const jsList = Array.isArray(dep.js) ? dep.js : [dep.js]; let lastErr = null; for (const url of jsList) { try { onProgress?.(`載入依賴腳本...`); await Utils.loadScript(url, 30000); return; // 成功則返回 } catch (e) { lastErr = e; log('Dep JS script load failed:', url); // Fallback: GM fetch try { const js = await Utils.gmFetch(url, 30000); const fn = new Function(js); fn.call(PAGE_WIN); return; // 成功則返回 } catch (e2) { lastErr = e2; log('Dep JS GM fetch failed:', url); } } } throw lastErr || new Error('Dependency JS load failed'); }, /** * 載入額外依賴 * @param {string} editorKey - 編輯器鍵名 * @param {Function} onProgress - 進度回調 */ async loadExtraDeps(editorKey, onProgress) { const cfg = CONFIG.editors[editorKey]; if (!cfg.extraDeps) return; for (const [name, dep] of Object.entries(cfg.extraDeps)) { // 檢查是否已載入 if (typeof dep.ready === 'function') { try { if (dep.ready()) { log('Dep already loaded:', name); continue; } } catch (e) { // 繼續載入 } } else if (dep.global && PAGE_WIN[dep.global]) { log('Dep already loaded:', name); continue; } try { onProgress?.(`載入依賴:${name}...`); // 載入 CSS await this.loadDepCss(dep, onProgress); // 載入 JS await this.loadDepJs(dep, onProgress); // 等待就緒 if (typeof dep.ready === 'function') { await Utils.waitFor(() => { try { return dep.ready(); } catch (e) { return false; } }, 10000, 100); } else if (dep.global) { await Utils.waitFor(() => !!PAGE_WIN[dep.global], 10000, 100); } log('Dep loaded successfully:', name); } catch (e) { if (dep.optional) { logWarn(`Optional dep "${name}" load failed:`, e.message); continue; } throw new Error(`依賴 ${name} 載入失敗:${e.message}`); } } }, /** * 載入編輯器 * @param {string} editorKey - 編輯器鍵名 * @param {Function} onProgress - 進度回調 * @returns {Promise} CDN 基礎路徑 */ async loadEditor(editorKey, onProgress) { const cfg = CONFIG.editors[editorKey]; if (!cfg) throw new Error(`Unknown editor: ${editorKey}`); // 檢查是否已載入 if (cfg.globalCheck && cfg.globalCheck()) { this.loaded[editorKey] = this.cdnCache[editorKey] || cfg.cdn[0]; log('Editor already loaded:', editorKey); return this.loaded[editorKey]; } // 檢查是否正在載入(防止重複載入) if (this.loadingPromises[editorKey]) { log('Editor loading in progress:', editorKey); return this.loadingPromises[editorKey]; } // 開始載入 this.loadingPromises[editorKey] = (async () => { try { // 1. 測試可用的 CDN const cdnBase = await this.getAvailableCdn(editorKey, onProgress); // 2. 載入額外依賴 await this.loadExtraDeps(editorKey, onProgress); // 3. 載入編輯器 CSS await this.loadEditorCSS(editorKey, cdnBase, onProgress); // 4. 載入編輯器 JS await this.loadEditorJS(editorKey, cdnBase, onProgress); // 5. 等待全局物件可用 onProgress?.(`初始化 ${cfg.name}...`); await Utils.waitFor(() => cfg.globalCheck(), 15000, 100); this.loaded[editorKey] = cdnBase; log('Editor loaded successfully:', editorKey); return cdnBase; } finally { // 無論成功失敗,都清除 loading 狀態 delete this.loadingPromises[editorKey]; } })(); return this.loadingPromises[editorKey]; }, /** * 檢查編輯器是否已載入 * @param {string} editorKey - 編輯器鍵名 * @returns {boolean} */ isLoaded(editorKey) { return !!this.loaded[editorKey]; }, /** * 取得編輯器的 CDN 基礎路徑 * @param {string} editorKey - 編輯器鍵名 * @returns {string|null} */ getCdnBase(editorKey) { return this.loaded[editorKey] || this.cdnCache[editorKey] || null; }, /** * 重置載入狀態 * @param {string} editorKey - 編輯器鍵名(可選,不傳則重置所有) */ reset(editorKey) { if (editorKey) { delete this.loaded[editorKey]; delete this.cdnCache[editorKey]; delete this.loadingPromises[editorKey]; log('Loader reset:', editorKey); } else { this.loaded = {}; this.cdnCache = {}; this.loadingPromises = {}; this.failedCdnOnce.clear(); log('Loader reset: all'); } } }; // ======================================== // 編輯器適配器容器 // ======================================== /** * 編輯器適配器集合 * * 設計意圖: * - 使用適配器模式統一不同編輯器的操作介面 * - 每個適配器必須實現標準介面:init, getValue, setValue, getHTML, * insertValue, focus, refresh, setTheme, destroy * - EditorManager 通過適配器與具體編輯器交互,無需關心實現細節 */ const EditorAdapters = {}; // ======================================== // EasyMDE 適配器 // ======================================== /** * EasyMDE 編輯器適配器 * * 設計意圖: * - 封裝 EasyMDE 的特殊行為,使其在 Modal 中正常工作 * - 實現「安全」的全螢幕和並排預覽模式(與 Modal 協調) * - 處理主題切換和樣式注入 * * 特殊處理: * - EasyMDE 原生的 fullscreen 和 side-by-side 會使用 position:fixed * 覆蓋整個頁面,這在 Modal 中會造成問題 * - 團隊實現了 _safeToggleFullScreen 和 _safeToggleSideBySide * 來替代原生行為,與 Modal 的佈局協調 */ EditorAdapters.easymde = { /** @type {Object|null} EasyMDE 實例 */ instance: null, /** @type {HTMLElement|null} 容器元素 */ container: null, /** @type {HTMLTextAreaElement|null} 底層 textarea */ textarea: null, /** @type {string|null} 當前主題 */ currentTheme: null, /** @type {boolean} 安全全螢幕模式狀態 */ _safeFullscreenActive: false, /** @type {boolean} 安全並排預覽模式狀態 */ _safeSideBySideActive: false, /** @type {HTMLElement|null} 並排預覽包裝器 */ _sbsWrap: null, /** @type {HTMLElement|null} 預覽側面板 */ _previewSide: null, /** @type {HTMLElement|null} CodeMirror 滾動容器 */ _cmScroller: null, /** @type {Function|null} CodeMirror 滾動事件處理器 */ _onCmScroll: null, /** @type {Function|null} 預覽面板滾動事件處理器 */ _onPreviewScroll: null, /** @type {Function|null} 內容變更更新預覽的處理器 */ _onChangeUpdatePreview: null, /** * 初始化 EasyMDE 編輯器 * @param {HTMLElement} container - 容器元素 * @param {string} content - 初始內容 * @param {string} theme - 主題 ('light' | 'dark') */ async init(container, content, theme) { const EasyMDEClass = PAGE_WIN.EasyMDE; if (!EasyMDEClass) { throw new Error('EasyMDE not loaded'); } this.container = container; this.currentTheme = theme; // 清空容器並創建 textarea container.innerHTML = ''; this.textarea = document.createElement('textarea'); this.textarea.id = Utils.generateId('easymde'); container.appendChild(this.textarea); // 創建 EasyMDE 實例 this.instance = new EasyMDEClass({ element: this.textarea, initialValue: content || '', autofocus: false, spellChecker: false, autosave: { enabled: false }, placeholder: '開始撰寫 Markdown...', status: ['lines', 'words', 'cursor'], toolbar: [ 'bold', 'italic', 'strikethrough', 'heading', '|', 'quote', 'unordered-list', 'ordered-list', '|', 'link', 'image', 'table', 'horizontal-rule', '|', 'code', 'clean-block', '|', 'preview', 'side-by-side', 'fullscreen', '|', 'undo', 'redo', '|', 'guide' ], renderingConfig: { singleLineBreaks: false, codeSyntaxHighlighting: true }, // 根據主題設定預覽區 class previewClass: (theme === 'dark') ? ['editor-preview', 'editor-preview-dark'] : ['editor-preview'] }); // 注入安全佈局樣式 this._injectSafeLayoutStyles(); // 修補原生行為 this._patchEasyMDEActions(); // 確保並排佈局結構存在 this._ensureSideBySideLayout(); // 應用主題 if (theme === 'dark') { this.applyDarkTheme(); } else { this.applyLightTheme(); } // 等待初始化完成 await new Promise(r => setTimeout(r, 50)); log('EasyMDE initialized'); }, /** * 修補 EasyMDE 的原生行為 * * 設計意圖: * - 替換原生的 toggleFullScreen 和 toggleSideBySide * - 使用我們的「安全」版本,與 Modal 佈局協調 */ _patchEasyMDEActions() { if (!this.instance) return; const inst = this.instance; const originalTogglePreview = inst.togglePreview?.bind(inst); const originalIsPreviewActive = inst.isPreviewActive?.bind(inst); // 替換全螢幕行為 inst.toggleFullScreen = () => { this._safeToggleFullScreen(); }; // 替換並排預覽行為 inst.toggleSideBySide = () => { this._safeToggleSideBySide(); }; // 修補普通預覽(需要先關閉並排預覽) inst.togglePreview = () => { if (this._safeSideBySideActive) { this._safeToggleSideBySide(false); } if (typeof originalTogglePreview === 'function') { originalTogglePreview(); } }; // 修補 isPreviewActive(考慮並排預覽狀態) if (typeof originalIsPreviewActive === 'function') { inst.isPreviewActive = () => { try { return this._safeSideBySideActive || originalIsPreviewActive(); } catch (e) { return this._safeSideBySideActive; } }; } }, /** * 安全的全螢幕切換 * * 設計意圖: * - 不使用 EasyMDE 原生的 position:fixed 全螢幕 * - 而是與 Modal 的全螢幕功能協調 * * @param {boolean} force - 強制設定狀態 */ _safeToggleFullScreen(force) { const next = (typeof force === 'boolean') ? force : !this._safeFullscreenActive; try { // 嘗試使用 Modal 的全螢幕功能 if (typeof Modal !== 'undefined' && Modal?.toggleFullscreen && Modal?.isOpen) { if (!!Modal.isFullscreen !== next) { Modal.toggleFullscreen(); } this._safeFullscreenActive = !!Modal.isFullscreen; } else { this._safeFullscreenActive = next; } } catch (e) { this._safeFullscreenActive = next; } // 更新工具列圖標狀態 this._setToolbarIconActive('fa-arrows-alt', this._safeFullscreenActive); this.refresh(true); }, /** * 安全的並排預覽切換 * * 設計意圖: * - 不使用 EasyMDE 原生的並排預覽(會有佈局問題) * - 使用自己實現的佈局,確保在 Modal 中正常工作 * * @param {boolean} force - 強制設定狀態 */ _safeToggleSideBySide(force) { const next = (typeof force === 'boolean') ? force : !this._safeSideBySideActive; // 如果普通預覽正在開啟,先關閉 try { if (this.instance?.isPreviewActive?.() && next) { this.instance.togglePreview?.(); } } catch (e) { // 忽略錯誤 } // 確保佈局結構存在 this._ensureSideBySideLayout(); this._safeSideBySideActive = next; // 更新 DOM 狀態 const cmEl = this._getCodeMirrorEl(); if (this._sbsWrap) { this._sbsWrap.classList.toggle('mme-easymde-sbs-active', next); } if (this._previewSide) { this._previewSide.style.display = next ? 'block' : 'none'; } if (cmEl) { cmEl.classList.toggle('mme-easymde-sbs-cm', next); } // 更新工具列圖標狀態 this._setToolbarIconActive('fa-columns', next); // 啟動或停止滾動同步 if (next) { this._updateSidePreviewNow(); this._attachSideBySideListeners(); } else { this._detachSideBySideListeners(); } this.refresh(true); }, /** * 取得 CodeMirror 元素 * @returns {HTMLElement|null} */ _getCodeMirrorEl() { try { return this.container?.querySelector('.CodeMirror') || null; } catch (e) { return null; } }, /** * 確保並排預覽的 DOM 結構存在 * * 設計意圖: * - 創建一個 flex 容器包裹 CodeMirror 和預覽面板 * - 這樣可以實現真正的並排佈局 */ _ensureSideBySideLayout() { if (!this.container) return; if (this._sbsWrap && this._previewSide) return; const mdeContainer = this.container.querySelector('.EasyMDEContainer'); const cmEl = this._getCodeMirrorEl(); const statusbar = this.container.querySelector('.editor-statusbar'); if (!mdeContainer || !cmEl) return; // 創建並排包裝器 const wrap = document.createElement('div'); wrap.className = 'mme-easymde-sbs-wrap'; wrap.style.cssText = 'flex:1;min-height:0;display:flex;gap:0;'; // 將包裝器插入到正確位置 const toolbar = this.container.querySelector('.editor-toolbar'); if (toolbar && statusbar) { mdeContainer.insertBefore(wrap, statusbar); } else if (toolbar) { mdeContainer.appendChild(wrap); } // 將 CodeMirror 移入包裝器 wrap.appendChild(cmEl); // 創建或獲取預覽面板 let previewSide = this.container.querySelector('.editor-preview-side'); if (!previewSide) { previewSide = document.createElement('div'); previewSide.className = 'editor-preview-side'; previewSide.style.display = 'none'; previewSide.setAttribute('data-mme-preview-side', '1'); } wrap.appendChild(previewSide); this._sbsWrap = wrap; this._previewSide = previewSide; // 獲取 CodeMirror 滾動容器 try { this._cmScroller = this.instance?.codemirror?.getScrollerElement?.() || null; } catch (e) { this._cmScroller = null; } }, /** * 附加並排預覽的事件監聽器 * * 設計意圖: * - 監聽編輯器內容變更,即時更新預覽 * - 實現雙向滾動同步 */ _attachSideBySideListeners() { // 先清理舊的監聽器 this._detachSideBySideListeners(); if (!this._previewSide || !this._cmScroller || !this.instance?.codemirror) return; // 內容變更時更新預覽(節流) this._onChangeUpdatePreview = Utils.throttle(() => { if (!this._safeSideBySideActive) return; this._updateSidePreviewNow(); }, 250); try { this.instance.codemirror.on('change', this._onChangeUpdatePreview); } catch (e) { log('EasyMDE change listener attach failed:', e.message); } // 滾動同步(使用 lock 防止循環觸發) let lock = false; this._onCmScroll = () => { if (!this._safeSideBySideActive || lock) return; const a = this._cmScroller; const b = this._previewSide; const aMax = a.scrollHeight - a.clientHeight; const bMax = b.scrollHeight - b.clientHeight; if (aMax <= 0 || bMax <= 0) return; lock = true; const ratio = a.scrollTop / aMax; b.scrollTop = ratio * bMax; setTimeout(() => { lock = false; }, 0); }; this._onPreviewScroll = () => { if (!this._safeSideBySideActive || lock) return; const a = this._previewSide; const b = this._cmScroller; const aMax = a.scrollHeight - a.clientHeight; const bMax = b.scrollHeight - b.clientHeight; if (aMax <= 0 || bMax <= 0) return; lock = true; const ratio = a.scrollTop / aMax; b.scrollTop = ratio * bMax; setTimeout(() => { lock = false; }, 0); }; this._cmScroller.addEventListener('scroll', this._onCmScroll, { passive: true }); this._previewSide.addEventListener('scroll', this._onPreviewScroll, { passive: true }); }, /** * 移除並排預覽的事件監聽器 */ _detachSideBySideListeners() { // 移除內容變更監聽 if (this.instance?.codemirror && this._onChangeUpdatePreview) { try { this.instance.codemirror.off('change', this._onChangeUpdatePreview); } catch (e) { // 忽略錯誤 } } this._onChangeUpdatePreview = null; // 移除滾動監聽 if (this._cmScroller && this._onCmScroll) { try { this._cmScroller.removeEventListener('scroll', this._onCmScroll); } catch (e) { // 忽略錯誤 } } if (this._previewSide && this._onPreviewScroll) { try { this._previewSide.removeEventListener('scroll', this._onPreviewScroll); } catch (e) { // 忽略錯誤 } } this._onCmScroll = null; this._onPreviewScroll = null; }, /** * 立即更新並排預覽的內容 */ _updateSidePreviewNow() { if (!this._previewSide || !this.instance) return; const md = this.getValue(); // 嘗試使用 EasyMDE 的預覽渲染器 try { const pr = this.instance.options?.previewRender; if (typeof pr === 'function') { const out = pr(md, this._previewSide); if (typeof out === 'string') { this._previewSide.innerHTML = out; return; } // 如果返回的不是字串,可能是異步渲染到了元素中 if (this._previewSide.innerHTML && this._previewSide.innerHTML.trim()) { return; } } } catch (e) { // 繼續嘗試其他方法 } // 嘗試使用 marked try { if (PAGE_WIN.marked?.parse) { this._previewSide.innerHTML = PAGE_WIN.marked.parse(md); return; } } catch (e) { // 繼續 fallback } // Fallback: 純文本顯示 this._previewSide.innerHTML = `
${Utils.escapeHtml(md)}
`; }, /** * 設定工具列圖標的 active 狀態 * @param {string} faClass - Font Awesome class 名稱 * @param {boolean} active - 是否啟用 */ _setToolbarIconActive(faClass, active) { try { const btn = this.container?.querySelector(`.editor-toolbar a.${faClass}`); if (btn) btn.classList.toggle('active', !!active); } catch (e) { // 忽略錯誤 } }, /** * 注入安全佈局樣式 * * 設計意圖: * - 覆蓋 EasyMDE 原生的 fullscreen 和 side-by-side 樣式 * - 使這些功能在 Modal 中正常工作(不使用 position:fixed) */ _injectSafeLayoutStyles() { const id = `${CONFIG.prefix}easymde-safe-layout`; if (document.getElementById(id)) return; Utils.addStyle(` /* 覆蓋原生的 fullscreen 和 side-by-side 樣式 */ .${CONFIG.prefix}editor .CodeMirror-fullscreen, .${CONFIG.prefix}editor .editor-toolbar.fullscreen, .${CONFIG.prefix}editor .editor-preview-side { position: relative !important; top: auto !important; left: auto !important; right: auto !important; bottom: auto !important; z-index: auto !important; } /* 並排預覽包裝器 */ .${CONFIG.prefix}editor .mme-easymde-sbs-wrap { flex: 1 !important; min-height: 0 !important; display: flex !important; flex-direction: row !important; align-items: stretch !important; overflow: hidden !important; } /* 並排模式下的 CodeMirror */ .${CONFIG.prefix}editor .mme-easymde-sbs-wrap .CodeMirror { flex: 1 1 50% !important; min-width: 0 !important; height: auto !important; } /* 並排預覽面板 */ .${CONFIG.prefix}editor .mme-easymde-sbs-wrap .editor-preview-side { flex: 1 1 50% !important; min-width: 0 !important; height: auto !important; overflow: auto !important; border-left: 1px solid rgba(127,127,127,0.25); padding: 14px 18px; } /* 並排模式啟用時顯示預覽 */ .${CONFIG.prefix}editor .mme-easymde-sbs-wrap.mme-easymde-sbs-active .editor-preview-side { display: block !important; } `, id); }, /** * 應用深色主題 */ applyDarkTheme() { const p = CONFIG.prefix; const styleId = `${p}easymde-dark`; if (document.getElementById(styleId)) return; Utils.addStyle(` .${p}editor .EasyMDEContainer { background: #1a1a2e; } .${p}editor .EasyMDEContainer .CodeMirror { background: #1a1a2e; color: #e8e8e8; border-color: #2d3748; } .${p}editor .EasyMDEContainer .editor-toolbar { background: #1e1e2e; border-color: #2d3748; } .${p}editor .EasyMDEContainer .editor-toolbar button { color: #e8e8e8 !important; } .${p}editor .EasyMDEContainer .editor-toolbar button:hover { background: #2d3748; } .${p}editor .EasyMDEContainer .editor-toolbar button.active { background: #4facfe; } .${p}editor .EasyMDEContainer .editor-statusbar { background: #1e1e2e; border-color: #2d3748; color: #a0a0a0; } .${p}editor .EasyMDEContainer .editor-preview { background: #1a1a2e; color: #e8e8e8; } .${p}editor .EasyMDEContainer .editor-preview-side { background: #16213e; color: #e8e8e8; border-color: #2d3748; } .${p}editor .EasyMDEContainer .CodeMirror-cursor { border-left-color: #e8e8e8; } /* CodeMirror 語法高亮 - 深色 */ .${p}editor .EasyMDEContainer .cm-header { color: #61afef !important; } .${p}editor .EasyMDEContainer .cm-strong { color: #e5c07b !important; } .${p}editor .EasyMDEContainer .cm-em { color: #c678dd !important; } .${p}editor .EasyMDEContainer .cm-link { color: #61afef !important; } .${p}editor .EasyMDEContainer .cm-url { color: #98c379 !important; } .${p}editor .EasyMDEContainer .cm-comment { color: #5c6370 !important; } .${p}editor .EasyMDEContainer .cm-quote { color: #5c6370 !important; font-style: italic; } `, styleId); }, /** * 應用淺色主題 */ applyLightTheme() { const p = CONFIG.prefix; const styleId = `${p}easymde-light`; if (document.getElementById(styleId)) return; Utils.addStyle(` .${p}editor .EasyMDEContainer .editor-toolbar button { color: #333 !important; } .${p}editor .EasyMDEContainer .editor-toolbar button:hover { background: #e0e0e0; } .${p}editor .EasyMDEContainer .editor-toolbar button.active { background: #007bff; color: #fff !important; } .${p}editor .EasyMDEContainer .editor-toolbar i.separator { border-left-color: #ccc; border-right-color: #ccc; } .${p}editor .EasyMDEContainer .CodeMirror { border-color: #ccc; } /* CodeMirror 語法高亮 - 淺色 */ .${p}editor .EasyMDEContainer .cm-header { color: #0550ae !important; } .${p}editor .EasyMDEContainer .cm-strong { color: #24292e !important; } .${p}editor .EasyMDEContainer .cm-em { color: #6f42c1 !important; } .${p}editor .EasyMDEContainer .cm-link { color: #0366d6 !important; } .${p}editor .EasyMDEContainer .cm-url { color: #22863a !important; } .${p}editor .EasyMDEContainer .cm-comment { color: #6a737d !important; } .${p}editor .EasyMDEContainer .cm-quote { color: #6a737d !important; font-style: italic; } `, styleId); }, /** * 移除淺色主題樣式 */ removeLightTheme() { document.getElementById(`${CONFIG.prefix}easymde-light`)?.remove(); }, /** * 移除深色主題樣式 */ removeDarkTheme() { document.getElementById(`${CONFIG.prefix}easymde-dark`)?.remove(); }, /** * 取得編輯器內容 * @returns {string} Markdown 內容 */ getValue() { try { return this.instance?.value() || ''; } catch (e) { log('EasyMDE getValue error:', e.message); return ''; } }, /** * 設定編輯器內容 * @param {string} value - Markdown 內容 */ setValue(value) { try { this.instance?.value(value ?? ''); } catch (e) { log('EasyMDE setValue error:', e.message); } }, /** * 取得 HTML 輸出 * @returns {string} HTML 內容 */ getHTML() { try { const md = this.getValue(); if (PAGE_WIN.marked?.parse) { return PAGE_WIN.marked.parse(md); } return `
${Utils.escapeHtml(md)}
`; } catch (e) { log('EasyMDE getHTML error:', e.message); return ''; } }, /** * 在游標位置插入內容 * @param {string} value - 要插入的內容 */ insertValue(value) { try { const cm = this.instance?.codemirror; if (cm) { const doc = cm.getDoc(); doc.replaceRange(value, doc.getCursor()); } } catch (e) { log('EasyMDE insertValue error:', e.message); } }, /** * 選取指定範圍的文字 * * 設計意圖: * - 為 FindReplace 提供跨編輯器的選取支援 * - 使用字元索引而非行列座標,便於統一處理 * * @param {number} start - 起始字元索引 * @param {number} end - 結束字元索引 * @returns {boolean} 是否成功 */ selectRange(start, end) { try { const cm = this.instance?.codemirror; if (!cm) return false; const doc = cm.getDoc(); const startPos = doc.posFromIndex(start); const endPos = doc.posFromIndex(end); doc.setSelection(startPos, endPos); cm.scrollIntoView({ from: startPos, to: endPos }, 100); cm.focus(); return true; } catch (e) { log('EasyMDE selectRange error:', e.message); return false; } }, /** * 聚焦編輯器 */ focus() { try { this.instance?.codemirror?.focus(); } catch (e) { log('EasyMDE focus error:', e.message); } }, /** * 刷新編輯器佈局 * @param {boolean} force - 是否強制延遲刷新 */ refresh(force = false) { try { const cm = this.instance?.codemirror; if (!cm) return; if (force) { setTimeout(() => { try { cm.refresh(); } catch (e) { // 忽略刷新錯誤 } }, 60); } else { cm.refresh(); } } catch (e) { log('EasyMDE refresh error:', e.message); } }, /** * 設定主題 * @param {string} theme - 'light' 或 'dark' */ setTheme(theme) { if (theme === 'dark') { this.removeLightTheme(); this.applyDarkTheme(); } else { this.removeDarkTheme(); this.applyLightTheme(); } this.currentTheme = theme; }, /** * 銷毀編輯器 */ destroy() { log('EasyMDE destroying...'); // 移除並排預覽監聽器 try { this._detachSideBySideListeners(); } catch (e) { log('EasyMDE detach listeners error:', e.message); } // 銷毀 EasyMDE 實例 try { this.instance?.toTextArea(); } catch (e) { log('EasyMDE toTextArea error:', e.message); } // 清理主題樣式 this.removeDarkTheme(); this.removeLightTheme(); // 重置狀態 this.instance = null; this.textarea?.remove(); this.textarea = null; this.container = null; this._sbsWrap = null; this._previewSide = null; this._cmScroller = null; this._safeFullscreenActive = false; this._safeSideBySideActive = false; log('EasyMDE destroyed'); } }; // ======================================== // Toast UI Editor 適配器 // ======================================== /** * Toast UI Editor 適配器 * * 設計意圖: * - 封裝 Toast UI Editor 的操作 * - 處理主題切換 * * 注意:Toast UI Editor 已停止維護,但仍需支持現有用戶 */ EditorAdapters.toastui = { /** @type {Object|null} Toast UI Editor 實例 */ instance: null, /** @type {HTMLElement|null} 容器元素 */ container: null, /** @type {HTMLElement|null} 編輯器 div */ editorDiv: null, /** @type {string|null} 當前主題 */ currentTheme: null, /** * 初始化 Toast UI Editor * @param {HTMLElement} container - 容器元素 * @param {string} content - 初始內容 * @param {string} theme - 主題 ('light' | 'dark') */ async init(container, content, theme) { const ToastEditor = PAGE_WIN.toastui?.Editor; if (!ToastEditor) { throw new Error('Toast UI Editor not loaded'); } this.container = container; this.currentTheme = theme; const isDark = theme === 'dark'; // 清空容器並創建編輯器 div container.innerHTML = ''; this.editorDiv = document.createElement('div'); this.editorDiv.id = Utils.generateId('toastui'); this.editorDiv.style.cssText = 'width:100%;height:100%;'; container.appendChild(this.editorDiv); // 創建編輯器實例 this.instance = new ToastEditor({ el: this.editorDiv, initialValue: content || '', initialEditType: 'markdown', previewStyle: 'vertical', height: '100%', theme: isDark ? 'dark' : 'light', usageStatistics: false, hideModeSwitch: false, toolbarItems: [ ['heading', 'bold', 'italic', 'strike'], ['hr', 'quote'], ['ul', 'ol', 'task', 'indent', 'outdent'], ['table', 'image', 'link'], ['code', 'codeblock'], ['scrollSync'] ], placeholder: '開始撰寫 Markdown...' }); // 應用深色主題樣式 if (isDark) { this.applyDarkTheme(); } // 等待初始化完成 await new Promise(r => setTimeout(r, 80)); log('Toast UI Editor initialized'); }, /** * 應用深色主題 */ applyDarkTheme() { const p = CONFIG.prefix; const styleId = `${p}toastui-dark`; if (!document.getElementById(styleId)) { Utils.addStyle(` .${p}editor .toastui-editor-defaultUI { border-color: #2d3748 !important; } .${p}editor .toastui-editor-dark { background: #1a1a2e; } .${p}editor .toastui-editor-dark .toastui-editor-toolbar { background: #1e1e2e; border-color: #2d3748; } .${p}editor .toastui-editor-dark .toastui-editor-md-container { background: #1a1a2e; } .${p}editor .toastui-editor-dark .toastui-editor-md-preview { background: #16213e; } .${p}editor .toastui-editor-dark .ProseMirror { color: #e8e8e8; } .${p}editor .toastui-editor-dark .toastui-editor-toolbar button { color: #e8e8e8; } .${p}editor .toastui-editor-dark .toastui-editor-toolbar button:hover { background: #2d3748; } .${p}editor .toastui-editor-dark .toastui-editor-mode-switch { background: #1e1e2e; border-color: #2d3748; } .${p}editor .toastui-editor-dark .toastui-editor-mode-switch .tab-item { color: #a0a0a0; } .${p}editor .toastui-editor-dark .toastui-editor-mode-switch .tab-item.active { color: #e8e8e8; } /* 深色模式下的語法高亮 */ .${p}editor .toastui-editor-dark .toastui-editor-md-heading { color: #61afef !important; } .${p}editor .toastui-editor-dark .toastui-editor-md-strong { color: #e5c07b !important; } .${p}editor .toastui-editor-dark .toastui-editor-md-emph { color: #c678dd !important; } .${p}editor .toastui-editor-dark .toastui-editor-md-link, .${p}editor .toastui-editor-dark .toastui-editor-md-link-url { color: #61afef !important; } .${p}editor .toastui-editor-dark .toastui-editor-md-code { color: #98c379 !important; background: rgba(255,255,255,0.1); } .${p}editor .toastui-editor-dark .toastui-editor-md-block-quote { color: #5c6370 !important; } `, styleId); } // 添加深色主題 class try { const ui = this.editorDiv?.querySelector('.toastui-editor-defaultUI'); if (ui) ui.classList.add('toastui-editor-dark'); } catch (e) { log('Toast UI add dark class error:', e.message); } }, /** * 移除深色主題 */ removeDarkTheme() { document.getElementById(`${CONFIG.prefix}toastui-dark`)?.remove(); try { const ui = this.editorDiv?.querySelector('.toastui-editor-defaultUI'); if (ui) ui.classList.remove('toastui-editor-dark'); } catch (e) { log('Toast UI remove dark class error:', e.message); } }, /** * 取得編輯器內容 * @returns {string} Markdown 內容 */ getValue() { try { return this.instance?.getMarkdown() || ''; } catch (e) { log('Toast UI getValue error:', e.message); return ''; } }, /** * 設定編輯器內容 * @param {string} value - Markdown 內容 */ setValue(value) { try { this.instance?.setMarkdown(value ?? ''); } catch (e) { log('Toast UI setValue error:', e.message); } }, /** * 取得 HTML 輸出 * @returns {string} HTML 內容 */ getHTML() { try { return this.instance?.getHTML() || ''; } catch (e) { log('Toast UI getHTML error:', e.message); return ''; } }, /** * 在游標位置插入內容 * @param {string} value - 要插入的內容 */ insertValue(value) { try { this.instance?.insertText(value); } catch (e) { log('Toast UI insertValue error:', e.message); } }, /** * 選取指定範圍的文字(字元索引) * * 安全策略: * - 優先使用 Toast UI 可能提供的 getCodeMirror() * - 若無法取得 CodeMirror,則 fallback focus(避免依賴內部私有結構) */ selectRange(start, end) { try { // 1) 官方/半官方可能存在的 API const cm = this.instance?.getCodeMirror?.() || this.instance?.mdEditor?.cm || this.instance?.mdEditor?.editor?.cm || this.instance?.mdEditor?.editor?.getCodeMirror?.(); if (!cm) { this.focus(); return false; } const doc = cm.getDoc?.() || cm.doc; if (!doc?.posFromIndex || !doc?.setSelection) { this.focus(); return false; } const from = doc.posFromIndex(start); const to = doc.posFromIndex(end); doc.setSelection(from, to); cm.scrollIntoView?.({ from, to }, 100); cm.focus?.(); return true; } catch (e) { log('Toast UI selectRange error:', e?.message || e); try { this.focus(); } catch (_) {} return false; } }, /** * 聚焦編輯器 */ focus() { try { this.instance?.focus(); } catch (e) { log('Toast UI focus error:', e.message); } }, /** * 刷新編輯器佈局 * @param {boolean} force - 是否強制延遲刷新 */ refresh(force = false) { try { if (!this.editorDiv) return; if (force) { setTimeout(() => window.dispatchEvent(new Event('resize')), 60); } else { window.dispatchEvent(new Event('resize')); } } catch (e) { log('Toast UI refresh error:', e.message); } }, /** * 設定主題 * @param {string} theme - 'light' 或 'dark' */ setTheme(theme) { if (theme === 'dark') { this.applyDarkTheme(); } else { this.removeDarkTheme(); } this.currentTheme = theme; }, /** * 銷毀編輯器 */ destroy() { log('Toast UI Editor destroying...'); try { this.instance?.destroy(); } catch (e) { log('Toast UI destroy error:', e.message); } this.removeDarkTheme(); this.instance = null; this.editorDiv?.remove(); this.editorDiv = null; this.container = null; log('Toast UI Editor destroyed'); } }; // ======================================== // Cherry Markdown 適配器 // ======================================== /** * Cherry Markdown 編輯器適配器 * * 設計意圖: * - 封裝騰訊開源的 Cherry Markdown 編輯器 * - 處理 KaTeX 依賴的載入和等待 * - 實現完整的深色主題支持 * * 特殊處理: * - Cherry 需要 KaTeX 來渲染數學公式,初始化時必須等待 KaTeX 就緒 * - Cherry 原生的深色主題支持不完善,需要大量 CSS 覆蓋 */ EditorAdapters.cherry = { /** @type {Object|null} Cherry 實例 */ instance: null, /** @type {HTMLElement|null} 容器元素 */ container: null, /** @type {HTMLElement|null} 編輯器 div */ editorDiv: null, /** @type {string|null} 當前主題 */ currentTheme: null, /** @type {string} 深色主題樣式 ID */ _darkStyleId: `${CONFIG.prefix}cherry-dark`, /** * 初始化 Cherry Markdown 編輯器 * @param {HTMLElement} container - 容器元素 * @param {string} content - 初始內容 * @param {string} theme - 主題 ('light' | 'dark') */ async init(container, content, theme) { const CherryClass = PAGE_WIN.Cherry; if (!CherryClass) { throw new Error('Cherry Markdown not loaded'); } // 等待 KaTeX 就緒(Cherry 依賴 KaTeX 渲染數學公式) const katexReady = () => !!( PAGE_WIN.katex && typeof PAGE_WIN.katex.renderToString === 'function' ); try { await Utils.waitFor(katexReady, 10000, 100); } catch (e) { throw new Error('KaTeX 未就緒,Cherry 無法初始化'); } this.container = container; this.currentTheme = theme; const isDark = theme === 'dark'; // 清空容器並創建編輯器 div container.innerHTML = ''; this.editorDiv = document.createElement('div'); this.editorDiv.id = Utils.generateId('cherry'); this.editorDiv.style.cssText = 'width:100%;height:100%;'; container.appendChild(this.editorDiv); // 工具列配置 const toolbarConfig = { toolbar: [ 'bold', 'italic', 'strikethrough', '|', 'color', 'header', '|', 'list', { insert: [ 'image', 'audio', 'video', 'link', 'hr', 'br', 'code', 'formula', 'toc', 'table', 'pdf', 'word' ] }, 'graph', 'togglePreview', 'settings' ], bubble: [ 'bold', 'italic', 'underline', 'strikethrough', 'sub', 'sup', 'quote', '|', 'size', 'color' ], float: [ 'h1', 'h2', 'h3', '|', 'checklist', 'quote', 'quickTable', 'code' ] }; // 創建 Cherry 實例 try { this.instance = new CherryClass({ id: this.editorDiv.id, value: content || '', editor: { theme: isDark ? 'dark' : 'default', height: '100%', defaultModel: 'edit&preview' }, toolbars: { theme: isDark ? 'dark' : 'light', toolbar: toolbarConfig.toolbar, bubble: toolbarConfig.bubble, float: toolbarConfig.float }, previewer: { theme: isDark ? 'dark' : 'default' }, engine: { global: { urlProcessor: (url) => url }, syntax: { table: { enableChart: false }, fontEmphasis: { allowWhitespace: true }, mathBlock: { engine: 'katex' }, inlineMath: { engine: 'katex' } } }, callback: { afterInit: () => { log('Cherry afterInit callback'); }, afterChange: () => { // 內容變更回調(可用於自動保存等) } } }); } catch (e) { logError('Cherry init error:', e); throw new Error(`Cherry 初始化失敗:${e.message}`); } // 應用深色主題樣式 if (isDark) { this.applyDarkTheme(); } // 等待初始化完成 await new Promise(r => setTimeout(r, 120)); log('Cherry Markdown initialized'); }, /** * 套用深色主題 * * 設計意圖: * - Cherry 原生的深色主題支持不完善 * - 需要大量 CSS 來覆蓋各種元素(工具列、下拉選單、編輯器、預覽區等) * - 確保在深色模式下有良好的可讀性 */ applyDarkTheme() { const p = CONFIG.prefix; const styleId = this._darkStyleId; if (document.getElementById(styleId)) return; Utils.addStyle(` /* ===== Cherry Markdown 深色主題 ===== */ /* 主容器 */ .${p}editor .cherry, .cherry { background: #1a1a2e !important; border: none !important; color: #e0e0e0 !important; } /* ===== 工具列 ===== */ .${p}editor .cherry .cherry-toolbar, .cherry .cherry-toolbar { background: #1e1e2e !important; border-color: #2d3748 !important; } .${p}editor .cherry .cherry-toolbar .cherry-toolbar-button, .cherry .cherry-toolbar .cherry-toolbar-button { color: #e0e0e0 !important; } .${p}editor .cherry .cherry-toolbar .cherry-toolbar-button:hover, .cherry .cherry-toolbar .cherry-toolbar-button:hover { background: #2d3748 !important; } .${p}editor .cherry .cherry-toolbar svg, .cherry .cherry-toolbar svg { fill: #e0e0e0 !important; stroke: #e0e0e0 !important; } /* ===== 下拉選單 ===== */ .cherry-dropdown, .cherry-dropdown-menu, .cherry-insert-table-menu, .cherry-previewer-table-content-handler__input, .cherry-color-wrap, .cherry-dropdown-item, .cherry-bubble, .cherry-floatmenu, .cherry .cherry-dropdown, .cherry .cherry-dropdown-menu { background: #1e1e2e !important; background-color: #1e1e2e !important; border: 1px solid #2d3748 !important; color: #e0e0e0 !important; box-shadow: 0 4px 16px rgba(0,0,0,0.4) !important; } /* 下拉選單項目 */ .cherry-dropdown-item, .cherry-dropdown .cherry-dropdown-item, .cherry-dropdown-menu .cherry-dropdown-item, .cherry-dropdown-menu > *, .cherry-dropdown > *, .cherry-insert-table-menu td, .cherry-color-wrap span, .cherry-bubble button, .cherry-floatmenu button { background: transparent !important; background-color: transparent !important; color: #e0e0e0 !important; border-color: #2d3748 !important; } .cherry-dropdown-item:hover, .cherry-dropdown .cherry-dropdown-item:hover, .cherry-dropdown-menu .cherry-dropdown-item:hover, .cherry-bubble button:hover, .cherry-floatmenu button:hover { background: #2d3748 !important; background-color: #2d3748 !important; color: #fff !important; } /* 表格插入選單 */ .cherry-insert-table-menu { background: #1e1e2e !important; } .cherry-insert-table-menu td { border-color: #3d4758 !important; background: transparent !important; } .cherry-insert-table-menu td.active, .cherry-insert-table-menu td:hover { background: #4facfe !important; } /* 顏色選擇器 */ .cherry-color-wrap { background: #1e1e2e !important; } .cherry-color-wrap .cherry-color-item { border-color: #3d4758 !important; } /* ===== 編輯區主體 ===== */ .${p}editor .cherry .cherry-editor, .cherry .cherry-editor { background: #1a1a2e !important; } /* ===== CodeMirror 編輯器 ===== */ .${p}editor .cherry .CodeMirror, .cherry .CodeMirror { background: #1a1a2e !important; color: #e8e8e8 !important; } /* CodeMirror 所有文字 */ .${p}editor .cherry .CodeMirror pre, .${p}editor .cherry .CodeMirror-line, .${p}editor .cherry .CodeMirror-line span, .${p}editor .cherry .CodeMirror-code, .cherry .CodeMirror pre, .cherry .CodeMirror-line, .cherry .CodeMirror-line span, .cherry .CodeMirror-code { color: #e8e8e8 !important; } /* CodeMirror 游標 */ .${p}editor .cherry .CodeMirror-cursor, .cherry .CodeMirror-cursor { border-left-color: #4facfe !important; border-left-width: 2px !important; } /* CodeMirror 選中 */ .${p}editor .cherry .CodeMirror-selected, .${p}editor .cherry .CodeMirror-selectedtext, .cherry .CodeMirror-selected, .cherry .CodeMirror-selectedtext { background: rgba(79,172,254,0.3) !important; } /* CodeMirror 行號 */ .${p}editor .cherry .CodeMirror-gutters, .cherry .CodeMirror-gutters { background: #16213e !important; border-color: #2d3748 !important; } .${p}editor .cherry .CodeMirror-linenumber, .cherry .CodeMirror-linenumber { color: #6c8eb0 !important; } /* CodeMirror 當前行 */ .${p}editor .cherry .CodeMirror-activeline-background, .cherry .CodeMirror-activeline-background { background: rgba(79,172,254,0.08) !important; } /* ===== Markdown 語法高亮 ===== */ /* 標題 */ .${p}editor .cherry .cm-header, .cherry .cm-header, .${p}editor .cherry .cm-header-1, .${p}editor .cherry .cm-header-2, .${p}editor .cherry .cm-header-3, .${p}editor .cherry .cm-header-4, .${p}editor .cherry .cm-header-5, .${p}editor .cherry .cm-header-6, .cherry .cm-header-1, .cherry .cm-header-2, .cherry .cm-header-3, .cherry .cm-header-4, .cherry .cm-header-5, .cherry .cm-header-6 { color: #61afef !important; font-weight: bold !important; } /* 粗體 */ .${p}editor .cherry .cm-strong, .cherry .cm-strong { color: #fff !important; font-weight: bold !important; } /* 斜體 */ .${p}editor .cherry .cm-em, .cherry .cm-em { color: #c9d1d9 !important; font-style: italic !important; } /* 刪除線 */ .${p}editor .cherry .cm-strikethrough, .cherry .cm-strikethrough { color: #8b949e !important; text-decoration: line-through !important; } /* 連結 */ .${p}editor .cherry .cm-link, .${p}editor .cherry .cm-url, .cherry .cm-link, .cherry .cm-url { color: #58a6ff !important; } /* 代碼 */ .${p}editor .cherry .cm-comment, .cherry .cm-comment { color: #8b949e !important; } .${p}editor .cherry .cm-string, .cherry .cm-string { color: #a5d6ff !important; } .${p}editor .cherry .cm-keyword, .cherry .cm-keyword { color: #ff7b72 !important; } .${p}editor .cherry .cm-atom, .cherry .cm-atom { color: #d2a8ff !important; } .${p}editor .cherry .cm-number, .cherry .cm-number { color: #ffa657 !important; } .${p}editor .cherry .cm-variable, .${p}editor .cherry .cm-variable-2, .${p}editor .cherry .cm-variable-3, .cherry .cm-variable, .cherry .cm-variable-2, .cherry .cm-variable-3 { color: #ffa657 !important; } .${p}editor .cherry .cm-def, .cherry .cm-def { color: #79c0ff !important; } .${p}editor .cherry .cm-property, .cherry .cm-property { color: #d2a8ff !important; } .${p}editor .cherry .cm-operator, .cherry .cm-operator { color: #79c0ff !important; } .${p}editor .cherry .cm-meta, .cherry .cm-meta { color: #8b949e !important; } .${p}editor .cherry .cm-tag, .cherry .cm-tag { color: #7ee787 !important; } .${p}editor .cherry .cm-attribute, .cherry .cm-attribute { color: #d2a8ff !important; } .${p}editor .cherry .cm-bracket, .cherry .cm-bracket { color: #79c0ff !important; } /* 引用 */ .${p}editor .cherry .cm-quote, .cherry .cm-quote { color: #8b949e !important; font-style: italic !important; } /* 列表符號 */ .${p}editor .cherry .cm-formatting-list, .cherry .cm-formatting-list { color: #ffa657 !important; } /* 行內代碼 */ .${p}editor .cherry .cm-inline-code, .cherry .cm-inline-code, .${p}editor .cherry .cm-formatting-code, .cherry .cm-formatting-code { color: #a5d6ff !important; background: rgba(110,118,129,0.2) !important; } /* ===== 預覽區 ===== */ .${p}editor .cherry .cherry-previewer, .cherry .cherry-previewer { background: #16213e !important; color: #e0e0e0 !important; border-color: #2d3748 !important; } /* 預覽區標題 */ .${p}editor .cherry .cherry-previewer h1, .${p}editor .cherry .cherry-previewer h2, .${p}editor .cherry .cherry-previewer h3, .${p}editor .cherry .cherry-previewer h4, .${p}editor .cherry .cherry-previewer h5, .${p}editor .cherry .cherry-previewer h6, .cherry .cherry-previewer h1, .cherry .cherry-previewer h2, .cherry .cherry-previewer h3, .cherry .cherry-previewer h4, .cherry .cherry-previewer h5, .cherry .cherry-previewer h6 { color: #fff !important; border-color: #2d3748 !important; } /* 預覽區段落和列表 - 排除 KaTeX 數學公式以保留顏色命令 */ .${p}editor .cherry .cherry-previewer p, .${p}editor .cherry .cherry-previewer li, .${p}editor .cherry .cherry-previewer td, .${p}editor .cherry .cherry-previewer th, .cherry .cherry-previewer p, .cherry .cherry-previewer li, .cherry .cherry-previewer td, .cherry .cherry-previewer th { color: #e0e0e0 !important; } /* span 需要特殊處理:排除帶有 inline style 的元素(如 KaTeX 顏色) */ .${p}editor .cherry .cherry-previewer span:not([style]), .cherry .cherry-previewer span:not([style]) { color: #e0e0e0 !important; } /* KaTeX 公式保持原生樣式 */ .${p}editor .cherry .cherry-previewer .katex, .${p}editor .cherry .cherry-previewer .katex *, .cherry .cherry-previewer .katex, .cherry .cherry-previewer .katex * { color: inherit !important; } /* 允許 KaTeX 中的 color 命令生效 */ .${p}editor .cherry .cherry-previewer .katex [style*="color"], .cherry .cherry-previewer .katex [style*="color"] { color: unset !important; } /* 預覽區連結 */ .${p}editor .cherry .cherry-previewer a, .cherry .cherry-previewer a { color: #58a6ff !important; } /* 預覽區行內代碼 */ .${p}editor .cherry .cherry-previewer code, .cherry .cherry-previewer code { background: rgba(110,118,129,0.3) !important; color: #a5d6ff !important; padding: 2px 6px !important; border-radius: 4px !important; } /* 預覽區代碼塊 */ .${p}editor .cherry .cherry-previewer pre, .cherry .cherry-previewer pre { background: #0d1117 !important; border: 1px solid #30363d !important; border-radius: 6px !important; } .${p}editor .cherry .cherry-previewer pre code, .cherry .cherry-previewer pre code { background: transparent !important; color: #e0e0e0 !important; padding: 0 !important; } /* 預覽區引用塊 */ .${p}editor .cherry .cherry-previewer blockquote, .cherry .cherry-previewer blockquote { background: rgba(79,172,254,0.1) !important; border-left: 4px solid #4facfe !important; color: #a0a0a0 !important; padding: 12px 16px !important; margin: 16px 0 !important; } /* 預覽區表格 */ .${p}editor .cherry .cherry-previewer table, .cherry .cherry-previewer table { border-color: #30363d !important; } .${p}editor .cherry .cherry-previewer th, .cherry .cherry-previewer th { background: #21262d !important; border-color: #30363d !important; } .${p}editor .cherry .cherry-previewer td, .cherry .cherry-previewer td { border-color: #30363d !important; } .${p}editor .cherry .cherry-previewer tr:nth-child(even), .cherry .cherry-previewer tr:nth-child(even) { background: rgba(255,255,255,0.02) !important; } /* 預覽區分隔線 */ .${p}editor .cherry .cherry-previewer hr, .cherry .cherry-previewer hr { border-color: #30363d !important; background: #30363d !important; } /* ===== 側邊欄/目錄 ===== */ .${p}editor .cherry .cherry-sidebar, .cherry .cherry-sidebar { background: #16213e !important; border-color: #2d3748 !important; } .${p}editor .cherry .cherry-toc, .cherry .cherry-toc { color: #e0e0e0 !important; } .${p}editor .cherry .cherry-toc a, .cherry .cherry-toc a { color: #a0a0a0 !important; } .${p}editor .cherry .cherry-toc a:hover, .cherry .cherry-toc a:hover { color: #4facfe !important; } /* ===== 狀態列 ===== */ .${p}editor .cherry .cherry-status, .cherry .cherry-status { background: #1e1e2e !important; color: #a0a0a0 !important; border-color: #2d3748 !important; } /* ===== 編輯模式切換按鈕 ===== */ .${p}editor .cherry .cherry-editor-mask, .cherry .cherry-editor-mask, .${p}editor .cherry .cherry-switch-model, .cherry .cherry-switch-model { background: #1e1e2e !important; color: #e0e0e0 !important; } /* ===== 滾動條 ===== */ .${p}editor .cherry ::-webkit-scrollbar, .cherry ::-webkit-scrollbar { width: 8px; height: 8px; } .${p}editor .cherry ::-webkit-scrollbar-track, .cherry ::-webkit-scrollbar-track { background: #1a1a2e; } .${p}editor .cherry ::-webkit-scrollbar-thumb, .cherry ::-webkit-scrollbar-thumb { background: #3d4758; border-radius: 4px; } .${p}editor .cherry ::-webkit-scrollbar-thumb:hover, .cherry ::-webkit-scrollbar-thumb:hover { background: #4a5568; } `, styleId); }, /** * 移除深色主題樣式 */ removeDarkTheme() { document.getElementById(this._darkStyleId)?.remove(); }, /** * 取得編輯器內容 * @returns {string} Markdown 內容 */ getValue() { try { return this.instance?.getValue() || ''; } catch (e) { log('Cherry getValue error:', e.message); return ''; } }, /** * 設定編輯器內容 * @param {string} value - Markdown 內容 */ setValue(value) { try { this.instance?.setValue(value ?? ''); } catch (e) { log('Cherry setValue error:', e.message); } }, /** * 取得 HTML 輸出 * @returns {string} HTML 內容 */ getHTML() { try { return this.instance?.getHtml() || ''; } catch (e) { log('Cherry getHTML error:', e.message); return ''; } }, /** * 在游標位置插入內容 * @param {string} value - 要插入的內容 */ insertValue(value) { try { this.instance?.insert(value); } catch (e) { log('Cherry insertValue error:', e.message); } }, /** * 選取指定範圍的文字(字元索引) * Cherry 官方 API 提供 getCodeMirror(),穩定可用 */ selectRange(start, end) { try { const cm = this.instance?.getCodeMirror?.(); if (!cm) { this.focus(); return false; } const doc = cm.getDoc?.() || cm.doc; if (!doc?.posFromIndex || !doc?.setSelection) { this.focus(); return false; } const from = doc.posFromIndex(start); const to = doc.posFromIndex(end); doc.setSelection(from, to); cm.scrollIntoView?.({ from, to }, 100); cm.focus?.(); return true; } catch (e) { log('Cherry selectRange error:', e?.message || e); try { this.focus(); } catch (_) {} return false; } }, /** * 聚焦編輯器 */ focus() { try { this.instance?.focus(); } catch (e) { log('Cherry focus error:', e.message); } }, /** * 刷新編輯器佈局 * @param {boolean} force - 是否強制延遲刷新 */ refresh(force = false) { try { if (force) { setTimeout(() => window.dispatchEvent(new Event('resize')), 60); } else { window.dispatchEvent(new Event('resize')); } } catch (e) { log('Cherry refresh error:', e.message); } }, /** * 設定主題 * @param {string} theme - 'light' 或 'dark' */ setTheme(theme) { if (theme === 'dark') { this.applyDarkTheme(); } else { this.removeDarkTheme(); } this.currentTheme = theme; }, /** * 銷毀編輯器 */ destroy() { log('Cherry Markdown destroying...'); try { this.instance?.destroy(); } catch (e) { log('Cherry destroy error:', e.message); } this.removeDarkTheme(); this.instance = null; this.editorDiv?.remove(); this.editorDiv = null; this.container = null; log('Cherry Markdown destroyed'); } }; // ======================================== // Vditor 適配器(含模式切換保護) // ======================================== /** * Vditor 編輯器適配器 * * 設計意圖: * - 封裝 Vditor 編輯器的操作 * - 實現模式切換保護機制,防止內容丟失 * * 核心問題: * Vditor 有三種編輯模式(SV/IR/WYSIWYG),在模式切換時 * 內部同步機制有時會失敗,導致內容丟失或縮水。 * * 解決方案: * 1. 以 SV 模式為「真相來源」(SV 模式下 getValue() 最可靠) * 2. 定期保存 SV 模式的快照 * 3. 監聽模式變化,自動檢測內容縮水並還原 * 4. 提供手動還原和下載快照功能 */ EditorAdapters.vditor = { /** @type {Object|null} Vditor 實例 */ instance: null, /** @type {HTMLElement|null} 容器元素 */ container: null, /** @type {HTMLElement|null} 編輯器 div */ editorDiv: null, /** @type {string|null} 當前主題 */ currentTheme: null, /** @type {MutationObserver|null} 模式變化觀察器 */ _modeObserver: null, /** @type {string|null} 上次偵測到的模式 */ _lastMode: null, /** @type {boolean} 是否啟用保護機制 */ _guardEnabled: true, /** @type {number} 上次顯示保護提示的時間 */ _lastGuardToastAt: 0, /** @type {Function|null} 點擊事件捕獲處理器 */ _captureClickHandler: null, /** @type {Function|null} 鍵盤事件捕獲處理器 */ _captureKeyHandler: null, /** @type {number|null} 自動快照計時器 */ _autoSnapshotTimer: null, /** @type {number|null} 內容檢查計時器 */ _contentCheckTimer: null, // ===== SV 快照(核心保護機制)===== /** @type {string|null} 最後一次 SV 模式的內容 */ _lastSVContent: null, /** @type {number} 最後一次 SV 內容的長度(去空白) */ _lastSVLength: 0, /** @type {string|null} 最後一次 SV 內容的 hash */ _lastSVHash: null, /** @type {number} 最後一次 SV 快照的時間戳 */ _lastSVTimestamp: 0, // ===== 還原鎖(防止重複還原)===== /** @type {boolean} 是否正在還原 */ _restoreLock: false, /** @type {number} 上次還原的時間 */ _lastRestoreAt: 0, /** * 初始化 Vditor 編輯器 * @param {HTMLElement} container - 容器元素 * @param {string} content - 初始內容 * @param {string} theme - 主題 ('light' | 'dark') */ async init(container, content, theme) { const VditorClass = PAGE_WIN.Vditor; if (!VditorClass) { throw new Error('Vditor not loaded'); } this.container = container; this.currentTheme = theme; const isDark = theme === 'dark'; // 讀取偏好的編輯模式(預設使用 SV,因為最穩定) let preferredMode = Utils.storage.get(CONFIG.storageKeys.editorMode, 'sv'); if (!['sv', 'ir', 'wysiwyg'].includes(preferredMode)) { preferredMode = 'sv'; } // 檢查安全重建標記(用於安全切換模式後的重建) const safeFlag = Utils.storage.get(CONFIG.storageKeys.vditorSafeReinitFlag, false); if (safeFlag) { try { Utils.clearEditorCache('vditor'); } catch (e) { // 忽略清除錯誤 } Utils.storage.remove(CONFIG.storageKeys.vditorSafeReinitFlag); } // 取得 CDN 基礎路徑 const cdnBase = Loader.getCdnBase('vditor') || CONFIG.editors.vditor.cdn[0]; const cfg = CONFIG.editors.vditor; // 清空容器並創建編輯器 div container.innerHTML = ''; this.editorDiv = document.createElement('div'); this.editorDiv.id = Utils.generateId('vditor'); this.editorDiv.style.cssText = 'width:100%;height:100%;'; container.appendChild(this.editorDiv); // 創建 Vditor 實例 await new Promise((resolve, reject) => { try { this.instance = new VditorClass(this.editorDiv, { cdn: cdnBase, mode: preferredMode, theme: isDark ? 'dark' : 'classic', icon: 'material', lang: 'zh_TW', width: '100%', height: '100%', placeholder: '開始撰寫 Markdown...', toolbar: VDITOR_TOOLBAR, toolbarConfig: { pin: true }, cache: { enable: true, id: cfg.cacheId }, counter: { enable: true, type: 'markdown' }, outline: { enable: true, position: 'right' }, hint: { emojiPath: `${cdnBase}/dist/images/emoji` }, preview: { theme: { current: isDark ? 'dark' : 'light' }, hljs: { style: isDark ? 'dracula' : 'github', lineNumber: true }, markdown: { toc: true, mark: true, footnotes: true, autoSpace: true }, math: { engine: 'KaTeX' } }, value: content || '', after: () => { // 初始化完成後保存 SV 快照 setTimeout(() => { this._saveSVSnapshot('init'); VditorDiag.log('init', { mode: preferredMode, contentLen: (content || '').replace(/\s/g, '').length }); }, 200); resolve(); } }); } catch (e) { reject(e); } }); // 安裝模式切換保護 this._installModeSwitchGuard(); this._lastMode = this._detectModeFromDOM(); // 等待完全就緒 await new Promise(r => setTimeout(r, 80)); log('Vditor initialized, mode:', preferredMode); }, /** * 偵測當前編輯模式 * * 設計意圖: * - 使用多重策略偵測當前模式 * - 因為 Vditor 的內部狀態有時不可靠 * * @returns {string|null} 'sv' | 'ir' | 'wysiwyg' | null */ _detectModeFromDOM() { try { // 策略 1:使用 API if (this.instance?.getCurrentMode) { const mode = this.instance.getCurrentMode(); if (mode && ['sv', 'ir', 'wysiwyg'].includes(mode)) { return mode; } } // 策略 2:檢查 class const root = this.editorDiv?.querySelector('.vditor'); if (root) { if (root.classList.contains('vditor--sv')) return 'sv'; if (root.classList.contains('vditor--ir')) return 'ir'; if (root.classList.contains('vditor--wysiwyg')) return 'wysiwyg'; // 策略 3:檢查可見性 const svEl = root.querySelector('.vditor-sv'); const irEl = root.querySelector('.vditor-ir'); const wysiwygEl = root.querySelector('.vditor-wysiwyg'); const isVisible = (el) => { if (!el) return false; const style = window.getComputedStyle(el); return style.display !== 'none' && style.visibility !== 'hidden' && el.offsetHeight > 0; }; if (isVisible(svEl)) return 'sv'; if (isVisible(irEl)) return 'ir'; if (isVisible(wysiwygEl)) return 'wysiwyg'; } return null; } catch (e) { return null; } }, /** * 保存 SV 模式快照 * * 設計意圖: * - 只在 SV 模式下保存(因為 SV 模式最穩定) * - 保存到內存和 localStorage 雙重備份 * - 記錄到 VditorDiag 便於診斷 * * @param {string} reason - 保存原因 * @returns {Object|null} 快照信息 */ _saveSVSnapshot(reason = 'auto') { const mode = this._detectModeFromDOM(); // 只在 SV 模式下保存 if (mode !== 'sv') { VditorDiag.log('sv-save-skipped', { mode, reason }); return null; } const md = this.getValue(); if (!md) return null; const len = md.replace(/\s/g, '').length; const hash = Utils.hash32(md); // 檢查是否有變化 if (hash === this._lastSVHash && len === this._lastSVLength) { return null; // 無變化 } // 保存 SV 快照 this._lastSVContent = md; this._lastSVLength = len; this._lastSVHash = hash; this._lastSVTimestamp = Date.now(); // 持久化到 localStorage Utils.storage.set(CONFIG.storageKeys.vditorSnapshot, md); Utils.storage.set(CONFIG.storageKeys.vditorSnapshotMeta, { mode: 'sv', reason, ts: this._lastSVTimestamp, len, hash }); VditorDiag.log('sv-snapshot-saved', { len, hash, reason }); VditorDiag.lastSVSnapshot = { content: md, len, hash, ts: this._lastSVTimestamp }; return { md, len, hash }; }, /** * 檢查並自動還原 * * 設計意圖: * - 定期檢查內容是否異常縮水 * - 如果在非 SV 模式下檢測到內容大幅縮水,自動還原 */ _checkAndAutoRestore() { // 如果正在還原,跳過 if (this._restoreLock) return; // 必須有 SV 快照 if (!this._lastSVContent || this._lastSVLength < 100) return; const currentMd = this.getValue(); const currentLen = (currentMd || '').replace(/\s/g, '').length; const currentMode = this._detectModeFromDOM(); // 如果在 SV 模式,更新快照(而不是檢查還原) if (currentMode === 'sv') { if (currentLen >= this._lastSVLength * 0.9) { this._saveSVSnapshot('auto-sv'); } return; } // 在非 SV 模式下,檢查內容是否縮水 const ratio = currentLen / this._lastSVLength; const lost = this._lastSVLength - currentLen; // 縮水閾值:丟失超過 20% 或超過 300 字 if (ratio < 0.8 || (lost > 300 && ratio < 0.9)) { const now = Date.now(); // 防止連續還原(2 秒冷卻) if (now - this._lastRestoreAt < 2000) return; VditorDiag.log('auto-restore-trigger', { currentLen, svLen: this._lastSVLength, lost, ratio: (ratio * 100).toFixed(1) + '%', mode: currentMode }); this._performRestore('內容異常縮水'); } }, /** * 執行還原 * @param {string} reason - 還原原因 * @returns {Promise} 是否成功 */ async _performRestore(reason) { if (this._restoreLock) return false; if (!this._lastSVContent) return false; this._restoreLock = true; this._lastRestoreAt = Date.now(); try { const restoreContent = this._lastSVContent; const restoreLen = this._lastSVLength; // 設定內容 try { if (this.instance?.setValue) { this.instance.setValue(restoreContent); } } catch (e) { log('Restore setValue failed:', e); } // 延遲再次設定確保成功 await new Promise(r => setTimeout(r, 100)); try { if (this.instance?.setValue) { this.instance.setValue(restoreContent); } } catch (e) { // 忽略 } // 驗證還原 await new Promise(r => setTimeout(r, 200)); const afterLen = (this.getValue() || '').replace(/\s/g, '').length; VditorDiag.logRestore(reason, afterLen); if (afterLen >= restoreLen * 0.9) { // 還原成功 const now = Date.now(); if (now - this._lastGuardToastAt > 3000) { this._lastGuardToastAt = now; Toast.warning( `⚠️ Vditor 偵測到內容異常,已從 SV 快照還原。\n建議使用選單中的「安全切換模式」功能。`, 6000 ); } return true; } else { // 還原失敗 Toast.error( `⚠️ 自動還原失敗!\n請從「備份管理」或「還原快照」手動恢復內容。`, 0 ); return false; } } finally { // 延遲解鎖 setTimeout(() => { this._restoreLock = false; }, 1000); } }, /** * 安裝模式切換保護機制 * * 設計意圖: * - 定期保存 SV 快照 * - 監聽模式切換事件 * - 定期檢查內容完整性 */ _installModeSwitchGuard() { if (!this._guardEnabled || !this.editorDiv) return; // 初始保存 SV 快照 setTimeout(() => { this._saveSVSnapshot('init'); }, 500); // 定期保存 SV 快照(只在 SV 模式下) this._autoSnapshotTimer = setInterval(() => { const mode = this._detectModeFromDOM(); if (mode === 'sv') { this._saveSVSnapshot('auto-interval'); } }, CONFIG.timing.vditorSnapshotInterval); // 監聽模式切換點擊 this._captureClickHandler = (ev) => { try { const t = ev.target; if (!(t instanceof Element)) return; if (!this.editorDiv?.contains(t)) return; // edit-mode 按鈕 const editModeBtn = t.closest('[data-type="edit-mode"]'); if (editModeBtn) { const currentMode = this._detectModeFromDOM(); VditorDiag.log('click-edit-mode', { beforeMode: currentMode }); // 在切換前保存 SV 快照 if (currentMode === 'sv') { this._saveSVSnapshot('before-mode-switch'); } return; } // 模式選擇面板 const panelItem = t.closest('.vditor-panel--left button, .vditor-hint button'); if (panelItem) { const txt = (panelItem.textContent || '').toLowerCase(); if (txt.includes('sv') || txt.includes('ir') || txt.includes('wysiwyg') || txt.includes('分屏') || txt.includes('即時') || txt.includes('所見')) { const currentMode = this._detectModeFromDOM(); VditorDiag.log('click-mode-panel', { text: txt.trim(), beforeMode: currentMode }); if (currentMode === 'sv') { this._saveSVSnapshot('before-mode-select'); } } } } catch (e) { // 忽略錯誤 } }; document.addEventListener('click', this._captureClickHandler, true); // MutationObserver 監聽模式變化 this._modeObserver = new MutationObserver(Utils.throttle(() => { const mode = this._detectModeFromDOM(); if (!mode) return; if (this._lastMode && mode !== this._lastMode) { VditorDiag.log('mode-change-detected', { from: this._lastMode, to: mode }); // 如果切換到 SV,保存快照 if (mode === 'sv') { setTimeout(() => this._saveSVSnapshot('after-switch-to-sv'), 300); } // 如果從 SV 切換到其他模式,延遲檢查 if (this._lastMode === 'sv' && mode !== 'sv') { setTimeout(() => { this._checkAndAutoRestore(); }, 500); } } this._lastMode = mode; }, 200)); const vditorRoot = this.editorDiv?.querySelector('.vditor'); if (vditorRoot) { this._modeObserver.observe(vditorRoot, { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'style'] }); } else { this._modeObserver.observe(this.editorDiv, { subtree: true, childList: true, attributes: true }); } // 定期檢查內容完整性 this._contentCheckTimer = setInterval(() => { this._checkAndAutoRestore(); }, CONFIG.timing.vditorContentCheckInterval); log('Vditor mode switch guard installed'); }, /** * 卸載模式切換保護機制 */ _uninstallModeSwitchGuard() { // 清除計時器 if (this._autoSnapshotTimer) { clearInterval(this._autoSnapshotTimer); this._autoSnapshotTimer = null; } if (this._contentCheckTimer) { clearInterval(this._contentCheckTimer); this._contentCheckTimer = null; } // 移除事件監聽 if (this._captureClickHandler) { document.removeEventListener('click', this._captureClickHandler, true); this._captureClickHandler = null; } if (this._captureKeyHandler) { document.removeEventListener('keydown', this._captureKeyHandler, true); this._captureKeyHandler = null; } // 斷開 MutationObserver try { this._modeObserver?.disconnect(); } catch (e) { // 忽略錯誤 } this._modeObserver = null; log('Vditor mode switch guard uninstalled'); }, /** * 還原最後的快照 * @returns {boolean} 是否成功 */ restoreLastSnapshot() { // 優先使用記憶體中的 SV 快照 let md = this._lastSVContent; // 如果沒有,從 storage 讀取 if (!md) { md = Utils.storage.get(CONFIG.storageKeys.vditorSnapshot, ''); } if (!md) { Toast.info('沒有可用的 Vditor 快照', 3500); return false; } this.setValue(md); // 更新 SV 快照 this._lastSVContent = md; this._lastSVLength = md.replace(/\s/g, '').length; this._lastSVHash = Utils.hash32(md); Toast.success('已從快照還原內容', 3500); return true; }, /** * 下載最後的快照 * @returns {boolean} 是否成功 */ downloadLastSnapshot() { let md = this._lastSVContent || Utils.storage.get(CONFIG.storageKeys.vditorSnapshot, ''); if (!md) { Toast.info('沒有可下載的 Vditor 快照', 3500); return false; } const meta = Utils.storage.get(CONFIG.storageKeys.vditorSnapshotMeta, {}); const date = new Date(meta?.ts || Date.now()) .toISOString() .replace(/[:.]/g, '-') .slice(0, 19); return Utils.downloadFile( md, `vditor_snapshot_${date}.md`, 'text/markdown;charset=utf-8' ); }, /** * 取得編輯器內容 * @returns {string} Markdown 內容 */ getValue() { try { return this.instance?.getValue() || ''; } catch (e) { log('Vditor getValue error:', e.message); return ''; } }, /** * 設定編輯器內容 * @param {string} value - Markdown 內容 */ setValue(value) { try { this.instance?.setValue(value ?? ''); } catch (e) { log('Vditor setValue error:', e.message); } }, /** * 取得 HTML 輸出 * @returns {string} HTML 內容 */ getHTML() { try { return this.instance?.getHTML() || ''; } catch (e) { log('Vditor getHTML error:', e.message); return ''; } }, /** * 在游標位置插入內容 * @param {string} value - 要插入的內容 */ insertValue(value) { try { this.instance?.insertValue(value); } catch (e) { log('Vditor insertValue error:', e.message); } }, /** * 選取指定範圍的文字(字元索引) * * 安全策略: * - 僅在 SV 模式嘗試取得 CodeMirror 並選取(最穩) * - IR/WYSIWYG 模式 fallback focus(避免字元索引對應混亂) */ selectRange(start, end) { try { const mode = this._detectModeFromDOM?.() || null; // 只在 sv 嘗試做選取 if (mode !== 'sv') { this.focus(); return false; } const cm = this.instance?.vditor?.sv?.codemirror || this.instance?.vditor?.sv?.cm || this.instance?.vditor?.sv?.editor?.cm || this.instance?.vditor?.sv?.editor?.codemirror; if (!cm) { this.focus(); return false; } const doc = cm.getDoc?.() || cm.doc; if (!doc?.posFromIndex || !doc?.setSelection) { this.focus(); return false; } const from = doc.posFromIndex(start); const to = doc.posFromIndex(end); doc.setSelection(from, to); cm.scrollIntoView?.({ from, to }, 100); cm.focus?.(); return true; } catch (e) { log('Vditor selectRange error:', e?.message || e); try { this.focus(); } catch (_) {} return false; } }, /** * 聚焦編輯器 */ focus() { try { this.instance?.focus(); } catch (e) { log('Vditor focus error:', e.message); } }, /** * 刷新編輯器佈局 * @param {boolean} force - 是否強制延遲刷新 */ refresh(force = false) { try { if (force) { setTimeout(() => window.dispatchEvent(new Event('resize')), 60); } else { window.dispatchEvent(new Event('resize')); } } catch (e) { log('Vditor refresh error:', e.message); } }, /** * 設定主題 * @param {string} theme - 'light' 或 'dark' */ setTheme(theme) { if (!this.instance) return; const isDark = theme === 'dark'; try { this.instance.setTheme( isDark ? 'dark' : 'classic', isDark ? 'dark' : 'light', isDark ? 'dracula' : 'github' ); } catch (e) { log('Vditor setTheme error:', e.message); } this.currentTheme = theme; }, /** * 銷毀編輯器 * * 設計意圖: * - destroy() 負責整體銷毀流程的編排 * - 計時器清理責任統一委託給 _uninstallModeSwitchGuard() * - 避免重複清理造成的責任不清 */ destroy() { log('Vditor destroying...'); // 卸載模式切換保護機制(包含所有計時器和事件監聽器的清理) this._uninstallModeSwitchGuard(); // 銷毀 Vditor 實例 try { this.instance?.destroy(); } catch (e) { log('Vditor destroy error:', e.message); } // 重置所有狀態 this.instance = null; this.editorDiv = null; this.container = null; this._lastSVContent = null; this._lastSVLength = 0; this._lastSVHash = null; this._lastMode = null; log('Vditor destroyed'); } }; // ======================================== // EditorManager 編輯器管理器 // ======================================== /** * 編輯器管理器 * * 設計意圖: * - 提供統一的編輯器操作介面,調用者無需關心具體編輯器類型 * - 實現切換鎖與隊列機制,防止同時進行多個編輯器切換 * - 處理 Vditor 安全重建等特殊情況 * * 使用方式: * await EditorManager.switchEditor('vditor', container, content, theme, onProgress); * const value = EditorManager.getValue(); * EditorManager.setValue('# Hello'); */ const EditorManager = { /** @type {string|null} 當前編輯器鍵名 */ currentEditor: null, /** @type {Object|null} 當前適配器實例 */ currentAdapter: null, /** @type {HTMLElement|null} 編輯器容器 */ container: null, /** @type {boolean} 是否正在切換編輯器 */ _switching: false, /** @type {Array} 切換請求隊列 */ _queue: [], /** * 切換編輯器 * * 設計意圖: * - 使用切換鎖防止同時進行多個切換操作 * - 使用隊列確保請求不會丟失 * - 保留當前內容並傳遞給新編輯器 * * @param {string} editorKey - 編輯器鍵名 ('easymde' | 'toastui' | 'cherry' | 'vditor') * @param {HTMLElement} container - 編輯器容器元素 * @param {string} content - 初始內容(可能會被當前編輯器的內容覆蓋) * @param {string} theme - 主題 ('light' | 'dark') * @param {Function} onProgress - 進度回調函數,接收狀態訊息字串 * @returns {Promise} 適配器實例 * @throws {Error} 載入失敗時拋出錯誤 */ async switchEditor(editorKey, container, content, theme, onProgress) { // 防止競態:正在切換時加入隊列 if (this._switching) { log('Editor switch queued:', editorKey); return new Promise((resolve, reject) => { this._queue.push({ editorKey, container, content, theme, onProgress, resolve, reject }); }); } this._switching = true; log('Editor switching to:', editorKey); try { const adapter = await this._doSwitchEditor( editorKey, container, content, theme, onProgress ); return adapter; } catch (e) { logError('Editor switch failed:', e); // 嘗試恢復到可用狀態 try { if (this.currentAdapter) { // 保持當前適配器 Toast.error(`切換失敗:${e.message}\n已保留當前編輯器`); } else { // 嘗試載入預設編輯器 Toast.error(`載入失敗:${e.message}\n正在嘗試載入備用編輯器...`); const fallbackKey = CONFIG.defaultEditor; if (fallbackKey !== editorKey) { try { return await this._doSwitchEditor( fallbackKey, container, content, theme, onProgress ); } catch (e2) { Toast.error('備用編輯器也載入失敗,請重新整理頁面'); } } } } catch (recoveryError) { logError('Recovery also failed:', recoveryError); } throw e; } finally { this._switching = false; // 處理隊列中的下一個請求 if (this._queue.length > 0) { const next = this._queue.shift(); log('Processing queued switch:', next.editorKey); this.switchEditor( next.editorKey, next.container, next.content, next.theme, next.onProgress ).then(next.resolve).catch(next.reject); } } }, /** * 實際執行編輯器切換 * @private */ async _doSwitchEditor(editorKey, container, content, theme, onProgress) { const perfTimer = PerfMonitor.start(`switch-editor-${editorKey}`); // 檢查是否為 Vditor 安全重建 const isVditorSafeReinit = ( editorKey === 'vditor' && Utils.storage.get(CONFIG.storageKeys.vditorSafeReinitFlag, false) ); // 若為 Vditor 安全重建,優先使用快照內容 if (isVditorSafeReinit) { const snapshotContent = Utils.storage.get(CONFIG.storageKeys.vditorSnapshot, ''); if (snapshotContent && snapshotContent.trim()) { content = snapshotContent; log('Vditor safe reinit: using snapshot content'); } } // 保留當前編輯器的內容(非 Vditor 安全重建時) if (this.currentAdapter && !isVditorSafeReinit) { try { const oldContent = this.currentAdapter.getValue(); if (oldContent && oldContent.trim()) { content = oldContent; log('Preserving current content, length:', oldContent.length); } } catch (e) { log('Failed to get current content:', e.message); } } // 銷毀舊的適配器 if (this.currentAdapter) { log('Destroying current adapter:', this.currentEditor); try { this.currentAdapter.destroy(); } catch (e) { logWarn('Adapter destroy error:', e.message); } } this.container = container; container.innerHTML = ''; // 載入編輯器資源 await Loader.loadEditor(editorKey, onProgress); // 取得並初始化適配器 const adapter = EditorAdapters[editorKey]; if (!adapter) { throw new Error(`No adapter for: ${editorKey}`); } onProgress?.(`初始化 ${CONFIG.editors[editorKey].name}...`); await adapter.init(container, content, theme); // 更新狀態 this.currentEditor = editorKey; this.currentAdapter = adapter; Utils.storage.set(CONFIG.storageKeys.editor, editorKey); // 建立備份(如果有內容) if (content && content.trim()) { BackupManager.create(content, { editorKey, mode: adapter._detectModeFromDOM?.() }); } log('Editor switched successfully:', editorKey); perfTimer.end(); return adapter; }, /** * 取得編輯器內容 * @returns {string} Markdown 內容 */ getValue() { return this.currentAdapter?.getValue?.() || ''; }, /** * 設定編輯器內容 * @param {string} value - Markdown 內容 */ setValue(value) { this.currentAdapter?.setValue?.(value); }, /** * 取得 HTML 輸出 * @returns {string} HTML 內容 */ getHTML() { return this.currentAdapter?.getHTML?.() || ''; }, /** * 在游標位置插入內容 * @param {string} value - 要插入的內容 */ insertValue(value) { this.currentAdapter?.insertValue?.(value); }, /** * 聚焦編輯器 */ focus() { this.currentAdapter?.focus?.(); }, /** * 設定主題 * @param {string} theme - 'light' 或 'dark' */ setTheme(theme) { this.currentAdapter?.setTheme?.(theme); }, /** * 刷新編輯器佈局 * @param {boolean} force - 是否強制延遲刷新 */ refresh(force = false) { try { this.currentAdapter?.refresh?.(force); } catch (e) { log('Editor refresh error:', e.message); } }, /** * 銷毀當前編輯器 */ destroy() { log('EditorManager destroying...'); try { this.currentAdapter?.destroy?.(); } catch (e) { logWarn('Adapter destroy error:', e.message); } this.currentAdapter = null; this.currentEditor = null; this.container = null; }, /** * 取得當前編輯器資訊 * @returns {Object|null} 包含 key, config, adapter 的物件 */ getCurrentInfo() { if (!this.currentEditor) return null; return { key: this.currentEditor, config: CONFIG.editors[this.currentEditor], adapter: this.currentAdapter }; }, /** * 檢查編輯器是否就緒 * @returns {boolean} */ isReady() { return !!this.currentAdapter; }, /** * 檢查是否正在切換 * @returns {boolean} */ isSwitching() { return this._switching; } }; // ======================================== // SVG 圖標集合 // ======================================== /** * SVG 圖標集合 * * 設計意圖: * - 提供統一的圖標資源 * - 使用 SVG 確保可縮放和高清顯示 * - 使用 currentColor 確保圖標顏色跟隨父元素 * * 圖標分類: * - 描邊型:使用 stroke,fill="none" * - 填充型:使用 fill,無 stroke * * 修正說明: * - 原代碼中部分圖標缺少 fill/stroke 屬性 * - 導致在某些情況下圖標顯示為黑色或不可見 * - 現在每個圖標都明確指定正確的屬性 */ const Icons = { // ===== 填充型圖標 ===== /** Markdown 標誌(填充型) */ markdown: ``, /** 月亮圖標(填充型) */ moon: ``, /** 更多選項圖標(填充型 - 三個點) */ more: ``, // ===== 描邊型圖標 ===== /** 關閉圖標 */ close: ``, /** 放大圖標 */ maximize: ``, /** 縮小圖標 */ minimize: ``, /** 展開圖標 */ expand: ``, /** 收合圖標 */ collapse: ``, /** 導出圖標 */ exportFile: ``, /** 導入圖標 */ import: ``, /** 檔案圖標 */ file: ``, /** 清除圖標 */ clear: ``, /** 下載圖標 */ download: ``, /** 複製圖標 */ copy: ``, /** 代碼圖標 */ code: ``, /** 保存圖標 */ save: ``, /** 載入中圖標 */ loading: ``, /** 太陽圖標 */ sun: ``, /** 還原圖標 */ restore: ``, /** 盾牌圖標 */ shield: ``, /** 設定圖標 */ settings: ``, /** 專注模式圖標 */ focus: ``, /** 向上箭頭圖標 */ arrowUp: ``, /** 向下箭頭圖標 */ arrowDown: ``, /** 歷史記錄圖標 */ history: ``, /** 釘選圖標 */ pin: ``, /** 垃圾桶圖標 */ trash: ``, /** 眼睛圖標 */ eye: ``, /** 時鐘圖標 */ clock: ``, /** 資料庫圖標 */ database: ``, /** 插槽圖示(層疊方塊,表示多個存檔位置) */ slots: ``, /** 勾選圖標 */ check: ``, /** 叉號圖標 */ x: ``, /** 資訊圖標 */ info: ``, /** 警告圖標 */ warning: ``, /** 選單圖標 */ menu: ``, /** 外部連結圖標 */ externalLink: ``, /** 編輯圖標 */ edit: ``, /** 加號圖標 */ plus: ``, /** 減號圖標 */ minus: `` }; // ======================================== // 主樣式系統 // ======================================== /** * 樣式管理器 * * 設計意圖(保留): * - 以「單一 style 元素」承載整套 UI CSS * - 主題切換時替換整段 CSS(可靠、可預期、與頁面 CSS 隔離) * * 確認的修復/升級(本段落會做): * 1) 工具列按鈕溢出:加入 flex-wrap 與溢出處理,避免按鈕掉出視窗(使用者回報) * 2) SVG 圖示黑色/不可見:本樣式不再強制 fill:none / stroke:currentColor, * 讓每個 SVG 依照 Icons 內的 fill/stroke 自行決定(Icons 已在片段 7 修正) */ const Styles = { /** @type {HTMLStyleElement|null} */ el: null, /** * 產生主題 CSS * @param {string} theme - 'light' | 'dark' | 'auto'(auto 在 Theme 層處理成 light/dark) * @returns {string} */ getCSS(theme) { const isDark = theme === 'dark'; const p = CONFIG.prefix; // 主題色彩配置(保留原設計:生成整段 CSS) const c = isDark ? { bg1: '#1a1a2e', bg2: '#16213e', bg3: '#0f3460', text1: '#e8e8e8', text2: '#a0a0a0', text3: '#6c757d', border: '#2d3748', accent: '#4facfe', accentHover: '#00f2fe', accentLight: 'rgba(79,172,254,0.15)', header: '#1e1e2e', btn: '#2d3748', btnHover: '#4a5568', danger: '#dc3545', dangerHover: '#c82333', shadow: '0 25px 50px -12px rgba(0,0,0,0.5)', shadowSm: '0 4px 12px rgba(0,0,0,0.3)', overlay: 'rgba(0,0,0,0.75)', menuBg: '#151526', success: '#28a745', warning: '#ffc107' } : { bg1: '#ffffff', bg2: '#f8f9fa', bg3: '#e9ecef', text1: '#212529', text2: '#6c757d', text3: '#adb5bd', border: '#dee2e6', accent: '#007bff', accentHover: '#0056b3', accentLight: 'rgba(0,123,255,0.12)', header: '#f1f3f4', btn: '#e9ecef', btnHover: '#dee2e6', danger: '#dc3545', dangerHover: '#c82333', shadow: '0 25px 50px -12px rgba(0,0,0,0.25)', shadowSm: '0 4px 12px rgba(0,0,0,0.1)', overlay: 'rgba(0,0,0,0.5)', menuBg: '#ffffff', success: '#28a745', warning: '#ffc107' }; return ` /* ===== CSS Variables 準備(第二階段將完整遷移)===== */ /* * 設計說明: * 1. 目前仍使用整段 CSS 替換方式進行主題切換 * 2. 以下變數定義為第二階段遷移做準備 * 3. 第二階段將改為只切換 root 的類別來切換主題 */ :root { /* 基礎色彩 - 亮色模式 */ --mme-color-bg-primary: ${isDark ? c.bg1 : c.bg1}; --mme-color-bg-secondary: ${isDark ? c.bg2 : c.bg2}; --mme-color-bg-tertiary: ${isDark ? c.bg3 : c.bg3}; --mme-color-text-primary: ${isDark ? c.text1 : c.text1}; --mme-color-text-secondary: ${isDark ? c.text2 : c.text2}; --mme-color-text-muted: ${isDark ? c.text3 : c.text3}; --mme-color-border: ${isDark ? c.border : c.border}; --mme-color-accent: ${isDark ? c.accent : c.accent}; --mme-color-accent-hover: ${isDark ? c.accentHover : c.accentHover}; --mme-color-accent-light: ${isDark ? c.accentLight : c.accentLight}; /* 語義化色彩 */ --mme-color-success: ${c.success}; --mme-color-warning: ${c.warning}; --mme-color-danger: ${c.danger}; /* 陰影 */ --mme-shadow-lg: ${c.shadow}; --mme-shadow-sm: ${c.shadowSm}; /* 圓角 */ --mme-radius-sm: 6px; --mme-radius-md: 8px; --mme-radius-lg: 12px; /* 過渡 */ --mme-transition-fast: 0.15s ease; --mme-transition-normal: 0.25s ease; } /* ===== 基礎重置 ===== */ .${p}overlay *, .${p}overlay *::before, .${p}overlay *::after, #${p}portal *, #${p}portal *::before, #${p}portal *::after { box-sizing: border-box; } /* ===== Portal 容器 ===== */ #${p}portal { position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: ${CONFIG.zIndex + 500}; pointer-events: none; } #${p}portal > * { pointer-events: auto; } /* ===== 遮罩層 ===== */ .${p}overlay { position: fixed; inset: 0; background: ${c.overlay}; display: flex; align-items: center; justify-content: center; z-index: ${CONFIG.zIndex}; opacity: 0; visibility: hidden; transition: opacity 0.25s ease, visibility 0.25s ease; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } .${p}overlay.${p}active { opacity: 1; visibility: visible; } /* ===== Modal 主視窗 ===== */ .${p}modal { position: absolute; width: 88vw; height: 88vh; max-width: 1400px; max-height: 900px; min-width: 380px; min-height: 350px; display: flex; flex-direction: column; background: ${c.bg1}; border-radius: 12px; box-shadow: ${c.shadow}; overflow: visible; transform: scale(0.95); transition: transform 0.25s ease; } .${p}overlay.${p}active .${p}modal { transform: scale(1); } .${p}modal.${p}fullscreen { width: 100vw !important; height: 100vh !important; max-width: 100vw !important; max-height: 100vh !important; border-radius: 0; top: 0 !important; left: 0 !important; } /* 拖曳中 */ .${p}modal.${p}dragging { transition: none; user-select: none; } /* ===== 工具列 ===== */ .${p}toolbar { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: ${c.header}; border-bottom: 1px solid ${c.border}; user-select: none; cursor: move; border-radius: 12px 12px 0 0; /* 修復:避免窄視窗按鈕掉出 modal(使用者回報) */ flex-wrap: wrap; row-gap: 6px; max-height: 96px; /* 最多兩行 + 捲動 */ overflow-y: auto; overflow-x: hidden; } .${p}toolbar-left, .${p}toolbar-right { display: flex; align-items: center; gap: 4px; flex-wrap: nowrap; min-width: 0; } .${p}toolbar-spacer { flex: 1; } /* 工具列狀態 */ .${p}toolbar-status { display: flex; align-items: center; gap: 4px; font-size: 11px; color: ${c.text2}; padding: 0 8px; white-space: nowrap; } .${p}toolbar-status .${p}sep { color: ${c.text3}; } /* ===== Mini Slots Bar(工具列迷你插槽列)===== */ .${p}mini-slots { display: flex; align-items: center; gap: 4px; padding: 0 6px; border-left: 1px solid ${c.border}; margin-left: 6px; max-width: 40vw; flex-wrap: wrap; /* toolbar 已支援 wrap,這裡也允許 */ } .${p}mini-slot-btn { width: 26px; height: 26px; border-radius: 6px; border: 1px solid ${c.border}; background: ${c.btn}; color: ${c.text1}; font: 12px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; transition: all 0.15s ease; user-select: none; } .${p}mini-slot-btn:hover { background: ${c.btnHover}; border-color: ${c.accent}; } .${p}mini-slot-btn.${p}has-content { background: ${c.accent}; border-color: ${c.accent}; color: #fff; } .${p}mini-slot-btn.${p}empty { opacity: 0.65; } .${p}mini-slot-btn:active { transform: scale(0.97); } /* ===== 編輯器選擇器 ===== */ .${p}editor-select { appearance: none; padding: 5px 24px 5px 8px; border: 1px solid ${c.border}; border-radius: 6px; background: ${c.btn}; color: ${c.text1}; font-size: 12px; font-weight: 600; cursor: pointer; min-width: 120px; } .${p}editor-select:hover { background: ${c.btnHover}; border-color: ${c.accent}; } .${p}editor-select:focus { outline: none; box-shadow: 0 0 0 2px ${c.accentLight}; border-color: ${c.accent}; } /* ===== 按鈕樣式 ===== */ .${p}btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid ${c.border}; border-radius: 6px; background: ${c.btn}; color: ${c.text1}; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.15s ease; white-space: nowrap; } .${p}btn:hover { background: ${c.btnHover}; border-color: ${c.accent}; } .${p}btn:active { transform: scale(0.98); } /* 重要:不再強制 fill:none / stroke:currentColor 讓每個 SVG 依照 Icons 內部設定自行決定(避免填充型圖示消失) */ .${p}btn svg, .${p}icon-btn svg, .${p}theme-btn svg { width: 14px; height: 14px; flex: 0 0 auto; display: block; } /* ===== 圖示按鈕 ===== */ .${p}icon-btn { width: 28px; height: 28px; border: 1px solid ${c.border}; border-radius: 6px; background: ${c.btn}; color: ${c.text1}; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; transition: all 0.15s ease; flex-shrink: 0; } .${p}icon-btn:hover { background: ${c.btnHover}; border-color: ${c.accent}; } .${p}icon-btn:active { transform: scale(0.98); } .${p}icon-btn.${p}danger:hover { background: ${c.danger}; border-color: ${c.danger}; color: #fff; } .${p}icon-btn.${p}active { background: ${c.warning} !important; border-color: ${c.warning} !important; color: #000 !important; } /* ===== 主要按鈕 ===== */ .${p}primary { background: ${c.accent} !important; border-color: ${c.accent} !important; color: #fff !important; } .${p}primary:hover { background: ${c.accentHover} !important; border-color: ${c.accentHover} !important; } /* ===== 主題切換按鈕(保留) ===== */ .${p}theme-btn { width: 28px; height: 28px; border: 1px solid ${c.border}; border-radius: 6px; background: ${c.btn}; color: ${c.text1}; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.15s ease; } .${p}theme-btn:hover { background: ${c.btnHover}; border-color: ${c.accent}; } /* ===== 按鈕外觀模式(由 ToolbarPrefs 套用 class) ===== */ .${p}modal.${p}btn-icon-only .${p}btn span { display: none !important; } .${p}modal.${p}btn-icon-only .${p}btn { padding: 5px 6px !important; } .${p}modal.${p}btn-text-only .${p}btn svg { display: none !important; } .${p}modal.${p}btn-text-only .${p}btn { padding: 5px 10px !important; } .${p}modal.${p}btn-icon-text .${p}btn svg { display: inline-block; } .${p}modal.${p}btn-icon-text .${p}btn span { display: inline; } /* icon-btn 一律只顯示圖示 */ .${p}icon-btn span { display: none !important; } /* ===== 主體區域 ===== */ .${p}body { flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden; display: flex; flex-direction: column; } .${p}editor { flex: 1; width: 100%; min-height: 0; overflow: hidden; position: relative; } .${p}editor > * { height: 100% !important; } /* ===== 載入畫面 ===== */ .${p}loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; background: ${c.bg1}; color: ${c.text2}; font-size: 14px; z-index: 10; } .${p}loading.${p}hidden { display: none; } .${p}spinner { width: 44px; height: 44px; border: 3px solid ${c.border}; border-top-color: ${c.accent}; border-radius: 50%; animation: ${p}spin 0.8s linear infinite; } @keyframes ${p}spin { to { transform: rotate(360deg); } } .${p}loading-text { text-align: center; max-width: 320px; line-height: 1.5; } .${p}loading-error { color: ${c.danger}; margin-top: 8px; } /* ===== 狀態列 ===== */ .${p}status-bar { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 4px 12px; background: ${c.header}; border-top: 1px solid ${c.border}; font-size: 11px; color: ${c.text2}; border-radius: 0 0 12px 12px; } .${p}status-bar .${p}sep { color: ${c.text3}; } .${p}status-spacer { flex: 1; } #${p}status-text { color: ${c.text2}; } /* ===== Portal 下拉選單(更多) ===== */ .${p}menu-panel { position: fixed; background: ${c.menuBg}; border: 1px solid ${c.border}; border-radius: 12px; box-shadow: ${c.shadow}; padding: 10px; min-width: 220px; max-width: 320px; max-height: 70vh; overflow-y: auto; z-index: ${CONFIG.zIndex + 600}; display: none; } .${p}menu-panel.${p}open { display: block; } .${p}menu-item { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 8px; border: none; background: transparent; color: ${c.text1}; font-size: 13px; cursor: pointer; text-align: left; transition: background 0.15s; } .${p}menu-item:hover { background: ${c.btnHover}; } .${p}menu-item svg { width: 16px; height: 16px; flex-shrink: 0; display:block; } .${p}menu-sep { height: 1px; background: ${c.border}; margin: 6px 4px; } .${p}menu-header { padding: 6px 10px; font-size: 11px; font-weight: 600; color: ${c.text3}; text-transform: uppercase; letter-spacing: 0.5px; } /* ===== Portal 面板通用樣式 ===== */ .${p}portal-panel { position: fixed; background: ${c.bg1}; border: 1px solid ${c.border}; border-radius: 12px; box-shadow: ${c.shadow}; z-index: ${CONFIG.zIndex + 600}; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column; } .${p}portal-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid ${c.border}; background: ${c.header}; } .${p}portal-panel-header h3 { margin: 0; font-size: 14px; font-weight: 700; color: ${c.text1}; display: flex; flex-direction: row; align-items: center; gap: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .${p}portal-panel-header h3 svg { width: 18px; height: 18px; min-width: 18px; flex-shrink: 0; } .${p}portal-panel-body { flex: 1; overflow-y: auto; padding: 12px; } .${p}portal-panel-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid ${c.border}; background: ${c.header}; } /* ===== 導入/導出面板(Portal 版) ===== */ .${p}io-panel { position: fixed; background: ${c.bg1}; border: 1px solid ${c.border}; border-radius: 12px; box-shadow: ${c.shadow}; padding: 20px; min-width: 260px; max-width: 320px; z-index: ${CONFIG.zIndex + 600}; } .${p}io-panel h4 { margin: 0 0 12px; font-size: 15px; font-weight: 700; color: ${c.text1}; } .${p}io-panel .${p}btn { justify-content: flex-start; width: 100%; margin-bottom: 8px; } .${p}io-panel .${p}secondary { background: transparent; border-color: ${c.border}; color: ${c.text2}; margin-top: 8px; } .${p}io-panel .${p}secondary:hover { background: ${c.btnHover}; } .${p}io-hint { font-size: 11px; color: ${c.text3}; padding: 4px 0; } .${p}io-sep { height: 1px; background: ${c.border}; margin: 8px 0; } /* ===== 設定面板(Portal 版) ===== */ .${p}settings-panel { width: min(550px, 90vw); } .${p}settings-hint { font-size: 12px; color: ${c.text3}; margin-bottom: 12px; } .${p}settings-group { margin-bottom: 16px; padding: 12px; background: ${isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)'}; border-radius: 8px; border: 1px solid ${c.border}; } .${p}settings-group h4 { margin: 0 0 10px; font-size: 13px; font-weight: 600; color: ${c.text1}; } .${p}settings-section { margin-bottom: 16px; } .${p}settings-section h4 { margin: 0 0 8px; font-size: 13px; font-weight: 600; color: ${c.text1}; } .${p}settings-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid ${c.border}; border-radius: 8px; margin-bottom: 6px; background: ${isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)'}; } .${p}settings-row label { flex: 1; display: flex; align-items: center; gap: 8px; font-size: 13px; color: ${c.text1}; cursor: pointer; } .${p}settings-row input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; } .${p}settings-mini-btn { width: 26px; height: 26px; border-radius: 6px; border: 1px solid ${c.border}; background: ${c.btn}; color: ${c.text1}; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; } .${p}settings-mini-btn:hover { background: ${c.btnHover}; } .${p}settings-mini-btn svg { width: 12px; height: 12px; display:block; } .${p}settings-sub { margin-left: 24px; padding-left: 12px; border-left: 2px solid ${c.border}; } .${p}select { padding: 5px 8px; border: 1px solid ${c.border}; border-radius: 6px; background: ${c.btn}; color: ${c.text1}; font-size: 12px; cursor: pointer; min-width: 150px; } .${p}select:hover { border-color: ${c.accent}; } .${p}select:focus { outline: none; box-shadow: 0 0 0 2px ${c.accentLight}; border-color: ${c.accent}; } /* ===== 備份管理面板 ===== */ .${p}backup-panel { width: min(600px, 90vw); max-height: min(80vh, 600px); } .${p}backup-list { display: flex; flex-direction: column; gap: 8px; } .${p}backup-item { display: flex; align-items: center; gap: 12px; padding: 12px; background: ${c.bg2}; border: 1px solid ${c.border}; border-radius: 8px; transition: all 0.15s ease; } .${p}backup-item:hover { border-color: ${c.accent}; background: ${c.accentLight}; } .${p}backup-item.${p}pinned { border-color: ${c.warning}; background: rgba(255, 193, 7, 0.1); } .${p}backup-info { flex: 1; min-width: 0; } .${p}backup-time { font-size: 13px; font-weight: 600; color: ${c.text1}; margin-bottom: 2px; } .${p}backup-meta { font-size: 11px; color: ${c.text2}; display: flex; gap: 8px; flex-wrap: wrap; } .${p}backup-actions { display:flex; gap:4px; } .${p}backup-empty { text-align: center; padding: 40px 20px; color: ${c.text3}; } .${p}backup-stats { display: flex; gap: 16px; padding: 8px 12px; background: ${c.bg2}; border-radius: 8px; margin-bottom: 12px; font-size: 12px; color: ${c.text2}; } .${p}backup-stats b { color: ${c.text1}; } .${p}backup-info-box { background: ${isDark ? 'rgba(79,172,254,0.1)' : 'rgba(0,123,255,0.08)'}; border: 1px solid ${isDark ? 'rgba(79,172,254,0.3)' : 'rgba(0,123,255,0.2)'}; border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; } .${p}backup-info-title { font-weight: 700; font-size: 13px; color: ${c.accent}; margin-bottom: 8px; display:flex; align-items:center; gap:6px; } .${p}backup-info-title svg { width: 16px; height: 16px; display:block; } .${p}backup-info-list { margin: 0; padding-left: 20px; font-size: 12px; color: ${c.text2}; line-height: 1.8; } .${p}backup-info-list b { color: ${c.text1}; } .${p}backup-info-warning { margin-top: 10px; padding: 8px 12px; background: ${isDark ? 'rgba(255,193,7,0.15)' : 'rgba(255,193,7,0.1)'}; border-left: 3px solid ${c.warning}; border-radius: 4px; font-size: 12px; color: ${isDark ? '#ffc107' : '#856404'}; } .${p}backup-age-warning { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: ${isDark ? 'rgba(255,193,7,0.2)' : 'rgba(255,193,7,0.15)'}; color: ${isDark ? '#ffc107' : '#856404'}; margin-left: 8px; } .${p}backup-age-warning.${p}danger { background: ${isDark ? 'rgba(220,53,69,0.2)' : 'rgba(220,53,69,0.1)'}; color: ${c.danger}; } .${p}pin-badge { font-size: 11px; color: ${c.warning}; margin-left: 6px; } /* ===== Vditor 下拉選單 z-index 修正 ===== */ .vditor-hint, .vditor-panel--arrow, .vditor-tip { z-index: ${CONFIG.zIndex + 100} !important; } /* ===== 編輯器適配樣式 ===== */ .${p}editor .vditor { border: none !important; height: 100% !important; } .${p}editor .cherry { height: 100% !important; border: none !important; } .${p}editor .toastui-editor-defaultUI { height: 100% !important; } .${p}editor .EasyMDEContainer { height: 100% !important; display: flex !important; flex-direction: column !important; } .${p}editor .EasyMDEContainer .CodeMirror { flex: 1 !important; height: 0 !important; min-height: 0 !important; } /* ===== FAB 浮動按鈕 ===== */ .${p}fab { position: fixed; right: 24px; bottom: 24px; width: 56px; height: 56px; border: none; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; cursor: pointer; box-shadow: 0 4px 15px rgba(102, 126, 234, 0.45); z-index: 2147483645; display: flex; align-items: center; justify-content: center; transition: transform 0.2s ease, box-shadow 0.2s ease; touch-action: none; } .${p}fab:hover { transform: scale(1.08); box-shadow: 0 6px 20px rgba(102, 126, 234, 0.55); } .${p}fab:active { transform: scale(0.95); } .${p}fab.${p}dragging { transition: none; cursor: grabbing; } .${p}fab svg { width: 28px; height: 28px; display:block; } .${p}fab.${p}loading svg { animation: ${p}spin 0.8s linear infinite; } /* ===== Tooltip ===== */ .${p}tooltip { position: fixed; padding: 5px 10px; background: ${isDark ? '#4a5568' : '#333'}; color: #fff; font-size: 11px; border-radius: 4px; white-space: nowrap; pointer-events: none; z-index: ${CONFIG.zIndex + 800}; opacity: 0; transition: opacity 0.15s; } /* ===== Resize 手柄(主視窗) ===== */ .${p}resize-handle { background: transparent; transition: background 0.2s; } .${p}resize-handle:hover { background: ${c.accentLight}; } .${p}resize-right:hover, .${p}resize-left:hover { background: linear-gradient(90deg, transparent, ${c.accentLight}, transparent); } .${p}resize-top:hover, .${p}resize-bottom:hover { background: linear-gradient(180deg, transparent, ${c.accentLight}, transparent); } .${p}resize-corner:hover { background: ${c.accentLight}; } .${p}resizing { transition: none !important; user-select: none !important; } .${p}modal.${p}fullscreen .${p}resize-handle { display: none !important; } /* ===== 快速存檔插槽面板 ===== */ .${p}slots-panel { width: min(420px, 90vw); max-height: min(70vh, 500px); display: flex; flex-direction: column; padding: 16px; background: ${c.bg1}; border: 1px solid ${c.border}; border-radius: 12px; box-shadow: ${c.shadow}; } .${p}slots-panel h4 { margin: 0 0 12px; font-size: 16px; font-weight: 700; color: ${c.text1}; display: flex; align-items: center; gap: 8px; } .${p}slots-panel h4 svg { width: 20px; height: 20px; } .${p}slots-hint { font-size: 12px; color: ${c.text2}; margin-bottom: 12px; padding: 8px 12px; background: ${isDark ? 'rgba(79,172,254,0.1)' : 'rgba(0,123,255,0.08)'}; border-radius: 6px; border-left: 3px solid ${c.accent}; } .${p}slots-list { display: flex; flex-direction: column; gap: 8px; max-height: 300px; overflow-y: auto; padding-right: 4px; } .${p}slot-row { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: ${c.bg2}; border: 1px solid ${c.border}; border-radius: 8px; transition: all 0.15s ease; } .${p}slot-row:hover { border-color: ${c.accent}; background: ${c.accentLight}; } .${p}slot-row.${p}empty { opacity: 0.6; } .${p}slot-row.${p}empty:hover { opacity: 0.8; } .${p}slot-number { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: ${c.btn}; border: 1px solid ${c.border}; border-radius: 6px; font-size: 13px; font-weight: 700; color: ${c.text1}; flex-shrink: 0; } .${p}slot-row.${p}has-content .${p}slot-number { background: ${c.accent}; border-color: ${c.accent}; color: #fff; } .${p}slot-info { flex: 1; min-width: 0; overflow: hidden; } .${p}slot-label { font-size: 13px; font-weight: 600; color: ${c.text1}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .${p}slot-meta { font-size: 11px; color: ${c.text2}; display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px; } .${p}slot-empty-label { font-size: 13px; color: ${c.text3}; font-style: italic; } .${p}slot-actions { display: flex; gap: 4px; flex-shrink: 0; } .${p}slot-actions .${p}icon-btn { width: 26px; height: 26px; } .${p}slot-actions .${p}icon-btn svg { width: 12px; height: 12px; } /* 插槽快捷鍵提示 */ .${p}slot-shortcut { font-size: 10px; padding: 2px 5px; background: ${c.btn}; border: 1px solid ${c.border}; border-radius: 4px; color: ${c.text3}; font-family: 'SF Mono', Consolas, monospace; margin-left: 4px; } /* 插槽設定區塊 */ .${p}slots-settings { margin-top: 12px; padding-top: 12px; border-top: 1px solid ${c.border}; } .${p}slots-stats { display: flex; gap: 16px; padding: 8px 12px; background: ${c.bg2}; border-radius: 6px; margin-bottom: 12px; font-size: 12px; color: ${c.text2}; } .${p}slots-stats b { color: ${c.text1}; } /* 插槽預覽面板 */ .${p}slot-preview-panel { width: min(600px, 90vw); max-height: min(70vh, 500px); } .${p}slot-preview-content { max-height: 300px; overflow-y: auto; padding: 12px; background: ${isDark ? '#1a1a2e' : '#fafafa'}; border: 1px solid ${c.border}; border-radius: 6px; font-family: 'SF Mono', Consolas, monospace; font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; } /* 插槽標籤編輯 */ .${p}slot-label-input { width: 100%; padding: 6px 10px; border: 1px solid ${c.border}; border-radius: 6px; background: ${c.bg1}; color: ${c.text1}; font-size: 13px; margin-bottom: 8px; } .${p}slot-label-input:focus { outline: none; border-color: ${c.accent}; box-shadow: 0 0 0 2px ${c.accentLight}; } /* 插槽面板底部按鈕區 */ .${p}slots-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; padding-top: 12px; border-top: 1px solid ${c.border}; gap: 8px; } .${p}slots-footer-left, .${p}slots-footer-right { display: flex; gap: 8px; } /* ===== 拖曳導入功能 ===== */ /* 拖曳覆蓋層 */ .${p}drop-overlay { position: fixed; inset: 0; background: ${isDark ? 'rgba(79, 172, 254, 0.12)' : 'rgba(79, 172, 254, 0.15)'}; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); z-index: ${CONFIG.zIndex + 200}; display: flex; align-items: center; justify-content: center; opacity: 0; visibility: hidden; transition: opacity 0.25s ease, visibility 0.25s ease; cursor: copy; } .${p}drop-overlay.${p}active { opacity: 1; visibility: visible; } /* 拖曳提示框 */ .${p}drop-hint { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 48px 64px; background: ${c.bg1}; border: 3px dashed ${c.accent}; border-radius: 20px; box-shadow: ${c.shadow}; text-align: center; pointer-events: none; animation: ${p}drop-hint-pulse 2s ease-in-out infinite; } @keyframes ${p}drop-hint-pulse { 0%, 100% { border-color: ${c.accent}; transform: scale(1); } 50% { border-color: ${isDark ? '#00f2fe' : '#0056b3'}; transform: scale(1.02); } } .${p}drop-icon { width: 64px; height: 64px; color: ${c.accent}; animation: ${p}drop-icon-bounce 1s ease-in-out infinite; } .${p}drop-icon svg { width: 100%; height: 100%; } @keyframes ${p}drop-icon-bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } } .${p}drop-title { font-size: 22px; font-weight: 700; color: ${c.text1}; } .${p}drop-subtitle { font-size: 14px; color: ${c.text2}; line-height: 1.6; } /* FAB 拖曳高亮 */ .${p}fab.${p}drag-over { transform: scale(1.2) !important; box-shadow: 0 0 0 4px rgba(79, 172, 254, 0.5), 0 0 20px rgba(79, 172, 254, 0.4), 0 6px 20px rgba(102, 126, 234, 0.55) !important; animation: ${p}fab-drag-pulse 1s ease-in-out infinite !important; } @keyframes ${p}fab-drag-pulse { 0%, 100% { box-shadow: 0 0 0 4px rgba(79, 172, 254, 0.5), 0 0 20px rgba(79, 172, 254, 0.4), 0 6px 20px rgba(102, 126, 234, 0.55); } 50% { box-shadow: 0 0 0 8px rgba(79, 172, 254, 0.3), 0 0 30px rgba(79, 172, 254, 0.5), 0 6px 20px rgba(102, 126, 234, 0.55); } } /* Modal 拖曳高亮 */ .${p}modal.${p}drag-over { box-shadow: ${c.shadow}, 0 0 0 4px rgba(79, 172, 254, 0.5), 0 0 30px rgba(79, 172, 254, 0.3) !important; } .${p}modal.${p}drag-over .${p}body { position: relative; } .${p}modal.${p}drag-over .${p}body::after { content: ''; position: absolute; inset: 0; background: ${isDark ? 'rgba(79, 172, 254, 0.08)' : 'rgba(79, 172, 254, 0.05)'}; border: 2px dashed ${c.accent}; border-radius: 8px; pointer-events: none; animation: ${p}modal-drag-pulse 1.5s ease-in-out infinite; } @keyframes ${p}modal-drag-pulse { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } } /* 拖曳時禁用 Modal 內的 pointer-events(避免干擾) */ .${p}modal.${p}drag-over .${p}editor { pointer-events: none; } /* ===== 檔案系統設定 ===== */ .${p}fs-status { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: ${isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)'}; border-radius: 8px; margin-bottom: 8px; } .${p}fs-status-icon { width: 20px; height: 20px; flex-shrink: 0; } .${p}fs-status-icon svg { width: 100%; height: 100%; } .${p}fs-status-text { flex: 1; font-size: 13px; color: ${c.text1}; } .${p}fs-status-badge { padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; } .${p}fs-status-badge.${p}supported { background: rgba(40, 167, 69, 0.15); color: ${isDark ? '#5cb85c' : '#28a745'}; } .${p}fs-status-badge.${p}unsupported { background: rgba(220, 53, 69, 0.15); color: ${c.danger}; } .${p}fs-dir-info { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: ${isDark ? 'rgba(79,172,254,0.1)' : 'rgba(0,123,255,0.08)'}; border: 1px solid ${isDark ? 'rgba(79,172,254,0.3)' : 'rgba(0,123,255,0.2)'}; border-radius: 6px; margin-top: 8px; } .${p}fs-dir-info svg { width: 16px; height: 16px; color: ${c.accent}; flex-shrink: 0; } .${p}fs-dir-name { flex: 1; font-size: 13px; color: ${c.text1}; font-weight: 500; } .${p}fs-dir-clear { padding: 4px 8px; font-size: 11px; color: ${c.text2}; background: transparent; border: 1px solid ${c.border}; border-radius: 4px; cursor: pointer; transition: all 0.15s; } .${p}fs-dir-clear:hover { background: ${c.danger}; border-color: ${c.danger}; color: #fff; } /* ===== 快捷鍵面板 ===== */ .${p}shortcuts-panel { width: min(500px, 90vw); max-height: min(70vh, 550px); } .${p}shortcuts-content { display: flex; flex-direction: column; gap: 16px; } .${p}shortcuts-category { background: ${isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)'}; border: 1px solid ${c.border}; border-radius: 8px; padding: 12px 16px; } .${p}shortcuts-category h5 { margin: 0 0 10px; font-size: 13px; font-weight: 700; color: ${c.accent}; display: flex; align-items: center; gap: 6px; } .${p}shortcuts-table { width: 100%; border-collapse: collapse; } .${p}shortcuts-table tr { border-bottom: 1px solid ${isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}; } .${p}shortcuts-table tr:last-child { border-bottom: none; } .${p}shortcuts-table td { padding: 8px 0; font-size: 13px; } .${p}shortcut-key { width: 140px; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 12px; } .${p}shortcut-key code { padding: 3px 8px; background: ${c.btn}; border: 1px solid ${c.border}; border-radius: 4px; color: ${c.text1}; white-space: nowrap; } .${p}shortcut-desc { color: ${c.text2}; } /* ===== 閱讀時間顯示 ===== */ .${p}reading-time { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: ${c.text2}; } .${p}reading-time svg { width: 12px; height: 12px; } /* ===== 設定區塊分隔 ===== */ .${p}settings-divider { height: 1px; background: ${c.border}; margin: 16px 0; } .${p}settings-note { font-size: 11px; color: ${c.text3}; padding: 8px 12px; background: ${isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)'}; border-radius: 6px; margin-top: 8px; line-height: 1.5; } /* ===== 備份匯出/匯入按鈕組 ===== */ .${p}backup-io-group { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; } .${p}backup-io-group .${p}btn { flex: 1; min-width: 120px; justify-content: center; } /* ===== 備份分頁 ===== */ .${p}backup-pagination { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 12px; border-top: 1px solid ${c.border}; margin-top: 12px; } .${p}backup-page-info { font-size: 12px; color: ${c.text2}; } .${p}backup-pagination .${p}btn[disabled] { opacity: 0.5; cursor: not-allowed; } /* ===== 螢幕閱讀器專用 ===== */ .${p}sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } /* 焦點指示器增強 */ .${p}btn:focus-visible, .${p}icon-btn:focus-visible, .${p}editor-select:focus-visible { outline: 2px solid ${c.accent}; outline-offset: 2px; } /* 減少動態效果(尊重使用者偏好) */ @media (prefers-reduced-motion: reduce) { .${p}overlay, .${p}modal, .${p}toast, .${p}fab, .${p}drop-overlay, .${p}drop-hint { transition: none !important; animation: none !important; } } /* ===== 尋找與取代面板 ===== */ .${p}find-panel { position: fixed; z-index: ${CONFIG.zIndex + 700}; background: ${c.bg1}; border: 1px solid ${c.border}; border-radius: 8px; box-shadow: ${c.shadowSm}; padding: 12px; min-width: 320px; max-width: 450px; display: none; flex-direction: column; gap: 8px; } .${p}find-row, .${p}find-replace-row { display: flex; align-items: center; gap: 6px; } .${p}find-input { flex: 1; padding: 6px 10px; border: 1px solid ${c.border}; border-radius: 6px; background: ${c.bg2}; color: ${c.text1}; font-size: 13px; min-width: 0; } .${p}find-input:focus { outline: none; border-color: ${c.accent}; box-shadow: 0 0 0 2px ${c.accentLight}; } .${p}find-options { display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; } .${p}find-option { display: flex; align-items: center; gap: 4px; color: ${c.text2}; cursor: pointer; } .${p}find-option input { margin: 0; } .${p}find-option:hover { color: ${c.text1}; } .${p}find-status { font-size: 12px; color: ${c.text2}; padding: 4px 0; } .${p}btn-sm { padding: 4px 10px; font-size: 12px; } /* ===== 響應式 ===== */ @media (max-width: 1000px) { .${p}btn span { display: none; } .${p}editor-select { min-width: 130px; } } @media (max-width: 760px) { .${p}modal { width: 100vw !important; height: 100vh !important; max-width: 100vw !important; max-height: 100vh !important; border-radius: 0 !important; } .${p}toolbar { border-radius: 0; } .${p}status-bar { border-radius: 0; } .${p}editor-select { min-width: 110px; } } `; }, /** * 初始化樣式系統 */ init() { this.el = Utils.addStyle(this.getCSS(Theme.get()), `${CONFIG.prefix}ui-style`); Theme.onChange((t) => this.update(t)); }, /** * 更新主題樣式 * @param {string} theme */ update(theme) { if (this.el) { this.el.textContent = this.getCSS(theme); } } }; // ======================================== // 工具列偏好設定 // ======================================== /** * 工具列偏好管理器 * * 設計意圖: * - 定義所有可用的工具列按鈕 * - 管理「顯示/隱藏」「左右區順序」「按鈕外觀」「狀態列設定」 * - 提供快取機制減少 storage 讀取 * * 快取策略: * - 使用 5 秒 TTL 平衡即時性與效能 * - save() 後主動清除快取確保一致性 * - 提供手動清除機制供特殊場景使用 */ const ToolbarPrefs = { /** * 效能優化:偏好設定快取 * 避免過於頻繁地讀取 storage */ _cache: null, _cacheTime: 0, _cacheTTL: 5000, // 快取存活時間延長到 5 秒 /** * 清除快取 * * 使用時機: * - save() 內部自動調用 * - 外部強制刷新設定時調用 */ clearCache() { this._cache = null; this._cacheTime = 0; if (DEBUG) { log('ToolbarPrefs: cache cleared'); } }, /** * 檢查快取是否有效 * @returns {boolean} */ _isCacheValid() { if (!this._cache) return false; return (Date.now() - this._cacheTime) < this._cacheTTL; }, /** * 所有可用的工具列按鈕定義 */ allButtons: { // 基本操作 import: { icon: 'import', label: '導入', group: 'basic', order: 1 }, export: { icon: 'exportFile', label: '導出', group: 'basic', order: 2 }, save: { icon: 'save', label: '保存', group: 'basic', order: 3, primary: true }, clear: { icon: 'clear', label: '清空', group: 'basic', order: 4 }, // 功能 backup: { icon: 'database', label: '備份管理', group: 'features', order: 10 }, focusMode: { icon: 'focus', label: '專注模式', group: 'features', order: 11 }, settings: { icon: 'settings', label: '偏好設定', group: 'features', order: 12 }, slots: { icon: 'slots', label: '快速存檔', group: 'features', order: 13, description: '快速存取多份文件' }, // Vditor 專用 vditorModeSv: { icon: 'code', label: 'SV 模式', group: 'vditor', order: 20, vditorOnly: true }, vditorModeIr: { icon: 'eye', label: 'IR 模式', group: 'vditor', order: 21, vditorOnly: true }, vditorModeWysiwyg: { icon: 'file', label: 'WYSIWYG 模式', group: 'vditor', order: 22, vditorOnly: true }, vditorRestore: { icon: 'restore', label: '還原快照', group: 'vditor', order: 23, vditorOnly: true }, vditorDownload: { icon: 'download', label: '下載快照', group: 'vditor', order: 24, vditorOnly: true }, vditorDiag: { icon: 'shield', label: '診斷報告', group: 'vditor', order: 25, vditorOnly: true }, // 主題和視窗 theme: { icon: 'sun', label: '主題', group: 'window', order: 30 }, maximize: { icon: 'expand', label: '放大', group: 'window', order: 31 }, more: { icon: 'more', label: '更多', group: 'window', order: 32, alwaysShow: true }, close: { icon: 'close', label: '關閉', group: 'window', order: 33, alwaysShow: true, danger: true } }, appearanceOptions: ['icon-text', 'icon-only', 'text-only'], /** * 取得預設偏好設定 */ defaultPrefs() { return { show: { import: true, export: true, save: true, clear: true, backup: false, focusMode: false, settings: false, slots: true, // 預設顯示快速存檔按鈕 vditorModeSv: false, vditorModeIr: false, vditorModeWysiwyg: false, vditorRestore: false, vditorDownload: false, vditorDiag: false, theme: true, maximize: true, more: true, close: true }, orderLeft: ['import', 'export', 'save', 'clear', 'slots', 'backup', 'focusMode', 'settings'], orderRight: [ 'vditorModeSv', 'vditorModeIr', 'vditorModeWysiwyg', 'vditorRestore', 'vditorDownload', 'vditorDiag', 'theme', 'maximize', 'more', 'close' ], // 'icon-text' | 'icon-only' | 'text-only' buttonAppearance: 'icon-text', // 狀態列設定 statusBar: { enabled: true, position: 'bottom', showWordCount: true, showLineCount: true, showReadingTime: true, showSaveTime: true }, // 專注模式 focusMode: false }; }, /** * 載入偏好設定(深度合併預設值) * * 效能優化:使用快取減少 storage 讀取 * 快取會在設定被修改時主動清除 * * @returns {Object} 完整的偏好設定物件 */ load() { // 快取有效時直接返回 if (this._isCacheValid()) { return this._cache; } const now = Date.now(); const raw = Utils.storage.get(CONFIG.storageKeys.toolbarCfg, null); const base = this.defaultPrefs(); if (!raw) { // 快取預設值 this._cache = base; this._cacheTime = now; return base; } const merged = { ...base, ...raw, show: { ...base.show, ...(raw.show || {}) }, statusBar: { ...base.statusBar, ...(raw.statusBar || {}) } }; // 確保順序陣列包含所有按鈕 const allLeftBtns = Object.keys(this.allButtons).filter(k => ['basic', 'features'].includes(this.allButtons[k].group) ); const allRightBtns = Object.keys(this.allButtons).filter(k => ['vditor', 'window'].includes(this.allButtons[k].group) ); merged.orderLeft = this._ensureAllItems(merged.orderLeft || [], allLeftBtns); merged.orderRight = this._ensureAllItems(merged.orderRight || [], allRightBtns); // 驗證外觀選項 if (!this.appearanceOptions.includes(merged.buttonAppearance)) { merged.buttonAppearance = 'icon-text'; } // 更新快取 this._cache = merged; this._cacheTime = now; return merged; }, /** * 確保陣列包含所有項目 * @private */ _ensureAllItems(arr, allItems) { const result = arr.filter(x => allItems.includes(x)); for (const item of allItems) { if (!result.includes(item)) result.push(item); } return result; }, /** * 儲存偏好設定 * @param {Object} prefs */ save(prefs) { Utils.storage.set(CONFIG.storageKeys.toolbarCfg, prefs); // 清除快取,確保下次 load() 會讀取新值 this.clearCache(); }, /** * 取得按鈕 HTML 內容(依外觀模式) * @param {string} key * @param {string} appearance * @returns {string} */ getButtonContent(key, appearance) { const btn = this.allButtons[key]; if (!btn) return ''; const icon = Icons[btn.icon] || ''; const label = btn.label || ''; switch (appearance) { case 'icon-only': return icon; case 'text-only': return `${label}`; case 'icon-text': default: return `${icon}${label}`; } }, /** * 套用偏好設定到 Modal * @param {Object} modal - Modal 管理器(需具 modal.modal) */ applyToModal(modal) { if (!modal?.modal) return; const p = CONFIG.prefix; const prefs = this.load(); const isVditor = EditorManager.getCurrentInfo()?.key === 'vditor'; // 套用專注模式 class(實際專注模式行為由 EnhanceUI/Modal 控制) modal.modal.classList.toggle(`${p}focus-mode`, !!prefs.focusMode); // 套用按鈕外觀 class modal.modal.classList.remove(`${p}btn-icon-only`, `${p}btn-text-only`, `${p}btn-icon-text`); modal.modal.classList.add(`${p}btn-${prefs.buttonAppearance}`); // 狀態列顯示位置 this._applyStatusBarSettings(modal, prefs); // 按鈕顯示/隱藏 + 內容刷新 this._applyButtonVisibility(modal, prefs, isVditor); // 修復:按鈕順序(原先不會生效) this.reorderToolbarButtons(modal, prefs); }, /** * 套用狀態列設定 * @private */ _applyStatusBarSettings(modal, prefs) { const p = CONFIG.prefix; const statusBar = prefs.statusBar; const bottomStatus = modal.modal.querySelector(`#${p}status-bar`); const toolbarStatus = modal.modal.querySelector(`#${p}toolbar-status`); if (bottomStatus) { bottomStatus.style.display = (statusBar.enabled && statusBar.position === 'bottom') ? '' : 'none'; } if (toolbarStatus) { toolbarStatus.style.display = (statusBar.enabled && statusBar.position === 'toolbar') ? '' : 'none'; } }, /** * 套用按鈕顯示設定並刷新按鈕內容 * @private */ _applyButtonVisibility(modal, prefs, isVditor) { Object.keys(this.allButtons).forEach(key => { const btnDef = this.allButtons[key]; const btn = modal.modal.querySelector(`[data-action="${key}"]`); if (!btn) return; // Vditor 專用按鈕 if (btnDef.vditorOnly && !isVditor) { btn.style.display = 'none'; return; } // 固定顯示 if (btnDef.alwaysShow) { btn.style.display = ''; // 固定顯示按鈕通常是 icon-btn(內容可能由 Modal 自己覆蓋 tooltip 等) btn.innerHTML = Icons[btnDef.icon] || btn.innerHTML; return; } // 依偏好顯示/隱藏 btn.style.display = prefs.show[key] ? '' : 'none'; // 更新按鈕內容(外觀模式變更時需要) const content = this.getButtonContent(key, prefs.buttonAppearance); if (content) btn.innerHTML = content; }); }, /** * 修復:重新排列工具列按鈕順序(依 prefs.orderLeft / orderRight) * * 為何這是「確定的修復」: * - 舊版只保存順序,但沒有對 DOM 做任何重排,因此使用者感知為「設定不生效」 * * @param {Object} modal * @param {Object} prefs */ reorderToolbarButtons(modal, prefs) { const p = CONFIG.prefix; const left = modal.modal.querySelector(`#${p}toolbar-left`); const right = modal.modal.querySelector(`#${p}toolbar-right`); if (!left || !right) return; const moveInOrder = (parent, order) => { order.forEach(key => { const el = parent.querySelector(`[data-action="${key}"]`); if (el) parent.appendChild(el); }); }; // 左/右兩區按各自順序重排 moveInOrder(left, prefs.orderLeft || []); moveInOrder(right, prefs.orderRight || []); } }; // ======================================== // EnhanceUI:增強 UI(專注模式 / 預覽面板 / FAB 選單) // ======================================== /** * EnhanceUI * * 設計意圖(推測並尊重): * - 原團隊把 EnhanceUI 定位為「補強樣式」,而非「掌控流程」 * - 因此此模組只負責:注入 CSS + 跟隨 Theme 更新 * * 本段確認屬於「修復/升級」的理由: * - 使用者回報專注模式/工作情境需要更舒適 * - 片段 8 已加入 toolbar wrap(避免按鈕溢出),專注模式需兼容該行為 * - FAB 的「潛力」要擴展,但先以低耦合樣式與框架為主 */ const EnhanceUI = { styleId: `${CONFIG.prefix}enhance-style`, getCSS(theme) { const isDark = theme === 'dark'; const p = CONFIG.prefix; const c = isDark ? { panel: '#1e1e2e', border: '#2d3748', text: '#e8e8e8', text2: '#a0a0a0', accent: '#4facfe', btnHover: '#2d3748', menuBg: '#151526', shadow: '0 25px 50px -12px rgba(0,0,0,0.5)' } : { panel: '#ffffff', border: '#dee2e6', text: '#212529', text2: '#6c757d', accent: '#007bff', btnHover: '#f1f3f4', menuBg: '#ffffff', shadow: '0 25px 50px -12px rgba(0,0,0,0.25)' }; return ` /* ======================================== EnhanceUI:專注模式 ======================================== */ /* 專注模式下工具列在底部,預設隱藏 注意:片段 8 已讓 toolbar 可 wrap 且 max-height 有捲動; 這裡在 focus-mode 時覆蓋,避免高度限制造成動畫/可用性問題。 */ .${p}modal.${p}focus-mode .${p}toolbar { position: absolute; bottom: 0; left: 0; right: 0; top: auto; background: ${c.panel}; border: none; border-top: 1px solid ${c.border}; border-radius: 0 0 12px 12px; padding: 8px 12px; /* focus-mode 下工具列可能需要多行 + 捲動,但不要受片段 8 的 max-height 影響 */ max-height: none !important; overflow: visible !important; opacity: 0; transform: translateY(100%); transition: opacity 0.2s ease, transform 0.2s ease; z-index: 10; cursor: default; pointer-events: none; } /* 專注模式:工具列可見 */ .${p}modal.${p}focus-mode .${p}toolbar.${p}visible, .${p}modal.${p}focus-mode .${p}toolbar:focus-within, .${p}modal.${p}focus-mode .${p}toolbar.${p}has-open-menu { opacity: 1; transform: translateY(0); pointer-events: auto; } /* 專注模式:工具列內部仍維持 flex(避免被隱藏的 display 覆蓋) */ .${p}modal.${p}focus-mode .${p}toolbar-left, .${p}modal.${p}focus-mode .${p}toolbar-status, .${p}modal.${p}focus-mode .${p}toolbar-spacer, .${p}modal.${p}focus-mode .${p}editor-select, .${p}modal.${p}focus-mode .${p}toolbar-right { display: flex !important; } /* 專注模式:主體填滿 */ .${p}modal.${p}focus-mode .${p}body { height: 100%; border-radius: 12px; } /* 專注模式提示 */ .${p}modal.${p}focus-mode.${p}show-hint::after { content: '專注模式 · 滑鼠移至底部顯示工具列 · 按 Esc 退出'; position: absolute; top: 20px; left: 50%; transform: translateX(-50%); padding: 8px 20px; background: rgba(0,0,0,0.8); color: #fff; font-size: 12px; border-radius: 20px; animation: ${p}focus-hint 4s ease forwards; pointer-events: none; z-index: 100; } @keyframes ${p}focus-hint { 0% { opacity: 0; } 10% { opacity: 1; } 80% { opacity: 1; } 100% { opacity: 0; } } /* ======================================== EnhanceUI:預覽面板(備份預覽等) ======================================== */ .${p}preview-panel { width: min(700px, 90vw); max-height: min(80vh, 600px); } .${p}preview-content { padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; color: ${c.text}; overflow-y: auto; max-height: 420px; background: ${isDark ? '#1a1a2e' : '#fafafa'}; border-radius: 8px; border: 1px solid ${c.border}; } .${p}preview-content pre { white-space: pre-wrap; word-break: break-word; font-family: 'SF Mono', Consolas, monospace; font-size: 13px; } /* ======================================== EnhanceUI:FAB 右鍵/長按選單(僅框架,行為由 FAB/Modal 決定) ======================================== */ .${p}fab-menu { position: fixed; min-width: 200px; max-width: 260px; background: ${c.menuBg}; border: 1px solid ${c.border}; border-radius: 12px; box-shadow: ${c.shadow}; padding: 8px; z-index: ${CONFIG.zIndex + 900}; display: none; } .${p}fab-menu.${p}open { display: block; } .${p}fab-menu-item { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: none; border-radius: 10px; background: transparent; color: ${c.text}; cursor: pointer; font-size: 13px; text-align: left; } .${p}fab-menu-item:hover { background: ${c.btnHover}; } .${p}fab-menu-item svg { width: 16px; height: 16px; display: block; flex-shrink: 0; } .${p}fab-menu-sep { height: 1px; background: ${c.border}; margin: 6px 6px; } `; }, apply() { Utils.addStyle(this.getCSS(Theme.get()), this.styleId); Theme.onChange((t) => { Utils.addStyle(this.getCSS(t), this.styleId); }); } }; // ======================================== // FAB:懸浮按鈕管理器(保留既有行為 + 低耦合擴展框架) // ======================================== /** * FAB * * 設計意圖(推測並尊重): * - FAB 是「入口」:讓使用者隨時打開編輯器 * - 原團隊避免把 FAB 做得太複雜,可能是因為 Modal/設定系統尚在迭代 * * 本段確認屬於「升級」的理由: * - 使用者明確要求發揮懸浮鈕潛力 * - 但仍遵守低耦合:FAB 只負責 UI 與 action 派發,實際功能由 Modal 執行(片段 10) */ const FAB = { el: null, // 拖曳狀態 isDragging: false, hasMoved: false, startPos: { x: 0, y: 0 }, offset: { x: 0, y: 0 }, // 選單 menuEl: null, _menuOpen: false, _docClickHandler: null, // 長按 _longPressTimer: null, // 暫存的頁面選取文字(用於導入選取) _pendingSelection: '', create() { const p = CONFIG.prefix; if (this.el) return; // 避免重複 create this.el = document.createElement('button'); this.el.className = `${p}fab`; this.el.type = 'button'; this.el.title = '開啟 Markdown 編輯器 (Alt+M)'; this.el.innerHTML = Icons.markdown; // 還原位置 const savedPos = Utils.storage.get(CONFIG.storageKeys.buttonPos); if (savedPos?.left && savedPos?.top) { this.el.style.left = savedPos.left; this.el.style.top = savedPos.top; this.el.style.right = 'auto'; this.el.style.bottom = 'auto'; } this.bindEvents(); document.body.appendChild(this.el); }, /** * 建立 FAB 選單(低耦合:只提供入口,實作交由 Modal) */ _ensureMenu() { if (this.menuEl) return; const p = CONFIG.prefix; const menu = document.createElement('div'); menu.className = `${p}fab-menu`; // 選單內容將由 _updateMenuContent() 動態生成 menu.innerHTML = ''; // 點擊選單項目 menu.addEventListener('click', (e) => { const item = e.target.closest(`[data-action]`); if (!item) return; const action = item.getAttribute('data-action'); this.hideMenu(); this._dispatch(action); }); // 放到 Portal(避免被頁面 overflow 裁切) try { Portal.append(menu); } catch (e) { // 若 Portal 尚未 init,也可直接丟到 body document.body.appendChild(menu); } this.menuEl = menu; // 點擊外部關閉 this._docClickHandler = (e) => { if (!this._menuOpen) return; if (this.menuEl?.contains(e.target)) return; if (this.el?.contains(e.target)) return; this.hideMenu(); }; document.addEventListener('click', this._docClickHandler, true); }, showMenu(x, y) { this._ensureMenu(); if (!this.menuEl) return; const p = CONFIG.prefix; // 每次顯示前更新選單內容(動態項目) this._updateMenuContent(); // 先顯示以計算尺寸 this.menuEl.classList.add(`${p}open`); this._menuOpen = true; // 初始位置 const pad = 10; const vw = window.innerWidth; const vh = window.innerHeight; // 先放置 this.menuEl.style.left = `${x}px`; this.menuEl.style.top = `${y}px`; // 邊界修正 const rect = this.menuEl.getBoundingClientRect(); let left = x; let top = y; if (left + rect.width > vw - pad) left = vw - rect.width - pad; if (top + rect.height > vh - pad) top = vh - rect.height - pad; left = Math.max(pad, left); top = Math.max(pad, top); this.menuEl.style.left = `${left}px`; this.menuEl.style.top = `${top}px`; }, hideMenu() { if (!this.menuEl) return; const p = CONFIG.prefix; this.menuEl.classList.remove(`${p}open`); this._menuOpen = false; }, /** * 更新 FAB 選單內容(動態生成) * 根據當前狀態顯示不同選項 */ _updateMenuContent() { if (!this.menuEl) return; const p = CONFIG.prefix; // 檢測專注模式狀態 let inFocusMode = false; try { const prefs = ToolbarPrefs.load(); inFocusMode = prefs.focusMode && Modal.isOpen; } catch (e) { // ToolbarPrefs 可能尚未初始化 } // 檢測 Modal 開啟狀態 const isModalOpen = Modal?.isOpen || false; // 檢測主題 let isDark = false; try { isDark = Theme.isDark(); } catch (e) { // Theme 可能尚未初始化 } // 動態生成選單 HTML let html = ''; // 開啟/關閉編輯器 html += ` `; // 專注模式下顯示退出選項 if (inFocusMode) { html += ` `; } html += `
`; this.menuEl.innerHTML = html; }, /** * 取得並清空暫存的選取文字 * @returns {string} 暫存的選取文字 */ getPendingSelection() { const text = this._pendingSelection; this._pendingSelection = ''; return text; }, /** * action 派發(低耦合:盡量透過 Modal 執行) */ _dispatch(action) { try { // 確保 Modal 已初始化 if (typeof Modal === 'undefined' || !Modal._inited) { Toast.warning('編輯器正在載入,請稍候...'); return; } switch (action) { case 'toggle': Modal.toggle(); return; case 'exit-focus': try { if (Modal?.isOpen) { const prefs = ToolbarPrefs.load(); if (prefs.focusMode) { prefs.focusMode = false; ToolbarPrefs.save(prefs); ToolbarPrefs.applyToModal(Modal); Modal._applyStatusMetricVisibility?.(); Modal.syncThemeButton?.(); Modal.syncMaximizeButton?.(); Toast.info('已退出專注模式'); } else { Toast.info('目前不在專注模式中'); } } else { Toast.info('請先開啟編輯器'); } } catch (e) { logError('Exit focus mode error:', e); Toast.error('操作失敗'); } return; case 'backup': if (Modal.isOpen) { Modal.showBackupPanel?.(); } else { Modal.open?.().then(() => { setTimeout(() => Modal.showBackupPanel?.(), 200); }).catch(e => { Toast.error('無法開啟編輯器'); log('FAB backup open error:', e); }); } return; case 'settings': if (Modal.isOpen) { Modal.showSettingsPanel?.(); } else { Modal.open?.().then(() => { setTimeout(() => Modal.showSettingsPanel?.(), 200); }).catch(e => { Toast.error('無法開啟編輯器'); log('FAB settings open error:', e); }); } return; case 'theme': { const newTheme = Theme.toggle(); Modal.syncThemeButton?.(); try { EditorManager.setTheme(Theme.get()); } catch (e) { /* ignore - 編輯器可能尚未初始化 */ } Toast.info(`已切換到${newTheme === 'dark' ? '深色' : '淺色'}主題`); return; } case 'import-selection': { // 預先檢查是否有選取文字(避免開啟 Modal 後才發現沒內容) const pendingText = this._pendingSelection; if (!pendingText && !Utils.getSelectedText()) { Toast.warning('請先在頁面上選取文字'); return; } if (Modal.isOpen) { Modal.importText?.(); } else { Modal.open?.().then(() => { setTimeout(() => Modal.importText?.(), 250); }).catch(e => { Toast.error('無法開啟編輯器'); }); } return; } default: log('FAB unknown action:', action); } } catch (e) { logError('FAB dispatch error:', e?.message || e); Toast.error('操作失敗,請重試'); } }, bindEvents() { const p = CONFIG.prefix; // ======================================== // 在任何互動前捕獲頁面選取文字 // 理由: click/mousedown 導致選取被清除 // ======================================== const captureSelection = () => { try { const sel = window.getSelection(); const text = sel?.toString() || ''; if (text.trim()) { this._pendingSelection = text; } } catch (e) { // 某些頁面可能限制 selection API } }; // 在 mousedown 時捕獲(比 click 更早) this.el.addEventListener('mousedown', captureSelection, true); // 在 contextmenu 時也捕獲(右鍵選單) this.el.addEventListener('contextmenu', (e) => { captureSelection(); // 注意:後續的 contextmenu 處理會在下方 }, true); // click:未拖曳時切換 Modal this.el.addEventListener('click', (e) => { // 若剛拖曳移動,忽略 click(避免誤觸) if (this.hasMoved) return; e.preventDefault(); e.stopPropagation(); if (typeof Modal !== 'undefined' && Modal?.toggle) { Modal.toggle(); } }); // 右鍵選單 this.el.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); this.showMenu(e.clientX, e.clientY); }); // 長按(行動裝置) this.el.addEventListener('touchstart', (e) => { if (e.touches?.length !== 1) return; clearTimeout(this._longPressTimer); this._longPressTimer = setTimeout(() => { const t = e.touches[0]; this.showMenu(t.clientX, t.clientY); }, CONFIG.timing.fabLongPressDelay); }, { passive: true }); this.el.addEventListener('touchend', () => { clearTimeout(this._longPressTimer); }, { passive: true }); this.el.addEventListener('touchmove', () => { clearTimeout(this._longPressTimer); }, { passive: true }); // 拖曳 const getPos = (e) => { if (e.touches?.[0]) { return { x: e.touches[0].clientX, y: e.touches[0].clientY }; } return { x: e.clientX, y: e.clientY }; }; const onStart = (e) => { // 只允許左鍵拖曳 if (e.type === 'mousedown' && e.button !== 0) return; const rect = this.el.getBoundingClientRect(); const pos = getPos(e); this.offset = { x: pos.x - rect.left, y: pos.y - rect.top }; this.startPos = pos; this.isDragging = true; this.hasMoved = false; this.el.classList.add(`${p}dragging`); }; const onMove = (e) => { if (!this.isDragging) return; const pos = getPos(e); const dist = Math.hypot(pos.x - this.startPos.x, pos.y - this.startPos.y); if (dist > 5) { // 開始判定為拖曳 this.hasMoved = true; const x = Utils.clamp( pos.x - this.offset.x, 0, window.innerWidth - this.el.offsetWidth ); const y = Utils.clamp( pos.y - this.offset.y, 0, window.innerHeight - this.el.offsetHeight ); this.el.style.left = `${x}px`; this.el.style.top = `${y}px`; this.el.style.right = 'auto'; this.el.style.bottom = 'auto'; // 防止頁面被拖曳時觸控滾動 if (e.cancelable) e.preventDefault(); } }; const onEnd = () => { if (!this.isDragging) return; this.el.classList.remove(`${p}dragging`); this.isDragging = false; if (this.hasMoved) { Utils.storage.set(CONFIG.storageKeys.buttonPos, { left: this.el.style.left, top: this.el.style.top }); // 短暫延遲避免 click 立刻觸發 setTimeout(() => { this.hasMoved = false; }, 120); } }; this.el.addEventListener('mousedown', onStart); this.el.addEventListener('touchstart', onStart, { passive: false }); document.addEventListener('mousemove', onMove); document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('mouseup', onEnd); document.addEventListener('touchend', onEnd); }, /** * 設定 FAB loading 狀態 * @param {boolean} loading */ setLoading(loading) { const p = CONFIG.prefix; if (!this.el) return; if (loading) { this.el.classList.add(`${p}loading`); this.el.innerHTML = Icons.loading; } else { this.el.classList.remove(`${p}loading`); this.el.innerHTML = Icons.markdown; } } }; // ======================================== // [SEGMENT_10A] // Modal 主視窗(Core) // ======================================== const Modal = { // ===== DOM refs ===== container: null, overlay: null, modal: null, toolbar: null, editorContainer: null, fileInput: null, loadingEl: null, loadingText: null, // 狀態列容器 statusBar: null, toolbarStatus: null, // status metrics (bottom) wcEl: null, lcEl: null, rtEl: null, // 閱讀時間 saveTimeEl: null, // status metrics (toolbar) wcToolbarEl: null, lcToolbarEl: null, rtToolbarEl: null, // 閱讀時間(工具列) saveTimeToolbarEl: null, // metric 元素快取(用於 _applyStatusMetricVisibility) _statusMetrics: null, statusTextEl: null, // portal panels moreBtn: null, morePanel: null, importPanel: null, exportPanel: null, settingsPanel: null, backupPanel: null, tooltipEl: null, // ===== state ===== isOpen: false, isFullscreen: false, isDragging: false, dragOffset: { x: 0, y: 0 }, _opening: false, _hasOpenMenu: false, miniSlotsEl: null, // timers autoSaveTimer: null, wordCountTimer: null, // 效能優化:內容 hash 快取(避免重複計算字數) _lastContentHash: null, _lastWordCountStats: null, // resize observer _ro: null, // background scroll lock _savedBodyOverflow: '', _savedBodyPaddingRight: '', _savedHtmlOverflow: '', // init guard _inited: false, init() { if (this._inited) return; this._inited = true; Portal.init(); this.createDOM(); this.createPortalPanels(); this.bindCoreEvents(); this.restorePosition(); this._initModalResize(); this.initResizeObserver(); EditorPreviewStyles.inject(); // Backup auto callback:避免 BackupManager 直接依賴 EditorManager // 使用 SafeExecute 確保錯誤被正確處理且不影響其他功能 BackupManager.setAutoBackupCallback(() => { SafeExecute.run(async () => { if (!this.isOpen) return; if (!EditorManager.isReady()) return; const content = EditorManager.getValue(); if (!content || !content.trim()) return; const info = EditorManager.getCurrentInfo(); // 建立內部備份 BackupManager.create(content, { editorKey: info?.key, mode: info?.adapter?._detectModeFromDOM?.() }); // 嘗試備份到檔案系統(異步,不阻塞) try { await FileSystemManager.autoBackupToDirectory(content); } catch (fsError) { // 靜默失敗,不打擾使用者 log('FileSystem auto backup skipped:', fsError?.message || 'no handle'); } }, null, 'auto-backup-callback'); }); // 套用工具列偏好(含順序/顯示) ToolbarPrefs.applyToModal(this); // 綁定 Modal 拖曳事件 try { DragDropManager.bindModalDragEvents(this.modal); } catch (e) { log('Modal: DragDrop binding skipped:', e?.message); } // 讓 statusBar 的 metrics 顯示控制真正生效 this._applyStatusMetricVisibility(); // 同步動態按鈕(尊重 buttonAppearance) this.syncThemeButton(); this.syncMaximizeButton(); this._bindMiniSlotsBarEventsOnce?.(); this.updateMiniSlotsBar?.(); }, // ---------------------------------------- // DOM 建立 // ---------------------------------------- createDOM() { const p = CONFIG.prefix; const prefs = ToolbarPrefs.load(); const savedEditor = Utils.storage.get(CONFIG.storageKeys.editor, CONFIG.defaultEditor); const editorOptions = getSortedEditors().map(([key, cfg]) => `` ).join(''); const leftButtons = this._generateToolbarButtons(prefs.orderLeft, prefs); const rightButtons = this._generateToolbarButtons(prefs.orderRight, prefs); this.container = document.createElement('div'); this.container.id = `${p}container`; this.overlay = document.createElement('div'); this.overlay.className = `${p}overlay`; this.modal = document.createElement('div'); this.modal.className = `${p}modal ${p}btn-${prefs.buttonAppearance}`; this.modal.setAttribute('role', 'dialog'); this.modal.setAttribute('aria-modal', 'true'); this.modal.setAttribute('aria-labelledby', `${p}modal-title`); this.modal.setAttribute('aria-describedby', `${p}modal-desc`); // 為螢幕閱讀器新增隱藏的描述 const srDesc = document.createElement('div'); srDesc.id = `${p}modal-desc`; srDesc.className = `${p}sr-only`; srDesc.textContent = 'Markdown 編輯器視窗。使用 Escape 鍵關閉,使用 Tab 鍵在控制項之間移動。'; this.modal.appendChild(srDesc); // 還原尺寸 const savedSize = Utils.storage.get(CONFIG.storageKeys.modalSize); if (savedSize?.width && savedSize?.height) { this.modal.style.width = savedSize.width; this.modal.style.height = savedSize.height; } this.modal.innerHTML = `
${leftButtons}
0 · 0 · ${Icons.clock} 約 1 分鐘 · 未保存
${rightButtons}
正在載入編輯器...
0 · 0 · ${Icons.clock} 約 1 分鐘 · 未保存 就緒
`; this.fileInput = document.createElement('input'); this.fileInput.type = 'file'; this.fileInput.accept = '.md,.txt,.markdown,.mdown,.mkd,.mkdn,.mdwn,.mdtxt,.mdtext,.text'; this.fileInput.style.display = 'none'; this.overlay.appendChild(this.modal); this.overlay.appendChild(this.fileInput); this.container.appendChild(this.overlay); document.body.appendChild(this.container); // ======================================== // 元素快取(減少重複 DOM 查詢) // ======================================== // 基礎結構元素 this.toolbar = this.modal.querySelector(`.${p}toolbar`); this.editorContainer = this.modal.querySelector(`#${p}editor`); this.loadingEl = this.modal.querySelector(`#${p}loading`); this.loadingText = this.modal.querySelector(`#${p}loading-text`); // 狀態列容器(底部) this.statusBar = this.modal.querySelector(`#${p}status-bar`); // 工具列狀態區(工具列內) this.toolbarStatus = this.modal.querySelector(`#${p}toolbar-status`); // 底部狀態列元素 this.wcEl = this.modal.querySelector(`#${p}wc`); this.lcEl = this.modal.querySelector(`#${p}lc`); this.rtEl = this.modal.querySelector(`#${p}reading-time`); this.saveTimeEl = this.modal.querySelector(`#${p}save-time`); // 工具列狀態元素 this.wcToolbarEl = this.modal.querySelector(`#${p}wc-toolbar`); this.lcToolbarEl = this.modal.querySelector(`#${p}lc-toolbar`); this.rtToolbarEl = this.modal.querySelector(`#${p}reading-time-toolbar`); this.saveTimeToolbarEl = this.modal.querySelector(`#${p}save-time-toolbar`); // 狀態列 metric 元素快取(用於 _applyStatusMetricVisibility) // 底部狀態列 this._statusMetrics = { bottom: this.statusBar ? { wc: this.statusBar.querySelector(`[data-metric="wc"]`), lc: this.statusBar.querySelector(`[data-metric="lc"]`), rt: this.statusBar.querySelector(`[data-metric="rt"]`), save: this.statusBar.querySelector(`[data-metric="save"]`), sepWc: this.statusBar.querySelector(`[data-metric="sep-wc"]`), sepLc: this.statusBar.querySelector(`[data-metric="sep-lc"]`), sepRt: this.statusBar.querySelector(`[data-metric="sep-rt"]`) } : null, // 工具列狀態區 toolbar: this.toolbarStatus ? { wc: this.toolbarStatus.querySelector(`[data-metric="wc"]`), lc: this.toolbarStatus.querySelector(`[data-metric="lc"]`), rt: this.toolbarStatus.querySelector(`[data-metric="rt"]`), save: this.toolbarStatus.querySelector(`[data-metric="save"]`), sepWc: this.toolbarStatus.querySelector(`[data-metric="sep-wc"]`), sepLc: this.toolbarStatus.querySelector(`[data-metric="sep-lc"]`), sepRt: this.toolbarStatus.querySelector(`[data-metric="sep-rt"]`) } : null }; // 其他元素 this.statusTextEl = this.modal.querySelector(`#${p}status-text`); this.moreBtn = this.modal.querySelector(`[data-action="more"]`); this.miniSlotsEl = this.modal.querySelector(`#${p}mini-slots`); }, _generateToolbarButtons(order, prefs) { const p = CONFIG.prefix; const btns = ToolbarPrefs.allButtons; return (order || []).map(key => { const btn = btns[key]; if (!btn) return ''; // 增強的 tooltip 和 aria-label let tooltip = btn.label; let ariaLabel = btn.label; if (key === 'save') { tooltip = '保存草稿(Ctrl+S)'; ariaLabel = '保存草稿,快捷鍵 Control 加 S'; } if (key === 'export') { tooltip = '導出(下載/複製)'; ariaLabel = '導出選單,提供下載和複製選項'; } if (key === 'backup') { tooltip = '備份管理(歷史版本)'; ariaLabel = '開啟備份管理面板,查看歷史版本'; } if (key === 'close') { tooltip = '關閉編輯器(Escape)'; ariaLabel = '關閉編輯器,快捷鍵 Escape'; } const content = ToolbarPrefs.getButtonContent(key, prefs.buttonAppearance); const classes = [ btn.alwaysShow ? `${p}icon-btn` : `${p}btn`, btn.primary ? `${p}primary` : '', btn.danger ? `${p}danger` : '' ].filter(Boolean).join(' '); const display = (prefs.show?.[key] || btn.alwaysShow) ? '' : 'display:none;'; return ` `; }).join(''); }, createPortalPanels() { const p = CONFIG.prefix; // more menu(10B 會完整渲染互補內容) this.morePanel = document.createElement('div'); this.morePanel.className = `${p}menu-panel`; this.morePanel.id = `${p}more-panel`; Portal.append(this.morePanel); // import panel this.importPanel = document.createElement('div'); this.importPanel.className = `${p}io-panel`; this.importPanel.id = `${p}import-panel`; this.importPanel.style.display = 'none'; this.importPanel.setAttribute('role', 'dialog'); this.importPanel.setAttribute('aria-label', '導入內容'); this.importPanel.setAttribute('aria-modal', 'true'); this.importPanel.innerHTML = `

導入內容

支援 .md, .txt, .markdown 等格式
`; Portal.append(this.importPanel); // export panel this.exportPanel = document.createElement('div'); this.exportPanel.className = `${p}io-panel`; this.exportPanel.id = `${p}export-panel`; this.exportPanel.style.display = 'none'; this.exportPanel.setAttribute('role', 'dialog'); this.exportPanel.setAttribute('aria-label', '導出內容'); this.exportPanel.setAttribute('aria-modal', 'true'); this.exportPanel.innerHTML = `

導出 / 複製

導出:下載到本機;複製:放入剪貼簿
`; Portal.append(this.exportPanel); // slots panel(快速存檔) this.slotsPanel = document.createElement('div'); this.slotsPanel.className = `${p}io-panel ${p}slots-panel`; this.slotsPanel.id = `${p}slots-panel`; this.slotsPanel.style.display = 'none'; this.slotsPanel.setAttribute('role', 'dialog'); this.slotsPanel.setAttribute('aria-label', '快速存檔插槽'); this.slotsPanel.setAttribute('aria-modal', 'true'); Portal.append(this.slotsPanel); // settings / backup(10B 會 render) this.settingsPanel = document.createElement('div'); this.settingsPanel.className = `${p}portal-panel ${p}settings-panel`; this.settingsPanel.id = `${p}settings-panel`; this.settingsPanel.style.display = 'none'; this.settingsPanel.setAttribute('role', 'dialog'); this.settingsPanel.setAttribute('aria-label', '偏好設定'); this.settingsPanel.setAttribute('aria-modal', 'true'); Portal.append(this.settingsPanel); this.backupPanel = document.createElement('div'); this.backupPanel.className = `${p}portal-panel ${p}backup-panel`; this.backupPanel.id = `${p}backup-panel`; this.backupPanel.style.display = 'none'; this.backupPanel.setAttribute('role', 'dialog'); this.backupPanel.setAttribute('aria-label', '備份管理'); this.backupPanel.setAttribute('aria-modal', 'true'); Portal.append(this.backupPanel); // tooltip(10B 會完整初始化) this.tooltipEl = document.createElement('div'); this.tooltipEl.className = `${p}tooltip`; Portal.append(this.tooltipEl); }, // ---------------------------------------- // Core events // ---------------------------------------- bindCoreEvents() { const p = CONFIG.prefix; // overlay click to close this.overlay.addEventListener('click', (e) => { if (e.target === this.overlay) this.close(); }); // toolbar actions (delegation) this.toolbar.addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.getAttribute('data-action'); if (!action) return; // more menu toggle(10B 會覆寫成互補版) if (action === 'more') { e.preventDefault(); e.stopPropagation(); this.toggleMoreMenu(); return; } this.closeAllPanels(); this._handleAction(action, btn); }); // import panel actions this.importPanel.addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.getAttribute('data-action'); switch (action) { case 'import-selection': this.importText(); this.hideImportPanel(); break; case 'import-file': this.fileInput.click(); this.hideImportPanel(); break; case 'import-cancel': this.hideImportPanel(); break; } }); // export panel actions this.exportPanel.addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.getAttribute('data-action'); switch (action) { case 'export-md': this.downloadMD(); this.hideExportPanel(); break; case 'export-html': this.exportHTML(); this.hideExportPanel(); break; case 'copy-md': this.copyMD(); this.hideExportPanel(); break; case 'export-txt': this.exportAsText(); this.hideExportPanel(); break; case 'export-pdf': this.exportAsPDF(); this.hideExportPanel(); break; case 'copy-html': this.copyHTML(); this.hideExportPanel(); break; case 'export-cancel': this.hideExportPanel(); break; } }); // editor select this.modal.querySelector(`#${p}editor-select`).addEventListener('change', async (e) => { await this.switchEditor(e.target.value); }); // file open this.fileInput.addEventListener('change', (e) => this.handleFileOpen(e)); // drag modal this.toolbar.addEventListener('mousedown', (e) => { if (e.target.closest('button, select, input')) return; this.onDragStart(e); }); document.addEventListener('mousemove', (e) => this.onDragMove(e)); document.addEventListener('mouseup', () => this.onDragEnd()); // dblclick toolbar toggle maximize this.toolbar.addEventListener('dblclick', (e) => { if (e.target.closest('button, select, input')) return; this.toggleMaximize(); }); // theme sync Theme.onChange(() => { this.syncThemeButton(); EditorManager.setTheme(Theme.get()); }); // 提前初始化 Tooltip(不等待 10B) this._initTooltipEarly(); }, /** * 提前初始化 Tooltip(Core 版本,10B 會增強) */ _initTooltipEarly() { if (this._tooltipEarlyBound) return; this._tooltipEarlyBound = true; if (!this.tooltipEl) return; const showTooltip = (e) => { const target = e.target.closest('[data-tooltip]'); if (!target) return; const text = target.getAttribute('data-tooltip'); if (!text) return; this.tooltipEl.textContent = text; this.tooltipEl.style.opacity = '0'; this.tooltipEl.style.visibility = 'visible'; requestAnimationFrame(() => { const rect = target.getBoundingClientRect(); const ttRect = this.tooltipEl.getBoundingClientRect(); const isBottomHalf = rect.top > window.innerHeight / 2; let top = isBottomHalf ? rect.top - ttRect.height - 8 : rect.bottom + 8; let left = rect.left + rect.width / 2 - ttRect.width / 2; top = Math.max(5, Math.min(top, window.innerHeight - ttRect.height - 5)); left = Math.max(5, Math.min(left, window.innerWidth - ttRect.width - 5)); this.tooltipEl.style.top = `${top}px`; this.tooltipEl.style.left = `${left}px`; this.tooltipEl.style.opacity = '1'; }); }; const hideTooltip = () => { if (this.tooltipEl) { this.tooltipEl.style.opacity = '0'; } }; document.addEventListener('mouseover', showTooltip); document.addEventListener('mouseout', (e) => { if (e.target.closest('[data-tooltip]')) hideTooltip(); }); }, /** * action handler(Core 版本已移至 10B) * 此處保留空殼以防 10B patch 失敗時的 fallback */ _handleAction(action, anchorEl) { // 10B 會覆蓋此方法,這裡只做基本 fallback log('_handleAction called before 10B patch:', action); switch (action) { case 'close': this.close(); break; default: Toast.warning('模組載入中,請稍候重試'); } }, // ---------------------------------------- // Panels (core) // ---------------------------------------- closeAllPanels() { const p = CONFIG.prefix; if (this.morePanel) this.morePanel.classList.remove(`${p}open`); if (this.importPanel) this.importPanel.style.display = 'none'; if (this.exportPanel) this.exportPanel.style.display = 'none'; if (this.settingsPanel) this.settingsPanel.style.display = 'none'; if (this.backupPanel) this.backupPanel.style.display = 'none'; if (this.slotsPanel) this.slotsPanel.style.display = 'none'; this._hasOpenMenu = false; this.toolbar?.classList.remove(`${p}has-open-menu`); }, _markMenuOpen() { const p = CONFIG.prefix; this._hasOpenMenu = true; this.toolbar?.classList.add(`${p}has-open-menu`); }, toggleMoreMenu() { // 10B 會替換成互補邏輯(此處保底顯示一個簡單提示) const p = CONFIG.prefix; const isOpen = this.morePanel.classList.contains(`${p}open`); if (isOpen) { this.morePanel.classList.remove(`${p}open`); this._hasOpenMenu = false; this.toolbar?.classList.remove(`${p}has-open-menu`); return; } this.closeAllPanels(); this.morePanel.innerHTML = `
更多
下一子片段將完成互補選單、偏好設定與備份管理。
`; this.morePanel.classList.add(`${p}open`); Portal.positionAt(this.morePanel, this.moreBtn, { placement: 'bottom-end' }); this._markMenuOpen(); }, showImportPanel(anchor) { this.closeAllPanels(); this.importPanel.style.display = 'block'; Portal.positionAt(this.importPanel, anchor || this.moreBtn, { placement: 'bottom-start' }); this._markMenuOpen(); }, hideImportPanel() { this.importPanel.style.display = 'none'; this._hasOpenMenu = false; this.toolbar?.classList.remove(`${CONFIG.prefix}has-open-menu`); }, showExportPanel(anchor) { this.closeAllPanels(); this.exportPanel.style.display = 'block'; Portal.positionAt(this.exportPanel, anchor || this.moreBtn, { placement: 'bottom-start' }); this._markMenuOpen(); }, hideExportPanel() { this.exportPanel.style.display = 'none'; this._hasOpenMenu = false; this.toolbar?.classList.remove(`${CONFIG.prefix}has-open-menu`); }, // ---------------------------------------- // Drag / resize // ---------------------------------------- restorePosition() { const savedPos = Utils.storage.get(CONFIG.storageKeys.modalPos); if (savedPos?.left && savedPos?.top) { this.modal.style.left = savedPos.left; this.modal.style.top = savedPos.top; } }, onDragStart(e) { if (this.isFullscreen) return; const rect = this.modal.getBoundingClientRect(); this.dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; this.isDragging = true; this.modal.classList.add(`${CONFIG.prefix}dragging`); }, onDragMove(e) { if (!this.isDragging) return; const x = Utils.clamp( e.clientX - this.dragOffset.x, 0, window.innerWidth - this.modal.offsetWidth ); const y = Utils.clamp( e.clientY - this.dragOffset.y, 0, window.innerHeight - this.modal.offsetHeight ); this.modal.style.left = `${x}px`; this.modal.style.top = `${y}px`; }, onDragEnd() { if (!this.isDragging) return; this.isDragging = false; this.modal.classList.remove(`${CONFIG.prefix}dragging`); Utils.storage.set(CONFIG.storageKeys.modalPos, { left: this.modal.style.left, top: this.modal.style.top }); }, _initModalResize() { ResizeManager.enable(this.modal, { minWidth: 380, minHeight: 350, maxWidth: window.innerWidth - 20, maxHeight: window.innerHeight - 20, edges: ['right', 'bottom', 'corner'], onResize: () => EditorManager.refresh(true), onResizeEnd: ({ width, height }) => { Utils.storage.set(CONFIG.storageKeys.modalSize, { width: `${Math.round(width)}px`, height: `${Math.round(height)}px` }); } }); }, initResizeObserver() { if (typeof ResizeObserver === 'undefined') return; // 儲存尺寸:使用較長 debounce(減少儲存頻率) const saveSize = Utils.debounce(() => { if (!this.isOpen || this.isFullscreen) return; const rect = this.modal.getBoundingClientRect(); Utils.storage.set(CONFIG.storageKeys.modalSize, { width: `${Math.round(rect.width)}px`, height: `${Math.round(rect.height)}px` }); }, 500); // 刷新編輯器:使用較短 debounce(更及時響應) const refreshEditor = Utils.debounce(() => { if (!this.isOpen) return; EditorManager.refresh(true); }, 100); const ro = new ResizeObserver(() => { saveSize(); refreshEditor(); }); ro.observe(this.modal); this._ro = ro; }, // ---------------------------------------- // Editor ops // ---------------------------------------- async switchEditor(editorKey) { const p = CONFIG.prefix; this.loadingEl.classList.remove(`${p}hidden`); this.loadingText.textContent = '正在載入編輯器...'; const content = EditorManager.getValue() || Utils.storage.get(CONFIG.storageKeys.content, ''); try { await EditorManager.switchEditor( editorKey, this.editorContainer, content, Theme.get(), (msg) => { this.loadingText.textContent = msg; } ); this.loadingEl.classList.add(`${p}hidden`); Toast.success(`已切換到 ${CONFIG.editors[editorKey].name}`); setTimeout(() => { EditorManager.focus(); EditorManager.refresh(true); // 清除字數統計快取,確保重新計算 this._lastContentHash = null; this._lastWordCountStats = null; this.updateWordCount(); this.updateSaveTimeLabel(); ToolbarPrefs.applyToModal(this); this._applyStatusMetricVisibility(); this.syncThemeButton(); this.syncMaximizeButton(); }, 120); } catch (err) { logError('Switch editor failed:', err); this.loadingText.innerHTML = `
載入失敗:${Utils.escapeHtml(err.message)}
`; Toast.error(`載入失敗:${err.message}`, 6000); } }, async toggle() { if (this.isOpen) this.close(); else await this.open(); }, async open() { if (this.isOpen || this._opening) return; this._opening = true; const p = CONFIG.prefix; try { FAB?.setLoading?.(true); } catch (e) {} // background scroll lock(確定修復:避免背景滾動) this._lockBackgroundScroll(); this.isOpen = true; this.overlay.classList.add(`${p}active`); const savedEditor = Utils.storage.get(CONFIG.storageKeys.editor, CONFIG.defaultEditor); const select = this.modal.querySelector(`#${p}editor-select`); if (select) select.value = savedEditor; if (!EditorManager.isReady()) { await this.switchEditor(savedEditor); } else { this.loadingEl.classList.add(`${p}hidden`); setTimeout(() => { EditorManager.focus(); EditorManager.refresh(true); this.updateWordCount(); }, 80); } this.startAutoSave(); this.startWordCountUpdate(); BackupManager.startAuto(); this.updateSaveTimeLabel(); ToolbarPrefs.applyToModal(this); this._applyStatusMetricVisibility(); this.syncThemeButton(); this.syncMaximizeButton(); this._bindMiniSlotsBarEventsOnce(); this.updateMiniSlotsBar(); try { FAB?.setLoading?.(false); } catch (e) {} // 顯示拖曳導入提示(首次使用時) try { DragDropManager.showHintIfNeeded(); } catch (e) { // DragDropManager 可能尚未初始化 } this._opening = false; }, close() { if (!this.isOpen) return; const p = CONFIG.prefix; this.isOpen = false; this.overlay.classList.remove(`${p}active`); this.closeAllPanels(); // 關閉 FindReplace 面板 try { FindReplace.hide(); } catch (e) { // FindReplace 可能尚未初始化,忽略錯誤 } this.stopAutoSave(); this.stopWordCountUpdate(); BackupManager.stopAuto(); this.saveDraft(true); // background scroll unlock this._unlockBackgroundScroll(); // 清除效能優化快取 this._lastContentHash = null; this._lastWordCountStats = null; }, toggleMaximize() { const p = CONFIG.prefix; this.isFullscreen = !this.isFullscreen; this.modal.classList.toggle(`${p}fullscreen`, this.isFullscreen); if (this.isFullscreen) { this.modal.style.left = ''; this.modal.style.top = ''; } else { this.restorePosition(); } this.syncMaximizeButton(); setTimeout(() => { window.dispatchEvent(new Event('resize')); EditorManager.refresh(true); }, 80); }, // EasyMDE safe fullscreen will call this toggleFullscreen() { this.toggleMaximize(); }, // ---------------------------------------- // background scroll lock helpers // ---------------------------------------- _lockBackgroundScroll() { try { const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; this._savedBodyOverflow = document.body.style.overflow || ''; this._savedBodyPaddingRight = document.body.style.paddingRight || ''; this._savedHtmlOverflow = document.documentElement.style.overflow || ''; document.documentElement.style.overflow = 'hidden'; document.body.style.overflow = 'hidden'; if (scrollbarWidth > 0) { document.body.style.paddingRight = `${scrollbarWidth}px`; } } catch (e) { log('lockBackgroundScroll error:', e?.message || e); } }, _unlockBackgroundScroll() { try { document.body.style.overflow = this._savedBodyOverflow || ''; document.body.style.paddingRight = this._savedBodyPaddingRight || ''; document.documentElement.style.overflow = this._savedHtmlOverflow || ''; } catch (e) { log('unlockBackgroundScroll error:', e?.message || e); } }, // ---------------------------------------- // import/export/actions // ---------------------------------------- importText() { // 優先使用 FAB 暫存的選取,其次才即時取得 // 理由:點擊 FAB 選單時,頁面選取已被清除 let text = ''; try { text = FAB.getPendingSelection(); } catch (e) { // FAB 可能尚未初始化 } // 如果暫存為空,嘗試即時取得(可能從 Modal 內部操作) if (!text) { text = Utils.getSelectedText(); } if (!text || !text.trim()) { return Toast.warning('請先在頁面上選取文字'); } if (!EditorManager.isReady()) { return Toast.warning('編輯器尚未就緒'); } EditorManager.insertValue(text); Toast.success(`已導入選中文字(${text.length} 字元)`); this.updateWordCount(); }, async handleFileOpen(e) { const file = e.target.files?.[0]; if (!file) return; const result = await SafeExecute.run(async () => { const content = await Utils.readFile(file); // 驗證內容 if (typeof content !== 'string') { throw new Error('檔案內容格式無效'); } // 設定編輯器內容 EditorManager.setValue(content); // 建立備份 BackupManager.create(content, { editorKey: EditorManager.currentEditor, manual: true }); return { success: true, filename: file.name, size: content.length }; }, { success: false }, 'file-open'); if (result.success) { Toast.success(`已開啟:${result.filename}(${result.size} 字元)`); this.updateWordCount(); } else { Toast.error('檔案讀取失敗,請確認檔案格式正確', 5000); } // 清空 input,允許重複選擇相同檔案 e.target.value = ''; }, clearContent() { if (!EditorManager.isReady()) return Toast.warning('編輯器尚未就緒'); if (!confirm('確定要清空所有內容嗎?此操作無法復原。')) return; const content = EditorManager.getValue(); if (content && content.trim()) { BackupManager.create(content, { editorKey: EditorManager.currentEditor, manual: true }); } EditorManager.setValue(''); Utils.storage.set(CONFIG.storageKeys.content, ''); const info = EditorManager.getCurrentInfo(); if (info?.key) Utils.clearEditorCache(info.key); Toast.info('內容已清空'); this.updateWordCount(); }, exportHTML() { if (!EditorManager.isReady()) return Toast.warning('編輯器尚未就緒'); const html = EditorManager.getHTML(); const fullHtml = ` Markdown Export ${html} `; const filename = `markdown_${Utils.formatDate()}.html`; const ok = Utils.downloadFile(fullHtml, filename, 'text/html;charset=utf-8'); Toast[ok ? 'success' : 'error'](ok ? 'HTML 導出成功' : '導出失敗'); }, /** * 匯出為純文字 */ exportAsText() { if (!EditorManager.isReady()) { return Toast.warning('編輯器尚未就緒'); } const content = EditorManager.getValue(); const filename = `document_${Utils.formatDate()}.txt`; const ok = Utils.downloadFile(content, filename, 'text/plain;charset=utf-8'); Toast[ok ? 'success' : 'error'](ok ? '純文字導出成功' : '導出失敗'); }, /** * 匯出為 PDF(透過瀏覽器列印) */ exportAsPDF() { if (!EditorManager.isReady()) { return Toast.warning('編輯器尚未就緒'); } const html = EditorManager.getHTML(); const title = document.title || 'Markdown Export'; // 建立列印視窗 const printWindow = window.open('', '_blank', 'width=800,height=600'); if (!printWindow) { Toast.warning('無法開啟列印視窗\n請檢查是否被瀏覽器封鎖'); return; } const printContent = ` ${Utils.escapeHtml(title)} ${html} `; printWindow.document.write(printContent); printWindow.document.close(); Toast.info('已開啟列印視窗\n請選擇「儲存為 PDF」'); }, downloadMD() { if (!EditorManager.isReady()) { return Toast.warning('編輯器尚未就緒'); } const result = SafeExecute.wrap(() => { const content = EditorManager.getValue(); if (!content) { Toast.info('目前無內容可下載'); return false; } const filename = `markdown_${Utils.formatDate()}.md`; return Utils.downloadFile(content, filename, 'text/markdown;charset=utf-8'); }, false, 'download-md')(); if (result) { Toast.success('下載成功'); } else if (result === false) { Toast.error('下載失敗,請重試'); } }, async copyMD() { if (!EditorManager.isReady()) { return Toast.warning('編輯器尚未就緒'); } const ok = await SafeExecute.run(async () => { const content = EditorManager.getValue(); if (!content) { Toast.info('目前無內容可複製'); return false; } return await Utils.copyToClipboard(content); }, false, 'copy-md'); if (ok) { Toast.success('Markdown 已複製到剪貼簿'); } else if (ok === false) { Toast.error('複製失敗,請手動選取複製'); } }, async copyHTML() { if (!EditorManager.isReady()) { return Toast.warning('編輯器尚未就緒'); } const ok = await SafeExecute.run(async () => { const html = EditorManager.getHTML(); if (!html) { Toast.info('目前無內容可複製'); return false; } return await Utils.copyToClipboard(html); }, false, 'copy-html'); if (ok) { Toast.success('HTML 已複製到剪貼簿'); } else if (ok === false) { Toast.error('複製失敗,請手動選取複製'); } }, // ---------------------------------------- // draft/save/metrics // ---------------------------------------- saveDraft(silent = false) { if (!EditorManager.isReady()) { if (!silent) Toast.warning('編輯器尚未就緒'); return false; } try { const content = EditorManager.getValue(); // 檢查內容大小 let sizeWarning = false; try { const bytes = new Blob([content]).size; if (bytes > CONFIG.maxDraftBytes) { const sizeMB = (bytes / 1024 / 1024).toFixed(2); if (!silent) { Toast.warning(`草稿較大(${sizeMB} MB),可能無法完整保存\n建議使用「匯出」功能備份`); } sizeWarning = true; } } catch (e) { // 無法計算大小,繼續嘗試保存 } // 嘗試保存 const ok = Utils.storage.set(CONFIG.storageKeys.content, content); if (!ok) { // 儲存失敗,可能是空間不足 if (!silent) { Toast.error('保存失敗:儲存空間可能不足\n請清理瀏覽器資料或使用「匯出」功能'); } return false; } const now = Date.now(); Utils.storage.set(CONFIG.storageKeys.lastSaveTime, now); this.updateSaveTimeLabel(); if (!silent && !sizeWarning) { Toast.success('草稿已保存'); } return true; } catch (e) { logError('saveDraft error:', e); // 記錄更多上下文以便除錯 if (DEBUG) { console.error('[MME] saveDraft context:', { isOpen: this.isOpen, editorReady: EditorManager.isReady(), currentEditor: EditorManager.currentEditor, timestamp: new Date().toISOString() }); } if (!silent) { Toast.error('保存時發生錯誤:' + (e.message || '未知錯誤')); } return false; } }, updateSaveTimeLabel() { const ts = Utils.storage.get(CONFIG.storageKeys.lastSaveTime, null); const label = (!ts) ? '未保存' : `保存 ${Utils.formatTime(new Date(ts))}`; if (this.saveTimeEl) this.saveTimeEl.textContent = label; if (this.saveTimeToolbarEl) this.saveTimeToolbarEl.textContent = label; // 每次更新保存時間都重算 metrics 可見性(以便 sep 顯示正確) this._applyStatusMetricVisibility(); }, /** * 更新字數統計 * * 效能優化: * 1. 使用 hash 比對,內容無變化時跳過計算 * 2. 使用快取的 DOM 元素引用 * * 此方法每 3 秒執行一次,優化對長時間使用體驗很重要 */ updateWordCount() { try { const text = EditorManager.getValue() || ''; // 計算內容 hash const hash = Utils.hash32(text); // 如果內容未變化,跳過計算和 DOM 更新 if (hash === this._lastContentHash && this._lastWordCountStats) { // 但仍需套用可見性設定(使用者可能在設定中更改了顯示項目) // 這個操作現在已經優化過了,開銷很小 this._applyStatusMetricVisibility(); return; } // 內容有變化,重新計算 const stats = Utils.countText(text); // 更新快取 this._lastContentHash = hash; this._lastWordCountStats = stats; // 更新字數(使用快取的元素引用) if (this.wcEl) this.wcEl.textContent = stats.charsNoSpace; if (this.lcEl) this.lcEl.textContent = stats.lines; if (this.wcToolbarEl) this.wcToolbarEl.textContent = stats.charsNoSpace; if (this.lcToolbarEl) this.lcToolbarEl.textContent = stats.lines; // 更新閱讀時間(使用快取的元素引用) const readingTimeText = `約 ${stats.readingTime} 分鐘`; if (this.rtEl) this.rtEl.textContent = readingTimeText; if (this.rtToolbarEl) this.rtToolbarEl.textContent = readingTimeText; this._applyStatusMetricVisibility(); } catch (e) { // 錯誤時清除快取並顯示佔位符 this._lastContentHash = null; this._lastWordCountStats = null; if (this.wcEl) this.wcEl.textContent = '--'; if (this.lcEl) this.lcEl.textContent = '--'; if (this.wcToolbarEl) this.wcToolbarEl.textContent = '--'; if (this.lcToolbarEl) this.lcToolbarEl.textContent = '--'; if (this.rtEl) this.rtEl.textContent = '--'; if (this.rtToolbarEl) this.rtToolbarEl.textContent = '--'; } }, /** * 套用狀態列 metric 的可見性設定 * * 設計意圖: * - 根據使用者偏好設定控制狀態列各項目的顯示/隱藏 * - 使用快取的元素引用,避免每次都進行 DOM 查詢 * - 此方法會被頻繁呼叫(updateWordCount、updateSaveTimeLabel 等) * * 防禦性設計: * - 對 _statusMetrics 及其子屬性進行完整的 null 檢查 * - 在 DEBUG 模式下記錄警告,便於問題追蹤 */ _applyStatusMetricVisibility() { // 防禦性檢查:確保 _statusMetrics 已初始化 if (!this._statusMetrics) { if (DEBUG) { log('_applyStatusMetricVisibility: _statusMetrics not initialized, skipping'); } return; } const prefs = ToolbarPrefs.load(); const sb = prefs.statusBar || {}; // 計算各項目的可見性(只計算一次,避免重複運算) const showWc = !!sb.showWordCount; const showLc = !!sb.showLineCount; const showRt = sb.showReadingTime !== false; // 預設顯示 const showSave = !!sb.showSaveTime; // 計算分隔符號的可見性(分隔符只在前後項目都顯示時才顯示) const showSepWc = showWc && (showLc || showRt || showSave); const showSepLc = showLc && (showRt || showSave); const showSepRt = showRt && showSave; /** * 安全地套用可見性到指定的 metric 集合 * @param {Object|null} metrics - 快取的 metric 元素集合 * @param {string} location - 位置標識(用於除錯日誌) */ const applyToMetrics = (metrics, location) => { if (!metrics) { if (DEBUG) { log(`_applyStatusMetricVisibility: ${location} metrics is null`); } return; } // 使用安全的樣式設定函數 const setDisplay = (el, show) => { if (el && el.style) { el.style.display = show ? '' : 'none'; } }; // 套用各項目的可見性 setDisplay(metrics.wc, showWc); setDisplay(metrics.lc, showLc); setDisplay(metrics.rt, showRt); setDisplay(metrics.save, showSave); // 套用分隔符的可見性 setDisplay(metrics.sepWc, showSepWc); setDisplay(metrics.sepLc, showSepLc); setDisplay(metrics.sepRt, showSepRt); }; // 套用到底部狀態列和工具列狀態區 applyToMetrics(this._statusMetrics.bottom, 'bottom'); applyToMetrics(this._statusMetrics.toolbar, 'toolbar'); }, startAutoSave() { this.stopAutoSave(); this.autoSaveTimer = setInterval(() => { if (this.isOpen && EditorManager.isReady()) this.saveDraft(true); }, CONFIG.autoSaveInterval); }, stopAutoSave() { if (this.autoSaveTimer) { clearInterval(this.autoSaveTimer); this.autoSaveTimer = null; } }, startWordCountUpdate() { this.stopWordCountUpdate(); // 使用較長的間隔減少 CPU 使用 this.wordCountTimer = setInterval(() => { if (this.isOpen && document.visibilityState === 'visible') { this.updateWordCount(); } }, CONFIG.timing.wordCountUpdateInterval); // 同時監聯頁面可見性變化 this._visibilityHandler = () => { if (document.visibilityState === 'visible' && this.isOpen) { this.updateWordCount(); } }; document.addEventListener('visibilitychange', this._visibilityHandler); }, stopWordCountUpdate() { if (this.wordCountTimer) { clearInterval(this.wordCountTimer); this.wordCountTimer = null; } if (this._visibilityHandler) { document.removeEventListener('visibilitychange', this._visibilityHandler); this._visibilityHandler = null; } }, // ---------------------------------------- // Dynamic buttons (尊重 buttonAppearance) // ---------------------------------------- syncThemeButton() { const btn = this.modal?.querySelector(`[data-action="theme"]`); if (!btn) return; const prefs = ToolbarPrefs.load(); const def = ToolbarPrefs.allButtons.theme; const label = def?.label || '主題'; const icon = Theme.isDark() ? Icons.moon : Icons.sun; const tooltip = Theme.isDark() ? '切換淺色' : '切換深色'; btn.setAttribute('data-tooltip', tooltip); switch (prefs.buttonAppearance) { case 'icon-only': btn.innerHTML = icon; break; case 'text-only': btn.innerHTML = `${Utils.escapeHtml(label)}`; break; case 'icon-text': default: btn.innerHTML = `${icon}${Utils.escapeHtml(label)}`; break; } }, syncMaximizeButton() { const btn = this.modal?.querySelector(`[data-action="maximize"]`); if (!btn) return; const prefs = ToolbarPrefs.load(); const def = ToolbarPrefs.allButtons.maximize; const label = def?.label || '放大'; const icon = this.isFullscreen ? Icons.collapse : Icons.expand; const tooltip = this.isFullscreen ? '還原' : '放大'; btn.setAttribute('data-tooltip', tooltip); switch (prefs.buttonAppearance) { case 'icon-only': btn.innerHTML = icon; break; case 'text-only': btn.innerHTML = `${Utils.escapeHtml(label)}`; break; case 'icon-text': default: btn.innerHTML = `${icon}${Utils.escapeHtml(label)}`; break; } } }; // ======================================== // [SEGMENT_10B] // Modal 主視窗(Advanced: menus/panels/focus/tooltip/shortcuts/vditor-safe) // ======================================== (function patchModal10B() { const p = CONFIG.prefix; Object.assign(Modal, { _seg10BBound: false, _tooltipBound: false, _outsideClickBound: false, _keyBound: false, _focusModeBound: false, // ============ // init wrapper // ============ _initAdvancedOnce() { if (this._seg10BBound) return; this._seg10BBound = true; // 1) more menu click delegation if (this.morePanel) { this.morePanel.addEventListener('click', (e) => { const item = e.target.closest('[data-action]'); if (!item) return; const action = item.getAttribute('data-action'); if (!action) return; // 互補選單:點擊後關閉所有面板再執行 this.closeAllPanels(); this._handleAction(action, this.moreBtn); }); } // 2) click outside closes panels this._bindOutsideClickToClosePanels(); // 3) tooltip this._initTooltip(); // 4) shortcuts this._bindKeyboardShortcuts(); // 5) focus mode control this._initFocusModeControl(); // 6) 初次生成更多選單內容(不強制打開) this._updateMoreMenuContent(); }, // 重新包裝 init:保留 10A init,並補上 advanced 綁定 _wrapInit10B() { if (this.__initWrapped10B) return; this.__initWrapped10B = true; const init10A = this.init; this.init = function init_10B() { init10A.call(this); this._initAdvancedOnce(); }; }, // ============ // action handler(補齊失效按鈕) // ============ _handleAction(action, anchorEl) { switch (action) { // basic case 'import': this.showImportPanel(anchorEl); break; case 'export': this.showExportPanel(anchorEl); break; case 'save': this.saveDraft(false); break; case 'clear': this.clearContent(); break; // features (修復:原先工具列按鈕失效) case 'backup': this.showBackupPanel(); break; case 'focusMode': this.toggleFocusMode(); break; case 'settings': this.showSettingsPanel(); break; case 'slots': this.showSlotsPanel(anchorEl); break; case 'shortcuts': this.showShortcutsPanel(); break; // window case 'theme': Theme.toggle(); this.syncThemeButton(); EditorManager.setTheme(Theme.get()); break; case 'maximize': this.toggleMaximize(); break; case 'close': this.close(); break; // vditor-only (修復:工具列按鈕失效) case 'vditorModeSv': this.handleVditorSafeSwitch('sv'); break; case 'vditorModeIr': this.handleVditorSafeSwitch('ir'); break; case 'vditorModeWysiwyg': this.handleVditorSafeSwitch('wysiwyg'); break; case 'vditorRestore': this.vditorRestoreSnapshot(); break; case 'vditorDownload': this.vditorDownloadSnapshot(); break; case 'vditorDiag': VditorDiag.printReport(); Toast.info('診斷報告已輸出到控制台 (F12)'); break; default: log('Unknown action:', action); } }, // ============ // More menu(互補原則) // ============ toggleMoreMenu() { const openClass = `${p}open`; const isOpen = this.morePanel.classList.contains(openClass); if (isOpen) { this.closeAllPanels(); return; } this.closeAllPanels(); this._updateMoreMenuContent(); this.morePanel.classList.add(openClass); Portal.positionAt(this.morePanel, this.moreBtn, { placement: this._isFocusMode() ? 'top-end' : 'bottom-end' }); this._markMenuOpen(); }, _updateMoreMenuContent() { if (!this.morePanel) return; const prefs = ToolbarPrefs.load(); const info = EditorManager.getCurrentInfo(); const isVditor = info?.key === 'vditor'; const wc = this.wcEl?.textContent || '0'; const lc = this.lcEl?.textContent || '0'; const saveTime = this.saveTimeEl?.textContent || '未保存'; const backupStats = BackupManager.getStats(); const addHeader = (title) => `
${Utils.escapeHtml(title)}
`; const addSep = () => `
`; const addItem = (key) => { const def = ToolbarPrefs.allButtons[key]; if (!def) return ''; const icon = Icons[def.icon] || ''; const label = def.label || key; return ` `; }; // 狀態區(永遠顯示,資訊型,不算互補功能重複) let html = ` ${addHeader('狀態')}
${Icons.info} ${Utils.escapeHtml(`${wc} 字 · ${lc} 行 · ${saveTime}`)}
${addSep()} `; // 收集互補項:show=false 的功能才出現於更多選單 // 並排除 alwaysShow(more/close) const hiddenKeys = []; for (const [key, def] of Object.entries(ToolbarPrefs.allButtons)) { if (def.alwaysShow) continue; if (def.vditorOnly && !isVditor) continue; if (prefs.show?.[key] === false) hiddenKeys.push(key); } // 依 group 分組(資料驅動,不寫死 keys 清單,避免漏項) const groupOrder = ['basic', 'features', 'vditor', 'window']; const groupTitle = { basic: '基本操作', features: '功能', vditor: 'Vditor', window: '視窗' }; for (const g of groupOrder) { const keys = hiddenKeys.filter(k => ToolbarPrefs.allButtons[k]?.group === g); if (!keys.length) continue; html += addHeader(groupTitle[g] || g); for (const k of keys) html += addItem(k); html += addSep(); } // 插槽摘要(資訊型) const slotSettings = QuickSlots.getSettings(); if (slotSettings.enabledCount > 0) { const slotStats = QuickSlots.getStats(); html += ` ${addHeader('快速存檔')}
${Icons.database} ${slotStats.used} / ${slotStats.total} 插槽已使用
`; } // 備份摘要(資訊型) html += ` ${addHeader('備份摘要')}
${Icons.database} ${backupStats.total} 筆備份 · ${backupStats.pinned} 筆釘選
`; // 快捷鍵入口(始終顯示) html += ` ${addSep()} `; this.morePanel.innerHTML = html; }, // ============ // Panels:Import/Export(focus-mode placement 改善) // ============ showImportPanel(anchor) { this.closeAllPanels(); this.importPanel.style.display = 'block'; Portal.positionAt(this.importPanel, anchor || this.moreBtn, { placement: this._isFocusMode() ? 'top-start' : 'bottom-start' }); this._markMenuOpen(); }, showExportPanel(anchor) { this.closeAllPanels(); this.exportPanel.style.display = 'block'; Portal.positionAt(this.exportPanel, anchor || this.moreBtn, { placement: this._isFocusMode() ? 'top-start' : 'bottom-start' }); this._markMenuOpen(); }, // ============ // Settings panel(偏好設定:工具列/狀態列) // ============ showSettingsPanel() { this.closeAllPanels(); this._renderSettingsPanel(); this.settingsPanel.style.display = 'flex'; Portal.positionAt(this.settingsPanel, null, { placement: 'center' }); this._markMenuOpen(); const header = this.settingsPanel.querySelector(`.${p}portal-panel-header`); if (header) Portal.enableDrag(this.settingsPanel, header); }, hideSettingsPanel() { this.settingsPanel.style.display = 'none'; this._hasOpenMenu = false; this.toolbar?.classList.remove(`${p}has-open-menu`); }, _renderSettingsPanel() { const p = CONFIG.prefix; const prefs = ToolbarPrefs.load(); const btns = ToolbarPrefs.allButtons; const slotSettings = QuickSlots.getSettings(); const fsStatus = FileSystemManager.getStatus(); // 定義主題色彩變量(用於模板字串) const isDark = Theme.isDark(); const c = isDark ? { bg1: '#1a1a2e', bg2: '#16213e', text1: '#e8e8e8', text2: '#a0a0a0', text3: '#6c757d', border: '#2d3748', accent: '#4facfe', btn: '#2d3748', btnHover: '#4a5568', danger: '#dc3545', warning: '#ffc107' } : { bg1: '#ffffff', bg2: '#f8f9fa', text1: '#212529', text2: '#6c757d', text3: '#adb5bd', border: '#dee2e6', accent: '#007bff', btn: '#e9ecef', btnHover: '#dee2e6', danger: '#dc3545', warning: '#ffc107' }; const groups = { basic: { title: '基本操作', keys: [] }, features: { title: '功能', keys: [] }, vditor: { title: 'Vditor 專用', keys: [] }, window: { title: '視窗控制', keys: [] } }; Object.keys(btns).forEach(key => { const g = btns[key]?.group; if (groups[g]) groups[g].keys.push(key); }); const renderGroup = (groupKey) => { const group = groups[groupKey]; if (!group?.keys?.length) return ''; const rows = group.keys.map(key => { const def = btns[key]; const isAlways = !!def.alwaysShow; const checked = !!prefs.show?.[key] || isAlways; return `
${!isAlways ? ` ` : ''}
`; }).join(''); return `

${Utils.escapeHtml(group.title)}

${rows}
`; }; this.settingsPanel.innerHTML = `

${Icons.settings} 偏好設定(工具列 / 狀態列)

按鈕外觀

狀態列

快速存檔插槽

備份警告設定

當備份內容超過設定的大小時,會顯示提示建議您使用「匯出」功能將內容備份到本機。 這不會影響備份的執行,只是一個提醒。

檔案系統備份

${fsStatus.supported ? Icons.check : Icons.x}
File System Access API
${fsStatus.supported ? '支援' : '不支援'}
${fsStatus.supported ? `
${fsStatus.directoryName ? `
${Icons.database} ${Utils.escapeHtml(fsStatus.directoryName)}
` : `
選擇資料夾後,備份將自動儲存到該位置。
注意:頁面重新載入後需要重新選擇資料夾。
`} ${fsStatus.hasHandle ? `
` : ''} ` : `
您的瀏覽器不支援 File System Access API。
請使用 Chrome 或 Edge 86 以上版本,或使用下方的手動匯出/匯入功能。
`}
手動匯出/匯入所有備份(適用於所有瀏覽器):

工具列按鈕

原則:工具列與「更多」選單互補。未勾選的按鈕將出現在「更多」選單中。

${renderGroup('basic')} ${renderGroup('features')} ${renderGroup('vditor')} ${renderGroup('window')}

拖曳導入

讓拖曳導入提示再次顯示
`; this._bindSettingsPanelEvents(); }, _bindSettingsPanelEvents() { // close this.settingsPanel.querySelector('[data-action="close-settings"]').onclick = () => { this.hideSettingsPanel(); }; // reset this.settingsPanel.querySelector('[data-action="reset-settings"]').onclick = () => { ToolbarPrefs.save(ToolbarPrefs.defaultPrefs()); this._renderSettingsPanel(); ToolbarPrefs.applyToModal(this); this._applyStatusMetricVisibility(); this.syncThemeButton(); this.syncMaximizeButton(); // 互補選單:若更多選單開著需刷新 if (this.morePanel?.classList.contains(`${p}open`)) { this._updateMoreMenuContent(); } Toast.info('已重置為預設配置'); }; // save/apply this.settingsPanel.querySelector('[data-action="save-settings"]').onclick = () => { this._saveSettingsFromPanel(); }; // move up/down this.settingsPanel.querySelectorAll(`.${p}settings-mini-btn`).forEach(btn => { btn.onclick = () => { const row = btn.closest(`.${p}settings-row`); const list = row?.parentElement; const move = btn.dataset.move; if (!row || !list) return; if (move === 'up' && row.previousElementSibling) { list.insertBefore(row, row.previousElementSibling); } else if (move === 'down' && row.nextElementSibling) { list.insertBefore(row.nextElementSibling, row); } }; }); // 重置拖曳提示 this.settingsPanel.querySelector(`#${p}reset-dragdrop-hint`)?.addEventListener('click', () => { DragDropManager.resetHintCount(); Toast.info('拖曳導入提示已重置'); }); // ===== 檔案系統設定 ===== // 選擇資料夾 this.settingsPanel.querySelector(`#${p}fs-select-dir`)?.addEventListener('click', async () => { const handle = await FileSystemManager.selectDirectory(); if (handle) { // 重新渲染設定面板以更新顯示 this._renderSettingsPanel(); this._bindSettingsPanelEvents(); } }); // 清除資料夾 this.settingsPanel.querySelector(`#${p}fs-clear-dir`)?.addEventListener('click', () => { FileSystemManager.clearDirectory(); // 重新渲染設定面板 this._renderSettingsPanel(); this._bindSettingsPanelEvents(); }); // 自動備份開關 this.settingsPanel.querySelector(`#${p}fs-auto-backup`)?.addEventListener('change', (e) => { FileSystemManager.saveSettings({ autoBackup: e.target.checked }); Toast.info(e.target.checked ? '已啟用自動備份到資料夾' : '已停用自動備份到資料夾'); }); // 匯出所有備份 this.settingsPanel.querySelector(`#${p}backup-export-all`)?.addEventListener('click', () => { const success = BackupManager.downloadAllBackups(); if (success) { Toast.success('備份已匯出'); } else { Toast.error('匯出失敗'); } }); // 匯入備份 this.settingsPanel.querySelector(`#${p}backup-import-all`)?.addEventListener('click', async () => { const result = await BackupManager.importBackupsFromFile(); if (result.success) { Toast.success(result.message); // 如果備份面板開啟,刷新它 if (this.backupPanel?.style.display !== 'none') { this._renderBackupPanel(); } } else if (result.message !== '已取消' && result.message !== '未選擇檔案') { Toast.error(result.message); } }); // 備份大小警告設定 const sizeWarningCheckbox = this.settingsPanel.querySelector(`#${p}backup-size-warning-enabled`); const sizeThresholdRow = this.settingsPanel.querySelector(`#${p}backup-size-threshold-row`); const sizeThresholdSelect = this.settingsPanel.querySelector(`#${p}backup-size-threshold`); // 根據開關狀態顯示/隱藏閾值設定 const updateThresholdVisibility = () => { if (sizeThresholdRow) { sizeThresholdRow.style.opacity = sizeWarningCheckbox?.checked ? '1' : '0.5'; if (sizeThresholdSelect) { sizeThresholdSelect.disabled = !sizeWarningCheckbox?.checked; } } }; sizeWarningCheckbox?.addEventListener('change', updateThresholdVisibility); updateThresholdVisibility(); // 初始狀態 // 快捷鍵一覽 this.settingsPanel.querySelector(`#${p}show-shortcuts`)?.addEventListener('click', () => { this.hideSettingsPanel(); this.showShortcutsPanel(); }); }, _saveSettingsFromPanel() { const prefs = ToolbarPrefs.load(); // ===== 儲存插槽設定 ===== const slotSettings = { enabledCount: parseInt(this.settingsPanel.querySelector(`#${p}slot-count`)?.value) || 5, showInToolbar: !!this.settingsPanel.querySelector(`#${p}slot-toolbar`)?.checked, confirmBeforeOverwrite: !!this.settingsPanel.querySelector(`#${p}slot-confirm-overwrite`)?.checked, confirmBeforeLoad: !!this.settingsPanel.querySelector(`#${p}slot-confirm-load`)?.checked, autoBackupBeforeLoad: !!this.settingsPanel.querySelector(`#${p}slot-auto-backup`)?.checked, // 迷你插槽列 showMiniBar: !!this.settingsPanel.querySelector(`#${p}slot-mini-bar`)?.checked, miniBarCount: parseInt(this.settingsPanel.querySelector(`#${p}slot-mini-count`)?.value || '5', 10), }; QuickSlots.saveSettings(slotSettings); // 根據插槽設定更新工具列按鈕顯示 prefs.show.slots = slotSettings.showInToolbar && slotSettings.enabledCount > 0; // appearance prefs.buttonAppearance = this.settingsPanel.querySelector(`#${p}btn-appearance`)?.value || 'icon-text'; // status bar prefs.statusBar.enabled = !!this.settingsPanel.querySelector(`#${p}status-enabled`)?.checked; prefs.statusBar.position = this.settingsPanel.querySelector(`#${p}status-position`)?.value || 'bottom'; prefs.statusBar.showWordCount = !!this.settingsPanel.querySelector(`#${p}status-word`)?.checked; prefs.statusBar.showLineCount = !!this.settingsPanel.querySelector(`#${p}status-line`)?.checked; prefs.statusBar.showSaveTime = !!this.settingsPanel.querySelector(`#${p}status-save`)?.checked; prefs.statusBar.showReadingTime = !!this.settingsPanel.querySelector(`#${p}status-reading`)?.checked; // show/hide this.settingsPanel.querySelectorAll(`input[type="checkbox"][data-action]`).forEach(cb => { const action = cb.dataset.action; const def = ToolbarPrefs.allButtons[action]; if (!action || !def) return; if (def.alwaysShow) return; prefs.show[action] = !!cb.checked; }); // order arrays (依 panel DOM 順序) prefs.orderLeft = Array.from( this.settingsPanel.querySelectorAll(`[data-group="basic"] .${p}settings-row, [data-group="features"] .${p}settings-row`) ).map(r => r.getAttribute('data-action')).filter(Boolean); prefs.orderRight = Array.from( this.settingsPanel.querySelectorAll(`[data-group="vditor"] .${p}settings-row, [data-group="window"] .${p}settings-row`) ).map(r => r.getAttribute('data-action')).filter(Boolean); // 儲存備份大小警告設定 const sizeWarningEnabled = !!this.settingsPanel.querySelector(`#${p}backup-size-warning-enabled`)?.checked; const sizeThresholdMB = parseFloat( this.settingsPanel.querySelector(`#${p}backup-size-threshold`)?.value || '1' ); BackupManager.setSizeWarningSettings(sizeWarningEnabled, sizeThresholdMB); ToolbarPrefs.save(prefs); // apply + 重要:同步動態 icon(避免被 applyToModal 覆蓋後狀態錯亂) ToolbarPrefs.applyToModal(this); this._applyStatusMetricVisibility(); this.syncThemeButton(); this.syncMaximizeButton(); this.updateMiniSlotsBar(); // 互補:更多選單若開啟需刷新 if (this.morePanel?.classList.contains(`${p}open`)) { this._updateMoreMenuContent(); } this.hideSettingsPanel(); Toast.success('偏好設定已套用'); }, // ============ // Backup panel // ============ showBackupPanel() { this.closeAllPanels(); // 讀取持久化頁碼(避免每次開啟都回到第 1 頁) const savedPage = Utils.storage.get(CONFIG.storageKeys.backupPage, 0); this._backupCurrentPage = Number.isFinite(savedPage) ? savedPage : 0; this._renderBackupPanel(); this.backupPanel.style.display = 'flex'; Portal.positionAt(this.backupPanel, null, { placement: 'center' }); this._markMenuOpen(); const header = this.backupPanel.querySelector(`.${p}portal-panel-header`); if (header) Portal.enableDrag(this.backupPanel, header); }, // ============ // Shortcuts panel(快捷鍵一覽) // ============ showShortcutsPanel() { const p = CONFIG.prefix; // 動態建立面板 let panel = document.getElementById(`${p}shortcuts-panel-instance`); if (!panel) { panel = document.createElement('div'); panel.id = `${p}shortcuts-panel-instance`; panel.className = `${p}portal-panel ${p}shortcuts-panel`; Portal.append(panel); } // 生成內容 let categoriesHtml = ''; KEYBOARD_SHORTCUTS.forEach(cat => { const itemsHtml = cat.items.map(item => ` ${Utils.escapeHtml(item.key)} ${Utils.escapeHtml(item.desc)} `).join(''); categoriesHtml += `
${Utils.escapeHtml(cat.category)}
${itemsHtml}
`; }); panel.innerHTML = `

${Icons.info} 快捷鍵一覽

${categoriesHtml}
`; // 顯示面板 panel.style.display = 'flex'; Portal.positionAt(panel, null, { placement: 'center' }); // 綁定關閉事件 const closeBtns = panel.querySelectorAll('[data-action="close"]'); closeBtns.forEach(btn => { btn.onclick = () => { panel.style.display = 'none'; }; }); // 啟用拖曳 const header = panel.querySelector(`.${p}portal-panel-header`); if (header) Portal.enableDrag(panel, header); }, // ============ // Slots panel(快速存檔) // ============ showSlotsPanel(anchor) { this.closeAllPanels(); // 確保 slotsPanel 的事件委派只綁定一次(避免重渲染後按鈕失效) this._ensureSlotsPanelDelegationOnce(); this._renderSlotsPanel(); this.slotsPanel.style.display = 'block'; Portal.positionAt(this.slotsPanel, anchor || this.moreBtn, { placement: this._isFocusMode() ? 'top-start' : 'bottom-start' }); this._markMenuOpen(); }, hideSlotsPanel() { const p = CONFIG.prefix; // 確保變量定義 if (this.slotsPanel) { this.slotsPanel.style.display = 'none'; } this._hasOpenMenu = false; this.toolbar?.classList.remove(`${p}has-open-menu`); }, _renderSlotsPanel() { const p = CONFIG.prefix; const settings = QuickSlots.getSettings(); const slots = QuickSlots.getAllSlotStatus(true); const stats = QuickSlots.getStats(); // 若未啟用任何插槽 if (settings.enabledCount === 0) { this.slotsPanel.innerHTML = `

${Icons.slots} 快速存檔

目前未啟用任何插槽。
請在「偏好設定」中調整插槽數量。
`; this._bindSlotsPanelEvents(); return; } // 建立插槽列表 HTML let listHtml = ''; slots.forEach(({ slot, isEmpty, meta }) => { const label = meta?.label || `插槽 ${slot}`; const timeStr = meta ? Utils.formatRelativeTime(meta.ts) : ''; const charsStr = meta ? `${meta.chars} 字` : ''; const linesStr = meta ? `${meta.lines} 行` : ''; listHtml += `
${slot}
${isEmpty ? `
(空插槽)
` : `
${Utils.escapeHtml(label)}
${charsStr} ${linesStr} ${timeStr}
`}
${!isEmpty ? ` ` : ''} ${!isEmpty ? ` ` : ''}
`; }); this.slotsPanel.innerHTML = `

${Icons.slots} 快速存檔

💡 快捷鍵:Ctrl+1~9 載入,Ctrl+Shift+1~9 儲存
已使用 ${stats.used} / ${stats.total} 插槽 ${stats.totalChars}
${listHtml}
`; this._bindSlotsPanelEvents(); }, _bindSlotsPanelEvents() { const p = CONFIG.prefix; const self = this; // 使用事件委派處理所有按鈕點擊 // 移除舊的監聽器(避免重複綁定) const newPanel = this.slotsPanel.cloneNode(true); this.slotsPanel.parentNode?.replaceChild(newPanel, this.slotsPanel); this.slotsPanel = newPanel; this.slotsPanel.addEventListener('click', (e) => { const actionBtn = e.target.closest('[data-action]'); if (!actionBtn) return; const action = actionBtn.getAttribute('data-action'); const slot = parseInt(actionBtn.getAttribute('data-slot') || '0'); e.preventDefault(); e.stopPropagation(); switch (action) { case 'slots-close': self.hideSlotsPanel(); break; case 'slot-load': if (slot) self._slotLoad(slot); break; case 'slot-save': if (slot) self._slotSave(slot); break; case 'slot-clear': if (slot) self._slotClear(slot); break; case 'slot-preview': if (slot) self._slotPreview(slot); break; case 'slot-edit-label': if (slot) self._slotEditLabel(slot); break; case 'slots-export': self._slotsExport(); break; case 'slots-import': self._slotsImport(); break; case 'slots-clear-all': self._slotsClearAll(); break; } }); }, /** * 載入插槽內容 */ _slotLoad(slot) { const settings = QuickSlots.getSettings(); // 檢查插槽是否為空 if (QuickSlots.isSlotEmpty(slot)) { Toast.warning(`插槽 ${slot} 為空`); return; } // 檢查當前是否有內容 const currentContent = EditorManager.getValue(); const hasCurrentContent = currentContent && currentContent.trim().length > 0; if (hasCurrentContent && settings.confirmBeforeLoad) { if (!confirm(`目前編輯器中有內容。\n載入插槽 ${slot} 將會覆蓋當前內容。\n\n確定要繼續嗎?`)) { return; } } // 自動備份當前內容 if (hasCurrentContent && settings.autoBackupBeforeLoad) { const info = EditorManager.getCurrentInfo(); BackupManager.create(currentContent, { editorKey: info?.key, mode: info?.adapter?._detectModeFromDOM?.(), manual: false }); log('QuickSlots: Auto-backed up current content before loading slot', slot); } // 載入插槽內容 const result = QuickSlots.loadFromSlot(slot); if (result.success) { EditorManager.setValue(result.content); const label = result.meta?.label || `插槽 ${slot}`; Toast.success(`已載入:${label}`); this.updateWordCount(); this.hideSlotsPanel(); } else { Toast.error(result.message); } }, /** * 儲存到插槽 */ _slotSave(slot) { if (!EditorManager.isReady()) { Toast.warning('編輯器尚未就緒'); return; } const content = EditorManager.getValue(); if (!content || !content.trim()) { Toast.warning('目前無內容可儲存'); return; } const settings = QuickSlots.getSettings(); const existing = QuickSlots.getSlotMeta(slot); // 確認覆蓋 if (existing && settings.confirmBeforeOverwrite) { const existingLabel = existing.label || `插槽 ${slot}`; if (!confirm(`插槽 ${slot}(${existingLabel})已有內容。\n\n確定要覆蓋嗎?`)) { return; } } // 儲存 const info = EditorManager.getCurrentInfo(); const result = QuickSlots.saveToSlot(slot, content, { editorKey: info?.key || null }); if (result.success) { Toast.success(`已儲存到插槽 ${slot}`); this._renderSlotsPanel(); // 刷新面板 } else { Toast.error(result.message); } }, /** * 清空插槽 */ _slotClear(slot) { const meta = QuickSlots.getSlotMeta(slot); if (!meta) { Toast.info(`插槽 ${slot} 已經是空的`); return; } const label = meta.label || `插槽 ${slot}`; if (!confirm(`確定要清空「${label}」嗎?\n\n此操作無法復原。`)) { return; } QuickSlots.clearSlot(slot); Toast.info(`已清空插槽 ${slot}`); this._renderSlotsPanel(); // 刷新面板 }, /** * 預覽插槽內容 */ _slotPreview(slot) { const result = QuickSlots.loadFromSlot(slot); if (!result.success) { Toast.error(result.message); return; } const p = CONFIG.prefix; const meta = result.meta; const label = meta?.label || `插槽 ${slot}`; const timeStr = meta ? Utils.formatRelativeTime(meta.ts) : ''; const panel = document.createElement('div'); panel.className = `${p}portal-panel ${p}slot-preview-panel`; panel.innerHTML = `

${Icons.eye} 預覽:${Utils.escapeHtml(label)}

${meta?.chars || 0} ${meta?.lines || 0} ${timeStr}
${Utils.escapeHtml(result.content)}
`; Portal.append(panel); panel.style.display = 'flex'; Portal.positionAt(panel, null, { placement: 'center' }); const header = panel.querySelector(`.${p}portal-panel-header`); if (header) Portal.enableDrag(panel, header); // 綁定事件 panel.querySelector('[data-action="close"]').onclick = () => Portal.remove(panel); panel.querySelector('[data-action="copy"]').onclick = async () => { const ok = await Utils.copyToClipboard(result.content); Toast[ok ? 'success' : 'error'](ok ? '已複製內容' : '複製失敗'); }; panel.querySelector('[data-action="load"]').onclick = () => { Portal.remove(panel); this._slotLoad(slot); }; }, /** * 編輯插槽標籤 */ _slotEditLabel(slot) { const meta = QuickSlots.getSlotMeta(slot); if (!meta) { Toast.warning(`插槽 ${slot} 為空`); return; } const currentLabel = meta.label || ''; const newLabel = prompt(`設定插槽 ${slot} 的標籤:`, currentLabel); if (newLabel === null) return; // 使用者取消 QuickSlots.setSlotLabel(slot, newLabel.trim()); Toast.success(newLabel.trim() ? `標籤已設為:${newLabel.trim()}` : '標籤已清除'); this._renderSlotsPanel(); // 刷新面板 }, /** * 匯出所有插槽 */ _slotsExport() { const data = QuickSlots.exportAllSlots(); const json = JSON.stringify(data, null, 2); const date = Utils.formatDate(); const filename = `mme_slots_${date}.json`; const ok = Utils.downloadFile(json, filename, 'application/json;charset=utf-8'); Toast[ok ? 'success' : 'error'](ok ? '插槽已匯出' : '匯出失敗'); }, /** * 匯入插槽 */ _slotsImport() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.style.display = 'none'; input.onchange = async (e) => { const file = e.target.files?.[0]; if (!file) return; try { const text = await Utils.readFile(file); const data = JSON.parse(text); const overwrite = confirm( '匯入選項:\n\n' + '點擊「確定」:覆蓋現有的非空插槽\n' + '點擊「取消」:僅匯入到空插槽' ); const result = QuickSlots.importSlots(data, { overwrite }); if (result.success) { Toast.success(result.message); this._renderSlotsPanel(); // 刷新面板 } else { Toast.error(result.message); } } catch (err) { Toast.error('匯入失敗:檔案格式無效'); logError('Slots import error:', err); } input.remove(); }; document.body.appendChild(input); input.click(); }, /** * 清空所有插槽 */ _slotsClearAll() { const stats = QuickSlots.getStats(); if (stats.used === 0) { Toast.info('所有插槽都是空的'); return; } if (!confirm(`確定要清空所有 ${stats.used} 個插槽嗎?\n\n此操作無法復原。`)) { return; } const count = QuickSlots.clearAllSlots(); Toast.info(`已清空 ${count} 個插槽`); this._renderSlotsPanel(); // 刷新面板 }, updateMiniSlotsBar() { const p = CONFIG.prefix; const el = this.miniSlotsEl; if (!el) return; const s = QuickSlots.getSettings(); // 是否顯示:需啟用插槽且 showMiniBar = true const enabled = (s.enabledCount > 0) && !!s.showMiniBar; if (!enabled) { el.style.display = 'none'; el.innerHTML = ''; return; } const count = Math.max(1, Math.min(s.enabledCount, s.miniBarCount || 5)); // 只渲染 1..count const btns = []; for (let slot = 1; slot <= count; slot++) { const meta = QuickSlots.getSlotMeta(slot); const isEmpty = !meta; const label = meta?.label || `插槽 ${slot}`; const timeStr = meta ? Utils.formatRelativeTime(meta.ts) : '空插槽'; const charsStr = meta ? `${meta.chars || 0} 字` : ''; const tooltip = isEmpty ? `插槽 ${slot}(空)\n點擊:載入(無效)\nShift+點擊:儲存` : `${label}\n${charsStr} · ${timeStr}\n點擊:載入\nShift+點擊:儲存(覆蓋)`; btns.push(` `); } el.innerHTML = btns.join(''); el.style.display = 'flex'; }, _bindMiniSlotsBarEventsOnce() { if (this._miniSlotsBound) return; this._miniSlotsBound = true; const el = this.miniSlotsEl; if (!el) return; el.addEventListener('click', (e) => { const btn = e.target.closest('[data-slot]'); if (!btn) return; const slot = parseInt(btn.getAttribute('data-slot') || '0', 10); if (!slot) return; // Shift+Click:儲存;Click:載入 if (e.shiftKey) { this._slotSave(slot); // 立即更新 mini bar(因為 meta 改變了) this.updateMiniSlotsBar(); } else { this._slotLoad(slot); // load 會更新 lastAccess,也更新一下 tooltip this.updateMiniSlotsBar(); } }); }, _ensureSlotsPanelDelegationOnce() { if (this._slotsDelegated) return; this._slotsDelegated = true; if (!this.slotsPanel) return; this.slotsPanel.addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.getAttribute('data-action'); const slot = parseInt(btn.getAttribute('data-slot') || '0', 10); switch (action) { case 'slots-close': this.hideSlotsPanel(); return; case 'slot-load': if (slot) this._slotLoad(slot); this.updateMiniSlotsBar(); return; case 'slot-save': if (slot) this._slotSave(slot); this.updateMiniSlotsBar(); return; case 'slot-clear': if (slot) this._slotClear(slot); this.updateMiniSlotsBar(); return; case 'slot-preview': if (slot) this._slotPreview(slot); return; case 'slot-edit-label': if (slot) this._slotEditLabel(slot); this.updateMiniSlotsBar(); return; case 'slots-export': this._slotsExport(); return; case 'slots-import': this._slotsImport(); // 匯入後會 re-render,再更新 mini bar setTimeout(() => this.updateMiniSlotsBar(), 50); return; case 'slots-clear-all': this._slotsClearAll(); this.updateMiniSlotsBar(); return; } }); }, hideBackupPanel() { this.backupPanel.style.display = 'none'; this._hasOpenMenu = false; this.toolbar?.classList.remove(`${p}has-open-menu`); }, _renderBackupPanel() { const p = CONFIG.prefix; const allIndex = BackupManager.getIndex(); const stats = BackupManager.getStats(); // 分頁設定 const PAGE_SIZE = CONFIG.backup.pageSize || 20; const currentPage = Number.isFinite(this._backupCurrentPage) ? this._backupCurrentPage : 0; const totalPages = Math.ceil(allIndex.length / PAGE_SIZE); const startIdx = currentPage * PAGE_SIZE; const endIdx = Math.min(startIdx + PAGE_SIZE, allIndex.length); const index = allIndex.slice(startIdx, endIdx); // 吸收 B 版亮點:加入“草稿/備份/快照”定位說明(不改行為,只補 UX) const infoBox = `
${Icons.shield} 備份 / 草稿 / 快照:概念說明
  • 草稿:您目前的工作內容(「保存」保存到瀏覽器)
  • 備份:歷史版本(可釘選永久保留,可下載)
  • 快照:Vditor 專用救援(防模式切換內容異常)
  • 📌 釘選的備份永久保留,不會被自動清理
  • ⏱️ 1 小時內:每 2 分鐘保留一筆
  • 📅 24 小時內:每 10 分鐘保留一筆
  • 📆 7 天內:每天保留一筆
  • 📊 最多保留 ${CONFIG.backup.maxBackups} 筆備份
⚠️ 重要:若內容重要,請使用「釘選」或「下載」以避免自動清理導致遺失。
`; // 如果有多頁,新增分頁控制 let paginationHtml = ''; if (totalPages > 1) { paginationHtml = `
第 ${currentPage + 1} / ${totalPages} 頁 (共 ${allIndex.length} 筆)
`; } let listHtml = ''; if (!index.length) { listHtml = `
${Icons.database}

暫無備份

編輯器會定期自動備份(可釘選)

`; } else { listHtml = index.map(meta => { const time = Utils.formatRelativeTime(meta.ts); const fullTime = new Date(meta.ts).toLocaleString('zh-TW'); const editorName = CONFIG.editors[meta.editorKey]?.name || meta.editorKey || '未知'; const age = Date.now() - meta.ts; const isOld = age > 86400000; const isVeryOld = age > 604800000; let ageWarning = ''; if (!meta.pinned) { if (isVeryOld) ageWarning = `⚠️ 可能即將被刪除`; else if (isOld) ageWarning = `📅 較舊備份`; } return `
${Utils.escapeHtml(time)} ${meta.pinned ? `📌 已釘選` : ''} ${ageWarning}
${meta.chars || 0} 字 ${meta.lines || 0} 行 ${Utils.escapeHtml(editorName)} ${meta.mode ? `${Utils.escapeHtml(meta.mode)}` : ''}
`; }).join(''); } this.backupPanel.innerHTML = `

${Icons.database} 備份管理

${infoBox}
${stats.total} 釘選 ${stats.pinned} ${stats.oldest ? `最早 ${Utils.escapeHtml(Utils.formatRelativeTime(stats.oldest))}` : ''}
${listHtml}
`; // bind header close this.backupPanel.querySelector('[data-action="close-backup"]').onclick = () => this.hideBackupPanel(); // 分頁控制 this.backupPanel.querySelector('[data-action="backup-prev"]')?.addEventListener('click', () => { if (this._backupCurrentPage > 0) { this._backupCurrentPage--; Utils.storage.set(CONFIG.storageKeys.backupPage, this._backupCurrentPage); this._renderBackupPanel(); } }); this.backupPanel.querySelector('[data-action="backup-next"]')?.addEventListener('click', () => { const totalPages = Math.ceil(BackupManager.getIndex().length / (CONFIG.backup.pageSize || 20)); if (this._backupCurrentPage < totalPages - 1) { this._backupCurrentPage++; Utils.storage.set(CONFIG.storageKeys.backupPage, this._backupCurrentPage); this._renderBackupPanel(); } }); // footer this.backupPanel.querySelector('[data-action="backup-now"]').onclick = () => { if (!EditorManager.isReady()) { Toast.warning('編輯器尚未就緒'); return; } const content = EditorManager.getValue(); const info = EditorManager.getCurrentInfo(); const meta = BackupManager.create(content, { editorKey: info?.key, mode: info?.adapter?._detectModeFromDOM?.(), manual: true }); if (meta) { Toast.success('已建立備份'); this._renderBackupPanel(); this._updateMoreMenuContent(); // 內容不一定變,但保持一致 } else { Toast.info('內容無變更,無需備份'); } }; this.backupPanel.querySelector('[data-action="pin-all"]').onclick = () => { const idx = BackupManager.getIndex(); let count = 0; idx.forEach(m => { if (!m.pinned) { m.pinned = true; count++; } }); BackupManager.saveIndex(idx); Toast.success(`已釘選 ${count} 筆備份`); this._renderBackupPanel(); }; this.backupPanel.querySelector('[data-action="clear-backups"]').onclick = () => { const pinnedCount = BackupManager.getStats().pinned; const msg = pinnedCount > 0 ? `確定要清除所有備份嗎?\n\n⚠️ 這將包括 ${pinnedCount} 筆已釘選備份!\n\n此操作無法復原。` : '確定要清除所有備份嗎?此操作無法復原。'; if (confirm(msg)) { BackupManager.clearAll(); Toast.info('已清除所有備份'); this._renderBackupPanel(); this._updateMoreMenuContent(); Utils.storage.set(CONFIG.storageKeys.backupPage, 0); this._backupCurrentPage = 0; } }; // item actions (event binding per item) this.backupPanel.querySelectorAll(`.${p}backup-item`).forEach(item => { const id = item.dataset.id; if (!id) return; item.querySelector('[data-action="preview"]')?.addEventListener('click', () => { this._showBackupPreview(id); }); item.querySelector('[data-action="restore"]')?.addEventListener('click', () => { const content = BackupManager.restore(id); if (content) { EditorManager.setValue(content); Toast.success('已還原備份'); this.updateWordCount(); } }); item.querySelector('[data-action="pin"]')?.addEventListener('click', () => { const pinned = BackupManager.togglePin(id); Toast.info(pinned ? '已釘選(永久保留)' : '已取消釘選(可能被自動清理)'); this._renderBackupPanel(); this._updateMoreMenuContent(); }); item.querySelector('[data-action="download"]')?.addEventListener('click', () => { const content = BackupManager.getBackup(id); const meta = BackupManager.getIndex().find(m => m.id === id); if (content) { const date = new Date(meta?.ts || Date.now()).toISOString().replace(/[:.]/g, '-').slice(0, 19); Utils.downloadFile(content, `backup_${date}.md`, 'text/markdown;charset=utf-8'); Toast.success('已下載備份'); } }); item.querySelector('[data-action="delete"]')?.addEventListener('click', () => { const meta = BackupManager.getIndex().find(m => m.id === id); const msg = meta?.pinned ? '⚠️ 此備份已被釘選!確定要刪除嗎?' : '確定要刪除此備份嗎?'; if (confirm(msg)) { BackupManager.delete(id); Toast.info('已刪除備份'); this._renderBackupPanel(); this._updateMoreMenuContent(); } }); }); }, _showBackupPreview(id) { const content = BackupManager.getBackup(id); const meta = BackupManager.getIndex().find(m => m.id === id); if (!content) { Toast.error('無法讀取備份'); return; } const panel = document.createElement('div'); panel.className = `${p}portal-panel ${p}preview-panel`; panel.innerHTML = `

${Icons.eye} 備份預覽 - ${Utils.escapeHtml(Utils.formatRelativeTime(meta?.ts || Date.now()))}

${Utils.escapeHtml(content)}
`; Portal.append(panel); panel.style.display = 'flex'; Portal.positionAt(panel, null, { placement: 'center' }); const header = panel.querySelector(`.${p}portal-panel-header`); if (header) Portal.enableDrag(panel, header); panel.querySelector('[data-action="close"]').onclick = () => Portal.remove(panel); panel.querySelector('[data-action="copy"]').onclick = async () => { const ok = await Utils.copyToClipboard(content); Toast[ok ? 'success' : 'error'](ok ? '已複製' : '複製失敗'); }; panel.querySelector('[data-action="restore"]').onclick = () => { EditorManager.setValue(content); Toast.success('已還原備份'); this.updateWordCount(); Portal.remove(panel); }; }, // ============ // Focus mode (behavior) — CSS in EnhanceUI // ============ toggleFocusMode() { const prefs = ToolbarPrefs.load(); prefs.focusMode = !prefs.focusMode; ToolbarPrefs.save(prefs); ToolbarPrefs.applyToModal(this); this._applyStatusMetricVisibility(); this.syncThemeButton(); this.syncMaximizeButton(); if (prefs.focusMode) { this.modal.classList.add(`${p}show-hint`); setTimeout(() => this.modal.classList.remove(`${p}show-hint`), 4500); Toast.info('已進入專注模式 · 滑鼠移至底部顯示工具列 · 按 Esc 退出', 4000); } else { Toast.info('已退出專注模式'); // 確保退出時工具列可見 this.toolbar?.classList.remove(`${p}visible`); this.toolbar?.classList.remove(`${p}has-open-menu`); } // 若更多選單開啟,刷新互補內容 if (this.morePanel?.classList.contains(`${p}open`)) { this._updateMoreMenuContent(); } }, _isFocusMode() { return this.modal?.classList.contains(`${p}focus-mode`); }, _initFocusModeControl() { if (this._focusModeBound) return; this._focusModeBound = true; const TRIGGER_ZONE_HEIGHT = CONFIG.dimensions.focusTriggerZoneHeight; this._focusModeMouseHandler = (e) => { if (!this.isOpen) return; const prefs = ToolbarPrefs.load(); if (!prefs.focusMode) return; if (this._hasOpenMenu) { this.toolbar.classList.add(`${p}visible`); return; } const modalRect = this.modal.getBoundingClientRect(); const distanceFromBottom = modalRect.bottom - e.clientY; if (distanceFromBottom >= 0 && distanceFromBottom <= TRIGGER_ZONE_HEIGHT) { this.toolbar.classList.add(`${p}visible`); } else { const tb = this.toolbar.getBoundingClientRect(); const inToolbar = ( e.clientY >= tb.top && e.clientY <= tb.bottom && e.clientX >= tb.left && e.clientX <= tb.right ); if (!inToolbar) this.toolbar.classList.remove(`${p}visible`); } }; this._focusModeLeaveHandler = (e) => { if (!this.isOpen) return; const prefs = ToolbarPrefs.load(); if (!prefs.focusMode) return; if (this._hasOpenMenu) return; if (!this.modal.contains(e.relatedTarget)) { this.toolbar.classList.remove(`${p}visible`); } }; document.addEventListener('mousemove', this._focusModeMouseHandler); this.modal.addEventListener('mouseleave', this._focusModeLeaveHandler); }, /** * 初始化 Tooltip 系統 * * 設計意圖: * - 為帶有 data-tooltip 屬性的元素顯示提示 * - 智慧定位:根據元素位置決定 Tooltip 顯示在上方或下方 * - 防閃爍:使用延遲顯示/隱藏機制 */ _initTooltip() { if (this._tooltipBound) return; this._tooltipBound = true; let hideTimer = null; let showTimer = null; let currentTarget = null; const SHOW_DELAY = 150; // 顯示延遲,避免快速移動時閃爍 const HIDE_DELAY = 100; // 隱藏延遲,允許滑鼠移到 tooltip 上 const PADDING = CONFIG.dimensions?.tooltipPadding || 5; /** * 顯示 Tooltip */ const showTooltip = (e) => { const target = e.target.closest('[data-tooltip]'); if (!target) return; // 清除隱藏計時器 clearTimeout(hideTimer); // 如果是同一個目標且已經顯示,不需要重新處理 if (target === currentTarget && this.tooltipEl.style.opacity === '1') { return; } // 清除之前的顯示計時器 clearTimeout(showTimer); const text = target.getAttribute('data-tooltip'); if (!text) return; // 延遲顯示,避免快速掃過時閃爍 showTimer = setTimeout(() => { currentTarget = target; this.tooltipEl.textContent = text; this.tooltipEl.style.visibility = 'visible'; this.tooltipEl.style.opacity = '0'; requestAnimationFrame(() => { if (!this.tooltipEl) return; const rect = target.getBoundingClientRect(); const ttRect = this.tooltipEl.getBoundingClientRect(); const isBottomHalf = rect.top > window.innerHeight / 2; // 計算位置 let top = isBottomHalf ? rect.top - ttRect.height - 8 : rect.bottom + 8; let left = rect.left + rect.width / 2 - ttRect.width / 2; // 邊界限制 top = Math.max(PADDING, Math.min(top, window.innerHeight - ttRect.height - PADDING)); left = Math.max(PADDING, Math.min(left, window.innerWidth - ttRect.width - PADDING)); this.tooltipEl.style.top = `${top}px`; this.tooltipEl.style.left = `${left}px`; this.tooltipEl.style.opacity = '1'; }); }, SHOW_DELAY); }; /** * 隱藏 Tooltip */ const hideTooltip = () => { clearTimeout(showTimer); hideTimer = setTimeout(() => { if (!this.tooltipEl) return; this.tooltipEl.style.opacity = '0'; currentTarget = null; }, HIDE_DELAY); }; // 事件綁定 document.addEventListener('mouseover', showTooltip); document.addEventListener('mouseout', (e) => { if (e.target.closest('[data-tooltip]')) { hideTooltip(); } }); // 主題變更時更新 Tooltip 背景色 Theme.onChange((t) => { if (!this.tooltipEl) return; this.tooltipEl.style.background = t === 'dark' ? '#4a5568' : '#333'; }); }, // ============ // Outside click closes panels // ============ _bindOutsideClickToClosePanels() { if (this._outsideClickBound) return; this._outsideClickBound = true; document.addEventListener('click', (e) => { if (!this.isOpen) return; const isInPanel = [ this.morePanel, this.importPanel, this.exportPanel, this.settingsPanel, this.backupPanel, this.slotsPanel, ].some(panel => panel && panel.contains(e.target)); const isInToolbar = this.toolbar?.contains(e.target); if (!isInPanel && !isInToolbar) { this.closeAllPanels(); } }, true); }, // ============ // Keyboard shortcuts // ============ /** * 檢查當前焦點是否位於編輯器的可編輯區域 * * 設計意圖: * - 當焦點在編輯器內的文字輸入區域時,應讓編輯器處理搜尋快捷鍵 * - 這包括 CodeMirror、textarea、contenteditable 元素 * - 各編輯器使用不同的技術,需要全面檢查 * * @returns {boolean} 是否在編輯器可編輯區域內 */ _isFocusInEditorEditableArea() { try { const activeEl = document.activeElement; if (!activeEl) return false; // 首先確認焦點在 Modal 的編輯器區域內 if (!this.editorContainer?.contains(activeEl)) { return false; } // 檢查 1:焦點在 textarea 內 if (activeEl.tagName === 'TEXTAREA') { return true; } // 檢查 2:焦點在 contenteditable 元素內 if (activeEl.getAttribute('contenteditable') === 'true') { return true; } // 檢查 3:焦點在 CodeMirror 內(EasyMDE、Cherry 使用) // CodeMirror 的焦點通常在 .CodeMirror-code 或其子元素 if (activeEl.closest('.CodeMirror')) { return true; } // 檢查 4:焦點在 Vditor 的編輯區域內 // Vditor 有三種模式,各有不同的容器 if (activeEl.closest('.vditor-sv') || activeEl.closest('.vditor-ir') || activeEl.closest('.vditor-wysiwyg')) { return true; } // 檢查 5:焦點在 Toast UI Editor 的編輯區域內 // Toast UI 使用 ProseMirror if (activeEl.closest('.ProseMirror') || activeEl.closest('.toastui-editor-md-container') || activeEl.closest('.toastui-editor-ww-container')) { return true; } // 檢查 6:通用的 contenteditable 祖先檢查 let parent = activeEl.parentElement; while (parent && parent !== this.editorContainer) { if (parent.getAttribute('contenteditable') === 'true') { return true; } parent = parent.parentElement; } return false; } catch (e) { // 發生錯誤時,保守起見返回 false(使用我們的 FindReplace) log('_isFocusInEditorEditableArea error:', e.message); return false; } }, _bindKeyboardShortcuts() { if (this._keyBound) return; this._keyBound = true; document.addEventListener('keydown', (e) => { // Alt+M global toggle if (e.altKey && (e.key === 'm' || e.key === 'M')) { e.preventDefault(); this.toggle(); return; } if (!this.isOpen) return; // Escape: close panels -> exit focus -> close if (e.key === 'Escape') { e.preventDefault(); if (this._hasOpenMenu) { this.closeAllPanels(); return; } const prefs = ToolbarPrefs.load(); if (prefs.focusMode) { prefs.focusMode = false; ToolbarPrefs.save(prefs); ToolbarPrefs.applyToModal(this); this._applyStatusMetricVisibility(); this.syncThemeButton(); this.syncMaximizeButton(); Toast.info('已退出專注模式'); return; } this.close(); return; } // Ctrl/Cmd+S save if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); this.saveDraft(false); return; } // F9 maximize if (e.key === 'F9') { e.preventDefault(); this.toggleMaximize(); return; } // Ctrl/Cmd+O open file if ((e.ctrlKey || e.metaKey) && (e.key === 'o' || e.key === 'O')) { e.preventDefault(); this.fileInput?.click(); return; } // Ctrl/Cmd+Shift+C copy markdown if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'c' || e.key === 'C')) { e.preventDefault(); this.copyMD(); return; } // Ctrl+F:尋找 // 若焦點在編輯器可編輯區域內,讓編輯器自己處理(尊重編輯器原生體驗) if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'f' || e.key === 'F')) { if (this._isFocusInEditorEditableArea()) { // 不攔截,讓編輯器處理 log('Ctrl+F: letting editor handle it'); return; } e.preventDefault(); FindReplace.show(false); return; } // Ctrl+H:尋找與取代 // 若焦點在編輯器可編輯區域內,讓編輯器自己處理 if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'h' || e.key === 'H')) { if (this._isFocusInEditorEditableArea()) { // 不攔截,讓編輯器處理 log('Ctrl+H: letting editor handle it'); return; } e.preventDefault(); FindReplace.show(true); return; } // ===== 插槽快捷鍵 ===== // Ctrl/Cmd + 1-9: 載入對應插槽 if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key >= '1' && e.key <= '9') { const slot = parseInt(e.key); const settings = QuickSlots.getSettings(); // 只有在插槽啟用範圍內才響應 if (slot <= settings.enabledCount) { e.preventDefault(); if (QuickSlots.isSlotEmpty(slot)) { Toast.info(`插槽 ${slot} 為空`); } else { this._slotLoad(slot); } } return; } // Ctrl/Cmd + Shift + 1-9: 儲存到對應插槽 if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && e.key >= '1' && e.key <= '9') { const slot = parseInt(e.key); const settings = QuickSlots.getSettings(); // 只有在插槽啟用範圍內才響應 if (slot <= settings.enabledCount) { e.preventDefault(); this._slotSave(slot); } return; } }); }, // ============ // Vditor safe switch / snapshot actions // ============ async handleVditorSafeSwitch(mode) { const info = EditorManager.getCurrentInfo(); if (info?.key !== 'vditor') { Toast.info('請先切換到 Vditor'); return; } const modeKey = (mode === 'wysiwyg') ? 'wysiwyg' : (mode === 'ir' ? 'ir' : 'sv'); const currentContent = EditorManager.getValue(); const adapter = info.adapter; // 若目前在 sv,盡量保存 sv 快照(與適配器策略一致) try { adapter?._saveSVSnapshot?.('pre-safe-switch'); } catch (e) { /* ignore */ } // persist snapshot + draft Utils.storage.set(CONFIG.storageKeys.vditorSnapshot, currentContent); Utils.storage.set(CONFIG.storageKeys.content, currentContent); // create backup (manual) BackupManager.create(currentContent, { editorKey: 'vditor', mode: adapter?._detectModeFromDOM?.(), manual: true }); // mode preference + safe reinit Utils.storage.set(CONFIG.storageKeys.editorMode, modeKey); Utils.storage.set(CONFIG.storageKeys.vditorSafeReinitFlag, true); Toast.info(`正在安全切換至 ${modeKey.toUpperCase()} 模式...`, 2000); setTimeout(async () => { await this.switchEditor('vditor'); setTimeout(() => { const afterContent = EditorManager.getValue(); const beforeLen = currentContent.replace(/\s/g, '').length; const afterLen = afterContent.replace(/\s/g, '').length; if (beforeLen > 50 && afterLen < beforeLen * 0.7) { Toast.error(`⚠️ 切換後偵測到內容異常(${beforeLen} → ${afterLen} 字)。正在自動還原...`, 5000); EditorManager.setValue(currentContent); } else { Toast.success(`已切換至 ${modeKey.toUpperCase()} 模式`, 2500); } }, 600); }, 120); }, vditorRestoreSnapshot() { const info = EditorManager.getCurrentInfo(); if (info?.key !== 'vditor') return Toast.info('只有在 Vditor 時可用'); info.adapter?.restoreLastSnapshot?.(); }, vditorDownloadSnapshot() { const info = EditorManager.getCurrentInfo(); if (info?.key !== 'vditor') return Toast.info('只有在 Vditor 時可用'); const ok = info.adapter?.downloadLastSnapshot?.(); Toast[ok ? 'success' : 'warning'](ok ? '已下載快照' : '沒有可下載快照'); } }); // 包裝 init(只做一次) Modal._wrapInit10B(); // 如果 Modal 已經初始化,補充調用 advanced 初始化 if (Modal._inited && !Modal._seg10BBound) { Modal._initAdvancedOnce(); } })(); // ======================================== // [SEGMENT_10C] // Global init / GM menu / unload / error / scheduleInit // ======================================== /** * 初始化腳本(單一入口) * * 設計意圖(尊重原團隊): * - 初始化順序集中管理,避免散落到 Modal 或其他模組造成重複與責任不清 * - userscript 跨站執行,需有 init guard 與 body ready 容錯 */ async function init() { if (window.__MME_INITED__) return; window.__MME_INITED__ = true; console.log(`[Multi Markdown Editor] Initializing v${SCRIPT_VERSION}...`); try { // 某些頁面在 document-idle 下 body 仍可能延後出現 if (!document.body) { try { await Utils.waitFor(() => !!document.body, 2000, 50); } catch (e) { // 若仍未出現 body,仍嘗試繼續(避免卡死) } } // 初始化順序:Theme → Styles → Toast → Portal → EnhanceUI → FAB → Modal Theme.init(); Styles.init(); Toast.init(); Portal.init(); EnhanceUI.apply(); FAB.create(); DragDropManager.init(); // 初始化拖曳導入 Modal.init(); // GM menu / unload / error registerMenuCommands(); setupUnloadProtection(); setupErrorHandling(); // 首次使用提示 if (!Utils.storage.get(CONFIG.storageKeys.welcomed)) { setTimeout(() => { Toast.info('按 Alt+M 或點擊右下角按鈕開啟編輯器', 6000); Utils.storage.set(CONFIG.storageKeys.welcomed, true); }, 1000); } // DEBUG:暴露到全域(團隊診斷用) if (DEBUG) { window.__MME_DEBUG__ = { CONFIG, Utils, Theme, Styles, Portal, Toast, Loader, BackupManager, VditorDiag, PerfMonitor, EditorManager, EditorAdapters, ToolbarPrefs, EnhanceUI, FAB, Modal, // 便捷診斷方法 diagnose() { console.group('[MME] 系統診斷'); console.log('版本:', SCRIPT_VERSION); console.log('主題:', Theme.get()); console.log('編輯器:', EditorManager.currentEditor); console.log('Modal 狀態:', Modal.isOpen ? '開啟' : '關閉'); console.log('備份統計:', BackupManager.getStats()); console.log('插槽統計:', QuickSlots.getStats()); console.log('儲存空間:', Utils.storage.estimateUsage()); console.log('效能統計:', PerfMonitor.getReport()); console.groupEnd(); } }; console.log('[MME] Debug mode enabled. window.__MME_DEBUG__ is available.'); } console.log('[Multi Markdown Editor] Ready!'); } catch (err) { console.error('[Multi Markdown Editor] Init failed:', err); } } /** * 註冊 GM 選單命令 * * 設計意圖(尊重原團隊): * - 提供 FAB 以外的入口 * - 提供救援/維護操作 */ function registerMenuCommands() { try { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('🖊️ 開啟/關閉編輯器', () => Modal.toggle()); GM_registerMenuCommand('🎨 切換主題', () => { const newTheme = Theme.toggle(); Toast.info(`已切換到${newTheme === 'dark' ? '深色' : '淺色'}主題`); Modal.syncThemeButton?.(); EditorManager.setTheme(Theme.get()); }); GM_registerMenuCommand('💾 備份管理', () => { if (Modal.isOpen) { Modal.showBackupPanel(); } else { Modal.open().then(() => setTimeout(() => Modal.showBackupPanel(), 300)); } }); GM_registerMenuCommand('💾 快速存檔插槽', () => { if (Modal.isOpen) { Modal.showSlotsPanel(); } else { Modal.open().then(() => setTimeout(() => Modal.showSlotsPanel(), 300)); } }); GM_registerMenuCommand('⚙️ 偏好設定(工具列/狀態列)', () => { if (Modal.isOpen) { Modal.showSettingsPanel(); } else { Modal.open().then(() => setTimeout(() => Modal.showSettingsPanel(), 300)); } }); GM_registerMenuCommand('🧯 Vditor:還原快照', () => { const info = EditorManager.getCurrentInfo(); if (info?.key === 'vditor') info.adapter?.restoreLastSnapshot?.(); else Toast.info('請先切換到 Vditor'); }); GM_registerMenuCommand('⬇️ Vditor:下載快照', () => { const info = EditorManager.getCurrentInfo(); if (info?.key === 'vditor') { const ok = info.adapter?.downloadLastSnapshot?.(); Toast[ok ? 'success' : 'warning'](ok ? '已下載快照' : '沒有可下載快照'); } else { Toast.info('請先切換到 Vditor'); } }); GM_registerMenuCommand('📊 Vditor:診斷報告(輸出到控制台)', () => { VditorDiag.printReport(); Toast.info('診斷報告已輸出到控制台 (F12)'); }); GM_registerMenuCommand('🔄 重置拖曳提示', () => { DragDropManager.resetHintCount(); Toast.info('拖曳導入提示已重置,下次開啟編輯器時會再次顯示'); }); GM_registerMenuCommand('📍 重置按鈕位置', () => { Utils.storage.remove(CONFIG.storageKeys.buttonPos); location.reload(); }); GM_registerMenuCommand('⚙️ 重置工具列設定', () => { ToolbarPrefs.save(ToolbarPrefs.defaultPrefs()); if (Modal.isOpen) { ToolbarPrefs.applyToModal(Modal); Modal._applyStatusMetricVisibility?.(); Modal.syncThemeButton?.(); Modal.syncMaximizeButton?.(); } Toast.info('已重置工具列設定'); }); GM_registerMenuCommand('🗑️ 清除所有備份', () => { if (confirm('確定要清除所有備份嗎?此操作無法復原。')) { BackupManager.clearAll(); Toast.info('已清除所有備份'); } }); GM_registerMenuCommand('🔄 重置所有設定(不清草稿/備份)', () => { if (!confirm('確定要重置所有設定嗎?\n\n這將清除:主題、編輯器偏好、視窗位置、工具列設定等。\n不會清除:草稿與備份。\n\n頁面將重新載入。')) { return; } const keys = CONFIG.storageKeys; [ keys.theme, keys.editor, keys.editorMode, keys.buttonPos, keys.modalSize, keys.modalPos, keys.toolbarCfg, keys.focusMode, keys.welcomed, keys.locale ].forEach(k => Utils.storage.remove(k)); Toast.info('設定已重置,頁面將重新載入'); setTimeout(() => location.reload(), 900); }); } catch (e) { console.warn('[MME] Failed to register menu commands:', e); } } /** * 頁面卸載保護:離頁/切換分頁時保存草稿,並在離頁時嘗試建立備份 * * 設計意圖(尊重原團隊): * - 盡可能保護使用者內容 * - 不在 beforeunload 做重 UI/互動(避免阻塞) */ function setupUnloadProtection() { window.addEventListener('beforeunload', () => { try { if (Modal.isOpen && EditorManager.isReady()) { const content = EditorManager.getValue(); if (content && content.trim()) { Utils.storage.set(CONFIG.storageKeys.content, content); Utils.storage.set(CONFIG.storageKeys.lastSaveTime, Date.now()); const info = EditorManager.getCurrentInfo(); BackupManager.create(content, { editorKey: info?.key, mode: info?.adapter?._detectModeFromDOM?.(), manual: false }); } } } catch (e) { // 避免 beforeunload 中 throw } }); document.addEventListener('visibilitychange', () => { if (!document.hidden) return; try { if (Modal.isOpen && EditorManager.isReady()) { const content = EditorManager.getValue(); if (content && content.trim()) { Utils.storage.set(CONFIG.storageKeys.content, content); Utils.storage.set(CONFIG.storageKeys.lastSaveTime, Date.now()); } } } catch (e) { // ignore } }); } /** * 全域錯誤處理:在“可能與腳本相關”時,做最後保底保存 * * 設計意圖(尊重原團隊): * - userscript 跨站運行,頁面本身錯誤很多,不宜過度介入 * - 但對使用者資料,必要時仍應做最後保護 */ function setupErrorHandling() { window.addEventListener('error', (e) => { try { const maybeOurScript = (typeof e?.filename === 'string' && e.filename.includes('userscript')) || (typeof e?.message === 'string' && e.message.includes('MME')); if (maybeOurScript) { console.error('[MME] Uncaught error:', e.error || e.message); } // 即使不確定是否為本腳本,也做輕量草稿保存(不做 Toast) if (Modal.isOpen && EditorManager.isReady()) { const content = EditorManager.getValue(); if (content && content.trim()) { Utils.storage.set(CONFIG.storageKeys.content, content); Utils.storage.set(CONFIG.storageKeys.lastSaveTime, Date.now()); } } } catch (saveErr) { console.error('[MME] Failed to save on error:', saveErr); } }); window.addEventListener('unhandledrejection', (e) => { try { if (DEBUG) { console.error('[MME] Unhandled promise rejection:', e.reason); } } catch (err) { /* ignore */ } }); } /** * 延遲初始化:requestIdleCallback 優先 */ function scheduleInit() { const doInit = () => init(); if (typeof requestIdleCallback === 'function') { requestIdleCallback(doInit, { timeout: 2000 }); } else { setTimeout(doInit, 100); } } // boot if (document.readyState === 'complete' || document.readyState === 'interactive') { scheduleInit(); } else { document.addEventListener('DOMContentLoaded', scheduleInit); } })();