在任意网页上启动多款 Markdown 编辑器(EasyMDE、Toast UI、Cherry Markdown、Vditor)。功能:快速存档插槽、拖拽导入、文件系统备份、查找与替换、完整备份管理、工具栏自定义。
// ==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
// ==/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/[email protected]',
'https://fastly.jsdelivr.net/npm/[email protected]',
'https://unpkg.com/[email protected]'
],
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/[email protected]/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/[email protected]/dist',
'https://unpkg.com/@toast-ui/[email protected]/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/[email protected]',
'https://fastly.jsdelivr.net/npm/[email protected]',
'https://unpkg.com/[email protected]'
],
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/[email protected]/dist/katex.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.25/katex.min.css'
],
js: [
'https://cdn.jsdelivr.net/npm/[email protected]/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/[email protected]',
'https://fastly.jsdelivr.net/npm/[email protected]',
'https://unpkg.com/[email protected]'
],
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<string>} 鍵名列表
*/
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<boolean>} 是否成功
*/
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<string>} 檔案內容
*/
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<string>} 回應內容
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<Function>} 主題變更監聽器集合 */
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 = `
<span class="${p}toast-icon">${icons[type] || icons.info}</span>
<span class="${p}toast-msg">${Utils.escapeHtml(message)}</span>
<button class="${p}toast-close" type="button" aria-label="關閉">✕</button>
`;
// 關閉函數
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_<id>: 單個備份的實際內容
*
* 分層保留策略(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<Object>} 匯入結果
*/
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>: 第 n 個插槽的內容
* - mme_slot_meta_<n>: 第 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<Function>} 事件清理函數列表 */
_cleanupFns: [],
/** @type {Array<string>} 支援的檔案副檔名 */
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 = `
<div class="${p}drop-hint">
<div class="${p}drop-icon">
${Icons.import}
</div>
<div class="${p}drop-title">拖曳到目標位置</div>
<div class="${p}drop-subtitle">
請將檔案拖曳到 <b>右下角按鈕</b> 或 <b>編輯器視窗</b> 上<br>
支援 .md、.txt、.markdown 等格式
</div>
</div>
`;
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<boolean|null>} 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<FileSystemDirectoryHandle|null>}
*/
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<string>} '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<boolean>}
*/
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<boolean>}
*/
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<string|null>}
*/
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<Array>}
*/
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<boolean>}
*/
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<boolean>}
*/
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<boolean>}
*/
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 = `
<div class="${p}find-row">
<input type="text"
id="${p}find-input"
class="${p}find-input"
placeholder="尋找..."
aria-label="搜尋文字">
<button class="${p}icon-btn" data-action="find-prev" title="上一個 (Shift+Enter)">
${Icons.arrowUp}
</button>
<button class="${p}icon-btn" data-action="find-next" title="下一個 (Enter)">
${Icons.arrowDown}
</button>
<button class="${p}icon-btn" data-action="find-close" title="關閉 (Escape)">
${Icons.close}
</button>
</div>
<div class="${p}find-replace-row" style="display:none;">
<input type="text"
id="${p}replace-input"
class="${p}find-input"
placeholder="取代為..."
aria-label="取代文字">
<button class="${p}btn ${p}btn-sm" data-action="replace-one">
取代
</button>
<button class="${p}btn ${p}btn-sm" data-action="replace-all">
全部取代
</button>
</div>
<div class="${p}find-options">
<label class="${p}find-option">
<input type="checkbox" id="${p}find-case">
<span>區分大小寫</span>
</label>
<label class="${p}find-option">
<input type="checkbox" id="${p}find-regex">
<span>正規表達式</span>
</label>
<label class="${p}find-option">
<input type="checkbox" id="${p}find-whole">
<span>全字匹配</span>
</label>
</div>
<div class="${p}find-status" id="${p}find-status">
就緒
</div>
`;
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<boolean>} 是否可用
*/
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<string>} 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<boolean>} 是否成功
*/
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<boolean>} 是否成功
*/
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<string>} 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 = `<pre>${Utils.escapeHtml(md)}</pre>`;
},
/**
* 設定工具列圖標的 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 `<div class="markdown-body"><pre>${Utils.escapeHtml(md)}</pre></div>`;
} 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<boolean>} 是否成功
*/
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<Object>} 適配器實例
* @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: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 5h18v14H3V5zm2 2v10h14V7H5zm2.5 2h2l1.5 2 1.5-2h2v6h-2v-3.5L11 14l-1.5-2.5V15h-2V9zm9 0h2v4h1.5L18 16l-1.5-3H15V9z"/></svg>`,
/** 月亮圖標(填充型) */
moon: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>`,
/** 更多選項圖標(填充型 - 三個點) */
more: `<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>`,
// ===== 描邊型圖標 =====
/** 關閉圖標 */
close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>`,
/** 放大圖標 */
maximize: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>`,
/** 縮小圖標 */
minimize: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/></svg>`,
/** 展開圖標 */
expand: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>`,
/** 收合圖標 */
collapse: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14h6v6M14 4h6v6M10 14l-7 7M21 3l-7 7"/></svg>`,
/** 導出圖標 */
exportFile: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>`,
/** 導入圖標 */
import: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>`,
/** 檔案圖標 */
file: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
/** 清除圖標 */
clear: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6"/></svg>`,
/** 下載圖標 */
download: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>`,
/** 複製圖標 */
copy: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>`,
/** 代碼圖標 */
code: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
/** 保存圖標 */
save: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>`,
/** 載入中圖標 */
loading: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>`,
/** 太陽圖標 */
sun: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>`,
/** 還原圖標 */
restore: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0115.36-6.36L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 01-15.36 6.36L3 16"/><path d="M3 21v-5h5"/></svg>`,
/** 盾牌圖標 */
shield: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
/** 設定圖標 */
settings: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>`,
/** 專注模式圖標 */
focus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/></svg>`,
/** 向上箭頭圖標 */
arrowUp: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>`,
/** 向下箭頭圖標 */
arrowDown: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>`,
/** 歷史記錄圖標 */
history: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
/** 釘選圖標 */
pin: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l2.4 7.4h7.6l-6 4.6 2.3 7-6.3-4.6-6.3 4.6 2.3-7-6-4.6h7.6z"/></svg>`,
/** 垃圾桶圖標 */
trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>`,
/** 眼睛圖標 */
eye: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
/** 時鐘圖標 */
clock: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
/** 資料庫圖標 */
database: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>`,
/** 插槽圖示(層疊方塊,表示多個存檔位置) */
slots: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>`,
/** 勾選圖標 */
check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
/** 叉號圖標 */
x: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
/** 資訊圖標 */
info: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
/** 警告圖標 */
warning: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
/** 選單圖標 */
menu: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>`,
/** 外部連結圖標 */
externalLink: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`,
/** 編輯圖標 */
edit: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
/** 加號圖標 */
plus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
/** 減號圖標 */
minus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>`
};
// ========================================
// 主樣式系統
// ========================================
/**
* 樣式管理器
*
* 設計意圖(保留):
* - 以「單一 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 `<span>${label}</span>`;
case 'icon-text':
default:
return `${icon}<span>${label}</span>`;
}
},
/**
* 套用偏好設定到 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 += `
<button class="${p}fab-menu-item" data-action="toggle">
${Icons.edit} <span>${isModalOpen ? '關閉編輯器' : '開啟編輯器'}</span>
</button>
`;
// 專注模式下顯示退出選項
if (inFocusMode) {
html += `
<button class="${p}fab-menu-item" data-action="exit-focus">
${Icons.collapse} <span>退出專注模式</span>
</button>
`;
}
html += `
<button class="${p}fab-menu-item" data-action="backup">
${Icons.database} <span>備份管理</span>
</button>
<button class="${p}fab-menu-item" data-action="settings">
${Icons.settings} <span>偏好設定</span>
</button>
<div class="${p}fab-menu-sep"></div>
<button class="${p}fab-menu-item" data-action="theme">
${isDark ? Icons.sun : Icons.moon} <span>${isDark ? '切換淺色' : '切換深色'}</span>
</button>
<button class="${p}fab-menu-item" data-action="import-selection">
${Icons.import} <span>導入選取文字</span>
</button>
`;
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]) =>
`<option value="${key}" ${key === savedEditor ? 'selected' : ''}>${cfg.icon} ${cfg.name}</option>`
).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 = `
<div class="${p}toolbar" data-action="drag-zone">
<select class="${p}editor-select" id="${p}editor-select" data-tooltip="選擇編輯器">
${editorOptions}
</select>
<div class="${p}mini-slots" id="${p}mini-slots" style="display:none;" aria-label="迷你插槽列"></div>
<div class="${p}toolbar-left" id="${p}toolbar-left">
${leftButtons}
</div>
<div class="${p}toolbar-spacer"></div>
<div class="${p}toolbar-status" id="${p}toolbar-status" style="display:${prefs.statusBar.enabled && prefs.statusBar.position === 'toolbar' ? '' : 'none'}">
<span class="${p}metric" data-metric="wc"><span id="${p}wc-toolbar">0</span> 字</span>
<span class="${p}sep" data-metric="sep-wc">·</span>
<span class="${p}metric" data-metric="lc"><span id="${p}lc-toolbar">0</span> 行</span>
<span class="${p}sep" data-metric="sep-lc">·</span>
<span class="${p}metric ${p}reading-time" data-metric="rt">
${Icons.clock}
<span id="${p}reading-time-toolbar">約 1 分鐘</span>
</span>
<span class="${p}sep" data-metric="sep-rt">·</span>
<span class="${p}metric" data-metric="save"><span id="${p}save-time-toolbar">未保存</span></span>
</div>
<div class="${p}toolbar-right" id="${p}toolbar-right">
${rightButtons}
</div>
</div>
<div class="${p}body">
<div class="${p}loading" id="${p}loading">
<div class="${p}spinner"></div>
<div class="${p}loading-text" id="${p}loading-text">正在載入編輯器...</div>
</div>
<div class="${p}editor" id="${p}editor"></div>
</div>
<div class="${p}status-bar" id="${p}status-bar" style="display:${prefs.statusBar.enabled && prefs.statusBar.position === 'bottom' ? '' : 'none'}">
<span class="${p}metric" data-metric="wc"><span id="${p}wc">0</span> 字</span>
<span class="${p}sep" data-metric="sep-wc">·</span>
<span class="${p}metric" data-metric="lc"><span id="${p}lc">0</span> 行</span>
<span class="${p}sep" data-metric="sep-lc">·</span>
<span class="${p}metric ${p}reading-time" data-metric="rt">
${Icons.clock}
<span id="${p}reading-time">約 1 分鐘</span>
</span>
<span class="${p}sep" data-metric="sep-rt">·</span>
<span class="${p}metric" data-metric="save"><span id="${p}save-time">未保存</span></span>
<span class="${p}status-spacer"></span>
<span id="${p}status-text">就緒</span>
</div>
`;
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 `
<button class="${classes}"
data-action="${key}"
data-tooltip="${Utils.escapeHtml(tooltip)}"
aria-label="${Utils.escapeHtml(ariaLabel)}"
style="${display}"
type="button">
${content}
</button>
`;
}).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 = `
<h4>導入內容</h4>
<button class="${p}btn" data-action="import-selection" type="button">
${Icons.import} <span>導入頁面選取文字</span>
</button>
<button class="${p}btn" data-action="import-file" type="button">
${Icons.file} <span>導入檔案</span>
</button>
<div class="${p}io-hint">支援 .md, .txt, .markdown 等格式</div>
<button class="${p}btn ${p}secondary" data-action="import-cancel" type="button">取消</button>
`;
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 = `
<h4>導出 / 複製</h4>
<div class="${p}io-hint">導出:下載到本機;複製:放入剪貼簿</div>
<button class="${p}btn" data-action="export-md" type="button">
${Icons.download} <span>下載 Markdown (.md)</span>
</button>
<button class="${p}btn" data-action="export-txt" type="button">
${Icons.download} <span>下載純文字 (.txt)</span>
</button>
<button class="${p}btn" data-action="export-html" type="button">
${Icons.code} <span>下載 HTML (.html)</span>
</button>
<button class="${p}btn" data-action="export-pdf" type="button">
${Icons.file} <span>匯出 PDF(列印)</span>
</button>
<div class="${p}io-sep"></div>
<button class="${p}btn" data-action="copy-md" type="button">
${Icons.copy} <span>複製 Markdown</span>
</button>
<button class="${p}btn" data-action="copy-html" type="button">
${Icons.copy} <span>複製 HTML</span>
</button>
<button class="${p}btn ${p}secondary" data-action="export-cancel" type="button">取消</button>
`;
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 = `
<div class="${p}menu-header">更多</div>
<div class="${p}menu-item" style="cursor:default;opacity:0.8;font-size:12px;">
下一子片段將完成互補選單、偏好設定與備份管理。
</div>
`;
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 = `<div class="${p}loading-error">載入失敗:${Utils.escapeHtml(err.message)}</div>`;
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 = `<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Export</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; line-height: 1.7; }
pre { background: #f5f5f5; padding: 16px; overflow-x: auto; border-radius: 6px; }
code { background: #f5f5f5; padding: 2px 6px; border-radius: 4px; }
blockquote { border-left: 4px solid #ddd; margin: 0; padding-left: 16px; color: #666; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; }
</style>
</head>
<body>
${html}
</body>
</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 = `
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${Utils.escapeHtml(title)}</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
line-height: 1.7;
color: #333;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.3;
}
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h3 { font-size: 1.25em; }
p { margin: 1em 0; }
pre {
background: #f5f5f5;
padding: 16px;
overflow-x: auto;
border-radius: 4px;
font-family: Consolas, Monaco, 'Andale Mono', monospace;
font-size: 14px;
}
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: Consolas, Monaco, 'Andale Mono', monospace;
font-size: 0.9em;
}
pre code {
background: none;
padding: 0;
}
blockquote {
border-left: 4px solid #ddd;
margin: 1em 0;
padding: 0.5em 1em;
color: #666;
background: #f9f9f9;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
th, td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
th { background: #f5f5f5; }
img { max-width: 100%; height: auto; }
a { color: #0366d6; }
hr { border: none; border-top: 1px solid #eee; margin: 2em 0; }
ul, ol { padding-left: 2em; }
li { margin: 0.3em 0; }
@media print {
body { padding: 0; max-width: none; }
pre { white-space: pre-wrap; word-wrap: break-word; }
}
</style>
</head>
<body>
${html}
<script>
// 自動觸發列印
window.onload = function() {
setTimeout(function() {
window.print();
}, 500);
};
</script>
</body>
</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 = `<span>${Utils.escapeHtml(label)}</span>`;
break;
case 'icon-text':
default:
btn.innerHTML = `${icon}<span>${Utils.escapeHtml(label)}</span>`;
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 = `<span>${Utils.escapeHtml(label)}</span>`;
break;
case 'icon-text':
default:
btn.innerHTML = `${icon}<span>${Utils.escapeHtml(label)}</span>`;
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) => `<div class="${p}menu-header">${Utils.escapeHtml(title)}</div>`;
const addSep = () => `<div class="${p}menu-sep"></div>`;
const addItem = (key) => {
const def = ToolbarPrefs.allButtons[key];
if (!def) return '';
const icon = Icons[def.icon] || '';
const label = def.label || key;
return `
<button class="${p}menu-item" data-action="${key}" type="button">
${icon} <span>${Utils.escapeHtml(label)}</span>
</button>
`;
};
// 狀態區(永遠顯示,資訊型,不算互補功能重複)
let html = `
${addHeader('狀態')}
<div class="${p}menu-item" style="cursor:default;opacity:0.85;font-size:12px;">
${Icons.info} <span>${Utils.escapeHtml(`${wc} 字 · ${lc} 行 · ${saveTime}`)}</span>
</div>
${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('快速存檔')}
<div class="${p}menu-item" style="cursor:default;opacity:0.85;font-size:12px;">
${Icons.database} <span>${slotStats.used} / ${slotStats.total} 插槽已使用</span>
</div>
`;
}
// 備份摘要(資訊型)
html += `
${addHeader('備份摘要')}
<div class="${p}menu-item" style="cursor:default;opacity:0.85;font-size:12px;">
${Icons.database} <span>${backupStats.total} 筆備份 · ${backupStats.pinned} 筆釘選</span>
</div>
`;
// 快捷鍵入口(始終顯示)
html += `
${addSep()}
<button class="${p}menu-item" data-action="shortcuts">
${Icons.info} <span>快捷鍵一覽</span>
</button>
`;
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 `
<div class="${p}settings-row" data-action="${key}">
<label>
<input type="checkbox" data-action="${key}"
${checked ? 'checked' : ''}
${isAlways ? 'disabled' : ''}>
<span class="${p}settings-icon">${Icons[def.icon] || ''}</span>
<span>${Utils.escapeHtml(def.label || key)}</span>
${def.vditorOnly ? `<span class="${p}tag">Vditor</span>` : ''}
${isAlways ? `<span class="${p}tag ${p}tag-info">固定</span>` : ''}
</label>
${!isAlways ? `
<button type="button" class="${p}settings-mini-btn" data-move="up" title="上移">
${Icons.arrowUp}
</button>
<button type="button" class="${p}settings-mini-btn" data-move="down" title="下移">
${Icons.arrowDown}
</button>
` : ''}
</div>
`;
}).join('');
return `
<div class="${p}settings-group" data-group="${groupKey}">
<h4>${Utils.escapeHtml(group.title)}</h4>
<div class="${p}settings-list">${rows}</div>
</div>
`;
};
this.settingsPanel.innerHTML = `
<div class="${p}portal-panel-header">
<h3>${Icons.settings} 偏好設定(工具列 / 狀態列)</h3>
<button class="${p}icon-btn" data-action="close-settings" type="button" title="關閉">${Icons.close}</button>
</div>
<div class="${p}portal-panel-body">
<div class="${p}settings-section">
<h4>按鈕外觀</h4>
<div class="${p}settings-row">
<label>顯示方式</label>
<select id="${p}btn-appearance" class="${p}select">
<option value="icon-text" ${prefs.buttonAppearance === 'icon-text' ? 'selected' : ''}>圖示和文字</option>
<option value="icon-only" ${prefs.buttonAppearance === 'icon-only' ? 'selected' : ''}>僅圖示</option>
<option value="text-only" ${prefs.buttonAppearance === 'text-only' ? 'selected' : ''}>僅文字</option>
</select>
</div>
</div>
<div class="${p}settings-section">
<h4>狀態列</h4>
<div class="${p}settings-row">
<label>
<input type="checkbox" id="${p}status-enabled" ${prefs.statusBar.enabled ? 'checked' : ''}>
<span>顯示狀態列</span>
</label>
</div>
<div class="${p}settings-row">
<label>顯示位置</label>
<select id="${p}status-position" class="${p}select">
<option value="bottom" ${prefs.statusBar.position === 'bottom' ? 'selected' : ''}>底部(獨立狀態列)</option>
<option value="toolbar" ${prefs.statusBar.position === 'toolbar' ? 'selected' : ''}>工具列中</option>
</select>
</div>
<div class="${p}settings-row ${p}settings-sub">
<label>
<input type="checkbox" id="${p}status-word" ${prefs.statusBar.showWordCount ? 'checked' : ''}>
<span>字數</span>
</label>
<label>
<input type="checkbox" id="${p}status-line" ${prefs.statusBar.showLineCount ? 'checked' : ''}>
<span>行數</span>
</label>
<label>
<input type="checkbox" id="${p}status-reading" ${prefs.statusBar.showReadingTime !== false ? 'checked' : ''}>
<span>閱讀時間</span>
</label>
<label>
<input type="checkbox" id="${p}status-save" ${prefs.statusBar.showSaveTime ? 'checked' : ''}>
<span>保存時間</span>
</label>
</div>
</div>
<div class="${p}settings-section">
<h4>快速存檔插槽</h4>
<div class="${p}settings-row">
<label>啟用插槽數量</label>
<select id="${p}slot-count" class="${p}select">
<option value="0" ${slotSettings.enabledCount === 0 ? 'selected' : ''}>停用</option>
<option value="3" ${slotSettings.enabledCount === 3 ? 'selected' : ''}>3 組</option>
<option value="5" ${slotSettings.enabledCount === 5 ? 'selected' : ''}>5 組(預設)</option>
<option value="7" ${slotSettings.enabledCount === 7 ? 'selected' : ''}>7 組</option>
<option value="9" ${slotSettings.enabledCount === 9 ? 'selected' : ''}>9 組(最大)</option>
</select>
</div>
<div class="${p}settings-row">
<label>
<input type="checkbox" id="${p}slot-toolbar" ${slotSettings.showInToolbar ? 'checked' : ''}>
<span>在工具列顯示快速存檔按鈕</span>
</label>
</div>
<div class="${p}settings-row">
<label>
<input type="checkbox" id="${p}slot-mini-bar" ${slotSettings.showMiniBar ? 'checked' : ''}>
<span>在工具列顯示迷你插槽列(點擊載入 / Shift+點擊儲存)</span>
</label>
</div>
<div class="${p}settings-row ${p}settings-sub">
<label>迷你插槽顯示數量</label>
<select id="${p}slot-mini-count" class="${p}select">
<option value="3" ${slotSettings.miniBarCount === 3 ? 'selected' : ''}>3</option>
<option value="5" ${slotSettings.miniBarCount === 5 ? 'selected' : ''}>5(建議)</option>
<option value="7" ${slotSettings.miniBarCount === 7 ? 'selected' : ''}>7</option>
<option value="9" ${slotSettings.miniBarCount === 9 ? 'selected' : ''}>9</option>
</select>
</div>
<div class="${p}settings-row">
<label>
<input type="checkbox" id="${p}slot-confirm-overwrite" ${slotSettings.confirmBeforeOverwrite ? 'checked' : ''}>
<span>覆蓋插槽前確認</span>
</label>
</div>
<div class="${p}settings-row">
<label>
<input type="checkbox" id="${p}slot-confirm-load" ${slotSettings.confirmBeforeLoad ? 'checked' : ''}>
<span>載入插槽前確認(當前有內容時)</span>
</label>
</div>
<div class="${p}settings-row">
<label>
<input type="checkbox" id="${p}slot-auto-backup" ${slotSettings.autoBackupBeforeLoad ? 'checked' : ''}>
<span>載入插槽前自動備份當前內容</span>
</label>
</div>
</div>
<div class="${p}settings-section">
<h4>備份警告設定</h4>
<div class="${p}settings-row">
<label>
<input type="checkbox" id="${p}backup-size-warning-enabled"
${BackupManager.getSizeWarningSettings().enabled ? 'checked' : ''}>
<span>備份較大時顯示警告</span>
</label>
</div>
<div class="${p}settings-row ${p}settings-sub" id="${p}backup-size-threshold-row">
<label>警告閾值</label>
<select id="${p}backup-size-threshold" class="${p}select">
<option value="0.5" ${BackupManager.getSizeWarningSettings().thresholdMB === 0.5 ? 'selected' : ''}>0.5 MB</option>
<option value="1" ${BackupManager.getSizeWarningSettings().thresholdMB === 1 ? 'selected' : ''}>1 MB(預設)</option>
<option value="2" ${BackupManager.getSizeWarningSettings().thresholdMB === 2 ? 'selected' : ''}>2 MB</option>
<option value="3" ${BackupManager.getSizeWarningSettings().thresholdMB === 3 ? 'selected' : ''}>3 MB</option>
</select>
</div>
<div class="${p}settings-note">
當備份內容超過設定的大小時,會顯示提示建議您使用「匯出」功能將內容備份到本機。
這不會影響備份的執行,只是一個提醒。
</div>
</div>
<div class="${p}settings-section">
<h4>檔案系統備份</h4>
<!-- API 支援狀態 -->
<div class="${p}fs-status">
<div class="${p}fs-status-icon">
${fsStatus.supported ? Icons.check : Icons.x}
</div>
<div class="${p}fs-status-text">
File System Access API
</div>
<span class="${p}fs-status-badge ${fsStatus.supported ? p + 'supported' : p + 'unsupported'}">
${fsStatus.supported ? '支援' : '不支援'}
</span>
</div>
${fsStatus.supported ? `
<!-- 選擇資料夾 -->
<div class="${p}settings-row">
<button class="${p}btn" id="${p}fs-select-dir" type="button">
${Icons.database} <span>選擇備份資料夾</span>
</button>
</div>
${fsStatus.directoryName ? `
<div class="${p}fs-dir-info">
${Icons.database}
<span class="${p}fs-dir-name">${Utils.escapeHtml(fsStatus.directoryName)}</span>
<button class="${p}fs-dir-clear" id="${p}fs-clear-dir" type="button">清除</button>
</div>
` : `
<div class="${p}settings-note">
選擇資料夾後,備份將自動儲存到該位置。<br>
注意:頁面重新載入後需要重新選擇資料夾。
</div>
`}
${fsStatus.hasHandle ? `
<!-- 自動備份選項 -->
<div class="${p}settings-row" style="margin-top:8px;">
<label>
<input type="checkbox" id="${p}fs-auto-backup" ${fsStatus.autoBackup ? 'checked' : ''}>
<span>自動備份到資料夾</span>
</label>
</div>
` : ''}
` : `
<div class="${p}settings-note">
您的瀏覽器不支援 File System Access API。<br>
請使用 Chrome 或 Edge 86 以上版本,或使用下方的手動匯出/匯入功能。
</div>
`}
<div class="${p}settings-divider"></div>
<!-- 手動匯出/匯入 -->
<div class="${p}settings-row">
<span style="font-size:12px;color:${c.text2};">
手動匯出/匯入所有備份(適用於所有瀏覽器):
</span>
</div>
<div class="${p}backup-io-group">
<button class="${p}btn" id="${p}backup-export-all" type="button">
${Icons.download} <span>匯出備份</span>
</button>
<button class="${p}btn" id="${p}backup-import-all" type="button">
${Icons.import} <span>匯入備份</span>
</button>
</div>
<div class="${p}settings-divider"></div>
<!-- 快捷鍵一覽 -->
<div class="${p}settings-row">
<button class="${p}btn" id="${p}show-shortcuts" type="button">
${Icons.info} <span>查看快捷鍵一覽</span>
</button>
</div>
</div>
<div class="${p}settings-section">
<h4>工具列按鈕</h4>
<p class="${p}settings-hint">原則:工具列與「更多」選單互補。未勾選的按鈕將出現在「更多」選單中。</p>
${renderGroup('basic')}
${renderGroup('features')}
${renderGroup('vditor')}
${renderGroup('window')}
</div>
<div class="${p}settings-section">
<h4>拖曳導入</h4>
<div class="${p}settings-row">
<label>
<span>支援將文字或檔案拖曳到編輯器按鈕或視窗進行導入</span>
</label>
</div>
<div class="${p}settings-row">
<button class="${p}btn" id="${p}reset-dragdrop-hint" type="button">
${Icons.restore} <span>重置使用提示</span>
</button>
<span style="font-size:11px;color:${c.text3};margin-left:8px;">
讓拖曳導入提示再次顯示
</span>
</div>
</div>
</div>
<div class="${p}portal-panel-footer">
<button class="${p}btn" data-action="reset-settings" type="button">重置預設</button>
<button class="${p}btn ${p}primary" data-action="save-settings" type="button">套用</button>
</div>
`;
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 => `
<tr>
<td class="${p}shortcut-key"><code>${Utils.escapeHtml(item.key)}</code></td>
<td class="${p}shortcut-desc">${Utils.escapeHtml(item.desc)}</td>
</tr>
`).join('');
categoriesHtml += `
<div class="${p}shortcuts-category">
<h5>${Utils.escapeHtml(cat.category)}</h5>
<table class="${p}shortcuts-table">
${itemsHtml}
</table>
</div>
`;
});
panel.innerHTML = `
<div class="${p}portal-panel-header">
<h3>${Icons.info} 快捷鍵一覽</h3>
<button class="${p}icon-btn" data-action="close" type="button" title="關閉">${Icons.close}</button>
</div>
<div class="${p}portal-panel-body">
<div class="${p}shortcuts-content">
${categoriesHtml}
</div>
</div>
<div class="${p}portal-panel-footer">
<button class="${p}btn ${p}secondary" data-action="close" type="button">關閉</button>
</div>
`;
// 顯示面板
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 = `
<h4>${Icons.slots} 快速存檔</h4>
<div class="${p}slots-hint">
目前未啟用任何插槽。<br>
請在「偏好設定」中調整插槽數量。
</div>
<button class="${p}btn ${p}secondary" data-action="slots-close" type="button">關閉</button>
`;
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 += `
<div class="${p}slot-row ${isEmpty ? p + 'empty' : p + 'has-content'}" data-slot="${slot}">
<div class="${p}slot-number">${slot}</div>
<div class="${p}slot-info">
${isEmpty ? `
<div class="${p}slot-empty-label">(空插槽)</div>
` : `
<div class="${p}slot-label">${Utils.escapeHtml(label)}</div>
<div class="${p}slot-meta">
<span>${charsStr}</span>
<span>${linesStr}</span>
<span>${timeStr}</span>
</div>
`}
</div>
<div class="${p}slot-actions">
${!isEmpty ? `
<button class="${p}icon-btn" data-action="slot-preview" data-slot="${slot}" title="預覽內容" type="button">
${Icons.eye}
</button>
<button class="${p}icon-btn" data-action="slot-load" data-slot="${slot}" title="載入此插槽 (Ctrl+${slot})" type="button">
${Icons.import}
</button>
` : ''}
<button class="${p}icon-btn ${!isEmpty ? '' : p + 'primary'}" data-action="slot-save" data-slot="${slot}" title="儲存到此插槽 (Ctrl+Shift+${slot})" type="button">
${Icons.save}
</button>
${!isEmpty ? `
<button class="${p}icon-btn" data-action="slot-edit-label" data-slot="${slot}" title="編輯標籤" type="button">
${Icons.edit}
</button>
<button class="${p}icon-btn ${p}danger" data-action="slot-clear" data-slot="${slot}" title="清空此插槽" type="button">
${Icons.trash}
</button>
` : ''}
</div>
</div>
`;
});
this.slotsPanel.innerHTML = `
<h4>${Icons.slots} 快速存檔</h4>
<div class="${p}slots-hint">
💡 快捷鍵:<code>Ctrl+1~9</code> 載入,<code>Ctrl+Shift+1~9</code> 儲存
</div>
<div class="${p}slots-stats">
<span>已使用 <b>${stats.used}</b> / <b>${stats.total}</b> 插槽</span>
<span>共 <b>${stats.totalChars}</b> 字</span>
</div>
<div class="${p}slots-list">
${listHtml}
</div>
<div class="${p}slots-footer">
<div class="${p}slots-footer-left">
<button class="${p}btn" data-action="slots-export" type="button" title="匯出所有插槽">
${Icons.download} <span>匯出</span>
</button>
<button class="${p}btn" data-action="slots-import" type="button" title="匯入插槽">
${Icons.import} <span>匯入</span>
</button>
</div>
<div class="${p}slots-footer-right">
<button class="${p}btn ${p}danger" data-action="slots-clear-all" type="button" title="清空所有插槽">
${Icons.trash} <span>全部清空</span>
</button>
<button class="${p}btn ${p}secondary" data-action="slots-close" type="button">關閉</button>
</div>
</div>
`;
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 = `
<div class="${p}portal-panel-header">
<h3>${Icons.eye} 預覽:${Utils.escapeHtml(label)}</h3>
<button class="${p}icon-btn" data-action="close" type="button" title="關閉">${Icons.close}</button>
</div>
<div class="${p}portal-panel-body">
<div class="${p}slots-stats" style="margin-bottom:12px;">
<span><b>${meta?.chars || 0}</b> 字</span>
<span><b>${meta?.lines || 0}</b> 行</span>
<span>${timeStr}</span>
</div>
<div class="${p}slot-preview-content">${Utils.escapeHtml(result.content)}</div>
</div>
<div class="${p}portal-panel-footer">
<button class="${p}btn" data-action="copy" type="button">${Icons.copy} 複製內容</button>
<button class="${p}btn ${p}primary" data-action="load" type="button">${Icons.import} 載入此插槽</button>
</div>
`;
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(`
<button type="button"
class="${p}mini-slot-btn ${isEmpty ? p + 'empty' : p + 'has-content'}"
data-slot="${slot}"
data-tooltip="${Utils.escapeHtml(tooltip)}"
aria-label="${Utils.escapeHtml(`迷你插槽 ${slot}`)}">
${slot}
</button>
`);
}
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 = `
<div class="${p}backup-info-box">
<div class="${p}backup-info-title">
${Icons.shield} 備份 / 草稿 / 快照:概念說明
</div>
<ul class="${p}backup-info-list">
<li><b>草稿</b>:您目前的工作內容(「保存」保存到瀏覽器)</li>
<li><b>備份</b>:歷史版本(可釘選永久保留,可下載)</li>
<li><b>快照</b>:Vditor 專用救援(防模式切換內容異常)</li>
<li>📌 <b>釘選</b>的備份永久保留,不會被自動清理</li>
<li>⏱️ <b>1 小時內</b>:每 2 分鐘保留一筆</li>
<li>📅 <b>24 小時內</b>:每 10 分鐘保留一筆</li>
<li>📆 <b>7 天內</b>:每天保留一筆</li>
<li>📊 最多保留 <b>${CONFIG.backup.maxBackups}</b> 筆備份</li>
</ul>
<div class="${p}backup-info-warning">
⚠️ <b>重要</b>:若內容重要,請使用「釘選」或「下載」以避免自動清理導致遺失。
</div>
</div>
`;
// 如果有多頁,新增分頁控制
let paginationHtml = '';
if (totalPages > 1) {
paginationHtml = `
<div class="${p}backup-pagination">
<button class="${p}btn" data-action="backup-prev" ${currentPage === 0 ? 'disabled' : ''}>
${Icons.arrowUp} 上一頁
</button>
<span class="${p}backup-page-info">
第 ${currentPage + 1} / ${totalPages} 頁
(共 ${allIndex.length} 筆)
</span>
<button class="${p}btn" data-action="backup-next" ${currentPage >= totalPages - 1 ? 'disabled' : ''}>
下一頁 ${Icons.arrowDown}
</button>
</div>
`;
}
let listHtml = '';
if (!index.length) {
listHtml = `
<div class="${p}backup-empty">
${Icons.database}
<p>暫無備份</p>
<p style="font-size:12px;">編輯器會定期自動備份(可釘選)</p>
</div>
`;
} 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 = `<span class="${p}backup-age-warning ${p}danger">⚠️ 可能即將被刪除</span>`;
else if (isOld) ageWarning = `<span class="${p}backup-age-warning">📅 較舊備份</span>`;
}
return `
<div class="${p}backup-item ${meta.pinned ? p + 'pinned' : ''}" data-id="${meta.id}">
<div class="${p}backup-info">
<div class="${p}backup-time" title="${Utils.escapeHtml(fullTime)}">
${Utils.escapeHtml(time)}
${meta.pinned ? `<span class="${p}pin-badge">📌 已釘選</span>` : ''}
${ageWarning}
</div>
<div class="${p}backup-meta">
<span>${meta.chars || 0} 字</span>
<span>${meta.lines || 0} 行</span>
<span>${Utils.escapeHtml(editorName)}</span>
${meta.mode ? `<span>${Utils.escapeHtml(meta.mode)}</span>` : ''}
</div>
</div>
<div class="${p}backup-actions">
<button class="${p}icon-btn" data-action="preview" type="button" title="預覽內容">${Icons.eye}</button>
<button class="${p}icon-btn" data-action="restore" type="button" title="還原此備份">${Icons.restore}</button>
<button class="${p}icon-btn ${meta.pinned ? p + 'active' : ''}" data-action="pin" type="button" title="${meta.pinned ? '取消釘選' : '釘選(永久保留)'}">${Icons.pin}</button>
<button class="${p}icon-btn" data-action="download" type="button" title="下載備份檔案">${Icons.download}</button>
<button class="${p}icon-btn ${p}danger" data-action="delete" type="button" title="刪除此備份">${Icons.trash}</button>
</div>
</div>
`;
}).join('');
}
this.backupPanel.innerHTML = `
<div class="${p}portal-panel-header">
<h3>${Icons.database} 備份管理</h3>
<button class="${p}icon-btn" data-action="close-backup" type="button" title="關閉">${Icons.close}</button>
</div>
<div class="${p}portal-panel-body">
${infoBox}
<div class="${p}backup-stats">
<span>共 <b>${stats.total}</b> 筆</span>
<span>釘選 <b>${stats.pinned}</b> 筆</span>
${stats.oldest ? `<span>最早 ${Utils.escapeHtml(Utils.formatRelativeTime(stats.oldest))}</span>` : ''}
</div>
<div class="${p}backup-list">${listHtml}</div>
</div>
<div class="${p}portal-panel-footer">
<button class="${p}btn" data-action="backup-now" type="button">${Icons.save} 立即備份</button>
<button class="${p}btn" data-action="pin-all" type="button" title="釘選所有備份">${Icons.pin} 全部釘選</button>
<button class="${p}btn ${p}danger" data-action="clear-backups" type="button">${Icons.trash} 清除所有</button>
</div>
`;
// 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 = `
<div class="${p}portal-panel-header">
<h3>${Icons.eye} 備份預覽 - ${Utils.escapeHtml(Utils.formatRelativeTime(meta?.ts || Date.now()))}</h3>
<button class="${p}icon-btn" data-action="close" type="button" title="關閉">${Icons.close}</button>
</div>
<div class="${p}portal-panel-body">
<div class="${p}preview-content"><pre>${Utils.escapeHtml(content)}</pre></div>
</div>
<div class="${p}portal-panel-footer">
<button class="${p}btn" data-action="copy" type="button">${Icons.copy} 複製內容</button>
<button class="${p}btn ${p}primary" data-action="restore" type="button">${Icons.restore} 還原此備份</button>
</div>
`;
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);
}
})();